mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
-50
@@ -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}`))
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user