refactor(agent): remove Agent Drive (#40887)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
盐粒 Yanli
2026-08-19 06:34:36 +00:00
committed by GitHub
parent 297fdc7cb8
commit 8874f3c80b
113 changed files with 991 additions and 11795 deletions
+10 -64
View File
@@ -28,7 +28,6 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
@@ -56,7 +55,6 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_RUNTIME_LAYER_ID = "runtime"
DIFY_CONFIG_LAYER_ID = "config"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
@@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
}
def _drive_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _config_layer_deps() -> dict[str, str]:
return {"shell": DIFY_SHELL_LAYER_ID}
def _shell_config_with_drive_ref(
shell_config: DifyShellLayerConfig | None,
drive_config: DifyDriveLayerConfig | None,
) -> DifyShellLayerConfig:
config = shell_config or DifyShellLayerConfig()
if drive_config is None:
return config
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
def _markdown_backtick_fence(text: str) -> str:
"""Choose a fence that will not terminate inside the prompt body."""
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
@@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635).
@@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
core_tools: DifyCoreToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
@@ -307,7 +285,7 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional shell / config / drive / history
execution context → optional shell / config / history
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
knowledge search / ask_human / structured output. Mirrors the
workflow-node layer ordering minus the workflow-job / previous-node
@@ -345,9 +323,7 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -357,16 +333,15 @@ class AgentBackendRunRequestBuilder:
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
# Sandboxed bash workspace (dify.shell). It enters before config/drive
# so eager pulls materialize content in the same filesystem used by
# model commands.
# Sandboxed bash workspace (dify.shell). It enters before config so
# eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -381,19 +356,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@@ -495,7 +457,7 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional shell / config / drive / history → LLM → optional
optional shell / config / history → LLM → optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
@@ -537,9 +499,7 @@ class AgentBackendRunRequestBuilder:
]
)
include_shell = (
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
)
include_shell = run_input.include_shell or run_input.config_layer_config is not None
if include_shell:
layers.append(
RunLayerSpec(
@@ -549,16 +509,15 @@ class AgentBackendRunRequestBuilder:
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
)
)
# Sandboxed bash workspace (dify.shell). It enters before drive so
# drive can materialize mentioned targets with `dify-agent drive pull`
# in the same shell-visible filesystem used by model commands.
# Sandboxed bash workspace (dify.shell). It enters before config so
# eager pulls materialize content in the same filesystem used by model commands.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps=_shell_layer_deps(),
metadata=run_input.metadata,
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
config=run_input.shell_config or DifyShellLayerConfig(),
)
)
@@ -573,19 +532,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): the catalog plus
# prompt-mentioned entries eagerly pulled through the shell layer.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps=_drive_layer_deps(),
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
-2
View File
@@ -72,7 +72,6 @@ from .app import (
agent_app_feature,
agent_app_sandbox,
agent_config_inspector,
agent_drive_inspector,
annotation,
app,
audio,
@@ -176,7 +175,6 @@ __all__ = [
"agent_app_sandbox",
"agent_composer",
"agent_config_inspector",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",
+4 -26
View File
@@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
@with_session(write=False)
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session,
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=req_data,
agent_id=str(agent_id),
)
findings = AgentComposerService.collect_validation_findings(payload=req_data)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
+4 -462
View File
@@ -1,21 +1,12 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -24,41 +15,13 @@ from controllers.console.wraps import (
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveFileRef,
normalize_drive_key,
)
from models.model import App, AppMode
from services.agent_service import AgentService
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Skill package (.zip or .skill).",
}
}
class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID")
@@ -70,27 +33,6 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
class AgentDriveFilePayload(BaseModel):
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
return uuid_value(value)
class AgentDriveMutationQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentDriveDeleteFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@@ -128,204 +70,7 @@ 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: AgentUploadedSkillResponse
manifest: SkillManifest
class AgentDriveFileResponse(ResponseModel):
name: str
drive_key: str
file_id: str
size: int | None = None
mime_type: str | None = None
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentUploadedSkillResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id_with_session(session=session)
def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
session=session,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
upload_file = session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
}, 201
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveDeleteFileQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
try:
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)],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _delete_skill_for_app(
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(session, app_model, node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
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),
],
session=session,
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
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(*, session: Session, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
register_response_schema_models(console_ns, AgentLogResponse)
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@@ -344,209 +89,6 @@ class AgentLogApi(Resource):
@get_app_model(mode=[AppMode.AGENT_CHAT])
@model_validate(AgentLogQuery)
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
"""Get agent logs"""
"""Get agent logs."""
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
class AgentSkillUploadByAgentApi(Resource):
@console_ns.doc("upload_agent_skill_by_agent")
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
@console_ns.doc(
consumes=["multipart/form-data"],
params={
"app_id": "Application ID",
**query_params_from_model(AgentDriveMutationQuery),
**_AGENT_SKILL_UPLOAD_PARAMS,
},
)
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and commit drive-backed skill files."""
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/files")
class AgentDriveFilesByAgentApi(Resource):
@console_ns.doc("commit_agent_drive_file_by_agent")
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
)
@console_ns.doc("delete_agent_drive_file_by_agent")
@console_ns.doc(description="Delete one Agent App drive file by key")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
)
@console_ns.route("/apps/<uuid:app_id>/agent/files")
class AgentDriveFilesApi(Resource):
@console_ns.doc("commit_agent_drive_file")
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
return _commit_drive_file_for_app(session=session, 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 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
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def delete(self, session: Session, current_user: Account, app_model: App):
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
class AgentSkillByAgentApi(Resource):
@console_ns.doc("delete_agent_skill_by_agent")
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
)
@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 by removing its known drive keys via commit-null")
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_session
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsByAgentApi(Resource):
@console_ns.doc("infer_agent_skill_tools_by_agent")
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsApi(Resource):
@console_ns.doc("infer_agent_skill_tools")
@console_ns.doc(
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, session: Session, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
@@ -1,434 +0,0 @@
"""Console read-only inspector for the agent drive (ENG-624).
``agent-drive`` looks at the *static* drive assets (standardized skills and
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
backend — drive data lives in the API's own DB/storage, served straight from
``AgentDriveService``. Download hands the browser an **external** signed URL
(the inner manifest hands agents internal ones — the two must never mix).
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveListQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveListByAgentQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveSkillInspectQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
file_kind: str
created_at: int | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDriveSkillItemResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
class AgentDriveSkillListResponse(ResponseModel):
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
class AgentDriveSkillFileResponse(ResponseModel):
path: str
name: str
type: str
drive_key: str | None = None
available_in_drive: bool
class AgentDriveSkillMarkdownResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveSkillInspectResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
source: str
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
file_tree: list[dict[str, Any]] = Field(default_factory=list)
skill_md: AgentDriveSkillMarkdownResponse
warnings: list[str] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveDownloadResponse(ResponseModel):
url: str
register_response_schema_models(
console_ns,
AgentDriveDownloadResponse,
AgentDriveListResponse,
AgentDrivePreviewResponse,
AgentDriveSkillInspectResponse,
AgentDriveSkillListResponse,
)
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id_with_session(session=session)
def _agent_not_bound() -> tuple[dict[str, object], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _json_response(data: Mapping[str, Any]):
return Response(
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
content_type="application/json; charset=utf-8",
)
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
class AgentDriveListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_files_by_agent")
@console_ns.doc(description="List agent drive entries for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
class AgentDriveSkillListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_skills_by_agent")
@console_ns.doc(description="List drive-backed skills for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectByAgentApi(Resource):
@console_ns.doc("inspect_agent_drive_skill_by_agent")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=tenant_id,
agent_id=str(agent_id),
skill_path=skill_path,
session=session,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
class AgentDriveDownloadByAgentApi(Resource):
@console_ns.doc("download_agent_drive_file_by_agent")
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
class AgentDriveListApi(Resource):
@console_ns.doc("list_agent_drive_files")
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
# inspector is a pure read surface and does not need value pointers
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
class AgentDriveSkillListApi(Resource):
@console_ns.doc("list_agent_drive_skills")
@console_ns.doc(description="List drive-backed skills for the bound agent")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectApi(Resource):
@console_ns.doc("inspect_agent_drive_skill")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(
params={
"app_id": "Application ID",
"skill_path": "Skill path/slug, e.g. tender-analyzer",
**query_params_from_model(AgentDriveSkillInspectQuery),
}
)
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App, skill_path: str):
query = query_params_from_request(AgentDriveSkillInspectQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
skill_path=skill_path,
session=session,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
class AgentDriveDownloadApi(Resource):
@console_ns.doc("download_agent_drive_file")
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_session(write=False)
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, session: Session, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(session, app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
"AgentDriveSkillInspectApi",
"AgentDriveSkillInspectByAgentApi",
"AgentDriveSkillListApi",
"AgentDriveSkillListByAgentApi",
]
+1 -2
View File
@@ -14,12 +14,11 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import agent_drive_archive, image_preview, tool_files, upload
from . import image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"agent_drive_archive",
"api",
"bp",
"files_ns",
@@ -1,69 +0,0 @@
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_models
from controllers.files import files_ns
from extensions.ext_database import db
from models.agent import AgentDriveFileKind
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveArchiveMemberQuery(BaseModel):
tenant_id: str = Field(..., description="Tenant ID")
agent_id: str = Field(..., description="Agent ID")
key: str = Field(..., description="Virtual drive key")
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
archive_file_id: str = Field(..., description="Archive file id")
member_path: str = Field(..., description="Zip member path")
timestamp: str = Field(..., description="Unix timestamp")
nonce: str = Field(..., description="Random nonce")
sign: str = Field(..., description="HMAC signature")
as_attachment: bool = Field(default=False, description="Download as attachment")
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
@files_ns.route("/agent-drive/archive-member")
class AgentDriveArchiveMemberApi(Resource):
@files_ns.doc("get_agent_drive_archive_member")
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
def get(self):
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
if not AgentDriveService.verify_archive_member_signature(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
timestamp=args.timestamp,
nonce=args.nonce,
sign=args.sign,
):
raise Forbidden("Invalid request.")
try:
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
tenant_id=args.tenant_id,
agent_id=args.agent_id,
key=args.key,
archive_file_kind=args.archive_file_kind,
archive_file_id=args.archive_file_id,
member_path=args.member_path,
session=db.session(),
)
except AgentDriveError as exc:
raise NotFound(exc.message) from exc
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
response.headers["Content-Length"] = str(len(payload))
if args.as_attachment and filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
return response
-2
View File
@@ -23,7 +23,6 @@ from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@@ -31,7 +30,6 @@ api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
"_agent_drive",
"_agent_files",
"_agent_llm",
"_agent_tools",
@@ -1,105 +0,0 @@
"""Inner API for the agent drive (agent 网盘) control plane.
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
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from extensions.ext_database import db
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
parse_agent_drive_ref,
)
class _CommitRequest(BaseModel):
tenant_id: str
user_id: str
items: list[DriveCommitItem]
def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
class AgentDriveManifestApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_manifest")
@inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
def get(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
items = AgentDriveService().manifest(
tenant_id=tenant_id,
agent_id=agent_id,
prefix=request.args.get("prefix", ""),
include_download_url=include_download_url,
session=db.session(),
)
except AgentDriveError as exc:
return _error_response(exc)
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, session=db.session())
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
class AgentDriveCommitApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_commit")
@inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
def post(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
try:
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
except ValidationError as exc:
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
user = get_user(body.tenant_id, body.user_id)
items = AgentDriveService().commit(
tenant_id=body.tenant_id,
user_id=user.id,
agent_id=agent_id,
items=body.items,
session=db.session(),
)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@@ -0,0 +1,109 @@
"""remove agent drive
Revision ID: 89919253ca7a
Revises: 56124e050600
Create Date: 2026-08-17 17:40:52.081816
"""
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from models.types import StringUUID
# revision identifiers, used by Alembic.
revision = "89919253ca7a"
down_revision = "56124e050600"
branch_labels = None
depends_on = None
def _rewrite_json_rows(table_name: str, column_name: str, transform) -> None:
# Offline SQL generation cannot run this read-modify-write cleanup.
if op.get_context().as_sql:
return
connection = op.get_bind()
rows = connection.execute(sa.text(f"SELECT id, {column_name} FROM {table_name}"))
for row_id, raw_value in rows:
if raw_value is None:
continue
value = json.loads(raw_value)
if not transform(value):
continue
connection.execute(
sa.text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"),
{"id": row_id, "value": json.dumps(value, ensure_ascii=False, separators=(",", ":"))},
)
def _remove_soul_files(value: object) -> bool:
if not isinstance(value, dict) or "files" not in value:
return False
del value["files"]
return True
def _remove_node_job_drive_keys(value: object) -> bool:
if not isinstance(value, dict):
return False
changed = False
metadata = value.get("metadata")
if isinstance(metadata, dict):
file_refs = metadata.get("file_refs")
if isinstance(file_refs, list):
for file_ref in file_refs:
if isinstance(file_ref, dict) and "drive_key" in file_ref:
del file_ref["drive_key"]
changed = True
declared_outputs = value.get("declared_outputs")
if isinstance(declared_outputs, list):
for output in declared_outputs:
if not isinstance(output, dict):
continue
check = output.get("check")
if not isinstance(check, dict):
continue
benchmark_file_ref = check.get("benchmark_file_ref")
if isinstance(benchmark_file_ref, dict) and "drive_key" in benchmark_file_ref:
del benchmark_file_ref["drive_key"]
changed = True
return changed
def upgrade() -> None:
_rewrite_json_rows("agent_config_snapshots", "config_snapshot", _remove_soul_files)
_rewrite_json_rows("agent_config_drafts", "config_snapshot", _remove_soul_files)
_rewrite_json_rows("workflow_agent_node_bindings", "node_job_config", _remove_node_job_drive_keys)
op.drop_table("agent_drive_files")
def downgrade() -> None:
op.create_table(
"agent_drive_files",
sa.Column("tenant_id", StringUUID(), nullable=False),
sa.Column("agent_id", StringUUID(), nullable=False),
sa.Column("key", sa.String(length=512), nullable=False),
sa.Column("file_kind", sa.String(length=32), nullable=False),
sa.Column("file_id", StringUUID(), nullable=False),
sa.Column("value_owned_by_drive", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("is_skill", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column("hash", sa.String(length=255), nullable=True),
sa.Column("mime_type", sa.String(length=255), nullable=True),
sa.Column("created_by", StringUUID(), nullable=True),
sa.Column("id", StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
)
op.create_index(
"agent_drive_files_tenant_agent_is_skill_key_idx",
"agent_drive_files",
["tenant_id", "agent_id", "is_skill", "key"],
)
-4
View File
@@ -17,8 +17,6 @@ from .agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentDriveFile,
AgentDriveFileKind,
AgentHomeSnapshot,
AgentIconType,
AgentKind,
@@ -168,8 +166,6 @@ __all__ = [
"AgentConfigSnapshot",
"AgentConfigVersionKind",
"AgentDebugConversation",
"AgentDriveFile",
"AgentDriveFileKind",
"AgentHomeSnapshot",
"AgentIconType",
"AgentKind",
-52
View File
@@ -536,55 +536,3 @@ class AgentWorkspaceBinding(DefaultFieldsMixin, Base):
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
class AgentDriveFileKind(StrEnum):
"""Kind of existing file record an agent-drive KV entry points at."""
UPLOAD_FILE = "upload_file"
TOOL_FILE = "tool_file"
class AgentDriveFile(DefaultFieldsMixin, Base):
"""Per-agent path-like KV index into existing file records (agent 网盘 / agent drive).
A row maps a path-like ``key`` to a *pointer* (``file_kind`` + ``file_id``) at an
existing ``UploadFile`` / ``ToolFile`` — it never stores file bytes. Scope/ownership
is ``tenant_id -> agent-<agent_id>`` (the drive ref; no standalone ``drive_id`` this
phase). ``key`` is opaque/path-like and carries no directory, permission, or
parent-child semantics on the API side; it maps 1:1 to a sandbox-relative path when
synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values
(created by the agent runtime or Skill standardization, not shared with other
business records) have their storage object + record deleted when the KV entry is
overwritten or removed; otherwise only the KV row is dropped. Skills are represented
by the canonical ``<path>/SKILL.md`` row with ``is_skill=True`` and a serialized
``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by``
(not a reliable refcount).
"""
__tablename__ = "agent_drive_files"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# drive ref = agent-<agent_id>; this phase has no standalone drive_id.
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# path-like opaque key; not a filesystem (no dir/permission/parent semantics).
# Bounded at 512 so the (tenant_id, agent_id, key) unique index stays within
# MySQL's 3072-byte index limit (CHAR(36)*2 + VARCHAR(512) utf8mb4 = 2336).
key: Mapped[str] = mapped_column(String(512), nullable=False)
file_kind: Mapped[AgentDriveFileKind] = mapped_column(EnumText(AgentDriveFileKind, length=32), nullable=False)
# points at UploadFile.id / ToolFile.id (the value), never the bytes.
file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
value_owned_by_drive: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
)
is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false"))
skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True)
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
-28
View File
@@ -150,33 +150,6 @@ class AgentFileRefConfig(AgentFlexibleConfig):
transfer_method: str | None = Field(default=None, max_length=64)
url: str | None = None
remote_url: str | None = None
# Drive key once the file is committed to the agent drive ("files/<name>",
# ENG-625). Files without it are plain upload references and stay invisible
# to the runtime drive manifest.
drive_key: str | None = Field(default=None, max_length=512)
class AgentSkillRefConfig(AgentFlexibleConfig):
id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
description: str | None = None
file_id: str | None = Field(default=None, max_length=255)
path: str | None = None
# Standardization outputs (ENG-594) — previously riding along via
# ``extra="allow"``, promoted to the explicit schema because the runtime
# drive manifest (ENG-623) keys off them.
skill_md_key: str | None = Field(default=None, max_length=512)
skill_md_file_id: str | None = Field(default=None, max_length=255)
full_archive_key: str | None = Field(default=None, max_length=512)
full_archive_file_id: str | None = Field(default=None, max_length=255)
# Zip member path listing from standardization (ENG-371): lets infer-tools
# show the model strong signals like ``scripts/*.sh`` without unpacking.
manifest_files: list[str] | None = None
class AgentSoulFilesConfig(BaseModel):
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
files: list[AgentFileRefConfig] = Field(default_factory=list)
def validate_config_name(name: str) -> str:
@@ -820,7 +793,6 @@ class AgentSoulConfig(BaseModel):
config_skills: list[AgentConfigSkillRefConfig] = Field(default_factory=list)
config_files: list[AgentConfigFileRefConfig] = Field(default_factory=list)
config_note: str = ""
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
model: AgentSoulModelConfig | None = None
-575
View File
@@ -972,85 +972,6 @@ Stop a running Agent App chat message generation
| 200 | Agent debug conversation refreshed | **application/json**: [AgentDebugConversationRefreshResponse](#agentdebugconversationrefreshresponse)<br> |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/drive/files
List agent drive entries for an Agent App
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)<br> |
### [GET] /agent/{agent_id}/drive/files/download
Time-limited external signed URL for one Agent App drive value
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)<br> |
### [GET] /agent/{agent_id}/drive/files/preview
Truncated text preview of one Agent App drive value
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)<br> |
### [GET] /agent/{agent_id}/drive/skills
List drive-backed skills for an Agent App
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect
Inspect one drive-backed skill for slash-menu hover/detail UI
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
### [POST] /agent/{agent_id}/features
Update an Agent App's presentation features (opener, follow-up, citations, ...)
@@ -1096,43 +1017,6 @@ Create or update Agent App message feedback
| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 404 | Agent or message not found | |
### [DELETE] /agent/{agent_id}/files
Delete one Agent App drive file by key
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /agent/{agent_id}/files
Commit an uploaded file into the Agent App drive under files/<name>
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
### [GET] /agent/{agent_id}/log-sources
#### Parameters
@@ -1322,60 +1206,6 @@ Read a text/binary preview file in an Agent App conversation sandbox
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /agent/{agent_id}/skills/upload
Upload + standardize a Skill into an Agent App drive
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
| 400 | Invalid skill package or no bound agent | |
### [DELETE] /agent/{agent_id}/skills/{slug}
Delete a standardized skill from an Agent App drive
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| slug | path | Skill slug (single path segment) | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /agent/{agent_id}/skills/{slug}/infer-tools
Infer CLI tool + ENV suggestions from a standardized Agent App skill
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| slug | path | Skill slug (single path segment) | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
### [GET] /agent/{agent_id}/statistics/summary
#### Parameters
@@ -2192,132 +2022,6 @@ Run draft workflow for advanced chat application
| ---- | ----------- | ------ |
| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files
List agent drive entries (read-only inspector; one endpoint for both tabs)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files/download
Time-limited external signed URL for one drive value (no streaming proxy)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files/preview
Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/skills
List drive-backed skills for the bound agent
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect
Inspect one drive-backed skill for slash-menu hover/detail UI
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
### [DELETE] /apps/{app_id}/agent/files
Delete one drive file by key via drive commit-null semantics
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /apps/{app_id}/agent/files
**ADD FILE: commit one uploaded file into the bound agent's drive**
Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
### [GET] /apps/{app_id}/agent/logs
**Get agent logs**
@@ -2338,68 +2042,6 @@ Get agent execution logs for an application
| 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)<br> |
| 400 | Invalid request parameters | |
### [POST] /apps/{app_id}/agent/skills/upload
**Upload a Skill, validate it, and commit drive-backed skill files**
Upload + standardize a Skill into the agent drive
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
| 400 | Invalid skill package or no bound agent | |
### [DELETE] /apps/{app_id}/agent/skills/{slug}
Delete a standardized skill by removing its known drive keys via commit-null
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| slug | path | Skill slug (single path segment) | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools
**Suggest CLI tools/env for a skill**
Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)
Saving still goes through composer validation.
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| slug | path | Skill slug (single path segment) | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
### [POST] /apps/{app_id}/annotation-reply/{action}
Enable or disable annotation reply for an app
@@ -13990,135 +13632,6 @@ Stable Agent Soul reference to one normalized skill archive.
| debug_conversation_id | string | | Yes |
| debug_conversation_message_count | integer | | No |
#### AgentDriveDeleteFileByAgentQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| key | string | Drive key, e.g. files/sample.pdf | Yes |
#### AgentDriveDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| removed_keys | [ string ] | | No |
| result | string | | Yes |
#### AgentDriveDownloadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
#### AgentDriveFileCommitResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
#### AgentDriveFilePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes |
#### AgentDriveFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| drive_key | string | | Yes |
| file_id | string | | Yes |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentDriveItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | No |
| file_kind | string | | Yes |
| hash | string | | No |
| is_skill | boolean | | No |
| key | string | | Yes |
| mime_type | string | | No |
| size | integer | | No |
| skill_metadata | string | | No |
#### AgentDriveListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No |
#### AgentDrivePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| key | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentDriveSkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| available_in_drive | boolean | | Yes |
| drive_key | string | | No |
| name | string | | Yes |
| path | string | | Yes |
| type | string | | Yes |
#### AgentDriveSkillInspectResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_key | string | | No |
| created_at | integer | | No |
| description | string | | Yes |
| file_tree | [ object ] | | No |
| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No |
| hash | string | | No |
| mime_type | string | | No |
| name | string | | Yes |
| path | string | | Yes |
| size | integer | | No |
| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes |
| skill_md_key | string | | Yes |
| source | string | | Yes |
| warnings | [ string ] | | No |
#### AgentDriveSkillItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_key | string | | No |
| created_at | integer | | No |
| description | string | | Yes |
| hash | string | | No |
| mime_type | string | | No |
| name | string | | Yes |
| path | string | | Yes |
| size | integer | | No |
| skill_md_key | string | | Yes |
#### AgentDriveSkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No |
#### AgentDriveSkillMarkdownResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| key | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentEnvVariableConfig
| Name | Type | Description | Required |
@@ -14142,7 +13655,6 @@ Stable Agent Soul reference to one normalized skill archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| drive_key | string | | No |
| file_id | string | | No |
| id | string | | No |
| name | string | | No |
@@ -14499,13 +14011,6 @@ section may be empty, which is how callers express "no knowledge layer".
| status | string | | Yes |
| total_tokens | integer | | Yes |
#### AgentLogQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conversation_id | string | Conversation UUID | Yes |
| message_id | string | Message UUID | Yes |
#### AgentLogResponse
| Name | Type | Description | Required |
@@ -14763,28 +14268,6 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### 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 | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes |
#### AgentSoulAppFeaturesConfig
| Name | Type | Description | Required |
@@ -14808,7 +14291,6 @@ Visibility and lifecycle scope of an Agent record.
| config_note | string | | No |
| config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No |
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
@@ -14866,13 +14348,6 @@ old Agent tool payloads can be read while new payloads stay explicit.
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
#### AgentSoulFilesConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
#### AgentSoulHumanConfig
| Name | Type | Description | Required |
@@ -15119,16 +14594,6 @@ Legacy Chat App model config used only for follow-up question generation.
| 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 |
@@ -16158,17 +15623,6 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| content | string | Child chunk text content. | Yes |
#### CliToolSuggestion
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| command | string | | No |
| description | string | | No |
| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No |
| inferred_from | string | | No |
| install_commands | [ string ] | | No |
| name | string | | Yes |
#### CloudPlan
Enum representing user plan types in the cloud platform.
@@ -17961,14 +17415,6 @@ declaration of an endpoint group
| name | string | | Yes |
| settings | object | | Yes |
#### EnvSuggestion
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| key | string | | Yes |
| reason | string | | No |
| secret_likely | boolean | | No |
#### EnvironmentVariableItemPayload
| Name | Type | Description | Required |
@@ -21871,27 +21317,6 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### SkillManifest
Validated metadata extracted from a Skill package.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| entry_path | string | | Yes |
| files | [ string ] | | Yes |
| hash | string | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
#### SkillToolInferenceResult
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No |
| inferable | boolean | | Yes |
| reason | string | | No |
#### SnippetDependencyCheckResponse
| Name | Type | Description | Required |
+3 -202
View File
@@ -5,7 +5,6 @@ from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from libs.helper import to_timestamp
@@ -20,7 +19,6 @@ from models.agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentDriveFile,
AgentIconType,
AgentKind,
AgentScope,
@@ -279,12 +277,7 @@ class AgentComposerService:
state = cls._serialize_workflow_state(
session=session, binding=binding, agent=agent, version=version, account_id=account_id
)
state["validation"] = cls.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=payload,
agent_id=binding.agent_id,
)
state["validation"] = cls.collect_validation_findings(payload=payload)
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
@@ -365,16 +358,6 @@ class AgentComposerService:
icon=source_agent.icon,
icon_background=source_agent.icon_background,
)
cls._copy_agent_drive_rows(
session=session,
tenant_id=tenant_id,
source_agent_id=source_agent.id,
target_agent_id=inline_agent.id,
account_id=account_id,
agent_soul=agent_soul,
node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
)
binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
binding.agent_id = inline_agent.id
binding.current_snapshot_id = inline_agent.active_config_snapshot_id
@@ -581,12 +564,7 @@ class AgentComposerService:
session.flush()
state = cls.load_agent_composer(session=session, tenant_id=tenant_id, agent_id=agent.id)
state["validation"] = cls.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=payload,
agent_id=agent.id,
)
state["validation"] = cls.collect_validation_findings(payload=payload)
return state
@classmethod
@@ -1051,12 +1029,9 @@ class AgentComposerService:
def collect_validation_findings(
cls,
*,
session: Session,
tenant_id: str,
payload: ComposerSavePayload,
agent_id: str | None = None,
) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
"""Collect non-blocking composer validation findings."""
existing_knowledge_set_ids = (
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
if payload.agent_soul is not None
@@ -1066,15 +1041,6 @@ class AgentComposerService:
payload,
existing_knowledge_set_ids=existing_knowledge_set_ids,
)
if agent_id and payload.agent_soul is not None:
findings["warnings"].extend(
cls._drive_mention_findings(
session=session,
tenant_id=tenant_id,
agent_id=agent_id,
prompt=payload.agent_soul.prompt.system_prompt,
)
)
return findings
@classmethod
@@ -1099,21 +1065,6 @@ class AgentComposerService:
+ ", ".join(missing_ids)
)
@classmethod
def resolve_bound_agent_id(cls, *, session: Session, tenant_id: str, app_id: str) -> str | None:
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
return session.scalar(
select(Agent.id)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
@classmethod
def resolve_workflow_node_agent_id(
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str
@@ -1128,54 +1079,6 @@ class AgentComposerService:
)
return binding.agent_id if binding else None
@classmethod
def _drive_mention_findings(
cls,
*,
session: Session,
tenant_id: str,
agent_id: str,
prompt: str,
) -> list[dict[str, str | None]]:
"""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
wanted_keys: dict[str, tuple[str, str]] = {}
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 []
existing_keys = set(
session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key.in_(sorted(wanted_keys)),
)
)
)
findings: list[dict[str, str | None]] = []
for key, (kind, display) in wanted_keys.items():
if key in existing_keys:
continue
findings.append(
{
"code": "mention_target_missing",
"surface": "agent_soul",
"kind": kind,
"id": key,
"message": f"{kind} '{display}' has no drive entry for key '{key}'.",
}
)
return findings
@classmethod
def get_workflow_candidates(
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str, user_id: str
@@ -1721,15 +1624,6 @@ class AgentComposerService:
operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER,
version_note=payload.version_note,
)
cls._copy_agent_drive_rows(
session=session,
tenant_id=tenant_id,
source_agent_id=source_agent.id,
target_agent_id=roster_agent.id,
account_id=account_id,
agent_soul=agent_soul,
node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
)
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.agent_id = roster_agent.id
binding.current_snapshot_id = roster_agent.active_config_snapshot_id
@@ -1801,99 +1695,6 @@ class AgentComposerService:
agent.active_config_is_published = True
return agent
@classmethod
def _copy_agent_drive_rows(
cls,
*,
session: Session,
tenant_id: str,
source_agent_id: str,
target_agent_id: str,
account_id: str,
agent_soul: AgentSoulConfig,
node_job: WorkflowNodeJobConfig | None = None,
) -> None:
exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job)
predicates: list[ColumnElement[bool]] = []
if exact_keys:
predicates.append(AgentDriveFile.key.in_(sorted(exact_keys)))
predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes))
if not predicates:
return
source_rows = list(
session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == source_agent_id,
or_(*predicates),
)
).all()
)
if not source_rows:
return
existing_target_keys = set(
session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == target_agent_id,
AgentDriveFile.key.in_([row.key for row in source_rows]),
)
).all()
)
for row in source_rows:
if row.key in existing_target_keys:
continue
session.add(
AgentDriveFile(
tenant_id=tenant_id,
agent_id=target_agent_id,
key=row.key,
file_kind=row.file_kind,
file_id=row.file_id,
value_owned_by_drive=row.value_owned_by_drive,
is_skill=row.is_skill,
skill_metadata=row.skill_metadata,
size=row.size,
hash=row.hash,
mime_type=row.mime_type,
created_by=account_id,
)
)
@staticmethod
def _drive_copy_scopes_from_agent_configs(
*, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
) -> tuple[set[str], set[str]]:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
exact_keys: set[str] = set()
prefixes: set[str] = set()
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
drive_key = decode_drive_mention_ref(mention.ref_id)
if not drive_key:
continue
if mention.kind == MentionKind.SKILL and "/" in drive_key:
prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
else:
exact_keys.add(drive_key)
if node_job is not None:
for file_ref in node_job.metadata.file_refs or []:
if file_ref.drive_key:
exact_keys.add(file_ref.drive_key)
for output in node_job.declared_outputs:
benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None
if benchmark_ref and benchmark_ref.drive_key:
exact_keys.add(benchmark_ref.drive_key)
return exact_keys, prefixes
@classmethod
def _create_roster_agent_for_composer(
cls,
@@ -1,9 +1,8 @@
"""Normalize uploaded config skills into one canonical ToolFile reference.
Config skills are Agent Soul-backed assets, not drive rows. This service keeps
the existing skill package validation rules, enforces the requested stable name,
stores the normalized archive as one ToolFile, and returns the persisted Soul
reference metadata used by ``AgentConfigService``.
This service keeps the existing skill package validation rules, enforces the
requested stable name, stores the normalized archive as one ToolFile, and
returns the persisted Soul reference metadata used by ``AgentConfigService``.
"""
from __future__ import annotations
+1 -14
View File
@@ -3,8 +3,7 @@
Agent runtime configuration is split across immutable Soul snapshots and
workflow-node bindings, while App and Snippet DSLs must be independent of the
source workspace's database identifiers. This module owns that translation.
It deliberately excludes drive payloads and stored credentials from portable
packages; same-workspace copies may use the separate server-side clone path.
It deliberately excludes stored credentials from portable packages.
"""
from __future__ import annotations
@@ -327,7 +326,6 @@ class AgentDslService:
node_id: str,
source_agent: Agent,
source_snapshot: AgentConfigSnapshot,
node_job: WorkflowNodeJobConfig,
account_id: str,
) -> tuple[Agent, AgentConfigSnapshot]:
"""Clone a same-workspace Inline Agent for a pasted target node."""
@@ -350,17 +348,6 @@ class AgentDslService:
source=AgentSource.WORKFLOW,
operation=AgentConfigRevisionOperation.CREATE_VERSION,
)
from services.agent.composer_service import AgentComposerService
AgentComposerService._copy_agent_drive_rows(
tenant_id=workflow.tenant_id,
source_agent_id=source_agent.id,
target_agent_id=agent.id,
account_id=account_id,
agent_soul=soul,
node_job=node_job,
session=self.session,
)
return agent, snapshot
def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]:
+2 -4
View File
@@ -66,9 +66,7 @@ _RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
MAX_MENTIONS_PER_PROMPT = 200
# 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.
# Mention ids are bounded independently of their owning configuration schema.
MAX_MENTION_REF_ID_LENGTH = 6144
MAX_MENTION_LABEL_LENGTH = 255
@@ -241,7 +239,7 @@ def scrub_mention_markers(text: str) -> str:
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
"""Resolve non-drive soul-surface mentions to canonical display names."""
"""Resolve Soul-surface mentions to canonical display names."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
+5 -6
View File
@@ -1,4 +1,4 @@
"""Validate and normalize uploaded Skill packages for drive standardization.
"""Validate and normalize uploaded Skill packages.
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
@@ -10,8 +10,7 @@ archive-root ``SKILL.md`` bytes.
It does NOT execute or load the skill the agent backend owns execution. It also
does not persist anything into Agent Soul or bind anything to config versions;
``SkillStandardizeService`` consumes the normalized package and commits the
canonical drive rows instead.
``ConfigSkillNormalizeService`` consumes the normalized package for Agent config.
"""
from __future__ import annotations
@@ -63,7 +62,7 @@ class SkillManifest(BaseModel):
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
"""Canonical skill package bytes and metadata ready to store as Agent config."""
manifest: SkillManifest
archive_bytes: bytes
@@ -72,10 +71,10 @@ class NormalizedSkillPackage(BaseModel):
class SkillPackageService:
"""Validate Skill archives and produce the normalized package stored in drive."""
"""Validate Skill archives and produce a normalized package."""
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
"""Return the canonical drive package for an uploaded skill archive.
"""Return the canonical package for an uploaded skill archive.
The shallowest ``SKILL.md`` defines the skill root. When exactly one
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
@@ -1,135 +0,0 @@
"""Standardize an uploaded Skill into the agent drive (ENG-594).
A validated Skill package is normalized into two **drive-owned** objects committed
to the agent drive (Agent Files §5.4 / §4):
* ``<slug>/SKILL.md`` the canonical entry, the source of truth for loading.
* ``<slug>/.DIFY-SKILL-FULL.zip`` the full archive, kept only to restore the
complete skill contents.
The archive's member list is stored in skill metadata and resolved lazily for
inspect/preview/runtime. Upload must not eagerly materialize every archive member
as a separate ToolFile; small archives with many files would otherwise perform
hundreds of storage writes and DB commits inside the request.
"""
from __future__ import annotations
import re
from typing import Any
from sqlalchemy.orm import Session
from core.tools.tool_file_manager import ToolFileManager
from services.agent.skill_package_service import SkillPackageService
from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata
_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
_SKILL_MD_NAME = "SKILL.md"
_SLUG_RE = re.compile(r"[^a-z0-9._-]+")
def slugify_skill_name(name: str) -> str:
slug = _SLUG_RE.sub("-", (name or "").strip().lower()).strip("-._")
return slug or "skill"
class SkillStandardizeService:
"""Persist a normalized skill package into drive-owned files for one agent.
Instances are intentionally stateful: ``standardize()`` updates
``last_committed_items`` with the drive commit result for the most recent call.
"""
def __init__(
self,
*,
package_service: SkillPackageService | None = None,
drive_service: AgentDriveService | None = None,
tool_file_manager: ToolFileManager | None = None,
) -> None:
self._package = package_service or SkillPackageService()
self._drive = drive_service or AgentDriveService()
self._tool_files = tool_file_manager or ToolFileManager()
self.last_committed_items: list[dict[str, Any]] = []
def standardize(
self,
*,
content: bytes,
filename: str,
tenant_id: str,
user_id: str,
agent_id: str,
session: Session,
) -> dict[str, Any]:
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
stores the drive commit rows in ``last_committed_items``, and returns the
console response shape ``{"skill": ..., "manifest": ...}``.
"""
package = self._package.validate_and_normalize(content=content, filename=filename)
manifest = package.manifest
slug = slugify_skill_name(manifest.name)
# Drive-owned files: canonical SKILL.md and the full archive. The
# archive member tree is preserved in metadata and resolved lazily.
md_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=package.skill_md_bytes,
mimetype="text/markdown",
filename=_SKILL_MD_NAME,
)
archive_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=package.archive_bytes,
mimetype="application/zip",
filename=_FULL_ARCHIVE_NAME,
)
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
committed_items = self._drive.commit(
tenant_id=tenant_id,
user_id=user_id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=skill_md_key,
file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(
name=manifest.name,
description=manifest.description,
manifest_files=manifest.files,
),
),
DriveCommitItem(
key=archive_key,
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
value_owned_by_drive=True,
),
],
session=session,
)
self.last_committed_items = committed_items
return {
"skill": {
"name": manifest.name,
"description": manifest.description,
"path": slug,
"skill_md_key": skill_md_key,
"archive_key": archive_key,
},
"manifest": manifest.model_dump(),
}
__all__ = ["SkillStandardizeService", "slugify_skill_name"]
@@ -1,179 +0,0 @@
"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371).
Reads the skill's SKILL.md from the agent drive, asks the tenant's default
reasoning model once (a plain LLM call, never an agent run), and returns
*draft* suggestions only nothing is persisted here. The frontend prefills
the TOOLS box (``inferred from <skill>`` badge) and the Pre-Authorize ENV
panel, and saving still goes through the composer's full shell/env/secret/
dangerous-command validation, so inference opens no bypass.
ENV suggestions carry only ``key`` + ``reason`` the model never produces a
value; users fill those in themselves and the runtime injects ``$VAR`` only.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import json_repair
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session
from core.errors.error import ProviderTokenNotInitError
from core.model_manager import ModelManager
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
from graphon.model_runtime.entities.model_entities import ModelType
from services.agent_drive_service import AgentDriveError, AgentDriveService
logger = logging.getLogger(__name__)
class SkillToolInferenceError(Exception):
"""Stable-code error for the infer-tools endpoint."""
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
self.code = code
self.message = message
self.status_code = status_code
super().__init__(message)
class EnvSuggestion(BaseModel):
key: str
reason: str = ""
secret_likely: bool = False
class CliToolSuggestion(BaseModel):
name: str
description: str = ""
command: str = ""
install_commands: list[str] = Field(default_factory=list)
env_suggestions: list[EnvSuggestion] = Field(default_factory=list)
inferred_from: str = ""
class SkillToolInferenceResult(BaseModel):
inferable: bool
cli_tools: list[CliToolSuggestion] = Field(default_factory=list)
reason: str | None = None
_SYSTEM_PROMPT = """\
You analyze an agent skill document (SKILL.md) and infer which command-line \
tools the skill depends on at runtime, so a user can pre-install them in the \
agent's sandbox.
Rules:
- Only suggest tools the document explicitly uses or clearly requires; never guess.
- For each tool give: name, a one-line reason-style description referencing the \
document, the base command, and install commands for a Debian-based sandbox \
(apt-get / pip / npm).
- If a step needs an environment variable (an API key, token, endpoint), add it \
to env_suggestions with the variable key and the reason. NEVER produce a value. \
Mark secret_likely=true for credentials.
- If the document describes no external command-line dependency, return \
{"inferable": false, "cli_tools": [], "reason": "<one short sentence why>"}.
Respond with JSON only, matching exactly:
{"inferable": bool,
"cli_tools": [{"name": str, "description": str, "command": str,
"install_commands": [str], "env_suggestions":
[{"key": str, "reason": str, "secret_likely": bool}]}],
"reason": str | null}
"""
class SkillToolInferenceService:
"""Single-shot LLM inference over a drive-stored SKILL.md."""
def __init__(self, *, drive_service: AgentDriveService | None = None) -> None:
self._drive = drive_service or AgentDriveService()
def infer(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> dict[str, Any]:
skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug, session=session)
user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
try:
result = self._parse(raw)
except (ValidationError, ValueError):
logger.warning("skill tool inference output unparsable, retrying once")
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
try:
result = self._parse(raw)
except (ValidationError, ValueError) as exc:
raise SkillToolInferenceError(
"inference_failed",
"inference_failed: the model output could not be parsed into tool suggestions.",
status_code=422,
) from exc
for tool in result.cli_tools:
tool.inferred_from = slug
return result.model_dump(mode="json")
def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> str:
try:
preview = self._drive.preview(
tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md", session=session
)
except AgentDriveError as exc:
if exc.code == "drive_key_not_found":
raise SkillToolInferenceError(
"skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404
) from exc
raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc
if preview["binary"] or not preview["text"]:
raise SkillToolInferenceError(
"skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404
)
return str(preview["text"])
@staticmethod
def _invoke(*, tenant_id: str, user_prompt: str) -> str:
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
except ProviderTokenNotInitError as exc:
raise SkillToolInferenceError(
"default_model_not_configured",
"default_model_not_configured: the workspace has no default reasoning model.",
status_code=400,
) from exc
try:
response = model_instance.invoke_llm(
prompt_messages=[
SystemPromptMessage(content=_SYSTEM_PROMPT),
UserPromptMessage(content=user_prompt),
],
model_parameters={"temperature": 0.1},
stream=False,
)
except Exception as exc:
raise SkillToolInferenceError(
"inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422
) from exc
return response.message.get_text_content()
@staticmethod
def _parse(raw: str) -> SkillToolInferenceResult:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = json_repair.loads(raw)
if not isinstance(parsed, dict):
raise ValueError("model output is not a JSON object")
return SkillToolInferenceResult.model_validate(parsed)
__all__ = [
"CliToolSuggestion",
"EnvSuggestion",
"SkillToolInferenceError",
"SkillToolInferenceResult",
"SkillToolInferenceService",
]
+2 -11
View File
@@ -186,22 +186,17 @@ class WorkflowAgentPublishService:
node_job=node_job,
)
ComposerConfigValidator.validate_publish_payload(payload)
# ENG-623 §4.4: drive-backed refs must point at real drive rows before
# publishing. This stays out of composer save so autosave/save-draft can
# persist incomplete refs and surface them as non-blocking findings.
cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul)
cls._require_config_asset_refs_resolved_for_publish(binding=binding, agent_soul=agent_soul)
@classmethod
def _require_drive_refs_resolved_for_publish(
def _require_config_asset_refs_resolved_for_publish(
cls,
*,
session: Session,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
del session
configured_skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
configured_file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
missing_refs: list[str] = []
@@ -359,7 +354,6 @@ class WorkflowAgentPublishService:
node_id=node_id,
source_agent_id=agent_id,
source_snapshot_id=current_snapshot_id,
node_job=node_job_config,
account_id=account_id,
)
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
@@ -422,7 +416,6 @@ class WorkflowAgentPublishService:
node_id: str,
source_agent_id: str,
source_snapshot_id: str,
node_job: WorkflowNodeJobConfig,
account_id: str,
) -> tuple[Agent, str]:
source_agent = session.scalar(
@@ -456,7 +449,6 @@ class WorkflowAgentPublishService:
node_id=node_id,
source_agent=source_agent,
source_snapshot=source_snapshot,
node_job=node_job,
account_id=account_id,
)
return agent, snapshot.id
@@ -709,7 +701,6 @@ class WorkflowAgentPublishService:
node_id=source.node_id,
source_agent_id=agent_id,
source_snapshot_id=snapshot_id,
node_job=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict),
account_id=account_id,
)
agent_id = agent.id
+10 -4
View File
@@ -50,7 +50,6 @@ from models.model import UploadFile
from models.tools import ToolFile
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import DriveFileRef
class AgentConfigVersionKind(StrEnum):
@@ -64,6 +63,13 @@ class AgentConfigMutationSurface(StrEnum):
CONSOLE = "console"
class ConfigFileRef(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["upload_file", "tool_file"]
id: str
class AgentConfigServiceError(Exception):
"""Config operation failure mapped to HTTP status at controller boundaries."""
@@ -82,14 +88,14 @@ class ConfigPushFileItem(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
file_ref: DriveFileRef | None = None
file_ref: ConfigFileRef | None = None
class ConfigPushSkillItem(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
file_ref: DriveFileRef | None = None
file_ref: ConfigFileRef | None = None
class ConfigPushPayload(BaseModel):
@@ -991,7 +997,7 @@ class AgentConfigService:
session: Session,
*,
tenant_id: str,
file_ref: DriveFileRef,
file_ref: ConfigFileRef,
) -> tuple[int | None, str | None, str | None]:
if file_ref.kind == "tool_file":
tool_file = self._require_tool_file_source(
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -24,7 +24,6 @@ from models import (
PinnedConversation,
SavedMessage,
)
from models.agent import AgentDriveFile, AgentDriveFileKind
from models.human_input import HumanInputDelivery, HumanInputFormRecipient
from models.tools import ToolConversationVariables, ToolFile
@@ -49,9 +48,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
"""Physically remove a soft-deleted conversation and its owned resources.
The storage object is deleted before its ``ToolFile`` row so a failed attempt
retains the durable ``file_key`` needed by the next retry. ToolFiles promoted
to Agent Drive are detached from the conversation, and their Drive references
take over lifecycle ownership.
retains the durable ``file_key`` needed by the next retry.
"""
with session_factory.create_session() as session:
@@ -68,25 +65,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
.with_for_update()
)
)
tool_file_ids = [tool_file.id for tool_file in tool_files]
drive_files = list(
session.scalars(
select(AgentDriveFile)
.where(
AgentDriveFile.file_kind == AgentDriveFileKind.TOOL_FILE,
AgentDriveFile.file_id.in_(tool_file_ids),
)
.order_by(AgentDriveFile.id)
.with_for_update()
)
)
drive_tool_file_ids = {drive_file.file_id for drive_file in drive_files}
for drive_file in drive_files:
drive_file.value_owned_by_drive = True
for tool_file in tool_files:
if tool_file.id in drive_tool_file_ids:
tool_file.conversation_id = None
continue
_delete_storage_object(tool_file.file_key)
session.delete(tool_file)
@@ -1,178 +0,0 @@
from threading import Event, Thread
from unittest.mock import patch
from sqlalchemy import event, select
from sqlalchemy.orm import Session
from models import AppMode, Conversation, ToolFile
from models.agent import AgentDriveFile, AgentDriveFileKind
from models.enums import ConversationFromSource, ConversationStatus
from tasks.delete_conversation_task import _cleanup_conversation_related_data
TENANT_ID = "11111111-1111-1111-1111-111111111111"
APP_ID = "22222222-2222-2222-2222-222222222222"
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
CONVERSATION_ID = "44444444-4444-4444-4444-444444444444"
AGENT_ID = "55555555-5555-5555-5555-555555555555"
def test_cleanup_deletes_owned_storage_and_preserves_drive_file(
db_session_with_containers: Session,
) -> None:
conversation = Conversation(
id=CONVERSATION_ID,
app_id=APP_ID,
mode=AppMode.CHAT,
name="Deleted conversation",
inputs={},
status=ConversationStatus.NORMAL,
from_source=ConversationFromSource.CONSOLE,
from_account_id=ACCOUNT_ID,
is_deleted=True,
)
owned_file = ToolFile(
user_id=ACCOUNT_ID,
tenant_id=TENANT_ID,
conversation_id=CONVERSATION_ID,
file_key=f"tools/{TENANT_ID}/owned.txt",
mimetype="text/plain",
name="owned.txt",
size=5,
)
drive_file = ToolFile(
user_id=ACCOUNT_ID,
tenant_id=TENANT_ID,
conversation_id=CONVERSATION_ID,
file_key=f"tools/{TENANT_ID}/drive.txt",
mimetype="text/plain",
name="drive.txt",
size=5,
)
db_session_with_containers.add_all([conversation, owned_file, drive_file])
db_session_with_containers.flush()
drive_entry = AgentDriveFile(
tenant_id=TENANT_ID,
agent_id=AGENT_ID,
key="drive.txt",
file_kind=AgentDriveFileKind.TOOL_FILE,
file_id=drive_file.id,
value_owned_by_drive=False,
is_skill=False,
)
db_session_with_containers.add(drive_entry)
db_session_with_containers.commit()
owned_file_id = owned_file.id
drive_file_id = drive_file.id
with patch("tasks.delete_conversation_task.storage") as storage_mock:
assert _cleanup_conversation_related_data(CONVERSATION_ID) is True
storage_mock.delete.assert_called_once_with(f"tools/{TENANT_ID}/owned.txt")
db_session_with_containers.expire_all()
assert db_session_with_containers.get(Conversation, CONVERSATION_ID) is None
assert db_session_with_containers.get(ToolFile, owned_file_id) is None
preserved = db_session_with_containers.get(ToolFile, drive_file_id)
assert preserved is not None
assert preserved.conversation_id is None
preserved_drive_entry = db_session_with_containers.scalar(
select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
)
assert preserved_drive_entry is not None
assert preserved_drive_entry.value_owned_by_drive is True
def test_cleanup_preserves_drive_file_committed_while_waiting_for_tool_file_lock(
db_session_with_containers: Session,
) -> None:
conversation = Conversation(
id=CONVERSATION_ID,
app_id=APP_ID,
mode=AppMode.CHAT,
name="Deleted conversation",
inputs={},
status=ConversationStatus.NORMAL,
from_source=ConversationFromSource.CONSOLE,
from_account_id=ACCOUNT_ID,
is_deleted=True,
)
drive_file = ToolFile(
user_id=ACCOUNT_ID,
tenant_id=TENANT_ID,
conversation_id=CONVERSATION_ID,
file_key=f"tools/{TENANT_ID}/concurrent-drive.txt",
mimetype="text/plain",
name="concurrent-drive.txt",
size=5,
)
db_session_with_containers.add_all([conversation, drive_file])
db_session_with_containers.commit()
drive_file_id = drive_file.id
engine = db_session_with_containers.get_bind()
drive_session = Session(engine)
locked_file = drive_session.scalar(select(ToolFile).where(ToolFile.id == drive_file_id).with_for_update())
assert locked_file is not None
drive_session.add(
AgentDriveFile(
tenant_id=TENANT_ID,
agent_id=AGENT_ID,
key="concurrent-drive.txt",
file_kind=AgentDriveFileKind.TOOL_FILE,
file_id=drive_file_id,
value_owned_by_drive=False,
is_skill=False,
)
)
drive_session.flush()
cleanup_result: list[bool] = []
cleanup_errors: list[BaseException] = []
def run_cleanup() -> None:
try:
cleanup_result.append(_cleanup_conversation_related_data(CONVERSATION_ID))
except BaseException as error:
cleanup_errors.append(error)
tool_file_lock_started = Event()
def signal_tool_file_lock(
_connection,
_cursor,
statement: str,
_parameters,
_context,
_executemany,
) -> None:
normalized_statement = statement.lower()
if "from tool_files" in normalized_statement and "for update" in normalized_statement:
tool_file_lock_started.set()
event.listen(engine, "before_cursor_execute", signal_tool_file_lock)
cleanup_thread = Thread(target=run_cleanup)
try:
with patch("tasks.delete_conversation_task.storage") as storage_mock:
cleanup_thread.start()
assert tool_file_lock_started.wait(timeout=5)
drive_session.commit()
cleanup_thread.join(timeout=5)
finally:
event.remove(engine, "before_cursor_execute", signal_tool_file_lock)
drive_session.rollback()
drive_session.close()
cleanup_thread.join(timeout=5)
assert not cleanup_thread.is_alive()
assert cleanup_errors == []
assert cleanup_result == [True]
storage_mock.delete.assert_not_called()
db_session_with_containers.expire_all()
preserved = db_session_with_containers.get(ToolFile, drive_file_id)
assert preserved is not None
assert preserved.conversation_id is None
preserved_drive_entry = db_session_with_containers.scalar(
select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
)
assert preserved_drive_entry is not None
assert preserved_drive_entry.value_owned_by_drive is True
-2
View File
@@ -15,9 +15,7 @@ extend-select = ["ANN401", "ARG"]
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
@@ -16,7 +16,6 @@ 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
@@ -44,7 +43,7 @@ from clients.agent_backend import (
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
def _run_input() -> AgentBackendWorkflowNodeRunInput:
@@ -363,25 +362,6 @@ 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_drive_to_shell_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,
"runtime": "runtime",
}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_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}
@@ -417,24 +397,6 @@ 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_drive_to_shell_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,
"runtime": "runtime",
}
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
run_input = _agent_app_input()
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
@@ -15,7 +15,6 @@ from controllers.console.agent import roster as roster_controller
from controllers.console.agent.composer import (
AgentComposerApi,
AgentComposerCandidatesApi,
AgentComposerValidateApi,
WorkflowAgentComposerApi,
WorkflowAgentComposerCandidatesApi,
WorkflowAgentComposerCopyFromRosterApi,
@@ -260,10 +259,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/build-draft",
"/agent/<uuid:agent_id>/build-draft/apply",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/sandbox/files",
"/agent/<uuid:agent_id>/skills/upload",
"/agent/<uuid:agent_id>/files",
"/agent/<uuid:agent_id>/api-access",
"/agent/<uuid:agent_id>/api-enable",
"/agent/<uuid:agent_id>/api-keys",
@@ -1328,10 +1324,6 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
)
monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
@@ -1514,10 +1506,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
captured["save"] = kwargs
return _agent_app_composer_response()
def collect_validation_findings(**kwargs: object) -> dict:
captured["validate"] = kwargs
return {"warnings": [], "knowledge_retrieval_placeholder": []}
def get_agent_app_candidates(**kwargs: object) -> dict:
captured["candidates"] = kwargs
return _candidates_response("agent_app")
@@ -1525,9 +1513,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", load_agent_composer)
monkeypatch.setattr(composer_controller.AgentComposerService, "save_agent_composer", save_agent_composer)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings
)
monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates)
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
assert composer["variant"] == "agent_app"
@@ -1545,15 +1530,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
assert saved_composer["variant"] == "agent_app"
assert saved_composer["active_config_is_published"] is True
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
assert unwrap(AgentComposerValidateApi.post)(
AgentComposerValidateApi(), composer_save_payload, MagicMock(), "tenant-1", agent_id
) == {
"result": "success",
"errors": [],
"warnings": [],
"knowledge_retrieval_placeholder": [],
}
assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id
candidates = unwrap(AgentComposerCandidatesApi.get)(
AgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, agent_id
)
@@ -1,310 +0,0 @@
"""Unit tests for the console agent drive inspector (ENG-624).
Handlers are unwrapped past the login/app-model decorators and invoked inside a
bare Flask request context with the drive service mocked covering agent
resolution, query handling, and error mapping, not auth.
"""
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import agent_drive_inspector as inspector
from controllers.console.app.agent_drive_inspector import (
AgentDriveDownloadApi,
AgentDriveDownloadByAgentApi,
AgentDriveListApi,
AgentDriveListByAgentApi,
AgentDrivePreviewApi,
AgentDrivePreviewByAgentApi,
AgentDriveSkillInspectApi,
AgentDriveSkillInspectByAgentApi,
AgentDriveSkillListApi,
AgentDriveSkillListByAgentApi,
)
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.console.app.agent_drive_inspector"
app = Flask(__name__)
def _raw(method):
return unwrap(method)
_APP = SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
bound_agent_id_with_session=lambda *, session: "agent-1",
)
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
resolver = MagicMock(return_value="agent-1")
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
result = inspector._resolve_agent_id(unbound_session, app_model, None)
assert result == "agent-1"
resolver.assert_called_once_with(session=unbound_session)
assert resolver.call_args.kwargs["session"] is unbound_session
def test_list_filters_value_pointers_out_of_console_payload(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.manifest.return_value = [
{
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"hash": "h",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tf-1",
"created_at": 1718000000,
}
]
body = raw(AgentDriveListApi(), unbound_session, _APP)
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
def test_list_by_agent_filters_value_pointers_out_of_console_payload(unbound_session: Session):
raw = _raw(AgentDriveListByAgentApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.manifest.return_value = [
{
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"hash": "h",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tf-1",
"created_at": 1718000000,
}
]
body = raw(AgentDriveListByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
assert drive.return_value.manifest.call_args.kwargs["session"] is unbound_session
def test_list_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.manifest.return_value = []
raw(AgentDriveListApi(), unbound_session, _APP)
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9"
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_skill_list_by_agent_calls_service(unbound_session: Session):
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.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": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1718000000,
}
]
body = raw(AgentDriveSkillListByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["items"][0]["path"] == "pdf-toolkit"
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
assert drive.return_value.list_skills.call_args.kwargs["session"] is unbound_session
def test_skill_list_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveSkillListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.list_skills.return_value = []
body = raw(AgentDriveSkillListApi(), unbound_session, _APP)
assert body == {"items": []}
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_skill_inspect_by_agent_returns_strict_json_response(unbound_session: Session):
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
payload = {
"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": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1718000000,
"source": "skill_md",
"files": [
{
"path": "SKILL.md",
"name": "SKILL.md",
"type": "file",
"drive_key": "pdf-toolkit/SKILL.md",
"available_in_drive": True,
}
],
"file_tree": [],
"skill_md": {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# PDF Toolkit\nUse it.\n",
},
"warnings": [],
}
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectByAgentApi(), unbound_session, "tenant-1", "agent-1", "pdf-toolkit")
assert response.status_code == 200
assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
assert drive.return_value.inspect_skill.call_args.kwargs["session"] is unbound_session
def test_skill_inspect_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveSkillInspectApi.get)
payload = {
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
"archive_key": None,
"name": "PDF Toolkit",
"description": "",
"size": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": None,
"source": "skill_md",
"files": [],
"file_tree": [],
"skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"},
"warnings": [],
}
with app.test_request_context("/?node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectApi(), unbound_session, _APP, "pdf-toolkit")
assert response.get_json()["path"] == "pdf-toolkit"
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_list_400_when_no_agent_bound(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
resolver = MagicMock(return_value=None)
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
with app.test_request_context("/"):
body, status = raw(AgentDriveListApi(), unbound_session, app_without_agent)
assert status == 400
assert body["code"] == "agent_not_bound"
resolver.assert_called_once_with(session=unbound_session)
def test_preview_passes_through_and_maps_errors(unbound_session: Session):
raw = _raw(AgentDrivePreviewApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.preview.return_value = {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewApi(), unbound_session, _APP)
assert body["text"] == "# hi"
with app.test_request_context("/?key=ghost/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewApi(), unbound_session, _APP)
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_preview_by_agent_passes_through_and_maps_errors(unbound_session: Session):
raw = _raw(AgentDrivePreviewByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.return_value = {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["text"] == "# hi"
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.preview.call_args.kwargs["session"] is unbound_session
with app.test_request_context("/?key=ghost/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_download_returns_signed_url_json(unbound_session: Session):
raw = _raw(AgentDriveDownloadApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadApi(), unbound_session, _APP)
assert body == {"url": "https://signed.example/zip"}
def test_download_by_agent_returns_signed_url_json(unbound_session: Session):
raw = _raw(AgentDriveDownloadByAgentApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body == {"url": "https://signed.example/zip"}
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.download_url.call_args.kwargs["session"] is unbound_session
@@ -1,423 +0,0 @@
"""Unit tests for the console agent Skill endpoints (ENG-370 / ENG-594).
Handlers are unwrapped past the login/app-model decorators and invoked inside a
bare Flask request context with the services mocked covering request handling
+ error mapping, not auth.
"""
from __future__ import annotations
import io
from datetime import UTC, datetime
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import agent as agent_controller
from controllers.console.app.agent import (
AgentDriveFilesByAgentApi,
AgentSkillByAgentApi,
AgentSkillInferToolsByAgentApi,
AgentSkillUploadApi,
AgentSkillUploadByAgentApi,
)
from extensions.storage.storage_type import StorageType
from models.enums import CreatorUserRole
from models.model import AppMode, UploadFile
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.console.app.agent"
app = Flask(__name__)
_TENANT_ID = "00000000-0000-0000-0000-000000000010"
_UPLOAD_FILE_ID = "0fa6f9bc-3416-4476-8857-a13129704dd9"
def _raw(method):
return unwrap(method)
def _file_ctx(*, files: dict[str, bytes] | None = None):
data = {name: (io.BytesIO(content), name) for name, content in (files or {}).items()}
return app.test_request_context("/", method="POST", data=data, content_type="multipart/form-data")
_USER = SimpleNamespace(id="user-1")
_APP = SimpleNamespace(
id="app-1",
tenant_id=_TENANT_ID,
mode=AppMode.AGENT,
bound_agent_id_with_session=lambda *, session: "agent-1",
)
_WORKFLOW_APP = SimpleNamespace(
id="app-1",
tenant_id=_TENANT_ID,
mode=AppMode.WORKFLOW,
bound_agent_id_with_session=lambda *, session: None,
)
def _persist_upload(session: Session, *, name: str = "sample.pdf") -> UploadFile:
upload = UploadFile(
tenant_id=_TENANT_ID,
storage_type=StorageType.LOCAL,
key=f"uploads/{name}",
name=name,
size=5,
extension="pdf",
mime_type="application/pdf",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
created_at=datetime.now(UTC),
used=False,
)
upload.id = _UPLOAD_FILE_ID
session.add(upload)
session.commit()
return upload
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
resolver = MagicMock(return_value="agent-1")
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
result = agent_controller._resolve_agent_id(unbound_session, app_model, None)
assert result == "agent-1"
resolver.assert_called_once_with(session=unbound_session)
assert resolver.call_args.kwargs["session"] is unbound_session
def test_upload_standardizes_into_drive_and_returns_skill_ref(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"zip-bytes"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
svc.return_value.standardize.return_value = {
"skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"},
"manifest": {"name": "Skill A"},
}
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 201
assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_upload_by_agent_resolves_app_and_standardizes_into_drive(unbound_session: Session):
raw = _raw(AgentSkillUploadByAgentApi.post)
with _file_ctx(files={"file": b"zip-bytes"}):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillStandardizeService") as svc,
):
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
body, status = raw(AgentSkillUploadByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
assert status == 201
assert body["skill"] == {"path": "skill-a"}
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_upload_no_file_is_400(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={}):
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 400
assert body["code"] == "no_file"
def test_upload_maps_package_error(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"bad"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
svc.return_value.standardize.side_effect = SkillPackageError(
"missing_skill_md", "no SKILL.md", status_code=400
)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 400
assert body["code"] == "missing_skill_md"
def test_upload_no_bound_agent_is_400(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
resolver = MagicMock(return_value=None)
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
with _file_ctx(files={"file": b"zip"}):
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, app_without_agent)
assert status == 400
assert body["code"] == "agent_not_bound"
resolver.assert_called_once_with(session=unbound_session)
def test_upload_resolves_workflow_node_agent(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with app.test_request_context(
"/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillStandardizeService") as svc:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _WORKFLOW_APP)
assert status == 201
assert body["skill"] == {"path": "s"}
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_upload_maps_drive_error(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"zip"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 404
assert body["code"] == "source_not_found"
def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""):
return app.test_request_context(f"/?{query_string}", method=method, json=payload or {})
def test_files_commit_validates_upload_and_returns_drive_ref(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
upload = _persist_upload(sqlite_session, name="sample qna.pdf")
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
with patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.AgentDriveService") as drive:
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
drive.return_value.commit.return_value = [
{"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
assert status == 201
assert body["file"]["drive_key"] == "files/sample qna.pdf"
assert body["file"]["file_id"] == upload.id
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"
def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(sqlite_session: Session):
raw = _raw(AgentDriveFilesByAgentApi.post)
_persist_upload(sqlite_session)
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.AgentDriveService") as drive,
):
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesByAgentApi(), sqlite_session, "tenant-1", _USER, "agent-1")
assert status == 201
resolve_app.assert_called_once_with(session=sqlite_session, tenant_id="tenant-1", agent_id="agent-1")
def test_files_commit_404_when_upload_not_in_tenant(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
other_upload = _persist_upload(sqlite_session)
other_upload.tenant_id = str(uuid4())
sqlite_session.commit()
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
with patch(f"{_MOD}.console_ns") as ns:
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
assert status == 404
assert body["code"] == "upload_file_not_found"
def test_files_commit_resolves_workflow_node_agent(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
_persist_upload(sqlite_session)
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.AgentDriveService") as drive,
patch(f"{_MOD}.AgentComposerService") as composer,
):
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _WORKFLOW_APP)
assert status == 201
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_files_delete_updates_soul_then_drive(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
calls: list[str] = []
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.commit.side_effect = lambda **kw: (
calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
)
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
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(unbound_session: Session):
raw = _raw(AgentDriveFilesByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
body = raw(AgentDriveFilesByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
def test_files_delete_resolves_workflow_node_agent(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _WORKFLOW_APP)
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(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.commit.side_effect = RuntimeError("storage down")
with pytest.raises(RuntimeError, match="storage down"):
raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
def test_skill_delete_uses_slug_prefix_and_is_idempotent(unbound_session: Session):
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
with _json_ctx(method="DELETE"):
with patch(f"{_MOD}.AgentDriveService") as drive:
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(), unbound_session, _USER, _APP, "tender-analyzer")
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(unbound_session: Session):
raw = _raw(AgentSkillByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
body = raw(AgentSkillByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1", "tender-analyzer")
assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
def test_skill_delete_rejects_path_like_slug(unbound_session: Session):
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
with _json_ctx(method="DELETE"):
body, status = raw(AgentSkillApi(), unbound_session, _USER, _APP, "a/b")
assert status == 400
assert body["code"] == "drive_key_invalid"
def test_infer_tools_returns_draft_suggestions(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
svc.return_value.infer.return_value = {
"inferable": True,
"cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}],
"reason": None,
}
body = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
assert body["inferable"] is True
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
def test_infer_tools_by_agent_uses_agent_route(unbound_session: Session):
raw = _raw(AgentSkillInferToolsByAgentApi.post)
with _json_ctx(query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
body = raw(
AgentSkillInferToolsByAgentApi(),
unbound_session,
"tenant-1",
"agent-1",
"audio-transcribe",
)
assert body["inferable"] is True
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1"
def test_infer_tools_resolves_workflow_node_agent(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx(query_string="node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillToolInferenceService") as svc:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
body = raw(AgentSkillInferToolsApi(), unbound_session, _WORKFLOW_APP, "audio-transcribe")
assert body["inferable"] is False
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_infer_tools_maps_inference_errors(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
from services.agent.skill_tool_inference_service import SkillToolInferenceError
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
svc.return_value.infer.side_effect = SkillToolInferenceError(
"default_model_not_configured", "no model", status_code=400
)
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
assert status == 400
assert body["code"] == "default_model_not_configured"
def test_infer_tools_rejects_path_like_slug_and_unbound_app(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "a/b")
assert (status, body["code"]) == (400, "drive_key_invalid")
app_without_agent = SimpleNamespace(bound_agent_id_with_session=MagicMock(return_value=None))
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), unbound_session, app_without_agent, "x")
assert (status, body["code"]) == (400, "agent_not_bound")
@@ -1,172 +0,0 @@
"""Unit tests for the agent drive inner-API controller (ENG-591).
Handlers are unwrapped past the auth/setup decorators and invoked inside a bare
Flask request context, with AgentDriveService mocked so this covers the
controller's request parsing + error mapping, not auth (tested separately).
"""
from __future__ import annotations
import inspect
from unittest.mock import ANY, patch
import pytest
from flask import Flask
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi
from models.enums import EndUserType
from models.model import EndUser
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.inner_api.plugin.agent_drive"
app = Flask(__name__)
def _raw(method):
return inspect.unwrap(method)
def _end_user(user_id: str) -> EndUser:
return EndUser(
id=user_id,
tenant_id="tenant-1",
type=EndUserType.SERVICE_API,
session_id="session-1",
)
def test_manifest_parses_query_and_returns_items():
raw = _raw(AgentDriveManifestApi.get)
with app.test_request_context("/?tenant_id=tenant-1&prefix=docs/&include_download_url=true"):
with patch(f"{_MOD}.AgentDriveService") as svc:
svc.return_value.manifest.return_value = [{"key": "docs/a.txt"}]
result = raw(AgentDriveManifestApi(), "agent-agent-1")
assert result == {"items": [{"key": "docs/a.txt"}]}
svc.return_value.manifest.assert_called_once_with(
tenant_id="tenant-1", agent_id="agent-1", prefix="docs/", include_download_url=True, session=ANY
)
def test_manifest_missing_tenant_id_is_400():
raw = _raw(AgentDriveManifestApi.get)
with app.test_request_context("/"):
body, status = raw(AgentDriveManifestApi(), "agent-agent-1")
assert status == 400
assert body["code"] == "missing_tenant_id"
def test_manifest_bad_drive_ref_is_400():
raw = _raw(AgentDriveManifestApi.get)
with app.test_request_context("/?tenant_id=tenant-1"):
body, status = raw(AgentDriveManifestApi(), "not-an-agent-ref")
assert status == 400
assert body["code"] == "invalid_drive_ref"
def test_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",
"session": ANY,
}
def test_commit_parses_body_and_returns_items():
raw = _raw(AgentDriveCommitApi.post)
payload = {
"tenant_id": "tenant-1",
"user_id": "user-1",
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
}
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{_MOD}.get_user", return_value=_end_user("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=_end_user("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():
raw = _raw(AgentDriveCommitApi.post)
with app.test_request_context("/", method="POST", json={"tenant_id": "t"}): # missing user_id/items
body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
assert status == 400
assert body["code"] == "invalid_request"
def test_commit_maps_service_error():
raw = _raw(AgentDriveCommitApi.post)
payload = {
"tenant_id": "tenant-1",
"user_id": "user-1",
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
}
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{_MOD}.get_user", return_value=_end_user("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, AgentDriveSkillsApi, AgentDriveCommitApi])
def test_endpoints_have_handlers(api_cls):
assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None))
@@ -495,7 +495,6 @@ class TestAgentAppConfigLayer:
"execution_context": "execution_context",
"runtime": "runtime",
}
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
def test_config_layer_for_build_draft_marks_config_writable(self):
builder = AgentAppRuntimeRequestBuilder(
@@ -1476,7 +1476,6 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
"runtime": "runtime",
}
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None
def test_workflow_run_request_contains_config_layer():
@@ -1,122 +0,0 @@
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_preserves_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 json.loads(stored_snapshot) == 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)
@@ -0,0 +1,184 @@
from __future__ import annotations
import importlib.util
import json
from io import StringIO
from pathlib import Path
from types import ModuleType
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
_MIGRATION_PATH = (
Path(__file__).resolve().parents[3] / "migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py"
)
def _load_migration_module() -> ModuleType:
spec = importlib.util.spec_from_file_location("remove_agent_drive", _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("id", sa.String(36), primary_key=True))
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
sa.Table(
table_name,
metadata,
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("config_snapshot", sa.Text(), nullable=False),
)
sa.Table(
"workflow_agent_node_bindings",
metadata,
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("node_job_config", sa.Text(), nullable=False),
)
metadata.create_all(engine)
def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None:
migration_step = module.__dict__[step_name]
if not callable(migration_step):
raise TypeError(f"migration step {step_name!r} is not callable")
with engine.begin() as connection:
operations = Operations(MigrationContext.configure(connection))
original_op = module.__dict__["op"]
module.__dict__["op"] = operations
try:
migration_step()
finally:
module.__dict__["op"] = original_op
def test_upgrade_removes_agent_drive_schema_and_legacy_json_fields() -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
soul = {
"files": {"skills": [{"name": "legacy"}]},
"config_skills": [{"name": "current", "file_id": "tool-1"}],
"prompt": {"system_prompt": "hello"},
}
node_job = {
"metadata": {
"file_refs": [
{"id": "upload-1", "drive_key": "files/input.pdf"},
{"id": "upload-2"},
]
},
"declared_outputs": [
{
"name": "report",
"type": "file",
"check": {"benchmark_file_ref": {"id": "upload-3", "drive_key": "files/reference.pdf"}},
}
],
}
with engine.begin() as connection:
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
connection.execute(
sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :value)"),
{"id": table_name, "value": json.dumps(soul)},
)
connection.execute(
sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :value)"),
{"id": "binding-1", "value": json.dumps(node_job)},
)
module = _load_migration_module()
_run_migration_step(module, engine, "upgrade")
assert "agent_drive_files" not in sa.inspect(engine).get_table_names()
with engine.begin() as connection:
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
stored = connection.execute(sa.text(f"SELECT config_snapshot FROM {table_name}")).scalar_one()
value = json.loads(stored)
assert "files" not in value
assert value["config_skills"] == soul["config_skills"]
assert value["prompt"] == soul["prompt"]
stored_node_job = connection.execute(
sa.text("SELECT node_job_config FROM workflow_agent_node_bindings")
).scalar_one()
migrated_node_job = json.loads(stored_node_job)
assert migrated_node_job["metadata"]["file_refs"] == [{"id": "upload-1"}, {"id": "upload-2"}]
assert migrated_node_job["declared_outputs"][0]["check"]["benchmark_file_ref"] == {"id": "upload-3"}
_run_migration_step(module, engine, "downgrade")
inspector = sa.inspect(engine)
assert "agent_drive_files" in inspector.get_table_names()
assert {
"tenant_id",
"agent_id",
"key",
"file_kind",
"file_id",
"value_owned_by_drive",
"is_skill",
"skill_metadata",
}.issubset({column["name"] for column in inspector.get_columns("agent_drive_files")})
assert "agent_drive_file_scope_key_unique" in {
constraint["name"] for constraint in inspector.get_unique_constraints("agent_drive_files")
}
assert "agent_drive_files_tenant_agent_is_skill_key_idx" in {
index["name"] for index in inspector.get_indexes("agent_drive_files")
}
def test_upgrade_supports_offline_sql_generation() -> None:
module = _load_migration_module()
output = StringIO()
migration_context = MigrationContext.configure(
dialect_name="postgresql",
opts={"as_sql": True, "output_buffer": output},
)
operations = Operations(migration_context)
migration_step = module.__dict__["upgrade"]
if not callable(migration_step):
raise TypeError("migration upgrade is not callable")
original_op = module.__dict__["op"]
module.__dict__["op"] = operations
try:
migration_step()
finally:
module.__dict__["op"] = original_op
generated_sql = output.getvalue()
assert "DROP TABLE agent_drive_files" in generated_sql
assert "SELECT id" not in generated_sql
@pytest.mark.parametrize(
("table_name", "column_name"),
[
pytest.param("agent_config_snapshots", "config_snapshot", id="config-snapshot"),
pytest.param("workflow_agent_node_bindings", "node_job_config", id="node-job-config"),
],
)
def test_upgrade_rejects_invalid_json_without_rewriting(table_name: str, column_name: str) -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
invalid_json = "not-json"
with engine.begin() as connection:
connection.execute(
sa.text(f"INSERT INTO {table_name} (id, {column_name}) VALUES (:id, :value)"),
{"id": "invalid-row", "value": invalid_json},
)
module = _load_migration_module()
with pytest.raises(json.JSONDecodeError):
_run_migration_step(module, engine, "upgrade")
with engine.begin() as connection:
stored = connection.execute(sa.text(f"SELECT {column_name} FROM {table_name}")).scalar_one()
assert stored == invalid_json
assert "agent_drive_files" in sa.inspect(engine).get_table_names()
-5
View File
@@ -39,9 +39,7 @@ project-excludes = [
"controllers/console/agent/test_agent_controllers.py",
"controllers/console/app/test_agent_app_sandbox.py",
"controllers/console/app/test_agent_config_inspector.py",
"controllers/console/app/test_agent_drive_inspector.py",
"controllers/console/app/test_agent_manage_guard.py",
"controllers/console/app/test_agent_skills.py",
"controllers/console/app/test_annotation_api.py",
"controllers/console/app/test_annotation_security.py",
"controllers/console/app/test_app_apis.py",
@@ -146,7 +144,6 @@ project-excludes = [
"controllers/files/test_upload.py",
"controllers/inner_api/app/test_dsl.py",
"controllers/inner_api/plugin/test_agent_config.py",
"controllers/inner_api/plugin/test_agent_drive.py",
"controllers/inner_api/plugin/test_plugin.py",
"controllers/inner_api/plugin/test_plugin_wraps.py",
"controllers/inner_api/test_auth_wraps.py",
@@ -723,7 +720,6 @@ project-excludes = [
"libs/test_workspace_member_helper.py",
"libs/test_workspace_permission.py",
"libs/test_yarl.py",
"migrations/test_agent_drive_skill_metadata_refactor.py",
"migrations/test_uuidv7_pg18_migration.py",
"models/test_account_models.py",
"models/test_agent.py",
@@ -819,7 +815,6 @@ project-excludes = [
"services/test_agent_app_feature_service.py",
"services/test_agent_app_sandbox_service.py",
"services/test_agent_config_service.py",
"services/test_agent_drive_service.py",
"services/test_annotation_service.py",
"services/test_api_token_service.py",
"services/test_app_generate_service.py",
@@ -44,24 +44,6 @@ def test_workflow_variant_rejects_agent_app_only_fields():
)
def test_workflow_variant_accepts_agent_soul_files_section():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
"agent_soul": {
"schema_version": 1,
"prompt": {"system_prompt": "jjjj"},
"files": {"skills": [], "files": []},
},
}
)
assert payload.agent_soul is not None
assert payload.agent_soul.files.skills == []
assert payload.agent_soul.files.files == []
def test_agent_app_variant_rejects_workflow_node_job():
with pytest.raises(ValueError):
ComposerSavePayload.model_validate(
@@ -18,7 +18,7 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSkillRefConfig, AgentSoulConfig
from services.agent.dsl_entities import (
AGENT_NODE_JOB_DSL_KEY,
AGENT_PACKAGE_REF_KEY,
@@ -465,44 +465,41 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict,
)
def test_clone_inline_binding_copies_soul_and_drive_rows(monkeypatch: pytest.MonkeyPatch) -> None:
def test_clone_inline_binding_copies_soul() -> None:
session = Mock()
service = AgentDslService(session)
target_agent = SimpleNamespace(id="target-agent")
target_snapshot = SimpleNamespace(id="target-snapshot")
service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot))
copy_rows = Mock()
monkeypatch.setattr("services.agent.composer_service.AgentComposerService._copy_agent_drive_rows", copy_rows)
source_agent = _agent()
source_snapshot = SimpleNamespace(
config_snapshot_dict=AgentSoulConfig(config_note="source").model_dump(mode="json")
source_soul = AgentSoulConfig(
config_note="source",
config_skills=[AgentConfigSkillRefConfig(name="summarizer", file_id="skill-file-1")],
config_files=[AgentConfigFileRefConfig(name="brief.pdf", file_kind="upload_file", file_id="config-file-1")],
)
source_snapshot = SimpleNamespace(config_snapshot_dict=source_soul.model_dump(mode="json"))
workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1")
node_job = WorkflowNodeJobConfig(workflow_prompt="work")
result = service.clone_inline_binding_for_node(
workflow=workflow,
node_id="target-node",
source_agent=source_agent,
source_snapshot=source_snapshot,
node_job=node_job,
account_id="account-1",
)
assert result == (target_agent, target_snapshot)
create_kwargs = service._create_workflow_only_agent.call_args.kwargs
assert create_kwargs["metadata"].name == source_agent.name
assert create_kwargs["soul"].config_note == "source"
cloned_soul = create_kwargs["soul"]
assert cloned_soul.config_note == "source"
assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_skills] == [
("summarizer", "tool_file", "skill-file-1")
]
assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_files] == [
("brief.pdf", "upload_file", "config-file-1")
]
assert create_kwargs["source"] == AgentSource.WORKFLOW
copy_rows.assert_called_once_with(
tenant_id="tenant-1",
source_agent_id="agent-1",
target_agent_id="target-agent",
account_id="account-1",
agent_soul=create_kwargs["soul"],
node_job=node_job,
session=session,
)
def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -20,8 +20,6 @@ from models.agent import (
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentDriveFile,
AgentDriveFileKind,
AgentHomeSnapshot,
AgentKind,
AgentScope,
@@ -2379,7 +2377,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
scope=AgentScope.WORKFLOW_ONLY,
)
create_roster_calls = []
copy_drive_calls = []
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent)
def fake_create_roster_agent_for_composer(**kwargs):
@@ -2391,11 +2388,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
"_create_roster_agent_for_composer",
fake_create_roster_agent_for_composer,
)
monkeypatch.setattr(
AgentComposerService,
"_copy_agent_drive_rows",
lambda **kwargs: copy_drive_calls.append(kwargs),
)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
monkeypatch.setattr(
AgentComposerService,
@@ -2496,17 +2488,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
assert create_roster_calls[1]["role"] == "Copied role"
assert create_roster_calls[1]["icon"] == "copied"
assert create_roster_calls[1]["icon_background"] == "#E0F2FE"
copy_drive_calls[0].pop("session", None)
assert copy_drive_calls == [
{
"tenant_id": "tenant-1",
"source_agent_id": "roster-agent-1",
"target_agent_id": "roster-agent-1",
"account_id": "account-1",
"agent_soul": payload.agent_soul,
"node_job": payload.node_job,
}
]
def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
@@ -2914,11 +2895,7 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
captured["create"] = kwargs
return inline_agent
def fake_copy_drive_rows(**kwargs):
captured["drive"] = kwargs
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent)
monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
@@ -2950,9 +2927,6 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me"
assert create_kwargs["name"] == "Nadia"
assert create_kwargs["role"] == "Clarifies tenders"
drive_kwargs = captured["drive"]
assert drive_kwargs["source_agent_id"] == "roster-agent-1"
assert drive_kwargs["target_agent_id"] == "inline-agent-1"
def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(
@@ -3196,191 +3170,6 @@ def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding(
)
def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
session = sqlite_session
skill_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="tender-analyzer/SKILL.md",
file_kind="tool_file",
file_id="tool-file-1",
value_owned_by_drive=True,
is_skill=True,
skill_metadata='{"name":"Tender Analyzer"}',
size=10,
mime_type="text/markdown",
)
script_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="tender-analyzer/scripts/run.sh",
file_kind="tool_file",
file_id="tool-file-2",
value_owned_by_drive=True,
size=20,
mime_type="text/x-shellscript",
)
file_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="files/qna.pdf",
file_kind="upload_file",
file_id="upload-file-1",
value_owned_by_drive=False,
size=30,
mime_type="application/pdf",
)
session.add_all([skill_row, script_row, file_row])
session.commit()
agent_soul = AgentSoulConfig.model_validate(
{
"prompt": {
"system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
{"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}}
)
AgentComposerService._copy_agent_drive_rows(
session=session,
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
node_job=node_job,
)
session.flush()
copied = list(
session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == "tenant-1",
AgentDriveFile.agent_id == "inline-agent-1",
)
)
)
assert {row.key for row in copied} == {
"tender-analyzer/SKILL.md",
"tender-analyzer/scripts/run.sh",
"files/qna.pdf",
}
assert {row.agent_id for row in copied} == {"inline-agent-1"}
copied_by_key = {row.key: row for row in copied}
assert copied_by_key["tender-analyzer/SKILL.md"].file_id == "tool-file-1"
assert copied_by_key["tender-analyzer/SKILL.md"].is_skill is True
assert copied_by_key["files/qna.pdf"].value_owned_by_drive is False
def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
):
session = sqlite_session
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}})
AgentComposerService._copy_agent_drive_rows(
session=session,
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
)
assert not session.new
def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
session = sqlite_session
source_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="files/qna.pdf",
file_kind="upload_file",
file_id="upload-file-1",
value_owned_by_drive=False,
size=30,
mime_type="application/pdf",
)
target_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="inline-agent-1",
key=source_row.key,
file_kind=source_row.file_kind,
file_id=source_row.file_id,
value_owned_by_drive=source_row.value_owned_by_drive,
size=source_row.size,
mime_type=source_row.mime_type,
)
session.add_all([source_row, target_row])
session.commit()
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}})
AgentComposerService._copy_agent_drive_rows(
session=session,
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
)
session.flush()
target_rows = list(
session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == "tenant-1",
AgentDriveFile.agent_id == "inline-agent-1",
)
)
)
assert [row.key for row in target_rows] == ["files/qna.pdf"]
def test_drive_copy_scopes_include_declared_output_benchmark_files():
agent_soul = AgentSoulConfig.model_validate(
{
"prompt": {
"system_prompt": (
"[§file:files/source.pdf:source.pdf§] "
"[§knowledge:dataset-1:Docs§] "
"[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
)
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
{
"declared_outputs": [
{
"name": "qna_report",
"type": "file",
"check": {
"enabled": True,
"prompt": "Compare the generated file with the benchmark.",
"benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"},
},
},
{
"name": "summary",
"type": "string",
"check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}},
},
],
}
)
exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs(
agent_soul=agent_soul,
node_job=node_job,
)
assert exact_keys == {"files/source.pdf", "files/expected.pdf"}
assert prefixes == {"tender-analyzer/"}
def test_composer_create_agents_syncs_active_config_has_model(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
@@ -5835,7 +5624,7 @@ class TestWorkflowAgentDraftBindingSync:
draft_workflow=self._agent_workflow(),
)
def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_session: Session):
def test_publish_validation_rejects_dangling_agent_soul_config_refs(self, sqlite_session: Session):
session = sqlite_session
binding = self._agent_binding()
agent_soul = AgentSoulConfig.model_validate(
@@ -5845,7 +5634,7 @@ class TestWorkflowAgentDraftBindingSync:
"model_provider": "openai",
"model": "gpt-4o",
},
"prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."},
"prompt": {"system_prompt": "Use [§skill:research:Research§]."},
}
)
agent = self._publish_agent()
@@ -7045,135 +6834,6 @@ 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 prompt mention validation ─────────────────────
def _drive_soul(**overrides):
from services.entities.agent_entities import AgentSoulConfig
base = {
"prompt": {
"system_prompt": (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
)
},
}
base.update(overrides)
return AgentSoulConfig.model_validate(base)
def _session_with_drive_keys(sqlite_session: Session, existing_keys: list[str]) -> Session:
session = sqlite_session
session.add_all(
[
AgentDriveFile(
id=f"drive-file-{index}",
tenant_id="tenant-1",
agent_id="agent-1",
key=key,
file_kind=AgentDriveFileKind.UPLOAD_FILE,
file_id=f"upload-{index}",
)
for index, key in enumerate(existing_keys, start=1)
]
)
session.commit()
return session
def test_drive_mention_findings_reports_missing_keys(sqlite_session: Session):
session = _session_with_drive_keys(sqlite_session, ["tender-analyzer/SKILL.md"])
findings = AgentComposerService._drive_mention_findings(
session=session,
tenant_id="tenant-1",
agent_id="agent-1",
prompt=_drive_soul().prompt.system_prompt,
)
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_mention_findings_clean_when_all_keys_exist(sqlite_session: Session):
session = _session_with_drive_keys(
sqlite_session,
["tender-analyzer/SKILL.md", "files/sample.pdf"],
)
assert (
AgentComposerService._drive_mention_findings(
session=session,
tenant_id="tenant-1",
agent_id="agent-1",
prompt=_drive_soul().prompt.system_prompt,
)
== []
)
def test_drive_mention_findings_skips_prompt_without_drive_mentions(sqlite_session: Session):
session = sqlite_session
# 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(
session=session,
tenant_id="tenant-1",
agent_id="agent-1",
prompt=soul.prompt.system_prompt,
)
assert findings == []
def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context(
sqlite_session: Session,
):
from services.entities.agent_entities import ComposerSavePayload
session = _session_with_drive_keys(sqlite_session, [])
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"save_strategy": "save_to_current_version",
"agent_soul": _drive_soul().model_dump(mode="json"),
}
)
findings = AgentComposerService.collect_validation_findings(
session=session, tenant_id="tenant-1", payload=payload, agent_id="agent-1"
)
codes = {w["code"] for w in findings["warnings"]}
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(
session=session, tenant_id="tenant-1", payload=payload
)
assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"])
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
def test_resolve_bound_agent_id_queries_active_roster_agent(sqlite_session: Session):
session = sqlite_session
session.add(
_agent(
agent_id="agent-9",
tenant_id="t-1",
source=AgentSource.ROSTER,
app_id="app-1",
)
)
session.commit()
assert AgentComposerService.resolve_bound_agent_id(session=session, tenant_id="t-1", app_id="app-1") == "agent-9"
def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
):
@@ -7207,129 +6867,3 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n")
== "agent-7"
)
def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
):
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"save_strategy": "node_job_only",
"agent_soul": _drive_soul().model_dump(mode="json"),
"soul_lock": {"locked": False},
}
)
binding = WorkflowAgentNodeBinding(
tenant_id="t-1",
app_id="app-1",
workflow_id="wf-1",
workflow_version="draft",
node_id="n-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="agent-1",
current_snapshot_id="version-1",
)
session = sqlite_session
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))
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
)
monkeypatch.setattr(
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
)
guarded: dict[str, str] = {}
def fake_collect(cls, *, session, 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, "collect_validation_findings", classmethod(fake_collect))
result = AgentComposerService.save_workflow_composer(
session=session,
tenant_id="t-1",
app_id="app-1",
node_id="n-1",
account_id="acc-1",
payload=payload,
)
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_reports_drive_mentions_for_roster_node_job_only(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
):
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"save_strategy": "node_job_only",
"agent_soul": _drive_soul().model_dump(mode="json"),
"soul_lock": {"locked": False},
}
)
binding = WorkflowAgentNodeBinding(
tenant_id="t-1",
app_id="app-1",
workflow_id="wf-1",
workflow_version="draft",
node_id="n-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="version-1",
)
session = sqlite_session
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))
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
)
monkeypatch.setattr(
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
)
captured: dict[str, str | None] = {}
def fake_collect(cls, *, session, tenant_id, payload, agent_id=None):
captured["agent_id"] = agent_id
return {"warnings": []}
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
result = AgentComposerService.save_workflow_composer(
session=session,
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 captured["agent_id"] == "agent-1"
@@ -7,8 +7,6 @@ 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, WorkflowPreviousNodeOutputRef
@@ -65,12 +63,6 @@ def test_parse_skips_oversized_id_or_label():
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 ────────────────────────────────────────────────────────────
@@ -1,140 +0,0 @@
"""Unit tests for Skill standardization into the agent drive (ENG-594)."""
from __future__ import annotations
import io
import zipfile
from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
from models.tools import ToolFile
from services.agent.skill_standardize_service import SkillStandardizeService, slugify_skill_name
from services.agent_drive_service import DriveSkillMetadata
_TENANT_ID = "11111111-1111-1111-1111-111111111111"
_AGENT_ID = "22222222-2222-2222-2222-222222222222"
_USER_ID = "33333333-3333-3333-3333-333333333333"
_SKILL_MD = b"""---
name: PDF Toolkit
description: Work with PDFs.
---
# PDF Toolkit
"""
def _zip(members: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, data in members.items():
archive.writestr(name, data)
return buffer.getvalue()
def test_slugify_skill_name():
assert slugify_skill_name("PDF Toolkit") == "pdf-toolkit"
assert slugify_skill_name(" Weird/Name!! ") == "weird-name"
assert slugify_skill_name("") == "skill"
@pytest.mark.parametrize("sqlite_session", [(Agent, ToolFile, AgentDriveFile)], indirect=True)
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(sqlite_session: Session):
content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"})
agent = Agent(
id=_AGENT_ID,
tenant_id=_TENANT_ID,
name="Drive Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
)
md_tool_file = ToolFile(
user_id=_USER_ID,
tenant_id=_TENANT_ID,
conversation_id=None,
file_key="tools/skill-md",
mimetype="text/markdown",
name="SKILL.md",
size=len(_SKILL_MD),
)
archive_tool_file = ToolFile(
user_id=_USER_ID,
tenant_id=_TENANT_ID,
conversation_id=None,
file_key="tools/skill-archive",
mimetype="application/zip",
name=".DIFY-SKILL-FULL.zip",
size=len(content),
)
sqlite_session.add_all([agent, md_tool_file, archive_tool_file])
sqlite_session.commit()
tool_files = MagicMock()
tool_files.create_file_by_raw.side_effect = [md_tool_file, archive_tool_file]
service = SkillStandardizeService(tool_file_manager=tool_files)
result = service.standardize(
content=content,
filename="skill.zip",
tenant_id=_TENANT_ID,
user_id=_USER_ID,
agent_id=_AGENT_ID,
session=sqlite_session,
)
assert not sqlite_session.in_transaction()
# ToolFiles: SKILL.md and the full archive. Archive members stay lazy.
assert tool_files.create_file_by_raw.call_count == 2
md_call, zip_call = tool_files.create_file_by_raw.call_args_list
assert md_call.kwargs["mimetype"] == "text/markdown"
assert md_call.kwargs["file_binary"] == _SKILL_MD
assert zip_call.kwargs["mimetype"] == "application/zip"
assert zip_call.kwargs["file_binary"] != content
with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive:
assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [
"SKILL.md",
"scripts/run.py",
]
# Committed as drive-owned with the standardized keys. Member paths are
# carried in metadata for inspect/preview/runtime lazy resolution.
rows = {
row.key: row
for row in sqlite_session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == _TENANT_ID,
AgentDriveFile.agent_id == _AGENT_ID,
)
)
}
assert set(rows) == {"pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"}
skill_row = rows["pdf-toolkit/SKILL.md"]
archive_row = rows["pdf-toolkit/.DIFY-SKILL-FULL.zip"]
assert skill_row.file_kind == AgentDriveFileKind.TOOL_FILE
assert skill_row.file_id == md_tool_file.id
assert skill_row.value_owned_by_drive is True
assert skill_row.is_skill is True
assert skill_row.skill_metadata is not None
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
assert skill_metadata.name == "PDF Toolkit"
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
assert archive_row.file_id == archive_tool_file.id
assert archive_row.value_owned_by_drive is True
assert archive_row.is_skill is False
assert len(service.last_committed_items) == 2
# 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["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
assert result["manifest"]["entry_path"] == "SKILL.md"
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
assert "_committed_items" not in result
@@ -1,188 +0,0 @@
"""Unit tests for skill → CLI tool inference (ENG-371)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceService,
)
from services.agent_drive_service import AgentDriveError
_MOD = "services.agent.skill_tool_inference_service"
_SKILL_MD_PREVIEW = {
"key": "audio-transcribe/SKILL.md",
"size": 100,
"truncated": False,
"binary": False,
"text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.",
}
def _service(preview=_SKILL_MD_PREVIEW):
drive = MagicMock()
drive.preview.return_value = preview
return SkillToolInferenceService(drive_service=drive), drive
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session):
service, drive = _service()
raw = (
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
' "description": "transcoding for step 2", "command": "ffmpeg",'
' "install_commands": ["apt-get install -y ffmpeg"],'
' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
)
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(
tenant_id="t-1",
agent_id="a-1",
slug="audio-transcribe",
session=sqlite_session,
)
assert result["inferable"] is True
tool = result["cli_tools"][0]
assert tool["name"] == "ffmpeg"
assert tool["inferred_from"] == "audio-transcribe"
assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
drive.preview.assert_called_once_with(
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session
)
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session):
service, _ = _service()
captured: dict[str, str] = {}
def fake_invoke(*, tenant_id, user_prompt):
captured["prompt"] = user_prompt
return '{"inferable": false, "cli_tools": [], "reason": "none"}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert "Files inside the skill package" not in captured["prompt"]
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session):
service, _ = _service()
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", session=sqlite_session)
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session):
service, _ = _service()
calls: list[int] = []
def bad_invoke(**kwargs):
calls.append(1)
return "not json at all ]["
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert len(calls) == 2 # one retry
assert exc_info.value.code == "inference_failed"
assert exc_info.value.status_code == 422
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session):
service, _ = _service()
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", session=sqlite_session)
assert result["inferable"] is True
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_missing_skill_maps_to_404(sqlite_session: Session):
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session)
assert exc_info.value.code == "skill_not_found"
assert exc_info.value.status_code == 404
assert not sqlite_session.in_transaction()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_binary_skill_md_maps_to_404(sqlite_session: Session):
service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
assert exc_info.value.code == "skill_not_found"
assert not sqlite_session.in_transaction()
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch):
import services.agent.skill_tool_inference_service as module
from core.errors.error import ProviderTokenNotInitError
fake_manager = MagicMock()
fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default")
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
with pytest.raises(SkillToolInferenceError) as exc_info:
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert exc_info.value.code == "default_model_not_configured"
assert exc_info.value.status_code == 400
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch):
import services.agent.skill_tool_inference_service as module
fake_manager = MagicMock()
fake_instance = MagicMock()
fake_manager.get_default_model_instance.return_value = fake_instance
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
fake_instance.invoke_llm.side_effect = RuntimeError("provider down")
with pytest.raises(SkillToolInferenceError) as exc_info:
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert exc_info.value.code == "inference_failed"
assert exc_info.value.status_code == 422
fake_instance.invoke_llm.side_effect = None
fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}'
raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert raw == '{"inferable": false}'
call = fake_instance.invoke_llm.call_args.kwargs
assert call["model_parameters"] == {"temperature": 0.1}
assert call["stream"] is False
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session):
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
assert exc_info.value.code == "agent_not_found"
assert not sqlite_session.in_transaction()
@@ -6,7 +6,6 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from models.agent import Agent, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import WorkflowNodeJobConfig
from models.enums import AppStatus
from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
@@ -326,7 +325,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
target_snapshot = SimpleNamespace(id="target-snapshot")
clone = Mock(return_value=(target_agent, target_snapshot))
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
node_job = WorkflowNodeJobConfig(workflow_prompt="work")
result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node(
session=session,
@@ -334,7 +332,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
node_id="target-node",
source_agent_id="source-agent",
source_snapshot_id="source-snapshot",
node_job=node_job,
account_id="account-1",
)
@@ -344,7 +341,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
node_id="target-node",
source_agent=source_agent,
source_snapshot=source_snapshot,
node_job=node_job,
account_id="account-1",
)
@@ -361,7 +357,6 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul
node_id="target-node",
source_agent_id="source-agent",
source_snapshot_id="source-snapshot",
node_job=WorkflowNodeJobConfig(),
account_id="account-1",
)
@@ -1,952 +0,0 @@
"""Unit tests for the agent drive service (ENG-591).
Pure helpers (key safety / drive-ref parsing) plus the commit/manifest lifecycle
exercised against the project's in-memory SQLite engine with seeded ToolFiles.
"""
from __future__ import annotations
import datetime
import io
import zipfile
from collections.abc import Generator
from unittest.mock import patch
import pytest
from sqlalchemy import delete, event, select
from sqlalchemy.exc import DataError
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from extensions.storage.storage_type import StorageType
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
from models.enums import CreatorUserRole
from models.model import UploadFile
from models.tools import ToolFile
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveSkillMetadata,
normalize_drive_key,
parse_agent_drive_ref,
)
TENANT = "11111111-1111-1111-1111-111111111111"
AGENT = "22222222-2222-2222-2222-222222222222"
USER = "33333333-3333-3333-3333-333333333333"
# ── pure helpers ──────────────────────────────────────────────────────────────
def test_parse_agent_drive_ref():
assert parse_agent_drive_ref("agent-abc") == "abc"
for bad in ["abc", "agent-", ""]:
with pytest.raises(AgentDriveError):
parse_agent_drive_ref(bad)
def test_normalize_drive_key_ok_and_collapses_slashes():
assert normalize_drive_key("a/b/c.txt") == "a/b/c.txt"
assert normalize_drive_key("/a//b.txt") == "a/b.txt"
assert normalize_drive_key("skill-name/SKILL.md") == "skill-name/SKILL.md"
@pytest.mark.parametrize("bad", ["", " ", "a/../b", "../etc", "a/\x00b", "a" * 1100])
def test_normalize_drive_key_rejects_unsafe(bad: str):
with pytest.raises(AgentDriveError):
normalize_drive_key(bad)
# ── service lifecycle (in-memory ORM) ─────────────────────────────────────────
@pytest.fixture(autouse=True)
def _tables() -> Generator[None, None, None]:
engine = session_factory.get_session_maker().kw["bind"]
for model in (Agent, ToolFile, UploadFile, AgentDriveFile):
model.__table__.create(bind=engine, checkfirst=True)
_seed_agent()
yield
with session_factory.create_session() as session:
session.execute(delete(AgentDriveFile))
session.execute(delete(UploadFile))
session.execute(delete(ToolFile))
session.execute(delete(Agent))
session.commit()
AgentDriveFile.__table__.drop(bind=engine, checkfirst=True)
def _seed_agent(*, tenant_id: str = TENANT, agent_id: str = AGENT) -> None:
agent = Agent(
id=agent_id,
tenant_id=tenant_id,
name="Drive Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
)
with session_factory.create_session() as session:
session.add(agent)
session.commit()
def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt", conversation_id: str | None = None) -> str:
tool_file = ToolFile(
user_id=user_id,
tenant_id=TENANT,
conversation_id=conversation_id,
file_key=f"tools/{TENANT}/{name}",
mimetype="text/plain",
name=name,
size=5,
)
with session_factory.create_session() as session:
session.add(tool_file)
session.commit()
return tool_file.id
def _zip_bytes(members: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, data in members.items():
archive.writestr(name, data)
return buffer.getvalue()
def _commit(key: str, tool_file_id: str, *, owned: bool = True):
return AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key=key,
file_ref={"kind": "tool_file", "id": tool_file_id},
value_owned_by_drive=owned,
)
],
session=session_factory.create_session(),
)
def test_commit_then_manifest_lists_the_entry():
tf = _seed_tool_file()
_commit("data/report.txt", tf)
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
assert [i["key"] for i in items] == ["data/report.txt"]
assert items[0]["file_kind"] == "tool_file"
assert items[0]["file_id"] == tf
assert items[0]["mime_type"] == "text/plain"
# prefix filter
assert (
AgentDriveService().manifest(
tenant_id=TENANT, agent_id=AGENT, prefix="data/", session=session_factory.create_session()
)
!= []
)
assert (
AgentDriveService().manifest(
tenant_id=TENANT, agent_id=AGENT, prefix="other/", session=session_factory.create_session()
)
== []
)
def test_commit_owned_tool_file_detaches_conversation_ownership():
conversation_id = "44444444-4444-4444-4444-444444444444"
tool_file_id = _seed_tool_file(conversation_id=conversation_id)
_commit("data/report.txt", tool_file_id, owned=True)
with session_factory.create_session() as session:
tool_file = session.get(ToolFile, tool_file_id)
assert tool_file is not None
assert tool_file.conversation_id is None
def test_commit_shared_tool_file_keeps_conversation_ownership():
conversation_id = "44444444-4444-4444-4444-444444444444"
tool_file_id = _seed_tool_file(conversation_id=conversation_id)
_commit("data/report.txt", tool_file_id, owned=False)
with session_factory.create_session() as session:
tool_file = session.get(ToolFile, tool_file_id)
assert tool_file is not None
assert tool_file.conversation_id == conversation_id
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."),
)
],
session=session_factory.create_session(),
)
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, session=session_factory.create_session())
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,
)
],
session=session_factory.create_session(),
)
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, session=session_factory.create_session())
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=""),
)
],
session=session_factory.create_session(),
)
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=""),
)
],
session=session_factory.create_session(),
)
def test_commit_rejects_tool_file_not_owned_by_user():
other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999")
with pytest.raises(AgentDriveError) as exc_info:
_commit("x.txt", other)
assert exc_info.value.status_code == 404
assert exc_info.value.code == "source_not_found"
def test_commit_rejects_agent_from_another_tenant():
tf = _seed_tool_file()
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().commit(
tenant_id="99999999-9999-9999-9999-999999999999",
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="x.txt",
file_ref={"kind": "tool_file", "id": tf},
value_owned_by_drive=True,
)
],
session=session_factory.create_session(),
)
assert exc_info.value.status_code == 404
assert exc_info.value.code == "agent_not_found"
def test_overwrite_cleans_old_drive_owned_value():
tf1 = _seed_tool_file(name="v1.txt")
tf2 = _seed_tool_file(name="v2.txt")
_commit("doc.txt", tf1, owned=True)
with patch("services.agent_drive_service.storage") as storage_mock:
_commit("doc.txt", tf2, owned=True)
storage_mock.delete.assert_called_once()
# old ToolFile physically removed; key now points at tf2
with session_factory.create_session() as session:
assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt")))
assert len(rows) == 1
assert rows[0].file_id == tf2
def test_batch_failure_does_not_delete_old_storage_before_commit():
tf1 = _seed_tool_file(name="v1.txt")
tf2 = _seed_tool_file(name="v2.txt")
_commit("doc.txt", tf1, owned=True)
with patch("services.agent_drive_service.storage") as storage_mock:
with session_factory.create_session() as session:
with pytest.raises(AgentDriveError):
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="doc.txt",
file_ref={"kind": "tool_file", "id": tf2},
value_owned_by_drive=True,
),
DriveCommitItem(
key="bad.txt",
file_ref={"kind": "tool_file", "id": "44444444-4444-4444-4444-444444444444"},
value_owned_by_drive=True,
),
],
session=session,
)
session.rollback()
storage_mock.delete.assert_not_called()
with session_factory.create_session() as session:
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt"))
assert row is not None
assert row.file_id == tf1
assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is not None
assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
def test_validate_source_db_error_maps_to_404():
"""A database UUID failure maps to 404 and rolls back the real transaction."""
rollback_events: list[Session] = []
def raise_data_error(_orm_execute_state: object) -> None:
raise DataError("bad uuid", {}, Exception("invalid input syntax for uuid"))
def record_rollback(session: Session) -> None:
rollback_events.append(session)
with session_factory.create_session() as session:
session.begin()
event.listen(session, "do_orm_execute", raise_data_error)
event.listen(session, "after_rollback", record_rollback)
try:
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService()._validate_source(
session,
tenant_id=TENANT,
user_id="not-a-uuid",
file_kind=AgentDriveFileKind.TOOL_FILE,
file_id="also-bad",
)
finally:
event.remove(session, "do_orm_execute", raise_data_error)
event.remove(session, "after_rollback", record_rollback)
assert exc_info.value.status_code == 404
assert exc_info.value.code == "source_not_found"
assert rollback_events == [session]
assert not session.in_transaction()
def test_recommit_same_value_is_idempotent_and_keeps_value():
tf = _seed_tool_file()
_commit("a.txt", tf)
_commit("a.txt", tf) # no error, no cleanup
with session_factory.create_session() as session:
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "a.txt")))
assert len(rows) == 1
def 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"),
)
],
session=session_factory.create_session(),
)
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"),
)
],
session=session_factory.create_session(),
)
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,
storage_type=StorageType.LOCAL,
key=f"upload_files/{TENANT}/{name}",
name=name,
size=7,
extension="txt",
mime_type="text/plain",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=USER,
created_at=datetime.datetime.now(tz=datetime.UTC),
used=False,
)
with session_factory.create_session() as session:
session.add(upload)
session.commit()
return upload.id
def _commit_upload(key: str, upload_file_id: str, *, owned: bool = True):
return AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key=key,
file_ref={"kind": "upload_file", "id": upload_file_id},
value_owned_by_drive=owned,
)
],
session=session_factory.create_session(),
)
def test_commit_upload_file_source_and_manifest():
uf = _seed_upload_file()
_commit_upload("docs/u.txt", uf)
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
assert items[0]["file_kind"] == "upload_file"
assert items[0]["file_id"] == uf
assert items[0]["mime_type"] == "text/plain"
def test_commit_rejects_missing_upload_file():
with pytest.raises(AgentDriveError) as exc_info:
_commit_upload("x.txt", "44444444-4444-4444-4444-444444444444")
assert exc_info.value.status_code == 404
assert exc_info.value.code == "source_not_found"
def test_overwrite_cleans_old_upload_file_value():
u1 = _seed_upload_file(name="v1.txt")
u2 = _seed_upload_file(name="v2.txt")
_commit_upload("doc.txt", u1, owned=True)
with patch("services.agent_drive_service.storage") as storage_mock:
_commit_upload("doc.txt", u2, owned=True)
storage_mock.delete.assert_called_once()
with session_factory.create_session() as session:
assert session.scalar(select(UploadFile).where(UploadFile.id == u1)) is None
assert session.scalar(select(UploadFile).where(UploadFile.id == u2)) is not None
def test_manifest_includes_internal_download_url():
tf = _seed_tool_file()
_commit("data/r.txt", tf)
with (
patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()),
patch("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, session=session_factory.create_session()
)
assert items[0]["download_url"] == "http://internal/files/x?sign=1"
# drive-owned resolution: internal URL (for_external=False)
assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False
def test_manifest_download_url_none_when_unresolvable():
tf = _seed_tool_file()
_commit("data/r.txt", tf)
with patch(
"services.agent_drive_service.file_factory.build_from_mapping",
side_effect=ValueError("not found"),
):
items = AgentDriveService().manifest(
tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session()
)
assert items[0]["download_url"] is None
# ── ENG-625 D5: delete ────────────────────────────────────────────────────────
def test_delete_by_key_cleans_drive_owned_value():
tf = _seed_tool_file(name="doomed.txt")
_commit("files/doomed.txt", tf, owned=True)
with patch("services.agent_drive_service.storage") as storage_mock:
removed = AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)],
session=session_factory.create_session(),
)
storage_mock.delete.assert_called_once()
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_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)
_commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True)
other = _seed_tool_file(name="other.txt")
_commit("files/other.txt", other, owned=True)
with patch("services.agent_drive_service.storage"):
removed = AgentDriveService().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),
],
session=session_factory.create_session(),
)
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
assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None
keys = [row.key for row in session.scalars(select(AgentDriveFile))]
assert keys == ["files/other.txt"]
def test_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)],
session=session_factory.create_session(),
)
assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}]
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().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[DriveCommitItem(key="files/shared.txt", file_ref=None)],
session=session_factory.create_session(),
)
storage_mock.delete.assert_not_called()
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
def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles():
"""ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill
overwrites <slug>/SKILL.md and <slug>/.DIFY-SKILL-FULL.zip, physically
cleaning both old drive-owned ToolFiles."""
old_md = _seed_tool_file(name="SKILL.md")
old_zip = _seed_tool_file(name="full-v1.zip")
_commit("pdf-toolkit/SKILL.md", old_md, owned=True)
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True)
new_md = _seed_tool_file(name="SKILL-v2.md")
new_zip = _seed_tool_file(name="full-v2.zip")
with patch("services.agent_drive_service.storage") as storage_mock:
_commit("pdf-toolkit/SKILL.md", new_md, owned=True)
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True)
assert storage_mock.delete.call_count == 2
with session_factory.create_session() as session:
assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None
rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))}
assert rows == {
"pdf-toolkit/SKILL.md": new_md,
"pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip,
}
# ── ENG-624: console drive inspector (service layer) ─────────────────────────
def test_preview_returns_text_with_truncation_flags():
tf = _seed_tool_file(name="SKILL.md")
_commit("pdf-toolkit/SKILL.md", tf)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"])
result = AgentDriveService().preview(
tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md", session=session_factory.create_session()
)
assert result == {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# PDF Toolkit\nUse responsibly.\n",
}
def test_preview_marks_binary_and_oversized_content():
tf = _seed_tool_file(name="blob.bin")
_commit("files/blob.bin", tf)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"])
binary = AgentDriveService().preview(
tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
)
assert binary["binary"] is True
assert binary["text"] is None
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)])
oversized = AgentDriveService().preview(
tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
)
assert oversized["truncated"] is True
assert oversized["binary"] is False
assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES
def test_preview_unknown_key_is_404():
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().preview(
tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md", session=session_factory.create_session()
)
assert exc_info.value.code == "drive_key_not_found"
assert exc_info.value.status_code == 404
def test_preview_rejects_cross_tenant_agent():
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().preview(
tenant_id="99999999-9999-9999-9999-999999999999",
agent_id=AGENT,
key="pdf-toolkit/SKILL.md",
session=session_factory.create_session(),
)
assert exc_info.value.code == "agent_not_found"
def test_download_url_signs_external_audience():
tf = _seed_tool_file(name="full.zip")
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf)
with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver:
url = AgentDriveService().download_url(
tenant_id=TENANT,
agent_id=AGENT,
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
session=session_factory.create_session(),
)
assert url == "https://signed.example/x"
# console downloads are for browsers: external signing, never the internal URL
assert resolver.call_args.kwargs["for_external"] is True
assert resolver.call_args.kwargs["as_attachment"] is True
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("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", session=session_factory.create_session()
)
assert url == "https://files.example/report.pdf"
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True
def test_manifest_items_carry_created_at_for_inspector():
tf = _seed_tool_file()
_commit("files/x.txt", tf)
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
# ── DIFY-2517: skill catalog / inspect ───────────────────────────────────────
def _commit_skill(*, manifest_files: list[str] | None = None) -> None:
md = _seed_tool_file(name="SKILL.md")
zf = _seed_tool_file(name="full.zip")
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="pdf-toolkit/SKILL.md",
file_ref={"kind": "tool_file", "id": md},
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(
name="PDF Toolkit",
description="Work with PDFs.",
manifest_files=manifest_files,
),
),
DriveCommitItem(
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
file_ref={"kind": "tool_file", "id": zf},
value_owned_by_drive=True,
),
],
session=session_factory.create_session(),
)
def test_list_skills_uses_canonical_skill_rows():
_commit_skill(manifest_files=["SKILL.md", "scripts/run.py"])
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
created_at = skills[0].pop("created_at")
assert skills == [
{
"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": 5,
"mime_type": "text/plain",
"hash": None,
}
]
assert created_at is None or isinstance(created_at, int)
def test_inspect_skill_returns_manifest_files_and_file_tree():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"])
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
result = AgentDriveService().inspect_skill(
tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
)
assert result["source"] == "skill_md"
assert result["warnings"] == []
assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
assert result["files"][0]["available_in_drive"] is True
assert result["files"][1]["available_in_drive"] is True
assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md"
assert result["file_tree"][0]["name"] == "references"
assert result["file_tree"][1]["name"] == "scripts"
assert result["file_tree"][2]["name"] == "SKILL.md"
assert result["skill_md"]["text"] == "# PDF Toolkit\n"
def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
_commit_skill(manifest_files=None)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
result = AgentDriveService().inspect_skill(
tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
)
assert result["warnings"] == ["manifest_files_unavailable"]
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
def test_preview_skill_archive_member_from_manifest_without_drive_row():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"})
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([archive])
result = AgentDriveService().preview(
tenant_id=TENANT,
agent_id=AGENT,
key="pdf-toolkit/references/guide.md",
session=session_factory.create_session(),
)
assert result == {
"key": "pdf-toolkit/references/guide.md",
"size": len(b"Guide content\n"),
"truncated": False,
"binary": False,
"text": "Guide content\n",
}
def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
with patch.object(
AgentDriveService,
"sign_archive_member_url",
return_value="https://signed.example/member",
) as sign:
url = AgentDriveService().download_url(
tenant_id=TENANT,
agent_id=AGENT,
key="pdf-toolkit/references/guide.md",
session=session_factory.create_session(),
)
assert url == "https://signed.example/member"
kwargs = sign.call_args.kwargs
assert kwargs["key"] == "pdf-toolkit/references/guide.md"
assert kwargs["member_path"] == "references/guide.md"
assert kwargs["for_external"] is True
def test_skill_metadata_rejects_non_canonical_rows():
tf = _seed_tool_file(name="not-skill.md")
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="files/not-skill.md",
file_ref={"kind": "tool_file", "id": tf},
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Bad"),
)
],
session=session_factory.create_session(),
)
assert exc_info.value.code == "invalid_skill_key"
@@ -27,7 +27,7 @@ from models import (
PinnedConversation,
SavedMessage,
)
from models.agent import AgentConfigDraftType, AgentDriveFile, AgentDriveFileKind
from models.agent import AgentConfigDraftType
from models.enums import (
ConversationFromSource,
ConversationStatus,
@@ -93,14 +93,13 @@ def _tool_file(*, name: str, conversation_id: str | None = CONVERSATION_ID) -> T
)
def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_session: Session) -> None:
def test_cleanup_removes_owned_resources(sqlite_session: Session) -> None:
conversation = _conversation(CONVERSATION_ID, deleted=True)
other_conversation = _conversation(OTHER_CONVERSATION_ID, deleted=False)
message = _message()
owned_file = _tool_file(name="owned.txt")
drive_file = _tool_file(name="drive.txt")
other_file = _tool_file(name="other.txt", conversation_id=OTHER_CONVERSATION_ID)
sqlite_session.add_all([conversation, other_conversation, message, owned_file, drive_file, other_file])
sqlite_session.add_all([conversation, other_conversation, message, owned_file, other_file])
sqlite_session.flush()
message_chain = MessageChain(message_id=MESSAGE_ID, type=MessageChainType.SYSTEM, input=None, output=None)
@@ -202,15 +201,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=CONVERSATION_ID,
),
AgentDriveFile(
tenant_id=TENANT_ID,
agent_id=AGENT_ID,
key="drive.txt",
file_kind=AgentDriveFileKind.TOOL_FILE,
file_id=drive_file.id,
value_owned_by_drive=False,
is_skill=False,
),
HumanInputFormRecipient(
form_id=form.id,
delivery_id=delivery.id,
@@ -230,7 +220,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
form_id = form.id
owned_file_id = owned_file.id
owned_file_key = owned_file.file_key
drive_file_id = drive_file.id
other_file_id = other_file.id
with patch("tasks.delete_conversation_task.storage") as storage_mock:
@@ -245,12 +234,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
)
assert sqlite_session.scalar(select(HumanInputForm).where(HumanInputForm.id == form_id)) is None
assert sqlite_session.get(ToolFile, owned_file_id) is None
preserved_drive_file = sqlite_session.get(ToolFile, drive_file_id)
assert preserved_drive_file is not None
assert preserved_drive_file.conversation_id is None
preserved_drive_entry = sqlite_session.scalar(select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id))
assert preserved_drive_entry is not None
assert preserved_drive_entry.value_owned_by_drive is True
assert sqlite_session.get(ToolFile, other_file_id) is not None
assert sqlite_session.get(Conversation, OTHER_CONVERSATION_ID) is not None
+1 -57
View File
@@ -1,6 +1,6 @@
// dify-agent-cli is the Go replacement for the Python dify-agent CLI.
// It communicates with the Agent Stub server via HTTP to provide
// connect, file, drive, and config operations inside the sandbox container.
// connect, file, and config operations inside the sandbox container.
package main
import (
@@ -17,7 +17,6 @@ import (
var knownRootCommands = map[string]struct{}{
"config": {},
"connect": {},
"drive": {},
"file": {},
}
@@ -76,7 +75,6 @@ func newRootCommand() *cobra.Command {
root.AddCommand(
newConnectCommand(),
newFileCommand(),
newDriveCommand(),
newConfigCommand(),
)
return root
@@ -142,60 +140,6 @@ func newFileCommand() *cobra.Command {
return cmd
}
func newDriveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "drive",
Short: "List, pull, or push agent drive files through the Agent Stub.",
}
var listJSON bool
list := &cobra.Command{
Use: "list [REMOTE_PREFIX]",
Short: "List drive files visible to the current sandbox execution.",
Args: cobra.MaximumNArgs(1),
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
prefix := ""
if len(args) > 0 {
prefix = args[0]
}
return agentcli.RunDriveList(env, prefix, listJSON)
}),
}
list.Flags().BoolVar(&listJSON, "json", false, "Emit the drive manifest as JSON.")
var pullTo string
var pullJSON bool
pull := &cobra.Command{
Use: "pull [REMOTE]...",
Short: "Pull one or more drive keys/prefixes into one local directory tree.",
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
localBase := pullTo
if localBase == "" {
localBase = agentcli.ReadDriveBase()
}
return agentcli.RunDrivePull(env, args, localBase, pullJSON)
}),
}
pull.Flags().StringVar(&pullTo, "to", "", "Local base directory for pulled drive files.")
pull.Flags().BoolVar(&pullJSON, "json", false, "Emit the pull result as JSON.")
var pushKind string
var pushJSON bool
push := &cobra.Command{
Use: "push LOCAL_PATH REMOTE_PATH",
Short: "Upload one local file or directory into the agent drive.",
Args: cobra.ExactArgs(2),
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
return agentcli.RunDrivePush(env, args[0], args[1], pushKind)
}),
}
push.Flags().StringVar(&pushKind, "kind", "", "Directory upload kind: skill or dir.")
push.Flags().BoolVar(&pushJSON, "json", false, "Accepted for consistency; drive push output is already emitted as JSON.")
cmd.AddCommand(list, pull, push)
return cmd
}
func newConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
@@ -38,7 +38,7 @@ func TestCommandHelp(t *testing.T) {
{
name: "root",
args: []string{"--help"},
want: []string{"Usage:", "dify-agent", "config", "connect", "drive", "file"},
want: []string{"Usage:", "dify-agent", "config", "connect", "file"},
},
{
name: "connect",
@@ -70,26 +70,6 @@ func TestCommandHelp(t *testing.T) {
args: []string{"file", "public-url", "--help"},
want: []string{"dify-agent file public-url", "Create a browser-visible download URL"},
},
{
name: "drive",
args: []string{"drive", "--help"},
want: []string{"dify-agent drive", "list", "pull", "push"},
},
{
name: "drive list",
args: []string{"drive", "list", "--help"},
want: []string{"dify-agent drive list", "List drive files", "--json"},
},
{
name: "drive pull",
args: []string{"drive", "pull", "--help"},
want: []string{"dify-agent drive pull", "Pull one or more drive", "--to", "--json"},
},
{
name: "drive push",
args: []string{"drive", "push", "--help"},
want: []string{"dify-agent drive push", "Upload one local file or directory", "--kind", "--json"},
},
{
name: "config",
args: []string{"config", "--help"},
+1 -2
View File
@@ -71,9 +71,8 @@ COPY --from=go-builder /bin/shellctl-runner /usr/local/bin/shellctl-runner
COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /mnt/drive \
&& chown dify:dify /home \
&& chown -R dify:dify /home/dify /mnt/drive
&& chown -R dify:dify /home/dify
USER dify
WORKDIR /home/dify
@@ -135,3 +135,26 @@ func extractZip(archivePath string, targetDir string) error {
}
return nil
}
func shouldSkipDir(name string) bool {
skip := map[string]bool{
".git": true, "__pycache__": true, ".pytest_cache": true,
".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true,
}
return skip[name]
}
func buildSkillArchive(dirPath string) (string, error) {
tmpFile, err := os.CreateTemp("", "skill-archive-*.zip")
if err != nil {
return "", fmt.Errorf("create temp archive: %w", err)
}
archivePath := tmpFile.Name()
_ = tmpFile.Close()
if err := createZipArchive(archivePath, dirPath); err != nil {
_ = os.Remove(archivePath)
return "", err
}
return archivePath, nil
}
@@ -10,10 +10,6 @@ type StubClient interface {
CreateToolFileUploadURL(ctx context.Context, filename, mimetype string) (string, error)
CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error)
// Drive operations (HTTP-only control-plane)
GetDriveManifest(ctx context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error)
CommitDrive(ctx context.Context, items []DriveCommitItem) ([]byte, error)
// Config operations (HTTP-only control-plane)
GetConfigManifest(ctx context.Context) ([]byte, error)
CreateConfigDownloadURL(ctx context.Context, kind, name string) (*FileDownloadResponse, error)
@@ -127,45 +127,6 @@ func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod
return &resp, nil
}
func (c *httpStubClient) GetDriveManifest(_ context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error) {
params := map[string]string{
"prefix": prefix,
}
if includeDownloadURL {
params["include_download_url"] = "true"
} else {
params["include_download_url"] = "false"
}
body, statusCode, err := c.http.getJSON("/drive/manifest", params)
if err != nil {
return nil, err
}
if err := checkHTTPError(body, statusCode, "drive manifest"); err != nil {
return nil, err
}
var manifest DriveManifestResponse
if err := json.Unmarshal(body, &manifest); err != nil {
return nil, fmt.Errorf("parse drive manifest: %w", err)
}
return &manifest, nil
}
func (c *httpStubClient) CommitDrive(_ context.Context, items []DriveCommitItem) ([]byte, error) {
payload := map[string]any{
"items": items,
}
body, statusCode, err := c.http.postJSON("/drive/commit", payload)
if err != nil {
return nil, err
}
if err := checkHTTPError(body, statusCode, "drive commit"); err != nil {
return nil, err
}
return body, nil
}
func (c *httpStubClient) GetConfigManifest(_ context.Context) ([]byte, error) {
body, statusCode, err := c.http.getJSON("/config/manifest", nil)
if err != nil {
+17 -12
View File
@@ -10,6 +10,11 @@ import (
const defaultConfigBase = ".dify_conf"
type ConfigFileRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// RunConfigManifest executes the `config manifest` command.
func RunConfigManifest(env *Environment) error {
client, err := NewStubClient(env)
@@ -216,8 +221,8 @@ func RunConfigSkillsPush(env *Environment, paths []string) error {
defer func() { _ = client.Close() }()
type skillPushItem struct {
Name string `json:"name"`
FileRef *DriveFileRef `json:"file_ref"`
Name string `json:"name"`
FileRef *ConfigFileRef `json:"file_ref"`
}
var skills []skillPushItem
@@ -243,7 +248,7 @@ func RunConfigSkillsPush(env *Environment, paths []string) error {
defer func() { _ = os.Remove(archivePath) }()
name := filepath.Base(absPath)
fileRef, err := uploadAndPrepareConfigItem(client, archivePath)
fileRef, err := uploadConfigFile(client, archivePath)
if err != nil {
return fmt.Errorf("upload config skill %q: %w", name, err)
}
@@ -280,8 +285,8 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
defer func() { _ = client.Close() }()
type filePushItem struct {
Name string `json:"name"`
FileRef *DriveFileRef `json:"file_ref"`
Name string `json:"name"`
FileRef *ConfigFileRef `json:"file_ref"`
}
var files []filePushItem
@@ -296,7 +301,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
}
name := filepath.Base(absPath)
fileRef, err := uploadAndPrepareConfigItem(client, absPath)
fileRef, err := uploadConfigFile(client, absPath)
if err != nil {
return fmt.Errorf("upload config file %q: %w", name, err)
}
@@ -320,7 +325,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error {
return nil
}
func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileRef, error) {
func uploadConfigFile(client StubClient, filePath string) (*ConfigFileRef, error) {
filename := filepath.Base(filePath)
mimetype := guessMIMEType(filename)
uploadURL, err := client.CreateToolFileUploadURL(context.Background(), filename, mimetype)
@@ -331,16 +336,16 @@ func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileR
if err != nil {
return nil, fmt.Errorf("upload data: %w", err)
}
var uploadResult map[string]any
var uploadResult struct {
ID string `json:"id"`
}
if err := json.Unmarshal(uploadBody, &uploadResult); err != nil {
return nil, fmt.Errorf("parse upload result: %w", err)
}
toolFileID, _ := uploadResult["id"].(string)
if toolFileID == "" {
if uploadResult.ID == "" {
return nil, fmt.Errorf("upload response is missing id")
}
return &DriveFileRef{Kind: "tool_file", ID: toolFileID}, nil
return &ConfigFileRef{Kind: "tool_file", ID: uploadResult.ID}, nil
}
// RunConfigSkillsDelete executes the `config skills delete` command.
@@ -13,6 +13,117 @@ import (
"testing"
)
type configPushCapture struct {
payload map[string]any
upload []byte
}
func newConfigPushServer(t *testing.T) (*httptest.Server, *configPushCapture) {
t.Helper()
capture := &configPushCapture{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/upload-request":
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/uploads/config-asset"})
case "/uploads/config-asset":
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing upload", http.StatusBadRequest)
return
}
defer func() { _ = file.Close() }()
capture.upload, err = io.ReadAll(file)
if err != nil {
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": "tool-file-1"})
case "/agent-stub/config/push":
if err := json.NewDecoder(r.Body).Decode(&capture.payload); err != nil {
http.Error(w, "bad config", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"result": "success"})
default:
http.NotFound(w, r)
}
}))
return server, capture
}
func assertConfigPushItem(t *testing.T, payload map[string]any, key string, name string) {
t.Helper()
items, ok := payload[key].([]any)
if !ok || len(items) != 1 {
t.Fatalf("%s = %#v, want one item", key, payload[key])
}
item, ok := items[0].(map[string]any)
if !ok || item["name"] != name {
t.Fatalf("%s item = %#v", key, items[0])
}
fileRef, ok := item["file_ref"].(map[string]any)
if !ok || fileRef["kind"] != "tool_file" || fileRef["id"] != "tool-file-1" {
t.Fatalf("file_ref = %#v", item["file_ref"])
}
}
func TestConfigFilesPushUploadsFileAndPushesToolFileRef(t *testing.T) {
server, capture := newConfigPushServer(t)
defer server.Close()
filePath := filepath.Join(t.TempDir(), "guide.txt")
if err := os.WriteFile(filePath, []byte("guide"), 0o644); err != nil {
t.Fatalf("write config file: %v", err)
}
if err := RunConfigFilesPush(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
[]string{filePath},
); err != nil {
t.Fatalf("push config file: %v", err)
}
if string(capture.upload) != "guide" {
t.Fatalf("uploaded file = %q", capture.upload)
}
assertConfigPushItem(t, capture.payload, "files", "guide.txt")
}
func TestConfigSkillsPushUploadsArchiveAndPushesToolFileRef(t *testing.T) {
server, capture := newConfigPushServer(t)
defer server.Close()
skillDir := filepath.Join(t.TempDir(), "alpha")
if err := os.Mkdir(skillDir, 0o755); err != nil {
t.Fatalf("create skill directory: %v", err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Alpha\n"), 0o644); err != nil {
t.Fatalf("write SKILL.md: %v", err)
}
if err := RunConfigSkillsPush(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
[]string{skillDir},
); err != nil {
t.Fatalf("push config skill: %v", err)
}
archive, err := zip.NewReader(bytes.NewReader(capture.upload), int64(len(capture.upload)))
if err != nil {
t.Fatalf("open uploaded skill archive: %v", err)
}
foundSkillMD := false
for _, file := range archive.File {
if file.Name == "SKILL.md" {
foundSkillMD = true
break
}
}
if !foundSkillMD {
t.Fatalf("uploaded skill archive does not contain SKILL.md")
}
assertConfigPushItem(t, capture.payload, "skills", "alpha")
}
func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) {
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n", "reference.md": "guide"})
tests := []struct {
@@ -1,353 +0,0 @@
package agentcli
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// DriveItem represents one item in a drive manifest.
type DriveItem struct {
Key string `json:"key"`
Size *int64 `json:"size,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Hash string `json:"hash,omitempty"`
DownloadURL *string `json:"download_url,omitempty"`
}
// DriveManifestResponse is the drive manifest from the Agent Stub.
type DriveManifestResponse struct {
Items []DriveItem `json:"items"`
}
// DrivePullResultItem represents one pulled drive file.
type DrivePullResultItem struct {
Key string `json:"key"`
LocalPath string `json:"local_path"`
}
// DrivePullResult is the JSON output for `dify-agent drive pull --json`.
type DrivePullResult struct {
Items []DrivePullResultItem `json:"items"`
}
// DriveCommitItem represents one file to commit into the drive.
type DriveCommitItem struct {
Key string `json:"key"`
FileRef DriveFileRef `json:"file_ref"`
}
// DriveFileRef is the reference to an uploaded file.
type DriveFileRef struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// DriveCommitResponse is the response from a drive commit.
type DriveCommitResponse struct {
Items []DriveItem `json:"items"`
}
// RunDriveList executes the `drive list` command.
func RunDriveList(env *Environment, pathPrefix string, jsonOutput bool) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
manifest, err := client.GetDriveManifest(context.Background(), pathPrefix, false)
if err != nil {
return err
}
if jsonOutput {
out, _ := json.Marshal(manifest)
fmt.Println(string(out))
return nil
}
for _, item := range manifest.Items {
size := "-"
if item.Size != nil {
size = fmt.Sprintf("%d", *item.Size)
}
mimeType := item.MimeType
if mimeType == "" {
mimeType = "-"
}
hash := item.Hash
if hash == "" {
hash = "-"
}
fmt.Printf("%s\t%s\t%s\t%s\n", size, mimeType, hash, item.Key)
}
return nil
}
// RunDrivePull executes the `drive pull` command.
func RunDrivePull(env *Environment, targets []string, localBase string, jsonOutput bool) error {
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
if localBase == "" {
localBase = ReadDriveBase()
}
resolvedBase, err := filepath.Abs(localBase)
if err != nil {
return fmt.Errorf("resolve drive base: %w", err)
}
if len(targets) == 0 {
targets = []string{""}
}
ctx := context.Background()
resultItems := []DrivePullResultItem{}
for _, target := range targets {
manifest, err := client.GetDriveManifest(ctx, target, true)
if err != nil {
return err
}
if len(manifest.Items) == 0 {
continue
}
localPath := resolveDriveDestination(resolvedBase, target)
resultItems = append(resultItems, DrivePullResultItem{Key: target, LocalPath: localPath})
for _, item := range manifest.Items {
if item.DownloadURL == nil || *item.DownloadURL == "" {
return fmt.Errorf("drive manifest item is missing download_url: %s", item.Key)
}
destPath := resolveDriveDestination(resolvedBase, item.Key)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
data, err := client.DownloadFromURL(*item.DownloadURL)
if err != nil {
return fmt.Errorf("download %s: %w", item.Key, err)
}
if err := os.WriteFile(destPath, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", destPath, err)
}
}
}
if jsonOutput {
out, _ := json.Marshal(DrivePullResult{Items: resultItems})
fmt.Println(string(out))
return nil
}
for _, item := range resultItems {
fmt.Println(item.LocalPath)
}
return nil
}
// RunDrivePush executes the `drive push` command.
func RunDrivePush(env *Environment, localPath string, drivePath string, kind string) error {
absPath, err := filepath.Abs(localPath)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("local path not found: %s", absPath)
}
client, err := NewStubClient(env)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
if info.IsDir() {
if kind == "" {
return fmt.Errorf("directory drive push requires --kind skill or --kind dir")
}
if kind == "file" {
return fmt.Errorf("--kind file requires a file")
}
if kind == "dir" {
return pushDirectory(client, absPath, drivePath)
}
return pushSkillDirectory(client, absPath, drivePath)
}
// Single file push
if kind == "skill" {
return fmt.Errorf("--kind skill requires a directory containing SKILL.md")
}
if kind == "dir" {
return fmt.Errorf("--kind dir requires a directory")
}
commitItem, err := uploadAndPrepareCommitItem(client, absPath, drivePath)
if err != nil {
return err
}
return commitDriveItems(client, []DriveCommitItem{*commitItem})
}
func pushDirectory(client StubClient, dirPath string, drivePath string) error {
var items []DriveCommitItem
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if shouldSkipDir(info.Name()) {
return filepath.SkipDir
}
return nil
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("drive push does not support symlinked files: %s", path)
}
relPath, _ := filepath.Rel(dirPath, path)
driveKey := joinDriveKey(drivePath, filepath.ToSlash(relPath))
commitItem, err := uploadAndPrepareCommitItem(client, path, driveKey)
if err != nil {
return err
}
items = append(items, *commitItem)
return nil
})
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("directory has no regular files: %s", dirPath)
}
return commitDriveItems(client, items)
}
func pushSkillDirectory(client StubClient, dirPath string, drivePath string) error {
skillMDPath := filepath.Join(dirPath, "SKILL.md")
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
return fmt.Errorf("--kind skill requires a directory containing SKILL.md")
}
// Upload SKILL.md
skillMDItem, err := uploadAndPrepareCommitItem(client, skillMDPath, joinDriveKey(drivePath, "SKILL.md"))
if err != nil {
return err
}
// Build and upload archive
archivePath, err := buildSkillArchive(dirPath)
if err != nil {
return err
}
defer func() { _ = os.Remove(archivePath) }()
archiveItem, err := uploadAndPrepareCommitItem(client, archivePath, joinDriveKey(drivePath, ".DIFY-SKILL-FULL.zip"))
if err != nil {
return err
}
return commitDriveItems(client, []DriveCommitItem{*skillMDItem, *archiveItem})
}
func uploadAndPrepareCommitItem(client StubClient, filePath string, driveKey string) (*DriveCommitItem, error) {
filename := filepath.Base(filePath)
mimetype := guessMIMEType(filename)
ctx := context.Background()
// Request upload URL
uploadURL, err := client.CreateFileUploadURL(ctx, filename, mimetype)
if err != nil {
return nil, err
}
// Upload
uploadBody, err := client.UploadFileToURL(uploadURL, filePath, filename, mimetype)
if err != nil {
return nil, err
}
var uploadResult map[string]any
if err := json.Unmarshal(uploadBody, &uploadResult); err != nil {
return nil, fmt.Errorf("parse upload result: %w", err)
}
toolFileID, _ := uploadResult["id"].(string)
if toolFileID == "" {
return nil, fmt.Errorf("upload response is missing id")
}
return &DriveCommitItem{
Key: driveKey,
FileRef: DriveFileRef{Kind: "tool_file", ID: toolFileID},
}, nil
}
func commitDriveItems(client StubClient, items []DriveCommitItem) error {
body, err := client.CommitDrive(context.Background(), items)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
func resolveDriveDestination(basePath string, key string) string {
if key == "" {
return basePath
}
return filepath.Join(basePath, filepath.FromSlash(key))
}
func joinDriveKey(base string, child string) string {
stripped := strings.TrimRight(base, "/")
child = strings.TrimLeft(child, "/")
if stripped == "" {
return child
}
return stripped + "/" + child
}
func shouldSkipDir(name string) bool {
skip := map[string]bool{
".git": true, "__pycache__": true, ".pytest_cache": true,
".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true,
}
return skip[name]
}
// buildSkillArchive creates a zip archive of the skill directory.
func buildSkillArchive(dirPath string) (string, error) {
// Create temp file for archive
tmpFile, err := os.CreateTemp("", "skill-archive-*.zip")
if err != nil {
return "", fmt.Errorf("create temp archive: %w", err)
}
archivePath := tmpFile.Name()
_ = tmpFile.Close()
if err := createZipArchive(archivePath, dirPath); err != nil {
_ = os.Remove(archivePath)
return "", err
}
return archivePath, nil
}
@@ -15,9 +15,6 @@ import (
const (
EnvAPIBaseURL = envvar.EnvAgentStubAPIBaseURL
EnvAuthJWE = envvar.EnvAgentStubAuthJWE
EnvDriveBase = envvar.EnvAgentStubDriveBase
DefaultDriveBase = envvar.DefaultDriveBase
)
// Environment holds validated Agent Stub connection parameters.
@@ -65,14 +62,6 @@ func HasEnvironment() bool {
return os.Getenv(EnvAPIBaseURL) != "" && os.Getenv(EnvAuthJWE) != ""
}
// ReadDriveBase returns the configured drive base or the default.
func ReadDriveBase() string {
if v := strings.TrimSpace(os.Getenv(EnvDriveBase)); v != "" {
return v
}
return DefaultDriveBase
}
// ParseEndpoint parses an Agent Stub URL and normalizes it.
func ParseEndpoint(rawURL string) (*Endpoint, error) {
stripped := strings.TrimSpace(rawURL)
@@ -140,17 +140,3 @@ func TestReadEnvironment_Valid(t *testing.T) {
t.Errorf("AuthJWE = %q, want %q", env.AuthJWE, "test-token")
}
}
func TestReadDriveBase_Default(t *testing.T) {
t.Setenv(EnvDriveBase, "")
if got := ReadDriveBase(); got != DefaultDriveBase {
t.Errorf("ReadDriveBase() = %q, want %q", got, DefaultDriveBase)
}
}
func TestReadDriveBase_Custom(t *testing.T) {
t.Setenv(EnvDriveBase, "/custom/drive")
if got := ReadDriveBase(); got != "/custom/drive" {
t.Errorf("ReadDriveBase() = %q, want %q", got, "/custom/drive")
}
}
@@ -28,13 +28,6 @@ const (
// EnvAgentStubAuthJWE is the per-request JWE token for Agent Stub auth.
EnvAgentStubAuthJWE = "DIFY_AGENT_STUB_AUTH_JWE"
// EnvAgentStubDriveBase is the sandbox-local drive directory for the agent.
EnvAgentStubDriveBase = "DIFY_AGENT_STUB_DRIVE_BASE"
// DefaultDriveBase is the default Agent Stub drive mount point.
// currently unused.
DefaultDriveBase = "/mnt/drive"
)
// PathIsolationEnabled returns whether Landlock filesystem isolation is active.
@@ -19,7 +19,7 @@ type Config struct {
var (
// DefaultRWPaths are directories granted read-write access besides HOME.
// Agent-specific paths (e.g. drive base) are added dynamically by the runner.
// Agent-specific paths are added dynamically by the runner.
DefaultRWPaths = []string{}
// DefaultROPaths are directories granted read-only + execute access.
+1 -1
View File
@@ -21,7 +21,7 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
# Dify API inner endpoints
# Base URL for Dify API inner endpoints used by Agent Stub config/file/drive requests.
# Base URL for Dify API inner endpoints used by Agent Stub config and file requests.
DIFY_AGENT_INNER_API_URL=http://localhost:5001
# Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY.
DIFY_AGENT_INNER_API_KEY=
@@ -28,7 +28,6 @@ RunLayerSpec(
| Config field | Meaning |
| --- | --- |
| `agent_stub_drive_ref` | Optional Drive ref used by shell-visible Agent Stub commands. |
| `cli_tools` | CLI bootstrap declarations with install commands and scoped environment metadata. |
| `env` | Normal environment variables exported to Shell commands. |
| `secret_refs` | Names of secret environment variables supplied by the backend environment. |
@@ -1,15 +0,0 @@
"""Zero-side-effect Agent Stub constants shared across client-safe modules."""
from __future__ import annotations
from typing import Final
AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE"
DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive"
__all__ = [
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
"DEFAULT_AGENT_STUB_DRIVE_BASE",
]
@@ -1,176 +0,0 @@
"""Shared drive download materialization helpers.
This module centralizes the safety-critical filesystem logic used by both the
sandbox-visible CLI and the runtime drive layer. It owns path resolution under
one local drive base, overwrite-via-temp-file semantics, payload size checks,
and safe extraction of downloaded skill archives so those invariants cannot
drift between the two call sites.
"""
from __future__ import annotations
import stat
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Final
from uuid import uuid4
from zipfile import BadZipFile, ZipFile, ZipInfo
SKILL_ARCHIVE_FILENAME: Final[str] = ".DIFY-SKILL-FULL.zip"
@dataclass(frozen=True, slots=True)
class DriveDownloadPayload:
"""One downloaded drive payload ready to materialize under a local base."""
key: str
payload: bytes
size: int | None = None
class DriveMaterializationValidationError(ValueError):
"""Raised when one drive key or archive entry is structurally unsafe."""
class DriveMaterializationTransferError(RuntimeError):
"""Raised when one downloaded payload cannot be safely materialized."""
def materialize_drive_downloads(
*,
base_path: Path,
downloads: list[DriveDownloadPayload],
) -> list[Path]:
"""Write downloaded drive payloads under one local base and extract skills.
The helper preserves caller-provided order in the returned list of paths.
Skill archives are extracted and deleted only after every payload has been
written successfully so partial extraction cannot outlive a later failure in
the same batch. The returned path for an archive is the path where it was
downloaded before successful extraction.
"""
resolved_base_path = base_path.expanduser().resolve()
try:
_ = resolved_base_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise DriveMaterializationTransferError(f"failed to prepare drive base {resolved_base_path}") from exc
written_paths: list[Path] = []
archive_paths: list[Path] = []
for download in downloads:
if download.size is not None and len(download.payload) != download.size:
raise DriveMaterializationTransferError(f"downloaded drive file size mismatch for {download.key}")
destination = resolve_drive_destination(resolved_base_path, download.key)
try:
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
_ = temp_path.write_bytes(download.payload)
_ = temp_path.replace(destination)
except OSError as exc:
raise DriveMaterializationTransferError(f"failed to materialize drive file {download.key}") from exc
written_paths.append(destination)
if destination.name == SKILL_ARCHIVE_FILENAME:
archive_paths.append(destination)
for archive_path in sorted(archive_paths):
extract_skill_archive(archive_path)
_delete_extracted_archive(archive_path)
return written_paths
def resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
"""Resolve one drive key under a local base and reject path traversal."""
destination = (base_path / Path(drive_key)).resolve()
try:
destination.relative_to(base_path)
except ValueError as exc:
raise DriveMaterializationValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc
return destination
def extract_archive_to_directory(archive_path: Path, *, target_dir: Path) -> None:
"""Safely extract one downloaded archive into one resolved target directory."""
resolved_target_dir = target_dir.resolve()
try:
with TemporaryDirectory(dir=resolved_target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
staging_dir = Path(staging_dir_name).resolve()
with ZipFile(archive_path) as archive:
for zip_info in archive.infolist():
destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
if _is_zip_symlink(zip_info):
raise DriveMaterializationValidationError(
f"skill archive contains unsupported symlink entry: {zip_info.filename}"
)
if zip_info.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.open(zip_info) as source_file:
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
_ = temp_path.write_bytes(source_file.read())
_ = temp_path.replace(destination)
for staged_path in sorted(staging_dir.rglob("*")):
if staged_path.is_dir():
continue
relative_path = staged_path.relative_to(staging_dir)
destination = (resolved_target_dir / relative_path).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
_ = staged_path.replace(destination)
except DriveMaterializationValidationError:
raise
except (BadZipFile, OSError) as exc:
raise DriveMaterializationTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
def extract_skill_archive(archive_path: Path) -> None:
"""Safely extract one downloaded skill archive into its containing directory."""
extract_archive_to_directory(archive_path, target_dir=archive_path.parent.resolve())
def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
normalized_name = entry_name.replace("\\", "/")
pure_path = PurePosixPath(normalized_name)
if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute():
raise DriveMaterializationValidationError(f"skill archive contains unsafe absolute path: {entry_name}")
if any(part in {"", ".", ".."} for part in pure_path.parts):
raise DriveMaterializationValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}")
destination = (target_dir / Path(*pure_path.parts)).resolve()
try:
destination.relative_to(target_dir)
except ValueError as exc:
raise DriveMaterializationValidationError(
f"skill archive entry resolves outside the skill directory: {entry_name}"
) from exc
return destination
def _is_zip_symlink(zip_info: ZipInfo) -> bool:
file_mode = zip_info.external_attr >> 16
return stat.S_ISLNK(file_mode)
def _delete_extracted_archive(archive_path: Path) -> None:
try:
archive_path.unlink(missing_ok=True)
except OSError as exc:
raise DriveMaterializationTransferError(
f"failed to delete extracted skill archive: {archive_path.name}"
) from exc
__all__ = [
"DriveDownloadPayload",
"DriveMaterializationTransferError",
"DriveMaterializationValidationError",
"SKILL_ARCHIVE_FILENAME",
"extract_archive_to_directory",
"extract_skill_archive",
"materialize_drive_downloads",
"resolve_drive_destination",
]
@@ -1,7 +1,5 @@
"""Client-safe protocol exports for the Dify Agent Stub package."""
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
from .agent_stub import (
AGENT_STUB_AUTH_JWE_ENV_VAR,
AGENT_STUB_PROTOCOL_VERSION,
@@ -20,12 +18,6 @@ from .agent_stub import (
AgentStubConfigPushSkillItem,
AgentStubConfigSkillItem,
AgentStubConfigVersionInfo,
AgentStubDriveCommitItem,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveFileRef,
AgentStubDriveItem,
AgentStubDriveManifestResponse,
AgentStubEndpoint,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
@@ -39,9 +31,6 @@ from .agent_stub import (
agent_stub_config_push_url,
agent_stub_config_skill_inspect_url,
agent_stub_connections_url,
agent_stub_drive_base_for_ref,
agent_stub_drive_commit_url,
agent_stub_drive_manifest_url,
agent_stub_file_download_request_url,
agent_stub_file_upload_request_url,
is_canonical_dify_file_reference,
@@ -51,10 +40,8 @@ from .agent_stub import (
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_PROTOCOL_VERSION",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
"DEFAULT_AGENT_STUB_DRIVE_BASE",
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubConfigDownloadSource",
@@ -69,12 +56,6 @@ __all__ = [
"AgentStubConfigPushSkillItem",
"AgentStubConfigSkillItem",
"AgentStubConfigVersionInfo",
"AgentStubDriveCommitItem",
"AgentStubDriveCommitRequest",
"AgentStubDriveCommitResponse",
"AgentStubDriveFileRef",
"AgentStubDriveItem",
"AgentStubDriveManifestResponse",
"AgentStubEndpoint",
"AgentStubFileDownloadRequest",
"AgentStubFileDownloadResponse",
@@ -88,9 +69,6 @@ __all__ = [
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_connections_url",
"agent_stub_drive_base_for_ref",
"agent_stub_drive_commit_url",
"agent_stub_drive_manifest_url",
"agent_stub_file_download_request_url",
"agent_stub_file_upload_request_url",
"is_canonical_dify_file_reference",
@@ -18,9 +18,6 @@ from urllib.parse import urlsplit, urlunsplit
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue, model_validator
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1
AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL"
AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE"
@@ -39,17 +36,6 @@ class AgentStubEndpoint:
path: str
def agent_stub_drive_base_for_ref(drive_ref: str | None) -> str:
"""Return the fixed sandbox-local Agent Stub drive base for one drive ref."""
normalized_ref = (drive_ref or "").strip()
if not normalized_ref:
return DEFAULT_AGENT_STUB_DRIVE_BASE
drive_ref_parts = normalized_ref.split("/")
if normalized_ref.startswith("/") or any(part in {"", ".", ".."} for part in drive_ref_parts):
raise ValueError("Agent Stub drive_ref must be a safe relative path")
return f"{DEFAULT_AGENT_STUB_DRIVE_BASE.rstrip('/')}/{'/'.join(drive_ref_parts)}"
def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
"""Parse an HTTP(S) Agent Stub endpoint and normalize its API root."""
stripped = url.strip()
@@ -103,16 +89,6 @@ def agent_stub_file_download_request_url(base_url: str) -> str:
return f"{_require_http_base_url(base_url)}/files/download-request"
def agent_stub_drive_manifest_url(base_url: str) -> str:
"""Return the stable HTTP drive-manifest endpoint URL for one base URL."""
return f"{_require_http_base_url(base_url)}/drive/manifest"
def agent_stub_drive_commit_url(base_url: str) -> str:
"""Return the stable HTTP drive-commit endpoint URL for one base URL."""
return f"{_require_http_base_url(base_url)}/drive/commit"
def agent_stub_config_manifest_url(base_url: str) -> str:
"""Return the stable HTTP config-manifest endpoint URL for one base URL."""
return f"{_require_http_base_url(base_url)}/config/manifest"
@@ -270,70 +246,6 @@ class AgentStubFileDownloadResponse(BaseModel):
download_url: str
class AgentStubDriveFileRef(BaseModel):
"""Trusted file reference used by Agent Stub drive commit requests."""
kind: Literal["upload_file", "tool_file"]
id: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubDriveCommitItem(BaseModel):
"""One drive key to file binding committed through the Agent Stub."""
key: str
file_ref: AgentStubDriveFileRef | None = None
value_owned_by_drive: bool = True
is_skill: bool = False
skill_metadata: dict[str, str] | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubDriveCommitRequest(BaseModel):
"""Request body for one Agent Stub drive commit batch."""
items: list[AgentStubDriveCommitItem]
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class AgentStubDriveItem(BaseModel):
"""One manifest or commit item returned by the Agent Stub drive API.
Known stable fields stay typed, while extra response metadata from the Dify
API is preserved for forward compatibility.
"""
key: str
size: int | None = None
hash: str | None = None
mime_type: str | None = None
file_kind: Literal["upload_file", "tool_file"] | None = None
file_id: str | None = None
created_at: int | None = None
download_url: str | None = None
value_owned_by_drive: bool | None = None
removed: bool | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
class AgentStubDriveManifestResponse(BaseModel):
"""Response body for one Agent Stub drive manifest request."""
items: list[AgentStubDriveItem]
class AgentStubDriveCommitResponse(BaseModel):
"""Response body for one Agent Stub drive commit request."""
items: list[AgentStubDriveItem]
class AgentStubConfigVersionInfo(BaseModel):
id: str
kind: Literal["snapshot", "draft", "build_draft"]
@@ -424,10 +336,8 @@ def _require_http_base_url(base_url: str) -> str:
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_PROTOCOL_VERSION",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
"DEFAULT_AGENT_STUB_DRIVE_BASE",
"AgentStubConnectRequest",
"AgentStubConnectResponse",
"AgentStubEndpoint",
@@ -445,12 +355,6 @@ __all__ = [
"AgentStubConfigSkillItem",
"AgentStubConfigSkillItemsResponse",
"AgentStubConfigVersionInfo",
"AgentStubDriveCommitItem",
"AgentStubDriveCommitRequest",
"AgentStubDriveCommitResponse",
"AgentStubDriveFileRef",
"AgentStubDriveItem",
"AgentStubDriveManifestResponse",
"AgentStubFileDownloadRequest",
"AgentStubFileDownloadResponse",
"AgentStubFileMapping",
@@ -463,9 +367,6 @@ __all__ = [
"agent_stub_config_push_url",
"agent_stub_config_skill_inspect_url",
"agent_stub_connections_url",
"agent_stub_drive_base_for_ref",
"agent_stub_drive_commit_url",
"agent_stub_drive_manifest_url",
"agent_stub_file_download_request_url",
"agent_stub_file_upload_request_url",
"is_canonical_dify_file_reference",
@@ -1,190 +0,0 @@
"""Server-side Dify API client for Agent Stub drive endpoints.
The Agent Stub drive API is an HTTP-only control plane over the existing Dify
agent drive inner APIs. Sandbox callers never send trusted tenant, agent, or
user ids directly; this module receives an authenticated ``AgentStubPrincipal``,
derives ``agent-<agent_id>`` from execution context, injects trusted identity
fields into the Dify inner request, and normalizes transport, HTTP, JSON, and
schema failures into ``AgentStubDriveRequestError`` for the route layer.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol
import httpx
from pydantic import ValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
)
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
class AgentStubDriveRequestHandler(Protocol):
"""Trusted control-plane bridge from sandbox drive calls to Dify inner APIs."""
async def get_manifest(
self,
*,
principal: AgentStubPrincipal,
prefix: str,
include_download_url: bool,
) -> AgentStubDriveManifestResponse: ...
async def commit(
self,
*,
principal: AgentStubPrincipal,
request: AgentStubDriveCommitRequest,
) -> AgentStubDriveCommitResponse: ...
class AgentStubDriveRequestError(RuntimeError):
"""Raised when the Agent Stub cannot complete one drive control-plane call."""
status_code: int
detail: object
def __init__(self, status_code: int, detail: object) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(str(detail))
@dataclass(slots=True)
class DifyApiAgentStubDriveRequestHandler:
"""Call Dify API inner drive endpoints on behalf of authenticated sandboxes.
Manifest requests require ``tenant_id`` and ``agent_id`` from execution
context and forward query parameters to
``/inner/api/drive/agent-<agent_id>/manifest``. Commit requests additionally
require ``user_id`` and post a raw JSON payload to
``/inner/api/drive/agent-<agent_id>/commit``. Dify drive endpoints return
raw ``{"items": [...]}`` payloads instead of plugin-style ``data`` envelopes,
so this module validates the raw success payload directly.
"""
inner_api_url: str
inner_api_key: str
timeout: httpx.Timeout | float = 30.0
async def get_manifest(
self,
*,
principal: AgentStubPrincipal,
prefix: str,
include_download_url: bool,
) -> AgentStubDriveManifestResponse:
"""Request one drive manifest from Dify's inner drive manifest endpoint."""
execution_context = self._require_agent_context(principal.execution_context)
payload = await self._get_inner_api(
f"/inner/api/drive/{self._drive_ref(execution_context)}/manifest",
{
"tenant_id": execution_context.tenant_id,
"prefix": prefix,
"include_download_url": str(include_download_url).lower(),
},
)
try:
return AgentStubDriveManifestResponse.model_validate(payload)
except ValidationError as exc:
raise AgentStubDriveRequestError(502, "Dify API drive manifest response is invalid") from exc
async def commit(
self,
*,
principal: AgentStubPrincipal,
request: AgentStubDriveCommitRequest,
) -> AgentStubDriveCommitResponse:
"""Commit one drive batch through Dify's inner drive commit endpoint."""
execution_context = self._require_user_context(self._require_agent_context(principal.execution_context))
payload = await self._post_inner_api(
f"/inner/api/drive/{self._drive_ref(execution_context)}/commit",
{
"tenant_id": execution_context.tenant_id,
"user_id": execution_context.user_id,
"items": [item.model_dump(mode="json", exclude_none=True) for item in request.items],
},
)
try:
return AgentStubDriveCommitResponse.model_validate(payload)
except ValidationError as exc:
raise AgentStubDriveRequestError(502, "Dify API drive commit response is invalid") from exc
def _require_agent_context(
self, execution_context: DifyExecutionContextLayerConfig
) -> DifyExecutionContextLayerConfig:
if execution_context.agent_id is None:
raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations")
return execution_context
def _require_user_context(
self, execution_context: DifyExecutionContextLayerConfig
) -> DifyExecutionContextLayerConfig:
if execution_context.user_id is None:
raise AgentStubDriveRequestError(400, "execution context user_id is required for drive commit")
return execution_context
@staticmethod
def _drive_ref(execution_context: DifyExecutionContextLayerConfig) -> str:
agent_id = execution_context.agent_id
if agent_id is None:
raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations")
return f"agent-{agent_id}"
async def _get_inner_api(self, path: str, params: Mapping[str, str]) -> object:
url = f"{self.inner_api_url.rstrip('/')}{path}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
try:
response = await client.get(
url,
params=dict(params),
headers={"X-Inner-Api-Key": self.inner_api_key},
)
except httpx.TimeoutException as exc:
raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
except httpx.RequestError as exc:
raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc
return self._normalize_payload(response)
async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> object:
url = f"{self.inner_api_url.rstrip('/')}{path}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
try:
response = await client.post(
url,
json=dict(payload),
headers={"X-Inner-Api-Key": self.inner_api_key},
)
except httpx.TimeoutException as exc:
raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc
except httpx.RequestError as exc:
raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc
return self._normalize_payload(response)
def _normalize_payload(self, response: httpx.Response) -> object:
raw_payload = self._parse_json(response)
if response.is_error:
detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload
raise AgentStubDriveRequestError(response.status_code, detail)
return raw_payload
@staticmethod
def _parse_json(response: httpx.Response) -> object:
try:
return response.json()
except ValueError as exc:
raise AgentStubDriveRequestError(502, "Dify API drive request returned invalid JSON") from exc
__all__ = [
"AgentStubDriveRequestError",
"AgentStubDriveRequestHandler",
"DifyApiAgentStubDriveRequestHandler",
]
@@ -2,7 +2,7 @@
The standalone stub server is only a convenience wrapper around the shared
router. It reuses the main ``ServerSettings`` model and derives the Agent Stub
token codec plus optional file and drive request bridges from the same helper
token codec plus optional file and config request bridges from the same helper
methods that the standard run server uses before mounting
``create_agent_stub_router(...)``.
"""
@@ -24,7 +24,6 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI:
token_codec=resolved_settings.create_agent_stub_token_codec(),
file_request_handler=resolved_settings.create_agent_stub_file_request_handler(),
config_request_handler=resolved_settings.create_agent_stub_config_request_handler(),
drive_request_handler=resolved_settings.create_agent_stub_drive_request_handler(),
)
)
return app
@@ -1,6 +1,6 @@
"""Shared Agent Stub HTTP control-plane service.
This layer owns authenticated delegation for file, config, and drive operations.
This layer owns authenticated delegation for file and config operations.
The HTTP adapter validates transport DTOs before calling into this service.
"""
@@ -15,16 +15,12 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigManifestResponse,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestError, AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import (
AgentStubPrincipal,
@@ -71,7 +67,6 @@ class AgentStubControlPlaneService:
token_codec: AgentStubTokenCodec | None
file_request_handler: AgentStubFileRequestHandler | None = None
config_request_handler: AgentStubConfigRequestHandler | None = None
drive_request_handler: AgentStubDriveRequestHandler | None = None
connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4()))
async def connect(self, *, authorization: str | None) -> AgentStubConnectResponse:
@@ -115,25 +110,6 @@ class AgentStubControlPlaneService:
except AgentStubFileRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def get_drive_manifest(
self,
*,
prefix: str,
include_download_url: bool,
authorization: str | None,
) -> AgentStubDriveManifestResponse:
"""Authenticate and delegate one drive manifest request."""
principal = self._authenticate(authorization)
handler = self._require_drive_request_handler()
try:
return await handler.get_manifest(
principal=principal,
prefix=prefix,
include_download_url=include_download_url,
)
except AgentStubDriveRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def get_config_manifest(
self,
*,
@@ -198,20 +174,6 @@ class AgentStubControlPlaneService:
except AgentStubConfigRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
async def commit_drive(
self,
*,
request: AgentStubDriveCommitRequest,
authorization: str | None,
) -> AgentStubDriveCommitResponse:
"""Authenticate and delegate one drive commit request."""
principal = self._authenticate(authorization)
handler = self._require_drive_request_handler()
try:
return await handler.commit(principal=principal, request=request)
except AgentStubDriveRequestError as exc:
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
def _authenticate(self, authorization: str | None, *, expose_expiration: bool = False) -> AgentStubPrincipal:
token_codec = self.token_codec
if token_codec is None:
@@ -238,11 +200,6 @@ class AgentStubControlPlaneService:
raise AgentStubConfigurationError(503, "Agent Stub config API is not configured")
return self.config_request_handler
def _require_drive_request_handler(self) -> AgentStubDriveRequestHandler:
if self.drive_request_handler is None:
raise AgentStubConfigurationError(503, "Agent Stub drive API is not configured")
return self.drive_request_handler
__all__ = [
"AgentStubAuthenticationError",
@@ -1,7 +1,7 @@
"""Embeddable router factory for Dify Agent stub endpoints.
Both the standalone stub server and the standard run server mount the same
router so the Agent Stub protocol, token validation, and file/drive
router so the Agent Stub protocol, token validation, and file/config
control-plane behavior stay identical regardless of hosting mode. The factory is
intentionally settings-agnostic: callers must pass already constructed
token-codec and request-handler dependencies rather than having this module read
@@ -13,7 +13,6 @@ from __future__ import annotations
from fastapi import APIRouter
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
@@ -23,14 +22,12 @@ def create_agent_stub_router(
*,
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Build the embeddable stub router from pre-built server dependencies."""
return create_agent_stub_http_router(
token_codec,
file_request_handler,
drive_request_handler,
config_request_handler,
)
@@ -2,7 +2,7 @@
The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It
keeps FastAPI-specific request parsing and HTTPException translation here while
the service owns auth and file/config/drive delegation.
the service owns auth and file/config delegation.
"""
from __future__ import annotations
@@ -17,16 +17,12 @@ from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConfigNoteUpdateRequest,
AgentStubConfigPushRequest,
AgentStubConfigPushResponse,
AgentStubDriveCommitRequest,
AgentStubDriveCommitResponse,
AgentStubDriveManifestResponse,
AgentStubFileDownloadRequest,
AgentStubFileDownloadResponse,
AgentStubFileUploadRequest,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
@@ -35,7 +31,6 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
def create_agent_stub_http_router(
token_codec: AgentStubTokenCodec | None,
file_request_handler: AgentStubFileRequestHandler | None = None,
drive_request_handler: AgentStubDriveRequestHandler | None = None,
config_request_handler: AgentStubConfigRequestHandler | None = None,
) -> APIRouter:
"""Create HTTP routes bound to the application's Agent Stub dependencies."""
@@ -44,7 +39,6 @@ def create_agent_stub_http_router(
token_codec=token_codec,
file_request_handler=file_request_handler,
config_request_handler=config_request_handler,
drive_request_handler=drive_request_handler,
)
@router.post("/connections", response_model=AgentStubConnectResponse)
@@ -132,31 +126,6 @@ def create_agent_stub_http_router(
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/drive/manifest", response_model=AgentStubDriveManifestResponse)
async def get_drive_manifest(
prefix: str = "",
include_download_url: bool = False,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> AgentStubDriveManifestResponse:
try:
return await service.get_drive_manifest(
prefix=prefix,
include_download_url=include_download_url,
authorization=authorization,
)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/drive/commit", response_model=AgentStubDriveCommitResponse)
async def commit_drive(
request: AgentStubDriveCommitRequest,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> AgentStubDriveCommitResponse:
try:
return await service.commit_drive(request=request, authorization=authorization)
except AgentStubControlPlaneError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return router
@@ -1,9 +1,8 @@
"""Client-safe shell environment helpers for Agent Stub forwarding.
Only user-visible ``shell.run`` commands receive these variables. Internal
lifecycle commands remain free of Agent Stub credentials and drive-base
defaults so workspace setup and cleanup cannot accidentally inherit
user-facing forwarding state. The module stays server-extra-free because the
lifecycle commands remain free of Agent Stub credentials so workspace setup
and cleanup cannot accidentally inherit user-facing forwarding state. The module stays server-extra-free because the
shell runtime and provider factory use it in sandbox-visible paths.
"""
@@ -11,11 +10,9 @@ from __future__ import annotations
from typing import Protocol
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR
from dify_agent.agent_stub.protocol.agent_stub import (
AGENT_STUB_API_BASE_URL_ENV_VAR,
AGENT_STUB_AUTH_JWE_ENV_VAR,
agent_stub_drive_base_for_ref,
normalize_agent_stub_api_base_url,
)
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -30,29 +27,21 @@ class ShellAgentStubTokenFactory(Protocol):
def build_shell_agent_stub_env(
*,
agent_stub_api_base_url: str | None,
agent_stub_drive_ref: str | None = None,
execution_context: DifyExecutionContextLayerConfig | None,
token_factory: ShellAgentStubTokenFactory | None,
session_id: str | None,
) -> dict[str, str] | None:
"""Build the shell-visible Agent Stub environment for one user command.
``agent_stub_drive_ref`` is the storage reference from the bound
``dify.drive`` layer. The sandbox-local base is fixed by the Agent Stub
contract and derived here at shell-run injection time.
"""
"""Build the shell-visible Agent Stub environment for one user command."""
if agent_stub_api_base_url is None or execution_context is None or token_factory is None:
return None
return {
AGENT_STUB_API_BASE_URL_ENV_VAR: normalize_agent_stub_api_base_url(agent_stub_api_base_url),
AGENT_STUB_AUTH_JWE_ENV_VAR: token_factory(execution_context, session_id=session_id),
AGENT_STUB_DRIVE_BASE_ENV_VAR: agent_stub_drive_base_for_ref(agent_stub_drive_ref),
}
__all__ = [
"AGENT_STUB_AUTH_JWE_ENV_VAR",
"AGENT_STUB_DRIVE_BASE_ENV_VAR",
"AGENT_STUB_API_BASE_URL_ENV_VAR",
"ShellAgentStubTokenFactory",
"build_shell_agent_stub_env",
@@ -15,10 +15,6 @@
"config skills pull": "Pull one or all visible config skills into ./.dify_conf/skills by default.\n\nUsage:\n dify-agent config skills pull [NAME]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local directory for pulled config skills.",
"config skills push": "Upload one or more local skill directories into the current config manifest.\n\nUsage:\n dify-agent config skills push PATH... [flags]\n\nFlags:\n -h, --help help for push",
"connect": "Establish one Agent Stub connection using the current environment.\n\nUsage:\n dify-agent connect [ARGV]... [flags]\n\nFlags:\n -h, --help help for connect\n --json Emit the connection response as JSON.",
"drive": "List, pull, or push agent drive files through the Agent Stub.\n\nUsage:\n dify-agent drive [command]\n\nAvailable Commands:\n list List drive files visible to the current sandbox execution.\n pull Pull one or more drive keys/prefixes into one local directory tree.\n push Upload one local file or directory into the agent drive.\n\nFlags:\n -h, --help help for drive\n\nUse \"dify-agent drive [command] --help\" for more information about a command.",
"drive list": "List drive files visible to the current sandbox execution.\n\nUsage:\n dify-agent drive list [REMOTE_PREFIX] [flags]\n\nFlags:\n -h, --help help for list\n --json Emit the drive manifest as JSON.",
"drive pull": "Pull one or more drive keys/prefixes into one local directory tree.\n\nUsage:\n dify-agent drive pull [REMOTE]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local base directory for pulled drive files.",
"drive push": "Upload one local file or directory into the agent drive.\n\nUsage:\n dify-agent drive push LOCAL_PATH REMOTE_PATH [flags]\n\nFlags:\n -h, --help help for push\n --json Accepted for consistency; drive push output is already emitted as JSON.\n --kind string Directory upload kind: skill or dir.",
"file": "Upload or download workflow files through the Agent Stub.\n\nUsage:\n dify-agent file [command]\n\nAvailable Commands:\n download Download one workflow file mapping into the local sandbox directory.\n public-url Create a browser-visible download URL for an existing ToolFile reference.\n upload Upload one sandbox-local file as a ToolFile output reference.\n\nFlags:\n -h, --help help for file\n\nUse \"dify-agent file [command] --help\" for more information about a command.",
"file download": "Download one workflow file mapping into the local sandbox directory.\n\nUsage:\n dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [flags]\n\nFlags:\n -h, --help help for download\n --to string Local directory for the downloaded file.",
"file public-url": "Create a browser-visible download URL for an existing ToolFile reference.\n\nUsage:\n dify-agent file public-url REFERENCE [flags]\n\nFlags:\n -h, --help help for public-url",
@@ -1,17 +0,0 @@
"""Client-safe exports for the Dify drive runtime catalog DTOs.
The layer implementation lives in the sibling ``layer`` module. Keep this
package root import-safe for client code that only builds run requests.
"""
from dify_agent.layers.drive.configs import (
DIFY_DRIVE_LAYER_TYPE_ID,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
__all__ = [
"DIFY_DRIVE_LAYER_TYPE_ID",
"DifyDriveLayerConfig",
"DifyDriveSkillConfig",
]
@@ -1,55 +0,0 @@
"""Client-safe DTOs for the Dify drive declaration layer.
The drive layer carries the runtime drive catalog plus the prompt-mentioned
targets that must be pulled eagerly when the layer enters. It is still config
only: skills are declared as metadata, not content, and plain files are listed
only when the prompt explicitly mentions their drive keys.
The API backend catalogs and writes this config; the Agent backend consumes it
by running sandbox-visible ``dify-agent drive pull`` commands through the shell
layer so materialized files live in the same filesystem that model shell jobs
use.
"""
from typing import Final
from pydantic import BaseModel, ConfigDict, Field
from agenton.layers import LayerConfig
DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive"
class DifyDriveSkillConfig(BaseModel):
"""Runtime declaration of one standardized skill — metadata, not content."""
model_config = ConfigDict(extra="forbid")
name: str
# The model judges from this description whether the skill is worth loading.
description: str
# "<slug>/SKILL.md" — the canonical entry document in the drive.
skill_md_key: str
# "<slug>/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill.
archive_key: str | None = None
path: str
class DifyDriveLayerConfig(LayerConfig):
"""Drive runtime catalog plus eager-pull instructions for mentioned targets."""
# "agent-<agent_id>" — storage addressing, deliberately explicit instead of
# derived from execution context so a shared (non-agent-bound) drive stays
# possible later.
drive_ref: str
skills: list[DifyDriveSkillConfig] = Field(default_factory=list)
mentioned_skill_keys: list[str] = Field(default_factory=list)
mentioned_file_keys: list[str] = Field(default_factory=list)
__all__ = [
"DIFY_DRIVE_LAYER_TYPE_ID",
"DifyDriveLayerConfig",
"DifyDriveSkillConfig",
]
@@ -1,268 +0,0 @@
"""Runtime Dify drive layer with shell-backed eager pulls.
The API backend sends the full drive skill catalog plus the ordered drive keys
mentioned in the prompt. When the layer enters a run context it eagerly pulls
those mentioned skills/files through the already-active shell layer by running
the sandbox-visible ``dify-agent drive pull`` command, then contributes a
concise prompt block describing what was loaded. It also contributes a suffix
prompt with the remaining skill catalog plus agent-visible ``dify-agent file``
usage captured from the real CLI. Drive commands remain internal for now and
are not exposed to the model.
"""
from __future__ import annotations
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar
from typing_extensions import Self, override
from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer
from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref
from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
_AGENT_STUB_FILE_HELP_COMMANDS = (
"dify-agent file --help",
"dify-agent file upload --help",
"dify-agent file download --help",
)
class DifyDriveLayerError(RuntimeError):
"""Raised when one eager-pull drive operation fails."""
class DifyDriveDeps(LayerDeps):
shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
"""Drive runtime layer that materializes prompt-mentioned targets via shell."""
type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID
config: DifyDriveLayerConfig
_loaded_skill_bodies: dict[str, str] = field(default_factory=dict)
_pulled_file_paths: dict[str, str] = field(default_factory=dict)
_agent_stub_cli_help: dict[str, str] = field(default_factory=dict)
@classmethod
@override
def from_config(cls, config: DifyDriveLayerConfig) -> Self:
return cls(config=DifyDriveLayerConfig.model_validate(config))
@property
@override
def prefix_prompts(self) -> list[str]:
return [self.build_prompt_context()]
@property
@override
def suffix_prompts(self) -> list[str]:
return [self.build_suffix_prompt()]
@override
async def on_context_create(self) -> None:
await self._load_agent_stub_cli_help()
await self._pull_mentioned_targets()
@override
async def on_context_resume(self) -> None:
await self._load_agent_stub_cli_help()
await self._pull_mentioned_targets()
def build_prompt_context(self) -> str:
sections: list[str] = []
loaded_skill_sections: list[str] = []
for skill_key in self.config.mentioned_skill_keys:
body = self._loaded_skill_bodies.get(skill_key)
if body is None:
continue
skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None)
if skill is None:
continue
pulled_skill_path = self._pulled_file_paths.get(skill_key)
if pulled_skill_path is None:
continue
local_path = Path(pulled_skill_path).parent
loaded_skill_sections.append(f"Path: {skill.path}\nLocal path: {local_path}\nSKILL.md:\n{body}")
if loaded_skill_sections:
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
mentioned_files = [
f"- {key} -> {self._pulled_file_paths[key]}"
for key in self.config.mentioned_file_keys
if key in self._pulled_file_paths
]
if mentioned_files:
sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files))
if not sections:
return ""
return "\n\n".join(sections)
def build_suffix_prompt(self) -> str:
sections: list[str] = []
mentioned_skill_keys = set(self.config.mentioned_skill_keys)
other_skills = [
f"- {skill.path}: {skill.name}{skill.description}"
for skill in self.config.skills
if skill.skill_md_key not in mentioned_skill_keys
]
if other_skills:
sections.append("Other available skills:\n" + "\n".join(other_skills))
if cli_help := self._format_agent_stub_cli_help():
sections.append(cli_help)
return "\n\n".join(sections)
def _format_agent_stub_cli_help(self) -> str:
command_sections = [
_format_command_output(command, self._agent_stub_cli_help[command])
for command in _AGENT_STUB_FILE_HELP_COMMANDS
if command in self._agent_stub_cli_help
]
if not command_sections:
return ""
return (
"Agent Stub file CLI reference for installed `dify-agent`:\n"
+ "\n\n".join(command_sections)
+ f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}"
)
async def _load_agent_stub_cli_help(self) -> None:
self._agent_stub_cli_help = {}
for command in _AGENT_STUB_FILE_HELP_COMMANDS:
result = await self.deps.shell.run_remote_script(command, timeout=10.0)
if result.exit_code != 0 or not result.output_complete:
continue
output = result.output.strip()
if output:
self._agent_stub_cli_help[command] = output
async def _pull_mentioned_targets(self) -> None:
self._loaded_skill_bodies = {}
self._pulled_file_paths = {}
targets = self._mentioned_pull_targets()
if not targets:
return
script = self._build_shell_pull_script(targets=targets)
result = await self.deps.shell.run_remote_script_complete(script, inject_agent_stub_env=True)
if result.exit_code != 0:
raise DifyDriveLayerError(
"drive mentioned pull failed in shell: "
+ f"{result.status} exit_code={result.exit_code} "
+ f"output_complete={result.output_complete} "
+ f"incomplete_reason={result.incomplete_reason} "
+ f"output_path={result.output_path}\n{result.output}"
)
try:
written_paths, skill_bodies = self._parse_shell_pull_output(result.output)
self._record_pulled_paths(written_paths)
for skill_key in self.config.mentioned_skill_keys:
body = skill_bodies.get(skill_key)
if body is None:
raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}")
self._loaded_skill_bodies[skill_key] = body
except DifyDriveLayerError:
if result.output_complete:
raise
raise DifyDriveLayerError(
"drive mentioned pull output incomplete before required SKILL.md content was captured: "
+ f"reason={result.incomplete_reason} output_path={result.output_path}\n{result.output}"
) from None
def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str:
pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets))
base_path = agent_stub_drive_base_for_ref(self.config.drive_ref)
lines = [
"set -eu",
f"base={shlex.quote(base_path)}",
"dify-agent drive pull " + " ".join(shlex.quote(target) for target in pull_targets) + ' --to "$base"',
]
for skill_key in self.config.mentioned_skill_keys:
skill_path = self._shell_local_path(skill_key)
lines.extend(
[
f"test -f {shlex.quote(skill_path)}",
f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(skill_key)} {shlex.quote(skill_path)}",
f"printf '__DIFY_DRIVE_SKILL_BEGIN__\\t%s\\n' {shlex.quote(skill_key)}",
f"cat {shlex.quote(skill_path)}",
f"printf '\\n__DIFY_DRIVE_SKILL_END__\\t%s\\n' {shlex.quote(skill_key)}",
]
)
for file_key in self.config.mentioned_file_keys:
file_path = self._shell_local_path(file_key)
lines.extend(
[
f"test -e {shlex.quote(file_path)}",
f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(file_key)} {shlex.quote(file_path)}",
]
)
return "\n".join(lines)
def _parse_shell_pull_output(self, output: str) -> tuple[dict[str, str], dict[str, str]]:
written_paths: dict[str, str] = {}
skill_bodies: dict[str, str] = {}
current_skill_key: str | None = None
current_skill_body: list[str] = []
for line in output.splitlines(keepends=True):
stripped_line = line.rstrip("\n")
if current_skill_key is not None:
if stripped_line == f"__DIFY_DRIVE_SKILL_END__\t{current_skill_key}":
skill_bodies[current_skill_key] = "".join(current_skill_body)
current_skill_key = None
current_skill_body = []
continue
current_skill_body.append(line)
continue
if stripped_line.startswith("__DIFY_DRIVE_MENTIONED_PATH__\t"):
parts = stripped_line.split("\t", 2)
if len(parts) != 3:
raise DifyDriveLayerError("drive mentioned pull emitted an invalid path marker")
_marker, key, path = parts
written_paths[key] = path
continue
if stripped_line.startswith("__DIFY_DRIVE_SKILL_BEGIN__\t"):
current_skill_key = stripped_line.split("\t", 1)[1]
current_skill_body = []
if current_skill_key is not None:
raise DifyDriveLayerError(f"drive mentioned pull omitted SKILL.md end marker for {current_skill_key}")
return written_paths, skill_bodies
def _record_pulled_paths(self, written_paths: dict[str, str]) -> None:
self._pulled_file_paths = written_paths
for file_key in self.config.mentioned_file_keys:
if file_key not in written_paths:
raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}")
for skill_key in self.config.mentioned_skill_keys:
if skill_key not in written_paths:
raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}")
def _mentioned_pull_targets(self) -> list[tuple[str, bool]]:
return [(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys] + [
(file_key, True) for file_key in self.config.mentioned_file_keys
]
def _shell_local_path(self, drive_key: str) -> str:
return f"{agent_stub_drive_base_for_ref(self.config.drive_ref).rstrip('/')}/{drive_key.lstrip('/')}"
@staticmethod
def _skill_prefix(skill_key: str) -> str:
return f"{skill_key.rsplit('/', 1)[0]}/"
def _format_command_output(command: str, output: str) -> str:
return f"Command:\n$ {command}\nOutput:\n{output}"
__all__ = ["DifyDriveLayer", "DifyDriveLayerError"]
@@ -4,8 +4,7 @@ Server-only Agent Stub and redaction settings are injected by the runtime
provider factory. The Sandbox dependency supplies the active shellctl data
plane. Public config carries product-level Agent Soul settings that affect the
workspace itself: CLI tool bootstrap commands, normal environment variables,
secret environment variable names, and the Agent Stub drive ref used by
shell-visible drive commands. Sandbox selection is a deployment concern.
secret environment variable names. Sandbox selection is a deployment concern.
"""
import re
@@ -73,8 +72,6 @@ class DifyShellLayerConfig(LayerConfig):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
# Optional because shell can be used without a drive layer.
agent_stub_drive_ref: str | None = Field(default=None, max_length=1024)
cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list)
env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)
@@ -545,7 +545,6 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
execution_context = execution_context_layer.config if execution_context_layer is not None else None
agent_stub_env = build_shell_agent_stub_env(
agent_stub_api_base_url=self.agent_stub_api_base_url,
agent_stub_drive_ref=self.config.agent_stub_drive_ref,
execution_context=execution_context,
token_factory=self.agent_stub_token_factory,
session_id=None,
@@ -46,7 +46,6 @@ from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.drive.layer import DifyDriveLayer
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
@@ -79,7 +78,6 @@ def create_default_layer_providers(
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_layer_type(DifyAskHumanLayer),
LayerProvider.from_layer_type(DifyConfigLayer),
LayerProvider.from_layer_type(DifyDriveLayer),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
-2
View File
@@ -60,7 +60,6 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
agent_stub_token_factory = issue_agent_stub_token
agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler()
agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler()
agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler()
runtime_backend_profile = resolved_settings.build_runtime_backend_profile()
layer_providers = create_default_layer_providers(
plugin_daemon_url=resolved_settings.plugin_daemon_url,
@@ -146,7 +145,6 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
token_codec=agent_stub_token_codec,
file_request_handler=agent_stub_file_request_handler,
config_request_handler=agent_stub_config_request_handler,
drive_request_handler=agent_stub_drive_request_handler,
)
)
return app
@@ -19,7 +19,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url
from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler
from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
@@ -243,20 +242,6 @@ class ServerSettings(BaseSettings):
timeout=self.create_outbound_http_timeout(),
)
def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None:
"""Return the Dify API drive bridge when both Dify API settings are configured.
Drive manifest and commit requests should honor the same outbound timeout
settings as the server's other trusted Dify API HTTP calls.
"""
if self.inner_api_key is None:
return None
return DifyApiAgentStubDriveRequestHandler(
inner_api_url=self.inner_api_url,
inner_api_key=self.inner_api_key,
timeout=self.create_outbound_http_timeout(),
)
def create_outbound_http_timeout(self) -> httpx.Timeout:
"""Build one shared outbound HTTP timeout object from server settings."""
return httpx.Timeout(
@@ -8,18 +8,11 @@ import pytest
from pydantic import ValidationError
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitItem,
AgentStubDriveCommitRequest,
AgentStubDriveFileRef,
AgentStubDriveManifestResponse,
AgentStubConfigDownloadSource,
AgentStubFileDownloadRequest,
AgentStubFileMapping,
AgentStubFileUploadRequest,
agent_stub_connections_url,
agent_stub_drive_base_for_ref,
agent_stub_drive_commit_url,
agent_stub_drive_manifest_url,
agent_stub_file_download_request_url,
agent_stub_file_upload_request_url,
normalize_agent_stub_api_base_url,
@@ -62,34 +55,6 @@ def test_agent_stub_file_upload_request_rejects_client_max_size() -> None:
)
def test_agent_stub_drive_request_urls_handle_trailing_slash() -> None:
assert agent_stub_drive_manifest_url("https://agent.example.com/agent-stub/") == (
"https://agent.example.com/agent-stub/drive/manifest"
)
assert agent_stub_drive_commit_url("https://agent.example.com/agent-stub") == (
"https://agent.example.com/agent-stub/drive/commit"
)
def test_agent_stub_drive_base_for_ref_uses_fixed_mount_with_drive_ref() -> None:
assert agent_stub_drive_base_for_ref("agent-1") == "/mnt/drive/agent-1"
assert agent_stub_drive_base_for_ref("shared/drive") == "/mnt/drive/shared/drive"
def test_agent_stub_drive_base_for_ref_uses_default_without_drive_ref() -> None:
assert agent_stub_drive_base_for_ref(None) == "/mnt/drive"
assert agent_stub_drive_base_for_ref(" ") == "/mnt/drive"
@pytest.mark.parametrize(
"drive_ref",
["/agent-1", "../agent-1", "agent-1/..", "agent-1/./files", "agent-1//files"],
)
def test_agent_stub_drive_base_for_ref_rejects_unsafe_refs(drive_ref: str) -> None:
with pytest.raises(ValueError, match="safe relative path"):
_ = agent_stub_drive_base_for_ref(drive_ref)
def test_normalize_agent_stub_api_base_url_rejects_query_and_fragment() -> None:
with pytest.raises(ValueError, match="query string or fragment"):
_ = normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub?x=1")
@@ -198,35 +163,6 @@ def test_agent_stub_config_download_source_rejects_invalid_names_and_identity_fi
_ = AgentStubConfigDownloadSource.model_validate(source)
def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
request = AgentStubDriveCommitRequest(
items=[
AgentStubDriveCommitItem(
key="skills/example/SKILL.md",
file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
)
]
)
assert request.items[0].file_ref is not None
assert request.items[0].file_ref.kind == "tool_file"
with pytest.raises(ValidationError, match="tool_file"):
_ = AgentStubDriveFileRef(kind="bad_kind", id="tool-file-1") # pyright: ignore[reportArgumentType]
item_without_file_ref = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"})
assert item_without_file_ref.file_ref is None
def test_agent_stub_drive_manifest_response_preserves_extra_item_fields() -> None:
response = AgentStubDriveManifestResponse.model_validate(
{"items": [{"key": "skills/example/SKILL.md", "name": "SKILL.md"}]}
)
assert response.items[0].model_extra == {"name": "SKILL.md"}
assert response.items[0].model_dump(mode="json")["name"] == "SKILL.md"
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
def test_agent_stub_file_mapping_rejects_non_remote_with_url(
transfer_method: Literal["tool_file", "local_file", "datasource_file"],
@@ -39,8 +39,6 @@ def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None:
assert "/agent-stub/connections" in created_paths
assert "/agent-stub/files/upload-request" in created_paths
assert "/agent-stub/files/download-request" in created_paths
assert "/agent-stub/drive/manifest" in created_paths
assert "/agent-stub/drive/commit" in created_paths
assert created_paths == module_paths
@@ -91,57 +89,3 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
assert response.status_code == 200
assert response.json() == {"upload_url": "https://files.example.com/files/upload/for-plugin?sign=1"}
def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None:
settings = ServerSettings(
agent_stub_api_base_url="https://agent.example.com/agent-stub",
server_secret_key=_base64url_secret(b"1" * 32),
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
sandbox_files_base_url="https://files.example.com",
)
token_codec = settings.create_agent_stub_token_codec()
assert token_codec is not None
token = token_codec.encode_connection_token(
_execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1
)
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == (
"https://api.example.com/inner/api/drive/agent-agent-1/manifest"
"?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false"
)
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
return httpx.Response(
200,
json={
"items": [
{
"key": "skills/example/SKILL.md",
"size": 12,
"hash": "sha256:abc",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tool-file-1",
}
]
},
)
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
client = TestClient(create_agent_stub_app(settings))
response = client.get(
"/agent-stub/drive/manifest",
headers={"Authorization": f"Bearer {token}"},
params={"prefix": "skills/"},
)
assert response.status_code == 200
assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
@@ -1,269 +0,0 @@
from __future__ import annotations
import asyncio
import json
import httpx
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitItem,
AgentStubDriveCommitRequest,
AgentStubDriveFileRef,
)
from dify_agent.agent_stub.server.agent_stub_drive import (
AgentStubDriveRequestError,
DifyApiAgentStubDriveRequestHandler,
)
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
def _principal() -> AgentStubPrincipal:
return AgentStubPrincipal(
execution_context=DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
workflow_id="workflow-1",
agent_id="agent-1",
agent_mode="workflow_run",
invoke_from="service-api",
),
session_id="session-1",
scope=["agent_stub:connect"],
token_id="token-1",
)
def _patch_async_client(monkeypatch, handler) -> None:
original_async_client = httpx.AsyncClient
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifest(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert str(request.url) == (
"https://api.example.com/inner/api/drive/agent-agent-1/manifest"
"?tenant_id=tenant-1&prefix=skills%2F&include_download_url=true"
)
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
return httpx.Response(
200,
json={
"items": [
{
"key": "skills/example/SKILL.md",
"name": "SKILL.md",
"size": 12,
"hash": "sha256:abc",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tool-file-1",
"created_at": 123,
"download_url": "https://files.example.com/download",
}
]
},
)
_patch_async_client(monkeypatch, handler)
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
async def scenario() -> None:
response = await drive_handler.get_manifest(
principal=_principal(),
prefix="skills/",
include_download_url=True,
)
assert response.items[0].download_url == "https://files.example.com/download"
assert response.items[0].model_extra == {"name": "SKILL.md"}
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/inner/api/drive/agent-agent-1/commit"
assert json.loads(request.content) == {
"tenant_id": "tenant-1",
"user_id": "user-1",
"items": [
{
"key": "skills/example/SKILL.md",
"file_ref": {"kind": "tool_file", "id": "tool-file-1"},
"value_owned_by_drive": True,
"is_skill": False,
}
],
}
return httpx.Response(
200,
json={
"items": [
{
"key": "skills/example/SKILL.md",
"size": 12,
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tool-file-1",
"value_owned_by_drive": True,
}
]
},
)
_patch_async_client(monkeypatch, handler)
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
async def scenario() -> None:
response = await drive_handler.commit(
principal=_principal(),
request=AgentStubDriveCommitRequest(
items=[
AgentStubDriveCommitItem(
key="skills/example/SKILL.md",
file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
)
]
),
)
assert response.items[0].value_owned_by_drive is True
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None:
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
principal = _principal()
principal.execution_context = principal.execution_context.model_copy(update={"agent_id": None})
async def scenario() -> None:
try:
await drive_handler.get_manifest(principal=principal, prefix="", include_download_url=False)
except AgentStubDriveRequestError as exc:
assert exc.status_code == 400
assert "agent_id" in str(exc)
else:
raise AssertionError("expected AgentStubDriveRequestError")
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_rejects_missing_user_id_for_commit() -> None:
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
principal = _principal()
principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
async def scenario() -> None:
try:
await drive_handler.commit(
principal=principal,
request=AgentStubDriveCommitRequest(
items=[
AgentStubDriveCommitItem(
key="skills/example/SKILL.md",
file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
)
]
),
)
except AgentStubDriveRequestError as exc:
assert exc.status_code == 400
assert "user_id" in str(exc)
else:
raise AssertionError("expected AgentStubDriveRequestError")
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_maps_invalid_json_response(monkeypatch) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"})
_patch_async_client(monkeypatch, handler)
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
async def scenario() -> None:
try:
await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False)
except AgentStubDriveRequestError as exc:
assert exc.status_code == 502
assert exc.detail == "Dify API drive request returned invalid JSON"
else:
raise AssertionError("expected AgentStubDriveRequestError")
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_rejects_malformed_success_payload(monkeypatch) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"unexpected": []})
_patch_async_client(monkeypatch, handler)
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
async def scenario() -> None:
try:
await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False)
except AgentStubDriveRequestError as exc:
assert exc.status_code == 502
assert exc.detail == "Dify API drive manifest response is invalid"
else:
raise AssertionError("expected AgentStubDriveRequestError")
asyncio.run(scenario())
def test_dify_api_agent_stub_drive_handler_preserves_non_2xx_detail(monkeypatch) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"code": "source_not_found", "message": "missing file"})
_patch_async_client(monkeypatch, handler)
drive_handler = DifyApiAgentStubDriveRequestHandler(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
)
async def scenario() -> None:
try:
await drive_handler.commit(
principal=_principal(),
request=AgentStubDriveCommitRequest(
items=[
AgentStubDriveCommitItem(
key="skills/example/SKILL.md",
file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"),
)
]
),
)
except AgentStubDriveRequestError as exc:
assert exc.status_code == 404
assert exc.detail == {"code": "source_not_found", "message": "missing file"}
else:
raise AssertionError("expected AgentStubDriveRequestError")
asyncio.run(scenario())
@@ -8,14 +8,7 @@ from typing import cast
from fastapi import FastAPI
from fastapi.testclient import TestClient
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubDriveCommitResponse,
AgentStubDriveItem,
AgentStubDriveManifestResponse,
AgentStubFileDownloadResponse,
AgentStubFileUploadResponse,
)
from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadResponse, AgentStubFileUploadResponse
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
from dify_agent.agent_stub.server.tokens.agent_stub import AGENT_STUB_TOKEN_TTL_SECONDS, AgentStubTokenCodec
@@ -344,137 +337,3 @@ def test_agent_stub_file_route_preserves_structured_handler_error_details() -> N
assert response.status_code == 400
assert response.json()["detail"] == {"detail": "bad request", "code": "inner_api_error"}
def test_agent_stub_drive_manifest_route_forwards_authenticated_request() -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
class FakeDriveHandler:
async def get_manifest(self, *, principal, prefix, include_download_url):
assert principal.execution_context.user_id == "user-1"
assert prefix == "skills/"
assert include_download_url is True
return AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key="skills/example/SKILL.md",
size=12,
hash="sha256:abc",
mime_type="text/markdown",
file_kind="tool_file",
file_id="tool-file-1",
created_at=123,
download_url="https://files.example.com/download",
)
]
)
async def commit(self, *, principal, request):
del principal, request
raise AssertionError("unexpected commit request")
drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
app = FastAPI()
app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
client = TestClient(app)
response = client.get(
"/agent-stub/drive/manifest",
headers={"Authorization": f"Bearer {token}"},
params={"prefix": "skills/", "include_download_url": "true"},
)
assert response.status_code == 200
assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
def test_agent_stub_drive_commit_route_forwards_authenticated_request() -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
class FakeDriveHandler:
async def commit(self, *, principal, request):
assert principal.execution_context.user_id == "user-1"
assert request.items[0].file_ref.id == "tool-file-1"
return AgentStubDriveCommitResponse(
items=[
AgentStubDriveItem(
key="skills/example/SKILL.md",
size=12,
hash=None,
mime_type="text/markdown",
file_kind="tool_file",
file_id="tool-file-1",
value_owned_by_drive=True,
)
]
)
async def get_manifest(self, *, principal, prefix, include_download_url):
del principal, prefix, include_download_url
raise AssertionError("unexpected manifest request")
drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
app = FastAPI()
app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
client = TestClient(app)
response = client.post(
"/agent-stub/drive/commit",
headers={"Authorization": f"Bearer {token}"},
json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]},
)
assert response.status_code == 200
assert response.json()["items"][0]["file_id"] == "tool-file-1"
def test_agent_stub_drive_routes_return_503_when_drive_api_is_unconfigured() -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
app = FastAPI()
app.include_router(create_agent_stub_http_router(codec, None, None))
client = TestClient(app)
manifest_response = client.get(
"/agent-stub/drive/manifest",
headers={"Authorization": f"Bearer {token}"},
)
commit_response = client.post(
"/agent-stub/drive/commit",
headers={"Authorization": f"Bearer {token}"},
json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]},
)
assert manifest_response.status_code == 503
assert commit_response.status_code == 503
assert manifest_response.json()["detail"] == "Agent Stub drive API is not configured"
assert commit_response.json()["detail"] == "Agent Stub drive API is not configured"
def test_agent_stub_drive_route_preserves_structured_handler_error_details() -> None:
codec = _token_codec()
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
class FakeDriveHandler:
async def get_manifest(self, *, principal, prefix, include_download_url):
del principal, prefix, include_download_url
raise AgentStubDriveRequestError(400, {"code": "invalid_key", "message": "bad request"})
async def commit(self, *, principal, request):
del principal, request
raise AssertionError("unexpected commit request")
drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler()))
app = FastAPI()
app.include_router(create_agent_stub_http_router(codec, None, drive_handler))
client = TestClient(app)
response = client.get(
"/agent-stub/drive/manifest",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 400
assert response.json()["detail"] == {"code": "invalid_key", "message": "bad request"}
@@ -19,7 +19,7 @@ from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShell
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
DifyShellLayerConfig(),
)
@@ -1,57 +0,0 @@
"""Contract tests for the dify.drive declaration layer (ENG-623)."""
import pytest
from pydantic import ValidationError
from dify_agent.layers.drive import (
DIFY_DRIVE_LAYER_TYPE_ID,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
from dify_agent.layers.drive.layer import DifyDriveLayer
def test_type_id_is_frozen_contract() -> None:
assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive"
assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID
def test_layer_config_round_trips_manifest_entries() -> None:
config = DifyDriveLayerConfig.model_validate(
{
"drive_ref": "agent-019e9112",
"skills": [
{
"path": "tender-analyzer",
"name": "Tender Analyzer",
"description": "Parses RFP documents step by step.",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
],
"mentioned_skill_keys": ["tender-analyzer/SKILL.md"],
"mentioned_file_keys": ["files/sample.pdf"],
}
)
dumped = config.model_dump(mode="json")
assert dumped["drive_ref"] == "agent-019e9112"
assert "drive_base" not in dumped
assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md"
assert dumped["mentioned_file_keys"] == ["files/sample.pdf"]
assert "content" not in DifyDriveSkillConfig.model_fields
def test_layer_config_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError):
DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"})
def test_drive_layer_is_registered_and_constructible_from_config() -> None:
layer = DifyDriveLayer.from_config(
DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]),
)
assert isinstance(layer, DifyDriveLayer)
assert layer.config.drive_ref == "agent-1"
assert not hasattr(layer, "local_drive_base")
@@ -1,237 +0,0 @@
"""Behavior tests for the runtime Dify drive layer."""
from __future__ import annotations
from typing import Literal
import pytest
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _AGENT_FILE_UPLOAD_REPLY_HINT
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
)
def _build_layer() -> DifyDriveLayer:
layer = DifyDriveLayer.from_config(
DifyDriveLayerConfig(
drive_ref="agent-1",
skills=[
DifyDriveSkillConfig(
path="tender-analyzer",
name="Tender Analyzer",
description="Parses RFPs.",
skill_md_key="tender-analyzer/SKILL.md",
archive_key="tender-analyzer/.DIFY-SKILL-FULL.zip",
),
DifyDriveSkillConfig(
path="other-skill",
name="Other Skill",
description="Fallback catalog entry.",
skill_md_key="other-skill/SKILL.md",
archive_key=None,
),
],
mentioned_skill_keys=["tender-analyzer/SKILL.md"],
mentioned_file_keys=["files/report.pdf"],
)
)
layer.bind_deps({"shell": _shell_layer()})
return layer
def _remote_result(
output: str,
*,
exit_code: int | None = 0,
output_complete: bool = True,
incomplete_reason: Literal["output_limit", "timeout"] | None = None,
) -> CompleteRemoteCommandResult:
return CompleteRemoteCommandResult(
job_id="remote-drive-pull",
status="exited",
done=True,
exit_code=exit_code,
output=output,
output_complete=output_complete,
incomplete_reason=incomplete_reason,
offset=len(output),
output_path="/tmp/output.log",
)
def _pulled_output() -> str:
return (
"/mnt/drive/agent-1/tender-analyzer\n"
"/mnt/drive/agent-1/files/report.pdf\n"
"__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
"__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n"
"# Tender Analyzer\n"
"Use carefully.\n"
"__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n"
"__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n"
)
def _file_help_output(command: str) -> str:
return f"Usage: {command.removesuffix(' --help')} [OPTIONS]\n\nAgent Stub file command help.\n"
def _patch_file_help(monkeypatch: pytest.MonkeyPatch) -> list[str]:
captured_scripts: list[str] = []
async def fake_run_remote_script(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
del self, timeout, inject_agent_stub_env
captured_scripts.append(script)
return _remote_result(_file_help_output(script))
monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script)
return captured_scripts
def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None:
layer = _build_layer()
layer._agent_stub_cli_help = {
"dify-agent file --help": _file_help_output("dify-agent file --help"),
"dify-agent file upload --help": _file_help_output("dify-agent file upload --help"),
"dify-agent file download --help": _file_help_output("dify-agent file download --help"),
}
assert len(layer.suffix_prompts) == 1
prompt = layer.suffix_prompts[0]
assert "Other available skills" in prompt
assert "other-skill: Other Skill" in prompt
assert "Agent Stub file CLI reference for installed `dify-agent`" in prompt
assert "$ dify-agent file upload --help" in prompt
assert "$ dify-agent file download --help" in prompt
assert prompt.index("$ dify-agent file upload --help") < prompt.index("$ dify-agent file download --help")
assert _AGENT_FILE_UPLOAD_REPLY_HINT in prompt
assert "dify-agent drive" not in prompt
@pytest.mark.anyio
async def test_on_context_create_pulls_mentioned_targets_through_shell(monkeypatch: pytest.MonkeyPatch) -> None:
layer = _build_layer()
captured: dict[str, object] = {}
help_scripts = _patch_file_help(monkeypatch)
async def fake_run_remote_script_complete(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
del self, timeout
captured["script"] = script
captured["inject_agent_stub_env"] = inject_agent_stub_env
return _remote_result(_pulled_output())
monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
await layer.on_context_create()
assert help_scripts == [
"dify-agent file --help",
"dify-agent file upload --help",
"dify-agent file download --help",
]
assert "dify-agent file download --help" in layer._agent_stub_cli_help
script = captured["script"]
assert isinstance(script, str)
assert captured["inject_agent_stub_env"] is True
assert 'dify-agent drive pull tender-analyzer/ files/report.pdf --to "$base"' in script
prompt = layer.build_prompt_context()
assert "Loaded mentioned skills" in prompt
assert "# Tender Analyzer\nUse carefully." in prompt
@pytest.mark.anyio
async def test_on_context_create_raises_when_shell_pull_fails(monkeypatch: pytest.MonkeyPatch) -> None:
layer = _build_layer()
_patch_file_help(monkeypatch)
async def fake_run_remote_script_complete(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
return _remote_result("permission denied\n", exit_code=1)
monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
with pytest.raises(DifyDriveLayerError) as exc_info:
await layer.on_context_create()
message = str(exc_info.value)
assert "drive mentioned pull failed in shell: exited exit_code=1" in message
assert "output_complete=True" in message
assert "output_path=/tmp/output.log" in message
@pytest.mark.anyio
async def test_on_context_create_raises_when_required_skill_marker_is_missing_from_complete_output(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
_patch_file_help(monkeypatch)
async def fake_run_remote_script_complete(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
return _remote_result("__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n")
monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
with pytest.raises(DifyDriveLayerError, match="missing pulled SKILL.md"):
await layer.on_context_create()
@pytest.mark.anyio
async def test_on_context_create_reports_incomplete_capture_when_required_marker_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
layer = _build_layer()
_patch_file_help(monkeypatch)
async def fake_run_remote_script_complete(
self: DifyShellLayer,
script: str,
*,
timeout: float = 10.0,
inject_agent_stub_env: bool = False,
) -> CompleteRemoteCommandResult:
del self, script, timeout, inject_agent_stub_env
output = (
"__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n"
)
return _remote_result(output, output_complete=False, incomplete_reason="output_limit")
monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete)
with pytest.raises(DifyDriveLayerError) as exc_info:
await layer.on_context_create()
message = str(exc_info.value)
assert "output incomplete before required SKILL.md content was captured" in message
assert "reason=output_limit" in message
@@ -27,7 +27,6 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None:
config = DifyShellLayerConfig()
assert config.model_dump() == {
"agent_stub_drive_ref": None,
"cli_tools": [],
"env": [],
"secret_refs": [],
@@ -50,7 +49,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
],
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")],
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")],
agent_stub_drive_ref="agent-1",
)
assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"]
@@ -58,7 +56,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
assert config.cli_tools[0].secret_refs[0].ref == "credential-2"
assert config.env[0].name == "PROJECT_NAME"
assert config.secret_refs[0].ref == "credential-1"
assert config.agent_stub_drive_ref == "agent-1"
def test_shell_layer_config_rejects_invalid_env_names() -> None:
@@ -75,6 +75,8 @@ if "jsonschema" not in sys.modules:
sys.modules["jsonschema.protocols"] = jsonschema_protocols_module
sys.modules["jsonschema.validators"] = jsonschema_validators_module
from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig
from dify_agent.layers.config.layer import DifyConfigLayer
from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
@@ -100,6 +102,18 @@ def _runtime_backend_profile() -> RuntimeBackendProfile:
)
def test_default_layer_providers_register_config_layer() -> None:
providers = create_default_layer_providers()
config_provider = next(provider for provider in providers if provider.type_id == DIFY_CONFIG_LAYER_TYPE_ID)
config = DifyConfigLayerConfig(agent_id="agent-1")
layer = config_provider.create_layer(config)
assert isinstance(layer, DifyConfigLayer)
assert layer.type_id == DIFY_CONFIG_LAYER_TYPE_ID
assert layer.config == config
def test_default_layer_providers_register_runtime_layer() -> None:
profile = _runtime_backend_profile()
@@ -292,10 +292,6 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
getattr(route, "path", None) == "/agent-stub/files/download-request"
for route in create_app(settings).routes
)
assert any(
getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes
)
assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes)
route_paths = create_app(settings).openapi()["paths"]
assert {
"/execution-bindings/files/list",
@@ -378,65 +374,6 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
assert fake_redis.closed is True
def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypatch: pytest.MonkeyPatch) -> None:
fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch)
settings = ServerSettings(
redis_url="redis://example.invalid/0",
agent_stub_api_base_url="https://agent.example.com/agent-stub",
server_secret_key=_base64url_secret(b"1" * 32),
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
sandbox_files_base_url="https://files.example.com",
)
token_codec = settings.create_agent_stub_token_codec()
assert token_codec is not None
token = token_codec.encode_connection_token(
_execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1
)
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == (
"https://api.example.com/inner/api/drive/agent-agent-1/manifest"
"?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false"
)
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
return httpx.Response(
200,
json={
"items": [
{
"key": "skills/example/SKILL.md",
"size": 12,
"hash": "sha256:abc",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tool-file-1",
}
]
},
)
monkeypatch.setattr(
"dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient",
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
)
with TestClient(create_app(settings)) as client:
response = client.get(
"/agent-stub/drive/manifest",
headers={"Authorization": f"Bearer {token}"},
params={"prefix": "skills/"},
)
assert response.status_code == 200
assert response.json()["items"][0]["key"] == "skills/example/SKILL.md"
assert FakeRunScheduler.created[0].shutdown_called is True
assert fake_http_client.is_closed is True
assert fake_redis.closed is True
def test_create_plugin_daemon_http_client_uses_generic_outbound_httpx_construction_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -420,7 +420,6 @@ async def test_download_shell_quotes_resolved_path_and_returns_only_reference_in
"HOME": "/home/agent",
"DIFY_AGENT_STUB_API_BASE_URL": "http://stub/agent-stub",
"DIFY_AGENT_STUB_AUTH_JWE": "secret-jwe",
"DIFY_AGENT_STUB_DRIVE_BASE": "/mnt/drive",
}
assert timeout == pytest.approx(60.0, rel=0, abs=0.01)
assert issued_tokens == [(context, None)]
@@ -2,13 +2,10 @@ from __future__ import annotations
from pathlib import Path
import secrets
from typing import cast
import httpx
import pytest
from pydantic import ValidationError
from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
from dify_agent.server.settings import ServerSettings
@@ -271,32 +268,6 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_
assert handler.max_upload_size_bytes == 72 * 1024 * 1024
def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None:
assert ServerSettings().create_agent_stub_drive_request_handler() is None
def test_server_settings_create_agent_stub_drive_request_handler_returns_handler_when_configured() -> None:
settings = ServerSettings(
inner_api_url="https://api.example.com",
inner_api_key="inner-secret",
outbound_http_connect_timeout=11,
outbound_http_read_timeout=22,
outbound_http_write_timeout=33,
outbound_http_pool_timeout=44,
)
handler = settings.create_agent_stub_drive_request_handler()
assert isinstance(handler, DifyApiAgentStubDriveRequestHandler)
assert handler.inner_api_url == "https://api.example.com"
assert handler.inner_api_key == "inner-secret"
timeout = cast(httpx.Timeout, handler.timeout)
assert timeout.connect == 11
assert timeout.read == 22
assert timeout.write == 33
assert timeout.pool == 44
def test_build_runtime_backend_profile_returns_none_when_local_endpoint_is_unset(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -68,7 +68,6 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
agent_cli_help_module = importlib.import_module("dify_agent.layers._agent_cli_help")
agent_stub_shell_env_module = importlib.import_module("dify_agent.agent_stub.shell_env")
shell_module = importlib.import_module("dify_agent.layers.shell")
drive_module = importlib.import_module("dify_agent.layers.drive")
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
plugin_module = importlib.import_module("dify_agent.layers.dify_plugin")
ask_human_module = importlib.import_module("dify_agent.layers.ask_human")
@@ -90,7 +89,6 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
assert "Usage:" in agent_cli_help_module.render_agent_stub_cli_help(("config",))
assert agent_stub_shell_env_module.build_shell_agent_stub_env is not None
assert shell_module.DifyShellLayerConfig is not None
assert drive_module.DifyDriveLayerConfig is not None
assert execution_context_module.DifyExecutionContextLayerConfig is not None
assert plugin_module.DifyPluginLLMLayerConfig is not None
assert ask_human_module.DifyAskHumanLayerConfig is not None
@@ -104,7 +104,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
blocked_imports=[
"anthropic",
"dify_agent.adapters.llm",
"dify_agent.layers.drive.layer",
"dify_agent.layers.execution_context.layer",
"dify_agent.layers.ask_human.layer",
"dify_agent.layers.dify_plugin.llm_layer",
@@ -125,7 +124,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
imports=[
"dify_agent.protocol",
"dify_agent.layers.drive",
"dify_agent.layers.execution_context",
"dify_agent.layers.ask_human",
"dify_agent.layers.dify_plugin",
@@ -135,7 +133,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
assertions=[
"assert hasattr(dify_agent_protocol, 'CreateRunRequest')",
"assert hasattr(dify_agent_layers_drive, 'DifyDriveLayerConfig')",
"assert hasattr(dify_agent_layers_execution_context, 'DifyExecutionContextLayerConfig')",
"assert hasattr(dify_agent_layers_ask_human, 'DifyAskHumanLayerConfig')",
"assert hasattr(dify_agent_layers_dify_plugin, 'DifyPluginLLMLayerConfig')",
+1 -1
View File
@@ -36,7 +36,7 @@ Agent v2 state belongs under `world.agentBuilder`:
- `fixtures` stores resolved models and seeded resources.
- `accessPoint`, `configure`, `speechToText`, and `workflow` store per-scenario state.
Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, drive files, and tool credentials in the existing typed cleanup fields.
Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, config assets, and tool credentials in the existing typed cleanup fields.
## Setup boundary
@@ -3,9 +3,6 @@ import type {
AgentConfigFileUploadResponse,
AgentConfigSkillRefConfig,
AgentConfigSkillUploadResponse,
AgentDriveSkillItemResponse,
AgentDriveSkillListResponse,
AgentSkillUploadResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import type { ConsoleClient } from '../../../support/api/console-client'
import { Buffer } from 'node:buffer'
@@ -31,46 +28,28 @@ const createSingleFileZip = ({ content, entryName }: { content: Buffer; entryNam
const localHeader = Buffer.alloc(30)
localHeader.writeUInt32LE(0x04034b50, 0)
localHeader.writeUInt16LE(20, 4)
localHeader.writeUInt16LE(0, 6)
localHeader.writeUInt16LE(0, 8)
localHeader.writeUInt16LE(0, 10)
localHeader.writeUInt16LE(0, 12)
localHeader.writeUInt32LE(checksum, 14)
localHeader.writeUInt32LE(content.length, 18)
localHeader.writeUInt32LE(content.length, 22)
localHeader.writeUInt16LE(entryNameBuffer.length, 26)
localHeader.writeUInt16LE(0, 28)
const centralDirectoryOffset = localHeader.length + entryNameBuffer.length + content.length
const centralDirectoryHeader = Buffer.alloc(46)
centralDirectoryHeader.writeUInt32LE(0x02014b50, 0)
centralDirectoryHeader.writeUInt16LE(20, 4)
centralDirectoryHeader.writeUInt16LE(20, 6)
centralDirectoryHeader.writeUInt16LE(0, 8)
centralDirectoryHeader.writeUInt16LE(0, 10)
centralDirectoryHeader.writeUInt16LE(0, 12)
centralDirectoryHeader.writeUInt16LE(0, 14)
centralDirectoryHeader.writeUInt32LE(checksum, 16)
centralDirectoryHeader.writeUInt32LE(content.length, 20)
centralDirectoryHeader.writeUInt32LE(content.length, 24)
centralDirectoryHeader.writeUInt16LE(entryNameBuffer.length, 28)
centralDirectoryHeader.writeUInt16LE(0, 30)
centralDirectoryHeader.writeUInt16LE(0, 32)
centralDirectoryHeader.writeUInt16LE(0, 34)
centralDirectoryHeader.writeUInt16LE(0, 36)
centralDirectoryHeader.writeUInt32LE(0, 38)
centralDirectoryHeader.writeUInt32LE(0, 42)
const centralDirectorySize = centralDirectoryHeader.length + entryNameBuffer.length
const endOfCentralDirectory = Buffer.alloc(22)
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0)
endOfCentralDirectory.writeUInt16LE(0, 4)
endOfCentralDirectory.writeUInt16LE(0, 6)
endOfCentralDirectory.writeUInt16LE(1, 8)
endOfCentralDirectory.writeUInt16LE(1, 10)
endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12)
endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16)
endOfCentralDirectory.writeUInt16LE(0, 20)
return Buffer.concat([
localHeader,
@@ -113,25 +92,6 @@ const toSkillArchiveUpload = async ({
const createUploadFile = (content: Buffer, name: string, type: string) =>
new File([Uint8Array.from(content)], name, { type })
export async function uploadAgentDriveSkill(
client: ConsoleClient,
{
agentId,
fileName,
filePath,
}: {
agentId: string
fileName: string
filePath: string
},
): Promise<AgentSkillUploadResponse> {
const upload = await toSkillArchiveUpload({ fileName, filePath })
return client.agent.byAgentId.skills.upload.post({
body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') },
params: { agent_id: agentId },
})
}
export async function uploadAgentConfigFileToDraft(
client: ConsoleClient,
{
@@ -195,13 +155,3 @@ export async function uploadAgentConfigSkillToDraft(
size: skill.size,
}
}
export async function getAgentDriveSkills(
client: ConsoleClient,
agentId: string,
): Promise<AgentDriveSkillItemResponse[]> {
const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({
params: { agent_id: agentId },
})
return body.items ?? []
}
@@ -110,33 +110,6 @@ export async function requirePreseededWorkflow(
}
}
export async function requirePreseededAgentDriveSkill(
world: DifyWorld,
client: ConsoleClient,
agentName: string,
skillName: string,
): Promise<PreseededResource> {
const agent = await requirePreseededAgent(world, client, agentName)
const response = await client.agent.byAgentId.drive.skills.get({
params: { agent_id: agent.id },
})
const skill = response.items?.find((item) => item.name === skillName)
if (!skill) {
return failFixturePrerequisite(
world,
`Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`,
)
}
return {
id: skill.path,
kind: 'skill',
name: skill.name,
}
}
export async function requirePreseededFullConfigAgentCoreConfiguration(
world: DifyWorld,
client: ConsoleClient,
@@ -146,13 +119,6 @@ export async function requirePreseededFullConfigAgentCoreConfiguration(
const agent = await requirePreseededAgent(world, client, agentName)
await requirePreseededAgentDriveSkill(
world,
client,
agentName,
agentBuilderPreseededResources.summarySkill,
)
const jsonTool = await requirePreseededTool(
world,
client,
@@ -225,13 +191,6 @@ export async function requirePreseededToolStatesAgentConfiguration(
): Promise<PreseededResource> {
const agent = await requirePreseededAgent(world, client, agentName)
await requirePreseededAgentDriveSkill(
world,
client,
agentName,
agentBuilderPreseededResources.summarySkill,
)
const jsonTool = await requirePreseededTool(
world,
client,
@@ -51,9 +51,7 @@ export const matchesNameOrLabel = (value: string, name: string, label?: unknown)
export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) =>
items.some((item) => {
const record = asRecord(item)
const values = [record.name, record.drive_key, record.reference, record.file_id, record.id].map(
asString,
)
const values = [record.name, record.reference, record.file_id, record.id].map(asString)
return values.some((value) => value === expectedName || value.endsWith(`/${expectedName}`))
})
+1 -19
View File
@@ -17,17 +17,12 @@ import {
agentBuilderFixedInputs,
agentBuilderPreseededResources,
} from './agent-builder-resources'
import {
getAgentDriveSkills,
uploadAgentConfigFileToDraft,
uploadAgentConfigSkillToDraft,
uploadAgentDriveSkill,
} from './agent-drive'
import {
createAgentSoulConfigWithKnowledgeDataset,
createAgentSoulConfigWithModel,
normalAgentSoulConfig,
} from './agent-soul'
import { uploadAgentConfigFileToDraft, uploadAgentConfigSkillToDraft } from './config-assets'
import { isRecord, matchesNameOrLabel } from './fixtures/common'
import { splitToolDisplayName } from './fixtures/tools'
import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials'
@@ -627,17 +622,6 @@ const saveSeededAgentComposer = async (
}
}
const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => {
const skills = await getAgentDriveSkills(client, agentId)
if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return
await uploadAgentDriveSkill(client, {
agentId,
fileName: agentBuilderTestMaterials.summarySkill,
filePath: getAgentBuilderTestMaterialPath('summarySkill'),
})
}
const seedFullConfigAgent = async (context: SeedContext) => {
const title = agentBuilderPreseededResources.fullConfigAgent
const model = getStableModelResource(context)
@@ -669,7 +653,6 @@ const seedFullConfigAgent = async (context: SeedContext) => {
fileName: agentBuilderTestMaterials.summarySkill,
filePath: getAgentBuilderTestMaterialPath('summarySkill'),
})
await ensureDriveSkill(context.consoleClient, agentId)
await saveSeededAgentComposer(context.consoleClient, {
agentId,
@@ -712,7 +695,6 @@ const seedToolStatesAgent = async (context: SeedContext) => {
fileName: agentBuilderTestMaterials.summarySkill,
filePath: getAgentBuilderTestMaterialPath('summarySkill'),
})
await ensureDriveSkill(context.consoleClient, agent.id)
await saveSeededAgentComposer(context.consoleClient, {
agentId: agent.id,
config: {

Some files were not shown because too many files have changed in this diff Show More