mirror of
https://github.com/langgenius/dify.git
synced 2026-06-13 11:38:28 +08:00
Compare commits
2 Commits
codex/fix-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b6f25fb9 | |||
| 8cac86d5c5 |
@ -26,6 +26,7 @@ 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,
|
||||
@ -50,6 +51,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_SHELL_LAYER_ID = "shell"
|
||||
|
||||
@ -134,6 +136,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
include_shell: bool = False
|
||||
@ -170,6 +175,9 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
include_shell: bool = False
|
||||
@ -228,6 +236,18 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -383,6 +403,18 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@ -31,3 +31,13 @@ class AgentBackendConfig(BaseSettings):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
|
||||
"into Agent runs. The declaration is an index only — the agent backend "
|
||||
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
|
||||
"until the agent backend registers the dify.drive layer type."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@ -54,6 +54,7 @@ from .app import (
|
||||
agent_app_access,
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_drive_inspector,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@ -155,6 +156,7 @@ __all__ = [
|
||||
"agent_app_feature",
|
||||
"agent_app_sandbox",
|
||||
"agent_composer",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
|
||||
@ -94,7 +94,13 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@ -220,7 +226,11 @@ class AgentAppComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=AgentComposerService.resolve_bound_agent_id(tenant_id=tenant_id, app_id=app_model.id),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
@ -13,13 +20,30 @@ 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
|
||||
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError, SkillPackageService
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
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 services.agent_service import AgentService
|
||||
from services.file_service import FileService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AGENT_DRIVE_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@ -31,6 +55,23 @@ 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 AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
@ -68,16 +109,58 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(RootModel[dict[str, Any]]):
|
||||
root: dict[str, Any]
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentSkillRefConfig
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentSkillStandardizeResponse(RootModel[dict[str, Any]]):
|
||||
root: dict[str, Any]
|
||||
class AgentSkillStandardizeResponse(ResponseModel):
|
||||
skill: AgentSkillRefConfig
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery)
|
||||
register_response_schema_models(console_ns, AgentLogResponse, AgentSkillUploadResponse, AgentSkillStandardizeResponse)
|
||||
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
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentSkillStandardizeResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@ -109,7 +192,7 @@ class AgentSkillUploadApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Validate an uploaded Skill package and persist the archive.
|
||||
@ -143,7 +226,7 @@ class AgentSkillUploadApi(Resource):
|
||||
class AgentSkillStandardizeApi(Resource):
|
||||
@console_ns.doc("standardize_agent_skill")
|
||||
@console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.response(
|
||||
201,
|
||||
"Skill standardized into drive",
|
||||
@ -153,13 +236,14 @@ class AgentSkillStandardizeApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
|
||||
agent_id = app_model.bound_agent_id
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400
|
||||
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:
|
||||
@ -178,3 +262,205 @@ class AgentSkillStandardizeApi(Resource):
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
@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
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
upload_file = db.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,
|
||||
)
|
||||
],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
file_ref = AgentFileRefConfig.model_validate(
|
||||
{
|
||||
"id": row["key"],
|
||||
"name": upload_file.name,
|
||||
"file_id": upload_file.id,
|
||||
"drive_key": row["key"],
|
||||
"type": row.get("mime_type"),
|
||||
"size": row.get("size"),
|
||||
}
|
||||
)
|
||||
config_version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_ref=file_ref,
|
||||
app_id=app_model.id,
|
||||
node_id=query.node_id,
|
||||
)
|
||||
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"),
|
||||
},
|
||||
"config_version_id": config_version_id,
|
||||
}, 201
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
|
||||
@console_ns.doc(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
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def delete(self, current_user: Account, app_model: App):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.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
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_key=key,
|
||||
app_id=app_model.id,
|
||||
node_id=query.node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
# Soul-first ordering: the ref is already gone; orphan KV rows are
|
||||
# harmless and an idempotent DELETE retry cleans them.
|
||||
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(
|
||||
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
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
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def delete(self, current_user: Account, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(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
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
skill_slug=slug,
|
||||
app_id=app_model.id,
|
||||
node_id=query.node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/"
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
|
||||
|
||||
@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
|
||||
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(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)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
162
api/controllers/console/app/agent_drive_inspector.py
Normal file
162
api/controllers/console/app/agent_drive_inspector.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""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
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
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 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 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
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = 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, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(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(
|
||||
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@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
|
||||
@get_app_model(mode=_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
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/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
|
||||
@get_app_model(mode=_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(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)
|
||||
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
|
||||
@get_app_model(mode=_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(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)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDrivePreviewApi",
|
||||
]
|
||||
@ -32,7 +32,11 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
WorkflowAgentPluginToolsBuilder,
|
||||
WorkflowAgentPluginToolsBuildError,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
append_runtime_warnings,
|
||||
build_drive_layer_config,
|
||||
build_shell_layer_config,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
|
||||
@ -112,6 +116,11 @@ class AgentAppRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
drive_config = None
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
@ -144,6 +153,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
drive_config=drive_config,
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
shell_config=build_shell_layer_config(agent_soul),
|
||||
session_snapshot=context.session_snapshot,
|
||||
|
||||
@ -15,12 +15,14 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
"tools.cli_tools",
|
||||
"env",
|
||||
"sandbox",
|
||||
# ENG-623: exposed at runtime as the dify.drive declaration layer
|
||||
# (an index the agent pulls through the back proxy).
|
||||
"skills_files",
|
||||
}
|
||||
)
|
||||
|
||||
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
{
|
||||
"skills_files",
|
||||
"knowledge",
|
||||
"human",
|
||||
"memory",
|
||||
@ -28,7 +30,11 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
|
||||
def build_runtime_feature_manifest(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
drive_manifest_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Describe PRD capabilities supported by or still reserved from Agent backend runtime."""
|
||||
warnings: list[dict[str, str]] = []
|
||||
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
||||
@ -46,7 +52,35 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
}
|
||||
)
|
||||
|
||||
has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files)
|
||||
if has_skills_files and not drive_manifest_enabled:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "drive_manifest_disabled",
|
||||
"message": (
|
||||
"skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; "
|
||||
"the drive declaration layer is not injected into this run."
|
||||
),
|
||||
}
|
||||
)
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if not skill.skill_md_key:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": (
|
||||
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
|
||||
"re-standardize it to expose it at runtime."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["skills_files"] = (
|
||||
"supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled"
|
||||
)
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
|
||||
@ -5,6 +5,11 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal, Protocol, assert_never, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.drive import (
|
||||
DifyDriveFileConfig,
|
||||
DifyDriveLayerConfig,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
from dify_agent.layers.execution_context import (
|
||||
DifyExecutionContextInvokeFrom,
|
||||
DifyExecutionContextLayerConfig,
|
||||
@ -169,6 +174,11 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
|
||||
request = self._request_builder.build_for_workflow_node(
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
@ -206,6 +216,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
user_prompt=user_prompt,
|
||||
output=self._build_output_config(node_job.declared_outputs),
|
||||
tools=tools_layer,
|
||||
drive_config=drive_config,
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
shell_config=build_shell_layer_config(agent_soul),
|
||||
session_snapshot=context.session_snapshot,
|
||||
@ -269,7 +280,10 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"agent_config_snapshot_id": context.snapshot.id,
|
||||
"binding_id": context.binding.id,
|
||||
"workflow_node_job_mode": node_job.mode.value,
|
||||
"runtime_support": build_runtime_feature_manifest(agent_soul),
|
||||
"runtime_support": build_runtime_feature_manifest(
|
||||
agent_soul,
|
||||
drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED,
|
||||
),
|
||||
}
|
||||
|
||||
def _build_workflow_context_prompt(
|
||||
@ -482,6 +496,89 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
)
|
||||
|
||||
|
||||
def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None:
|
||||
"""Merge build-time warnings into the metadata runtime-support manifest."""
|
||||
if not warnings:
|
||||
return
|
||||
manifest = metadata.setdefault("runtime_support", {})
|
||||
if isinstance(manifest, dict):
|
||||
existing = manifest.setdefault("unsupported_runtime_warnings", [])
|
||||
if isinstance(existing, list):
|
||||
existing.extend(warnings)
|
||||
|
||||
|
||||
def build_drive_layer_config(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
agent_id: str | None,
|
||||
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
|
||||
"""Catalog the soul's drive-backed Skills & Files into the dify.drive declaration.
|
||||
|
||||
Returns ``(config, warnings)`` — ``config is None`` means nothing to inject
|
||||
(no skills/files configured, or no agent identity to address the drive by).
|
||||
Refs that predate standardization (no drive key) are skipped with a warning
|
||||
instead of failing the run, so historic souls keep running.
|
||||
"""
|
||||
skill_refs = agent_soul.skills_files.skills
|
||||
file_refs = agent_soul.skills_files.files
|
||||
if not skill_refs and not file_refs:
|
||||
return None, []
|
||||
|
||||
warnings: list[dict[str, str]] = []
|
||||
if not agent_id:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": "skills_files is configured but the run has no bound agent to address a drive by.",
|
||||
}
|
||||
)
|
||||
return None, warnings
|
||||
|
||||
skills: list[DifyDriveSkillConfig] = []
|
||||
for skill in skill_refs:
|
||||
if not skill.skill_md_key:
|
||||
warnings.append(
|
||||
{
|
||||
"section": "agent_soul.skills_files",
|
||||
"code": "skill_ref_dangling",
|
||||
"message": (
|
||||
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
|
||||
"re-standardize it to expose it at runtime."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
skills.append(
|
||||
DifyDriveSkillConfig(
|
||||
name=skill.name or skill.skill_md_key.split("/", 1)[0],
|
||||
description=skill.description or "",
|
||||
skill_md_key=skill.skill_md_key,
|
||||
archive_key=skill.full_archive_key,
|
||||
)
|
||||
)
|
||||
|
||||
files: list[DifyDriveFileConfig] = []
|
||||
for file in file_refs:
|
||||
if not file.drive_key:
|
||||
# Plain upload references (pre-ENG-625) are not drive-backed; they are
|
||||
# simply invisible to the manifest rather than a defect worth warning on.
|
||||
continue
|
||||
size = file.get("size")
|
||||
files.append(
|
||||
DifyDriveFileConfig(
|
||||
name=file.name or file.drive_key.rsplit("/", 1)[-1],
|
||||
key=file.drive_key,
|
||||
size=size if isinstance(size, int) else None,
|
||||
mime_type=file.type,
|
||||
)
|
||||
)
|
||||
|
||||
if not skills and not files:
|
||||
return None, warnings
|
||||
return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings
|
||||
|
||||
|
||||
def _cli_tool_enabled(item: object) -> bool:
|
||||
"""A CLI tool is bootstrapped unless explicitly disabled (default is enabled)."""
|
||||
data = _plain_mapping(item)
|
||||
|
||||
@ -99,6 +99,10 @@ 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):
|
||||
@ -107,6 +111,16 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
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 AgentPermissionConfig(BaseModel):
|
||||
@ -175,6 +189,10 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
risk_accepted: bool = False
|
||||
approved: bool = False
|
||||
risk_level: AgentCliToolRiskLevel | None = None
|
||||
# Slug of the skill an infer-tools suggestion came from (ENG-371); drives
|
||||
# the "inferred from <skill>" badge. Plain provenance metadata — saving an
|
||||
# inferred tool still passes every composer validation rule.
|
||||
inferred_from: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
|
||||
@ -1042,6 +1042,98 @@ Upload one Agent App sandbox file as a Dify ToolFile mapping
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/files
|
||||
Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string |
|
||||
| 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 |
|
||||
| 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**
|
||||
|
||||
@ -1072,6 +1164,7 @@ Validate + standardize a Skill into the agent drive (ENG-594)
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
@ -1100,6 +1193,43 @@ plus its manifest. Standardizing into the agent drive is ENG-594.
|
||||
| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
|
||||
| 400 | Invalid skill package | |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/skills/{slug}
|
||||
Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@ -10852,6 +10982,7 @@ composer/publish validators and skipped by runtime request builders.
|
||||
| enabled | boolean, <br>**Default:** true | | No |
|
||||
| env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No |
|
||||
| id | string | | No |
|
||||
| inferred_from | string | | No |
|
||||
| install | string | | No |
|
||||
| install_command | string | | No |
|
||||
| install_commands | [ string ] | | No |
|
||||
@ -10930,6 +11061,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** file | | No |
|
||||
@ -10972,10 +11104,15 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** skill | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentComposerSoulCandidatesResponse
|
||||
|
||||
@ -11058,6 +11195,70 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
|
||||
#### AgentDriveDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| removed_keys | [ string ] | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentDriveDownloadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| url | string | | Yes |
|
||||
|
||||
#### AgentDriveFileCommitResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| 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 |
|
||||
| key | string | | Yes |
|
||||
| mime_type | string | | No |
|
||||
| size | integer | | 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 |
|
||||
|
||||
#### AgentEnvVariableConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11081,6 +11282,7 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
@ -11425,21 +11627,28 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| 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 |
|
||||
|
||||
#### AgentSkillStandardizeResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentSkillStandardizeResponse | object | | |
|
||||
| manifest | [SkillManifest](#skillmanifest) | | Yes |
|
||||
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentSkillUploadResponse | object | | |
|
||||
| manifest | [SkillManifest](#skillmanifest) | | Yes |
|
||||
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
|
||||
|
||||
#### AgentSoulAppFeaturesConfig
|
||||
|
||||
@ -12457,6 +12666,17 @@ Button styles for user actions.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | 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 |
|
||||
|
||||
#### CodeBasedExtensionQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -14087,6 +14307,14 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| success | boolean | Operation success | Yes |
|
||||
|
||||
#### EnvSuggestion
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| key | string | | Yes |
|
||||
| reason | string | | No |
|
||||
| secret_likely | boolean | | No |
|
||||
|
||||
#### EnvironmentVariableItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -17117,6 +17345,27 @@ 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 |
|
||||
|
||||
#### Snippet
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -12,6 +12,7 @@ from models.agent import (
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDriveFile,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
@ -20,6 +21,7 @@ from models.agent import (
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentFileRefConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
@ -28,7 +30,12 @@ from models.agent_config_entities import (
|
||||
from models.workflow import Workflow
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError
|
||||
from services.agent.errors import (
|
||||
AgentNameConflictError,
|
||||
AgentNotFoundError,
|
||||
AgentVersionNotFoundError,
|
||||
InvalidComposerConfigError,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
AgentSoulConfig,
|
||||
ComposerCandidatesResponse,
|
||||
@ -99,6 +106,21 @@ class AgentComposerService:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
# ENG-623 §4.4: drive-backed refs must point at real drive rows before the
|
||||
# soul is persisted. Only strategies that write the soul onto an *existing*
|
||||
# agent are checked — new-agent strategies create a fresh (empty) drive, so
|
||||
# any carried drive key would be flagged on the next save instead.
|
||||
if (
|
||||
payload.agent_soul is not None
|
||||
and binding is not None
|
||||
and binding.agent_id
|
||||
and payload.save_strategy
|
||||
in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION)
|
||||
):
|
||||
cls._require_drive_refs_resolved(
|
||||
tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul
|
||||
)
|
||||
|
||||
match payload.save_strategy:
|
||||
case ComposerSaveStrategy.NODE_JOB_ONLY:
|
||||
binding = cls._save_node_job_only(
|
||||
@ -220,6 +242,9 @@ class AgentComposerService:
|
||||
db.session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
|
||||
# ENG-623 §4.4: dangling drive-backed refs are rejected before persisting.
|
||||
cls._require_drive_refs_resolved(tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul)
|
||||
|
||||
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
@ -252,8 +277,18 @@ class AgentComposerService:
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
|
||||
def collect_validation_findings(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: ComposerSavePayload,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders.
|
||||
|
||||
With ``agent_id`` the drive-backed skill/file refs are also checked against
|
||||
the agent drive (ENG-623 §4.4) and dangling ones surface as warnings.
|
||||
"""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
@ -266,7 +301,242 @@ class AgentComposerService:
|
||||
existing_dataset_ids: set[str] | None = None
|
||||
if mentioned_ids:
|
||||
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
|
||||
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=payload.agent_soul)
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def remove_drive_refs(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
skill_slug: str | None = None,
|
||||
file_key: str | None = None,
|
||||
app_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Drop the soul refs backed by a drive skill/file before the drive rows go.
|
||||
|
||||
Soul-first ordering (ENG-625 D5): a mid-failure leaves harmless orphan KV
|
||||
rows that an idempotent DELETE retry cleans, instead of a soul ref that
|
||||
keeps failing dangling-ref validation. Returns the new config version id,
|
||||
or ``None`` when the soul held no matching ref (idempotent re-delete).
|
||||
"""
|
||||
if (skill_slug is None) == (file_key is None):
|
||||
raise ValueError("remove_drive_refs requires exactly one of skill_slug or file_key")
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
|
||||
|
||||
removed_display: str | None = None
|
||||
if skill_slug is not None:
|
||||
kept_skills = []
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
|
||||
if slug == skill_slug:
|
||||
removed_display = skill.name or skill.id or skill_slug
|
||||
continue
|
||||
kept_skills.append(skill)
|
||||
if removed_display is None:
|
||||
return None
|
||||
agent_soul.skills_files.skills = kept_skills
|
||||
note = f"Removed skill '{removed_display}' from the drive."
|
||||
else:
|
||||
kept_files = []
|
||||
for file in agent_soul.skills_files.files:
|
||||
if file.drive_key == file_key:
|
||||
removed_display = file.name or file.drive_key
|
||||
continue
|
||||
kept_files.append(file)
|
||||
if removed_display is None:
|
||||
return None
|
||||
agent_soul.skills_files.files = kept_files
|
||||
note = f"Removed file '{removed_display}' from the drive."
|
||||
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.updated_by = account_id
|
||||
cls._sync_draft_binding_snapshot(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
snapshot_id=version.id,
|
||||
account_id=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return version.id
|
||||
|
||||
@classmethod
|
||||
def add_drive_file_ref(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
file_ref: AgentFileRefConfig,
|
||||
app_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Add or replace one drive-backed file ref in the active Agent Soul.
|
||||
|
||||
``POST /agent/files`` is an ADD FILE user action, not just a low-level
|
||||
drive commit. The committed file must be present in ``skills_files.files``
|
||||
because runtime ``dify.drive`` is built from the active Agent Soul.
|
||||
"""
|
||||
if not file_ref.drive_key:
|
||||
raise ValueError("file_ref.drive_key is required")
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
|
||||
kept_files = [item for item in agent_soul.skills_files.files if item.drive_key != file_ref.drive_key]
|
||||
kept_files.append(file_ref)
|
||||
agent_soul.skills_files.files = kept_files
|
||||
|
||||
display = file_ref.name or file_ref.drive_key
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=f"Added file '{display}' to the drive.",
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.updated_by = account_id
|
||||
cls._sync_draft_binding_snapshot(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
snapshot_id=version.id,
|
||||
account_id=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return version.id
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
return db.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, *, tenant_id: str, app_id: str, node_id: str) -> str | None:
|
||||
"""The draft workflow node binding's agent id, if any (validate-endpoint context)."""
|
||||
try:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
except ValueError:
|
||||
return None
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
return binding.agent_id if binding else None
|
||||
|
||||
@classmethod
|
||||
def _sync_draft_binding_snapshot(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str | None,
|
||||
node_id: str | None,
|
||||
agent_id: str,
|
||||
snapshot_id: str,
|
||||
account_id: str,
|
||||
) -> None:
|
||||
"""Keep workflow node bindings on the new active snapshot after direct drive edits."""
|
||||
if not app_id or not node_id:
|
||||
return
|
||||
try:
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
except ValueError:
|
||||
return
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
if binding is None or binding.agent_id != agent_id:
|
||||
return
|
||||
binding.current_snapshot_id = snapshot_id
|
||||
binding.updated_by = account_id
|
||||
|
||||
@classmethod
|
||||
def _drive_ref_findings(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4).
|
||||
|
||||
Each finding message starts with its stable code token
|
||||
(``skill_ref_dangling`` / ``file_ref_dangling``) in the ENG-616/617 style.
|
||||
"""
|
||||
wanted_keys: dict[str, tuple[str, str]] = {}
|
||||
for skill in agent_soul.skills_files.skills:
|
||||
if skill.skill_md_key:
|
||||
wanted_keys[skill.skill_md_key] = ("skill_ref_dangling", skill.name or skill.id or "unknown")
|
||||
for file in agent_soul.skills_files.files:
|
||||
if file.drive_key:
|
||||
wanted_keys[file.drive_key] = ("file_ref_dangling", file.name or file.id or "unknown")
|
||||
if not wanted_keys:
|
||||
return []
|
||||
|
||||
existing_keys = set(
|
||||
db.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, (code, display) in wanted_keys.items():
|
||||
if key in existing_keys:
|
||||
continue
|
||||
kind = "skill" if code == "skill_ref_dangling" else "file"
|
||||
findings.append(
|
||||
{
|
||||
"code": code,
|
||||
"surface": "agent_soul",
|
||||
"kind": kind,
|
||||
"id": key,
|
||||
"message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def _require_drive_refs_resolved(cls, *, tenant_id: str, agent_id: str, agent_soul: AgentSoulConfig) -> None:
|
||||
"""Hard save-time guard: dangling drive-backed refs are rejected (400)."""
|
||||
findings = cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul)
|
||||
if findings:
|
||||
raise InvalidComposerConfigError("; ".join(str(finding["message"]) for finding in findings))
|
||||
|
||||
@classmethod
|
||||
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
|
||||
|
||||
@ -70,6 +70,7 @@ class SkillManifest(BaseModel):
|
||||
"size": self.size,
|
||||
"hash": self.hash,
|
||||
"entry_path": self.entry_path,
|
||||
"manifest_files": self.files,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -112,6 +112,8 @@ class SkillStandardizeService:
|
||||
"skill_md_key": skill_md_key,
|
||||
"full_archive_file_id": archive_tool_file.id,
|
||||
"full_archive_key": archive_key,
|
||||
# ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools.
|
||||
"manifest_files": manifest.files,
|
||||
}
|
||||
)
|
||||
return {
|
||||
|
||||
215
api/services/agent/skill_tool_inference_service.py
Normal file
215
api/services/agent/skill_tool_inference_service.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""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 import select
|
||||
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
from core.model_manager import ModelManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.agent import Agent
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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) -> dict[str, Any]:
|
||||
skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
|
||||
manifest_files = self._manifest_files_from_soul(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
|
||||
|
||||
user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
|
||||
if manifest_files:
|
||||
listing = "\n".join(manifest_files[:200])
|
||||
user_prompt += f"\n\nFiles inside the skill package:\n{listing}"
|
||||
|
||||
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
|
||||
try:
|
||||
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) -> str:
|
||||
try:
|
||||
preview = self._drive.preview(tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md")
|
||||
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 _manifest_files_from_soul(*, tenant_id: str, agent_id: str, slug: str) -> list[str]:
|
||||
"""The zip path listing standardize persisted onto the ref, if present.
|
||||
|
||||
Degrades to an empty list (SKILL.md-only inference) for refs that
|
||||
predate ``manifest_files``.
|
||||
"""
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return []
|
||||
from models.agent import AgentConfigSnapshot
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is None:
|
||||
return []
|
||||
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
for skill in soul.skills_files.skills:
|
||||
ref_slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
|
||||
if ref_slug != slug:
|
||||
continue
|
||||
files = skill.get("manifest_files")
|
||||
if isinstance(files, list):
|
||||
return [str(item) for item in files]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _invoke(*, tenant_id: str, user_prompt: str) -> str:
|
||||
try:
|
||||
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",
|
||||
]
|
||||
@ -131,6 +131,7 @@ class AgentDriveService:
|
||||
"mime_type": row.mime_type,
|
||||
"file_kind": row.file_kind.value,
|
||||
"file_id": row.file_id,
|
||||
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
|
||||
}
|
||||
if include_download_url:
|
||||
item["download_url"] = self._resolve_download_url(
|
||||
@ -169,6 +170,52 @@ class AgentDriveService:
|
||||
self._delete_storage(storage_key)
|
||||
return committed
|
||||
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str | None = None,
|
||||
key: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5).
|
||||
|
||||
Drive-owned values get their backing record + storage object cleaned via
|
||||
the same ``_cleanup_value`` path commit-overwrite uses; shared values only
|
||||
lose the KV row. Idempotent: deleting nothing returns ``[]``.
|
||||
"""
|
||||
if (prefix is None) == (key is None):
|
||||
raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key")
|
||||
removed_keys: list[str] = []
|
||||
pending_storage_deletes: list[str] = []
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
stmt = select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
)
|
||||
if key is not None:
|
||||
stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key))
|
||||
else:
|
||||
stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or "")))
|
||||
rows = list(session.scalars(stmt))
|
||||
for row in rows:
|
||||
if row.value_owned_by_drive:
|
||||
self._cleanup_value(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
exclude_row_id=row.id,
|
||||
pending_storage_deletes=pending_storage_deletes,
|
||||
)
|
||||
removed_keys.append(row.key)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
for storage_key in pending_storage_deletes:
|
||||
self._delete_storage(storage_key)
|
||||
return removed_keys
|
||||
|
||||
def _commit_one(
|
||||
self,
|
||||
session: Session,
|
||||
@ -338,7 +385,12 @@ class AgentDriveService:
|
||||
logger.warning("failed to delete drive storage object %s", storage_key, exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_download_url(*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str) -> str | None:
|
||||
def _resolve_download_url(
|
||||
*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False
|
||||
) -> str | None:
|
||||
"""Signed URL for a drive value. ``for_external`` selects the audience:
|
||||
the inner manifest hands agents *internal* URLs, while the console
|
||||
inspector must hand browsers *external* ones — never mix the two."""
|
||||
if file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id}
|
||||
else:
|
||||
@ -349,10 +401,86 @@ class AgentDriveService:
|
||||
# No FileAccessScope bound -> drive-owned: the builders still filter by
|
||||
# tenant_id, so resolution is tenant-scoped without user-level checks.
|
||||
file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller)
|
||||
return runtime.resolve_file_url(file=file, for_external=False)
|
||||
return runtime.resolve_file_url(file=file, for_external=for_external)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# ── console drive inspector (ENG-624) ────────────────────────────────────
|
||||
|
||||
# SKILL.md is the primary preview use case; 64 KiB covers it with headroom
|
||||
# while keeping the console payload bounded.
|
||||
PREVIEW_MAX_BYTES = 64 * 1024
|
||||
|
||||
def _require_row(self, session: Session, *, tenant_id: str, agent_id: str, key: str) -> AgentDriveFile:
|
||||
row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == normalize_drive_key(key),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
|
||||
return row
|
||||
|
||||
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
|
||||
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(
|
||||
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
|
||||
)
|
||||
if tool_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return tool_file.file_key
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
|
||||
)
|
||||
if upload_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return upload_file.key
|
||||
|
||||
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
|
||||
size = row.size
|
||||
|
||||
data = bytearray()
|
||||
for chunk in storage.load_stream(storage_key):
|
||||
data.extend(chunk)
|
||||
if len(data) > self.PREVIEW_MAX_BYTES:
|
||||
break
|
||||
truncated = len(data) > self.PREVIEW_MAX_BYTES
|
||||
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
|
||||
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
|
||||
if b"\x00" in sample:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if truncated:
|
||||
# A multi-byte char may sit on the cut point; retry without the tail.
|
||||
try:
|
||||
text = sample[:-3].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
else:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
"""External signed URL for a browser download of one drive value."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
url = self._resolve_download_url(
|
||||
tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True
|
||||
)
|
||||
if url is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
return url
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
|
||||
@ -283,6 +283,10 @@ 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_save_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",
|
||||
@ -354,6 +358,10 @@ def test_agent_app_composer_get_put_validate_and_candidates(
|
||||
lambda **kwargs: _agent_app_composer_response(),
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_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_agent_app_candidates",
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
"""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
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app.agent_drive_inspector import (
|
||||
AgentDriveDownloadApi,
|
||||
AgentDriveListApi,
|
||||
AgentDrivePreviewApi,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "controllers.console.app.agent_drive_inspector"
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _raw(method):
|
||||
return inspect.unwrap(method)
|
||||
|
||||
|
||||
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
|
||||
|
||||
|
||||
def test_list_filters_value_pointers_out_of_console_payload():
|
||||
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(), _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_resolves_workflow_node_binding_agent():
|
||||
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(), _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_list_400_when_no_agent_bound():
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
with app.test_request_context("/"):
|
||||
body, status = raw(AgentDriveListApi(), app_without_agent)
|
||||
assert status == 400
|
||||
assert body["code"] == "agent_not_bound"
|
||||
|
||||
|
||||
def test_preview_passes_through_and_maps_errors():
|
||||
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(), _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(), _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "drive_key_not_found"
|
||||
|
||||
|
||||
def test_download_returns_signed_url_json():
|
||||
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(), _APP)
|
||||
assert body == {"url": "https://signed.example/zip"}
|
||||
@ -32,7 +32,7 @@ def _file_ctx(*, files: dict[str, bytes] | None = None):
|
||||
|
||||
|
||||
_USER = SimpleNamespace(id="user-1")
|
||||
_APP = SimpleNamespace(tenant_id="tenant-1", bound_agent_id="agent-1")
|
||||
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
|
||||
|
||||
|
||||
def test_upload_validates_and_returns_skill_ref():
|
||||
@ -89,11 +89,30 @@ def test_standardize_returns_result():
|
||||
|
||||
def test_standardize_no_bound_agent_is_400():
|
||||
raw = _raw(AgentSkillStandardizeApi.post)
|
||||
app_without_agent = SimpleNamespace(tenant_id="tenant-1", bound_agent_id=None)
|
||||
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
with _file_ctx(files={"file": b"zip"}):
|
||||
body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent)
|
||||
assert status == 400
|
||||
assert body["code"] == "no_bound_agent"
|
||||
assert body["code"] == "agent_not_bound"
|
||||
|
||||
|
||||
def test_standardize_resolves_workflow_node_agent():
|
||||
raw = _raw(AgentSkillStandardizeApi.post)
|
||||
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
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(AgentSkillStandardizeApi(), _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_standardize_maps_drive_error():
|
||||
@ -104,3 +123,239 @@ def test_standardize_maps_drive_error():
|
||||
body, status = raw(AgentSkillStandardizeApi(), _USER, _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "source_not_found"
|
||||
|
||||
|
||||
# ── ENG-625: drive files commit + delete endpoints ────────────────────────────
|
||||
|
||||
|
||||
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():
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
upload = SimpleNamespace(id="uf-1", name="sample qna.pdf")
|
||||
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
|
||||
with (
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.db") as db_mock,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
):
|
||||
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
|
||||
db_mock.session.scalar.return_value = upload
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
|
||||
]
|
||||
composer.add_drive_file_ref.return_value = "ver-2"
|
||||
body, status = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
assert status == 201
|
||||
assert body["file"]["drive_key"] == "files/sample qna.pdf"
|
||||
assert body["file"]["file_id"] == "uf-1"
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
item = drive.return_value.commit.call_args.kwargs["items"][0]
|
||||
assert item.value_owned_by_drive is True
|
||||
assert item.file_ref.kind == "upload_file"
|
||||
file_ref = composer.add_drive_file_ref.call_args.kwargs["file_ref"]
|
||||
assert file_ref.drive_key == "files/sample qna.pdf"
|
||||
assert file_ref.name == "sample qna.pdf"
|
||||
assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1"
|
||||
|
||||
|
||||
def test_files_commit_404_when_upload_not_in_tenant():
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
|
||||
with (
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.db") as db_mock,
|
||||
):
|
||||
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
|
||||
db_mock.session.scalar.return_value = None
|
||||
body, status = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "upload_file_not_found"
|
||||
|
||||
|
||||
def test_files_commit_resolves_workflow_node_agent():
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
|
||||
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"):
|
||||
with (
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.db") as db_mock,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
):
|
||||
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
|
||||
db_mock.session.scalar.return_value = upload
|
||||
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"}
|
||||
]
|
||||
composer.add_drive_file_ref.return_value = "ver-2"
|
||||
body, status = raw(AgentDriveFilesApi(), _USER, workflow_app)
|
||||
|
||||
assert status == 201
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
assert composer.add_drive_file_ref.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_files_delete_updates_soul_then_drive():
|
||||
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}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.side_effect = lambda **kw: calls.append("soul") or "ver-2"
|
||||
drive.return_value.delete.side_effect = lambda **kw: calls.append("drive") or ["files/sample.pdf"]
|
||||
body = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
assert calls == ["soul", "drive"] # soul-first ordering
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"], "config_version_id": "ver-2"}
|
||||
assert composer.remove_drive_refs.call_args.kwargs["file_key"] == "files/sample.pdf"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
|
||||
|
||||
|
||||
def test_files_delete_resolves_workflow_node_agent():
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.delete)
|
||||
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
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"
|
||||
composer.remove_drive_refs.return_value = "ver-2"
|
||||
drive.return_value.delete.return_value = ["files/sample.pdf"]
|
||||
body = raw(AgentDriveFilesApi(), _USER, workflow_app)
|
||||
|
||||
assert body["config_version_id"] == "ver-2"
|
||||
assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_files_delete_survives_drive_failure():
|
||||
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}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.return_value = "ver-2"
|
||||
drive.return_value.delete.side_effect = RuntimeError("storage down")
|
||||
body = raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
# soul already updated; drive cleanup is best-effort and retryable
|
||||
assert body == {"result": "success", "removed_keys": [], "config_version_id": "ver-2"}
|
||||
|
||||
|
||||
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
|
||||
from controllers.console.app.agent import AgentSkillApi
|
||||
|
||||
raw = _raw(AgentSkillApi.delete)
|
||||
with _json_ctx(method="DELETE"):
|
||||
with (
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.remove_drive_refs.return_value = None # ref already gone
|
||||
drive.return_value.delete.return_value = []
|
||||
body = raw(AgentSkillApi(), _USER, _APP, "tender-analyzer")
|
||||
|
||||
assert body == {"result": "success", "removed_keys": [], "config_version_id": None}
|
||||
assert drive.return_value.delete.call_args.kwargs["prefix"] == "tender-analyzer/"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["skill_slug"] == "tender-analyzer"
|
||||
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
|
||||
|
||||
|
||||
def test_skill_delete_rejects_path_like_slug():
|
||||
from controllers.console.app.agent import AgentSkillApi
|
||||
|
||||
raw = _raw(AgentSkillApi.delete)
|
||||
with _json_ctx(method="DELETE"):
|
||||
body, status = raw(AgentSkillApi(), _USER, _APP, "a/b")
|
||||
assert status == 400
|
||||
assert body["code"] == "drive_key_invalid"
|
||||
|
||||
|
||||
# ── ENG-371: infer-tools endpoint ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_infer_tools_returns_draft_suggestions():
|
||||
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(), _APP, "audio-transcribe")
|
||||
|
||||
assert body["inferable"] is True
|
||||
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
|
||||
|
||||
|
||||
def test_infer_tools_resolves_workflow_node_agent():
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
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(), 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():
|
||||
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(), _APP, "audio-transcribe")
|
||||
assert status == 400
|
||||
assert body["code"] == "default_model_not_configured"
|
||||
|
||||
|
||||
def test_infer_tools_rejects_path_like_slug_and_unbound_app():
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
with _json_ctx():
|
||||
body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b")
|
||||
assert (status, body["code"]) == (400, "drive_key_invalid")
|
||||
|
||||
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
|
||||
with _json_ctx():
|
||||
body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x")
|
||||
assert (status, body["code"]) == (400, "agent_not_bound")
|
||||
|
||||
@ -186,3 +186,52 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
"dify_tool_names": [],
|
||||
"cli_tool_count": 1,
|
||||
}
|
||||
|
||||
|
||||
# ── ENG-623: drive declaration on the Agent App surface ──────────────────────
|
||||
|
||||
|
||||
def _soul_with_model_and_skill() -> AgentSoulConfig:
|
||||
from models.agent_config_entities import AgentSkillRefConfig
|
||||
|
||||
soul = _soul_with_model()
|
||||
soul.skills_files.skills = [
|
||||
AgentSkillRefConfig.model_validate(
|
||||
{
|
||||
"id": "abc",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
}
|
||||
)
|
||||
]
|
||||
return soul
|
||||
|
||||
|
||||
class TestAgentAppDriveLayer:
|
||||
def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model_and_skill()))
|
||||
|
||||
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
|
||||
assert drive.type == "dify.drive"
|
||||
assert drive.config.drive_ref == "agent-agent-1"
|
||||
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
|
||||
# injected right after execution_context, mirroring the workflow surface
|
||||
names = [layer.name for layer in result.request.composition.layers]
|
||||
assert names.index("drive") == names.index("execution_context") + 1
|
||||
|
||||
def test_no_drive_layer_when_flag_disabled(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model_and_skill()))
|
||||
assert all(layer.name != "drive" for layer in result.request.composition.layers)
|
||||
|
||||
@ -675,3 +675,124 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
|
||||
# the value still rides the Workflow context block, not the job prompt
|
||||
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
|
||||
assert "[§" not in json.dumps(dumped["composition"]["layers"][:3])
|
||||
|
||||
|
||||
# ── ENG-623: dify.drive declaration layer ─────────────────────────────────────
|
||||
|
||||
|
||||
def _soul_with_drive_skill() -> AgentSoulConfig:
|
||||
return AgentSoulConfig(
|
||||
prompt={"system_prompt": "You are careful."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
skills_files={
|
||||
"skills": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
},
|
||||
{"id": "legacy", "name": "Legacy Skill"}, # pre-standardization: no drive key
|
||||
],
|
||||
"files": [
|
||||
{"name": "sample.pdf", "drive_key": "files/sample.pdf", "type": "application/pdf"},
|
||||
{"name": "plain-upload.pdf", "file_id": "upload-1"}, # not drive-backed
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_build_drive_layer_config_catalogs_only_drive_backed_refs():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id="agent-1")
|
||||
|
||||
assert config is not None
|
||||
assert config.drive_ref == "agent-agent-1"
|
||||
assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"]
|
||||
assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip"
|
||||
assert [file.key for file in config.files] == ["files/sample.pdf"]
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
assert "Legacy Skill" in warnings[0]["message"]
|
||||
|
||||
|
||||
def test_build_drive_layer_config_skips_when_nothing_configured():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
soul = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test")
|
||||
)
|
||||
assert build_drive_layer_config(soul, agent_id="agent-1") == (None, [])
|
||||
|
||||
|
||||
def test_build_drive_layer_config_requires_agent_identity():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id=None)
|
||||
|
||||
assert config is None
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
|
||||
|
||||
def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Contract test: locks the dify.drive composition shape against cross-package drift."""
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
|
||||
assert "drive" in layer_names
|
||||
# injected right after execution_context, before history/llm
|
||||
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
|
||||
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
|
||||
assert drive["type"] == "dify.drive"
|
||||
assert drive["config"]["drive_ref"] == "agent-agent-1"
|
||||
assert drive["config"]["skills"] == [
|
||||
{
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
]
|
||||
assert drive["config"]["files"] == [
|
||||
{"name": "sample.pdf", "key": "files/sample.pdf", "size": None, "mime_type": "application/pdf"}
|
||||
]
|
||||
# the dangling legacy ref degraded to a warning instead of failing the run
|
||||
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
|
||||
assert any(w["code"] == "skill_ref_dangling" for w in warnings)
|
||||
# the drive layer is non-sensitive and must survive into persistable specs
|
||||
from dify_agent.protocol import extract_runtime_layer_specs
|
||||
|
||||
specs = extract_runtime_layer_specs(result.request.composition)
|
||||
assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs)
|
||||
|
||||
|
||||
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_drive_skill()
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"])
|
||||
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
|
||||
assert any(w["code"] == "drive_manifest_disabled" for w in warnings)
|
||||
|
||||
|
||||
def test_build_drive_layer_config_all_refs_dangling_yields_no_config():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
soul = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u1"}]},
|
||||
)
|
||||
config, warnings = build_drive_layer_config(soul, agent_id="agent-1")
|
||||
assert config is None
|
||||
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
|
||||
|
||||
@ -14,7 +14,7 @@ from models.agent import (
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import WorkflowNodeJobConfig
|
||||
from models.agent_config_entities import AgentFileRefConfig, WorkflowNodeJobConfig
|
||||
from models.workflow import Workflow
|
||||
from services.agent import composer_service, roster_service
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
@ -1233,3 +1233,334 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
|
||||
}
|
||||
assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"]
|
||||
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
|
||||
|
||||
|
||||
# ── ENG-623 §4.4: drive-backed ref validation ────────────────────────────────
|
||||
|
||||
|
||||
def _drive_soul(**overrides):
|
||||
from services.entities.agent_entities import AgentSoulConfig
|
||||
|
||||
base = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
],
|
||||
"files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
},
|
||||
}
|
||||
base.update(overrides)
|
||||
return AgentSoulConfig.model_validate(base)
|
||||
|
||||
|
||||
def _patch_drive_keys(monkeypatch, existing_keys):
|
||||
import services.agent.composer_service as composer_service_module
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_scalars(stmt):
|
||||
captured["stmt"] = stmt
|
||||
return list(existing_keys)
|
||||
|
||||
monkeypatch.setattr(composer_service_module.db, "session", type("S", (), {"scalars": staticmethod(fake_scalars)})())
|
||||
return captured
|
||||
|
||||
|
||||
def test_drive_ref_findings_reports_missing_keys(monkeypatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"])
|
||||
|
||||
findings = AgentComposerService._drive_ref_findings(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
)
|
||||
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")]
|
||||
assert str(findings[0]["message"]).startswith("file_ref_dangling: ")
|
||||
|
||||
|
||||
def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"])
|
||||
|
||||
assert (
|
||||
AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul())
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch):
|
||||
# No drive-backed ref at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(
|
||||
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]}
|
||||
)
|
||||
findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch):
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService._require_drive_refs_resolved(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
)
|
||||
|
||||
|
||||
def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch):
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
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(
|
||||
tenant_id="tenant-1", payload=payload, agent_id="agent-1"
|
||||
)
|
||||
|
||||
codes = {w["code"] for w in findings["warnings"]}
|
||||
assert {"skill_ref_dangling", "file_ref_dangling"} <= codes
|
||||
# without agent context the drive check is skipped entirely
|
||||
findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload)
|
||||
assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-625 D5: soul-first ref removal ───────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict):
|
||||
"""Wire the classmethod's collaborators so soul editing + versioning is observable."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None)
|
||||
snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict)
|
||||
committed: dict[str, object] = {}
|
||||
|
||||
fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True))
|
||||
monkeypatch.setattr(module.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot))
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note):
|
||||
captured["agent_soul"] = agent_soul
|
||||
captured["version_note"] = version_note
|
||||
return SimpleNamespace(id="snap-2")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update))
|
||||
return agent, captured, committed
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
{"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"},
|
||||
],
|
||||
"files": [],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills]
|
||||
assert kept == ["other-skill/SKILL.md"]
|
||||
assert "Tender Analyzer" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch):
|
||||
soul_dict = {"skills_files": {"skills": [], "files": []}}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert "agent_soul" not in captured
|
||||
assert committed == {}
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_file_by_key(monkeypatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "keep.pdf", "drive_key": "files/keep.pdf"},
|
||||
{"name": "drop.pdf", "drive_key": "files/drop.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
_, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"]
|
||||
|
||||
|
||||
def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "old.pdf", "drive_key": "files/old.pdf"},
|
||||
{"name": "stale.pdf", "drive_key": "files/new.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"),
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"]
|
||||
assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf"
|
||||
assert "new.pdf" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch):
|
||||
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None)
|
||||
_patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}})
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
|
||||
AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"),
|
||||
app_id="app-1",
|
||||
node_id="agent-node-1",
|
||||
)
|
||||
|
||||
assert binding.current_snapshot_id == "snap-2"
|
||||
assert binding.updated_by == "acc-1"
|
||||
|
||||
|
||||
def test_remove_drive_refs_requires_exactly_one_scope():
|
||||
with pytest.raises(ValueError):
|
||||
AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u")
|
||||
|
||||
|
||||
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
|
||||
|
||||
|
||||
def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: "agent-9"))
|
||||
assert AgentComposerService.resolve_bound_agent_id(tenant_id="t-1", app_id="app-1") == "agent-9"
|
||||
|
||||
|
||||
def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
def boom(cls, **kwargs):
|
||||
raise ValueError("no draft workflow")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", classmethod(boom))
|
||||
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: None))
|
||||
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_workflow_binding",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-7")),
|
||||
)
|
||||
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7"
|
||||
|
||||
|
||||
def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None)
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "save_to_current_version",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_workflow_binding",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")),
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
guarded["agent_id"] = agent_id
|
||||
raise InvalidComposerConfigError("skill_ref_dangling: boom")
|
||||
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
assert guarded["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch):
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
|
||||
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert committed == {}
|
||||
|
||||
@ -75,3 +75,6 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
assert skill["full_archive_file_id"] == "zip-tool-file"
|
||||
assert skill["skill_md_file_id"] == "md-tool-file"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
# ENG-371: zip member listing persisted for infer-tools signals
|
||||
assert "SKILL.md" in skill["manifest_files"]
|
||||
assert "scripts/run.py" in skill["manifest_files"]
|
||||
|
||||
@ -0,0 +1,225 @@
|
||||
"""Unit tests for skill → CLI tool inference (ENG-371)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _patch_soul_files(monkeypatch, files):
|
||||
monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files))
|
||||
|
||||
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
|
||||
service, drive = _service()
|
||||
_patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"])
|
||||
raw = (
|
||||
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
|
||||
' "description": "transcoding for step 2", "command": "ffmpeg",'
|
||||
' "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")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_infer_threads_manifest_files_into_the_prompt(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, ["scripts/run.sh"])
|
||||
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")
|
||||
|
||||
assert "scripts/run.sh" in captured["prompt"]
|
||||
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
|
||||
|
||||
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
|
||||
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
|
||||
|
||||
|
||||
def test_infer_retries_once_then_422(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
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")
|
||||
|
||||
assert len(calls) == 2 # one retry
|
||||
assert exc_info.value.code == "inference_failed"
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch):
|
||||
service, _ = _service()
|
||||
_patch_soul_files(monkeypatch, [])
|
||||
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
|
||||
assert result["inferable"] is True
|
||||
|
||||
|
||||
def test_missing_skill_maps_to_404():
|
||||
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")
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_binary_skill_md_maps_to_404():
|
||||
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")
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
|
||||
|
||||
# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ────
|
||||
|
||||
|
||||
def test_invoke_maps_missing_default_model_to_400(monkeypatch):
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
def test_load_skill_md_passes_through_non_missing_drive_errors():
|
||||
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")
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
|
||||
|
||||
def _patch_inference_db(monkeypatch, *, agent, snapshot):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
|
||||
results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results)))
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"name": "Other", "skill_md_key": "other/SKILL.md", "manifest_files": ["x.md"]},
|
||||
{"name": "Audio", "skill_md_key": "audio-transcribe/SKILL.md", "manifest_files": ["scripts/a.sh"]},
|
||||
]
|
||||
}
|
||||
}
|
||||
agent = SimpleNamespace(active_config_snapshot_id="snap-1")
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=soul_dict)
|
||||
_patch_inference_db(monkeypatch, agent=agent, snapshot=snapshot)
|
||||
|
||||
files = SkillToolInferenceService._manifest_files_from_soul(
|
||||
tenant_id="t-1", agent_id="a-1", slug="audio-transcribe"
|
||||
)
|
||||
assert files == ["scripts/a.sh"]
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch):
|
||||
_patch_inference_db(monkeypatch, agent=None, snapshot=None)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
_patch_inference_db(monkeypatch, agent=SimpleNamespace(active_config_snapshot_id="snap-1"), snapshot=None)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
|
||||
|
||||
|
||||
def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}}
|
||||
_patch_inference_db(
|
||||
monkeypatch,
|
||||
agent=SimpleNamespace(active_config_snapshot_id="snap-1"),
|
||||
snapshot=SimpleNamespace(config_snapshot_dict=soul_dict),
|
||||
)
|
||||
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="ghost") == []
|
||||
@ -337,3 +337,166 @@ def test_manifest_download_url_none_when_unresolvable():
|
||||
):
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True)
|
||||
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().delete(tenant_id=TENANT, agent_id=AGENT, key="files/doomed.txt")
|
||||
storage_mock.delete.assert_called_once()
|
||||
|
||||
assert removed == ["files/doomed.txt"]
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None
|
||||
assert list(session.scalars(select(AgentDriveFile))) == []
|
||||
|
||||
|
||||
def test_delete_by_prefix_removes_all_skill_keys():
|
||||
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().delete(tenant_id=TENANT, agent_id=AGENT, prefix="tender-analyzer/")
|
||||
|
||||
assert sorted(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_delete_is_idempotent():
|
||||
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/never-there.txt") == []
|
||||
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="ghost-skill/") == []
|
||||
|
||||
|
||||
def test_delete_requires_exactly_one_scope():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT)
|
||||
assert exc_info.value.code == "invalid_delete_scope"
|
||||
with pytest.raises(AgentDriveError):
|
||||
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="a/", key="a/b")
|
||||
|
||||
|
||||
def test_delete_keeps_shared_value_records():
|
||||
tf = _seed_tool_file(name="shared.txt")
|
||||
_commit("files/shared.txt", tf, owned=False)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/shared.txt")
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
assert removed == ["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")
|
||||
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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"
|
||||
)
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
|
||||
|
||||
19
dify-agent/src/dify_agent/layers/drive/__init__.py
Normal file
19
dify-agent/src/dify_agent/layers/drive/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Client-safe exports for the Dify drive declaration layer 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,
|
||||
DifyDriveFileConfig,
|
||||
DifyDriveLayerConfig,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_DRIVE_LAYER_TYPE_ID",
|
||||
"DifyDriveFileConfig",
|
||||
"DifyDriveLayerConfig",
|
||||
"DifyDriveSkillConfig",
|
||||
]
|
||||
67
dify-agent/src/dify_agent/layers/drive/configs.py
Normal file
67
dify-agent/src/dify_agent/layers/drive/configs.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Client-safe DTOs for the Dify drive declaration layer.
|
||||
|
||||
The drive layer is a config-only manifest of the Skills & Files an agent has
|
||||
in its drive. It is an index, never the content: each entry carries only a
|
||||
display name, a model-facing description, and the drive key needed to fetch
|
||||
the real bytes through the back proxy (``GET /inner/api/drive/<drive_ref>/
|
||||
manifest`` → internal download URL). Inlining SKILL.md bodies here would break
|
||||
the PRD's dynamic-loading principle and bloat every run request.
|
||||
|
||||
The API backend catalogs and writes this config; the Agent backend consumes it
|
||||
(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files).
|
||||
"""
|
||||
|
||||
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 — an index, 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
|
||||
|
||||
|
||||
class DifyDriveFileConfig(BaseModel):
|
||||
"""Runtime declaration of one plain drive file."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
# "files/<filename>" — the drive key of the file value.
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class DifyDriveLayerConfig(LayerConfig):
|
||||
"""Config-only declaration layer: API writes the catalog, the agent pulls
|
||||
the listed entries through the back proxy using ``drive_ref``."""
|
||||
|
||||
# "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)
|
||||
files: list[DifyDriveFileConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_DRIVE_LAYER_TYPE_ID",
|
||||
"DifyDriveFileConfig",
|
||||
"DifyDriveLayerConfig",
|
||||
"DifyDriveSkillConfig",
|
||||
]
|
||||
34
dify-agent/src/dify_agent/layers/drive/layer.py
Normal file
34
dify-agent/src/dify_agent/layers/drive/layer.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Inert Dify drive declaration layer.
|
||||
|
||||
Registering this layer makes ``dify.drive`` a known composition type id so a
|
||||
run that carries the declaration never fails as "unknown layer type", even
|
||||
before the consumption work (ENG-387) lands. It deliberately contributes no
|
||||
prompt and no tools: a model that can see skill names but cannot read SKILL.md
|
||||
would only hallucinate. The skills prompt (including the "pull SKILL.md via
|
||||
drive" guidance) ships together with the consumption implementation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
|
||||
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
|
||||
"""Config-only carrier of the drive Skills & Files manifest."""
|
||||
|
||||
type_id: ClassVar[str] = DIFY_DRIVE_LAYER_TYPE_ID
|
||||
|
||||
config: DifyDriveLayerConfig
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyDriveLayerConfig) -> Self:
|
||||
return cls(config=config)
|
||||
|
||||
|
||||
__all__ = ["DifyDriveLayer"]
|
||||
@ -6,6 +6,7 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the
|
||||
Dify execution-context layer, the stateful Dify shell layer, and the Dify
|
||||
plugin business-layer family:
|
||||
|
||||
- ``dify.drive`` for the inert Skills & Files drive declaration,
|
||||
- ``dify.execution_context`` for shared tenant/user/run daemon context,
|
||||
- ``dify.shell`` for shellctl-backed shell job control,
|
||||
- ``dify.plugin.llm`` for plugin-backed model selection, and
|
||||
@ -37,6 +38,7 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.ask_human.layer import DifyAskHumanLayer
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.drive.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.output.output_layer import DifyOutputLayer
|
||||
@ -83,6 +85,10 @@ def create_default_layer_providers(
|
||||
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
|
||||
LayerProvider.from_layer_type(DifyOutputLayer),
|
||||
LayerProvider.from_layer_type(DifyAskHumanLayer),
|
||||
# Inert declaration layer: makes ``dify.drive`` a known type id so runs
|
||||
# carrying the Skills & Files manifest never fail before the consumption
|
||||
# work (ENG-387) lands. Deliberately contributes no prompt and no tools.
|
||||
LayerProvider.from_layer_type(DifyDriveLayer),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyExecutionContextLayer,
|
||||
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
"""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,
|
||||
DifyDriveFileConfig,
|
||||
DifyDriveLayerConfig,
|
||||
DifyDriveSkillConfig,
|
||||
)
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
|
||||
def test_type_id_is_frozen_contract() -> None:
|
||||
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": [
|
||||
{
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFP documents step by step.",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"name": "sample.pdf", "key": "files/sample.pdf", "size": 1024, "mime_type": "application/pdf"}],
|
||||
}
|
||||
)
|
||||
|
||||
dumped = config.model_dump(mode="json")
|
||||
assert dumped["drive_ref"] == "agent-019e9112"
|
||||
assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md"
|
||||
assert dumped["files"][0]["key"] == "files/sample.pdf"
|
||||
# the declaration is an index only — there is no field that could carry file content
|
||||
assert "content" not in DifyDriveSkillConfig.model_fields
|
||||
assert "content" not in DifyDriveFileConfig.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_inert_layer_is_registered_and_constructible_from_config() -> None:
|
||||
providers = create_default_layer_providers()
|
||||
provider = next(p for p in providers if p.type_id == DIFY_DRIVE_LAYER_TYPE_ID)
|
||||
|
||||
layer = provider.create_layer({"drive_ref": "agent-1", "skills": [], "files": []})
|
||||
|
||||
assert isinstance(layer, DifyDriveLayer)
|
||||
assert layer.config.drive_ref == "agent-1"
|
||||
@ -72,6 +72,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
|
||||
agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol")
|
||||
agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main")
|
||||
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")
|
||||
@ -91,6 +92,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
|
||||
assert agent_stub_protocol_module.AgentStubConnectRequest is not None
|
||||
assert agent_stub_cli_main_module.main 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
|
||||
|
||||
@ -79,6 +79,7 @@ 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",
|
||||
@ -98,6 +99,7 @@ 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",
|
||||
@ -106,6 +108,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
|
||||
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveFileConfig', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
|
||||
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
|
||||
@ -389,6 +389,7 @@ export type AgentSandboxProviderConfig = {
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
@ -405,9 +406,14 @@ export type AgentFileRefConfig = {
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -423,6 +429,7 @@ export type AgentCliToolConfig = {
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
id?: string | null
|
||||
inferred_from?: string | null
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
|
||||
@ -386,6 +386,7 @@ export const zAgentSoulSandboxConfig = z.object({
|
||||
* AgentFileRefConfig
|
||||
*/
|
||||
export const zAgentFileRefConfig = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
@ -404,9 +405,14 @@ export const zAgentFileRefConfig = z.object({
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -544,6 +550,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
id: z.string().max(255).nullish(),
|
||||
inferred_from: z.string().max(255).nullish(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -272,6 +272,37 @@ export type SandboxUploadResponse = {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type AgentDriveListResponse = {
|
||||
items?: Array<AgentDriveItemResponse>
|
||||
}
|
||||
|
||||
export type AgentDriveDownloadResponse = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type AgentDrivePreviewResponse = {
|
||||
binary: boolean
|
||||
key: string
|
||||
size?: number | null
|
||||
text?: string | null
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type AgentDriveDeleteResponse = {
|
||||
config_version_id?: string | null
|
||||
removed_keys?: Array<string>
|
||||
result: string
|
||||
}
|
||||
|
||||
export type AgentDriveFilePayload = {
|
||||
upload_file_id: string
|
||||
}
|
||||
|
||||
export type AgentDriveFileCommitResponse = {
|
||||
config_version_id?: string | null
|
||||
file: AgentDriveFileResponse
|
||||
}
|
||||
|
||||
export type AgentLogResponse = {
|
||||
files?: Array<unknown>
|
||||
iterations: Array<AgentIterationLogResponse>
|
||||
@ -279,11 +310,19 @@ export type AgentLogResponse = {
|
||||
}
|
||||
|
||||
export type AgentSkillStandardizeResponse = {
|
||||
[key: string]: unknown
|
||||
manifest: SkillManifest
|
||||
skill: AgentSkillRefConfig
|
||||
}
|
||||
|
||||
export type AgentSkillUploadResponse = {
|
||||
[key: string]: unknown
|
||||
manifest: SkillManifest
|
||||
skill: AgentSkillRefConfig
|
||||
}
|
||||
|
||||
export type SkillToolInferenceResult = {
|
||||
cli_tools?: Array<CliToolSuggestion>
|
||||
inferable: boolean
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AnnotationReplyPayload = {
|
||||
@ -1405,6 +1444,23 @@ export type SandboxToolFileResponse = {
|
||||
transfer_method?: 'tool_file'
|
||||
}
|
||||
|
||||
export type AgentDriveItemResponse = {
|
||||
created_at?: number | null
|
||||
file_kind: string
|
||||
hash?: string | null
|
||||
key: string
|
||||
mime_type?: string | null
|
||||
size?: number | null
|
||||
}
|
||||
|
||||
export type AgentDriveFileResponse = {
|
||||
drive_key: string
|
||||
file_id: string
|
||||
mime_type?: string | null
|
||||
name: string
|
||||
size?: number | null
|
||||
}
|
||||
|
||||
export type AgentIterationLogResponse = {
|
||||
created_at: string
|
||||
files?: Array<unknown>
|
||||
@ -1426,6 +1482,38 @@ export type AgentLogMetaResponse = {
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
export type SkillManifest = {
|
||||
description: string
|
||||
entry_path: string
|
||||
files: Array<string>
|
||||
hash: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type CliToolSuggestion = {
|
||||
command?: string
|
||||
description?: string
|
||||
env_suggestions?: Array<EnvSuggestion>
|
||||
inferred_from?: string
|
||||
install_commands?: Array<string>
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AnnotationEmbeddingModelResponse = {
|
||||
embedding_model_name?: string | null
|
||||
embedding_provider_name?: string | null
|
||||
@ -2018,6 +2106,7 @@ export type AgentCliToolConfig = {
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
id?: string | null
|
||||
inferred_from?: string | null
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
@ -2057,14 +2146,20 @@ export type AgentKnowledgeDatasetConfig = {
|
||||
export type AgentComposerSkillCandidateResponse = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
full_archive_file_id?: string | null
|
||||
full_archive_key?: string | null
|
||||
id?: string | null
|
||||
kind?: 'skill'
|
||||
manifest_files?: Array<string> | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
skill_md_file_id?: string | null
|
||||
skill_md_key?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentComposerFileCandidateResponse = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
kind?: 'file'
|
||||
@ -2105,6 +2200,12 @@ export type AgentToolCallResponse = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EnvSuggestion = {
|
||||
key: string
|
||||
reason?: string
|
||||
secret_likely?: boolean
|
||||
}
|
||||
|
||||
export type SimpleModelConfig = {
|
||||
model_dict?: JsonValue | null
|
||||
pre_prompt?: string | null
|
||||
@ -2302,6 +2403,7 @@ export type AgentSandboxProviderConfig = {
|
||||
}
|
||||
|
||||
export type AgentFileRefConfig = {
|
||||
drive_key?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
@ -2315,15 +2417,6 @@ export type AgentFileRefConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSkillRefConfig = {
|
||||
description?: string | null
|
||||
file_id?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef | null
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
@ -3045,6 +3138,100 @@ export type PostAppsByAppIdAgentSandboxFilesUploadResponses = {
|
||||
export type PostAppsByAppIdAgentSandboxFilesUploadResponse
|
||||
= PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: {
|
||||
node_id?: string
|
||||
prefix?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/drive/files'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesResponses = {
|
||||
200: AgentDriveListResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesResponse
|
||||
= GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesDownloadData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query: {
|
||||
key: string
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/drive/files/download'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesDownloadResponses = {
|
||||
200: AgentDriveDownloadResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesDownloadResponse
|
||||
= GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query: {
|
||||
key: string
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/drive/files/preview'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesPreviewResponses = {
|
||||
200: AgentDrivePreviewResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentDriveFilesPreviewResponse
|
||||
= GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses]
|
||||
|
||||
export type DeleteAppsByAppIdAgentFilesData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query: {
|
||||
key: string
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/files'
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdAgentFilesResponses = {
|
||||
200: AgentDriveDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdAgentFilesResponse
|
||||
= DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses]
|
||||
|
||||
export type PostAppsByAppIdAgentFilesData = {
|
||||
body: AgentDriveFilePayload
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: {
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/files'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentFilesResponses = {
|
||||
201: AgentDriveFileCommitResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentFilesResponse
|
||||
= PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses]
|
||||
|
||||
export type GetAppsByAppIdAgentLogsData = {
|
||||
body?: never
|
||||
path: {
|
||||
@ -3073,7 +3260,9 @@ export type PostAppsByAppIdAgentSkillsStandardizeData = {
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
query?: {
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/skills/standardize'
|
||||
}
|
||||
|
||||
@ -3108,6 +3297,44 @@ export type PostAppsByAppIdAgentSkillsUploadResponses = {
|
||||
export type PostAppsByAppIdAgentSkillsUploadResponse
|
||||
= PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses]
|
||||
|
||||
export type DeleteAppsByAppIdAgentSkillsBySlugData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
slug: string
|
||||
}
|
||||
query?: {
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/skills/{slug}'
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdAgentSkillsBySlugResponses = {
|
||||
200: AgentDriveDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdAgentSkillsBySlugResponse
|
||||
= DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses]
|
||||
|
||||
export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
slug: string
|
||||
}
|
||||
query?: {
|
||||
node_id?: string
|
||||
}
|
||||
url: '/apps/{app_id}/agent/skills/{slug}/infer-tools'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = {
|
||||
200: SkillToolInferenceResult
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse
|
||||
= PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses]
|
||||
|
||||
export type PostAppsByAppIdAnnotationReplyByActionData = {
|
||||
body: AnnotationReplyPayload
|
||||
path: {
|
||||
|
||||
@ -125,14 +125,38 @@ export const zAgentSandboxUploadPayload = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillStandardizeResponse
|
||||
* AgentDriveDownloadResponse
|
||||
*/
|
||||
export const zAgentSkillStandardizeResponse = z.record(z.string(), z.unknown())
|
||||
export const zAgentDriveDownloadResponse = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillUploadResponse
|
||||
* AgentDrivePreviewResponse
|
||||
*/
|
||||
export const zAgentSkillUploadResponse = z.record(z.string(), z.unknown())
|
||||
export const zAgentDrivePreviewResponse = z.object({
|
||||
binary: z.boolean(),
|
||||
key: z.string(),
|
||||
size: z.int().nullish(),
|
||||
text: z.string().nullish(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveDeleteResponse
|
||||
*/
|
||||
export const zAgentDriveDeleteResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
removed_keys: z.array(z.string()).optional(),
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveFilePayload
|
||||
*/
|
||||
export const zAgentDriveFilePayload = z.object({
|
||||
upload_file_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationReplyPayload
|
||||
@ -1053,6 +1077,44 @@ export const zSandboxUploadResponse = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveItemResponse
|
||||
*/
|
||||
export const zAgentDriveItemResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
file_kind: z.string(),
|
||||
hash: z.string().nullish(),
|
||||
key: z.string(),
|
||||
mime_type: z.string().nullish(),
|
||||
size: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveListResponse
|
||||
*/
|
||||
export const zAgentDriveListResponse = z.object({
|
||||
items: z.array(zAgentDriveItemResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveFileResponse
|
||||
*/
|
||||
export const zAgentDriveFileResponse = z.object({
|
||||
drive_key: z.string(),
|
||||
file_id: z.string(),
|
||||
mime_type: z.string().nullish(),
|
||||
name: z.string(),
|
||||
size: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentDriveFileCommitResponse
|
||||
*/
|
||||
export const zAgentDriveFileCommitResponse = z.object({
|
||||
config_version_id: z.string().nullish(),
|
||||
file: zAgentDriveFileResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentLogMetaResponse
|
||||
*/
|
||||
@ -1066,6 +1128,52 @@ export const zAgentLogMetaResponse = z.object({
|
||||
total_tokens: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillManifest
|
||||
*
|
||||
* Validated metadata extracted from a Skill package.
|
||||
*/
|
||||
export const zSkillManifest = z.object({
|
||||
description: z.string(),
|
||||
entry_path: z.string(),
|
||||
files: z.array(z.string()),
|
||||
hash: z.string(),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillRefConfig
|
||||
*/
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillStandardizeResponse
|
||||
*/
|
||||
export const zAgentSkillStandardizeResponse = z.object({
|
||||
manifest: zSkillManifest,
|
||||
skill: zAgentSkillRefConfig,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillUploadResponse
|
||||
*/
|
||||
export const zAgentSkillUploadResponse = z.object({
|
||||
manifest: zSkillManifest,
|
||||
skill: zAgentSkillRefConfig,
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationEmbeddingModelResponse
|
||||
*/
|
||||
@ -2207,16 +2315,22 @@ export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
export const zAgentComposerSkillCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
full_archive_file_id: z.string().max(255).nullish(),
|
||||
full_archive_key: z.string().max(512).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('skill').optional().default('skill'),
|
||||
manifest_files: z.array(z.string()).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
skill_md_file_id: z.string().max(255).nullish(),
|
||||
skill_md_key: z.string().max(512).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerFileCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerFileCandidateResponse = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
kind: z.literal('file').optional().default('file'),
|
||||
@ -2266,6 +2380,36 @@ export const zAgentLogResponse = z.object({
|
||||
meta: zAgentLogMetaResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvSuggestion
|
||||
*/
|
||||
export const zEnvSuggestion = z.object({
|
||||
key: z.string(),
|
||||
reason: z.string().optional().default(''),
|
||||
secret_likely: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* CliToolSuggestion
|
||||
*/
|
||||
export const zCliToolSuggestion = z.object({
|
||||
command: z.string().optional().default(''),
|
||||
description: z.string().optional().default(''),
|
||||
env_suggestions: z.array(zEnvSuggestion).optional(),
|
||||
inferred_from: z.string().optional().default(''),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillToolInferenceResult
|
||||
*/
|
||||
export const zSkillToolInferenceResult = z.object({
|
||||
cli_tools: z.array(zCliToolSuggestion).optional(),
|
||||
inferable: z.boolean(),
|
||||
reason: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleModelConfig
|
||||
*/
|
||||
@ -2659,6 +2803,7 @@ export const zAgentSoulSandboxConfig = z.object({
|
||||
* AgentFileRefConfig
|
||||
*/
|
||||
export const zAgentFileRefConfig = z.object({
|
||||
drive_key: z.string().max(512).nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
@ -2671,25 +2816,6 @@ export const zAgentFileRefConfig = z.object({
|
||||
url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillRefConfig
|
||||
*/
|
||||
export const zAgentSkillRefConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
file_id: z.string().max(255).nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
path: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulSkillsFilesConfig
|
||||
*/
|
||||
@ -2698,6 +2824,14 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMetadata
|
||||
*/
|
||||
export const zWorkflowNodeJobMetadata = z.object({
|
||||
agent_soul: z.record(z.string(), z.unknown()).nullish(),
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@ -2783,6 +2917,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
id: z.string().max(255).nullish(),
|
||||
inferred_from: z.string().max(255).nullish(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
@ -3766,6 +3901,77 @@ export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({
|
||||
node_id: z.string().optional(),
|
||||
prefix: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* Drive entries
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({
|
||||
key: z.string().min(1),
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Signed URL
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({
|
||||
key: z.string().min(1),
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Preview
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse
|
||||
|
||||
export const zDeleteAppsByAppIdAgentFilesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zDeleteAppsByAppIdAgentFilesQuery = z.object({
|
||||
key: z.string().min(1),
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File removed
|
||||
*/
|
||||
export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload
|
||||
|
||||
export const zPostAppsByAppIdAgentFilesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAgentFilesQuery = z.object({
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File committed into the agent drive
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentLogsPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
@ -3784,6 +3990,10 @@ export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAgentSkillsStandardizeQuery = z.object({
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill standardized into drive
|
||||
*/
|
||||
@ -3798,6 +4008,34 @@ export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse
|
||||
|
||||
export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({
|
||||
app_id: z.string(),
|
||||
slug: z.string(),
|
||||
})
|
||||
|
||||
export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill removed
|
||||
*/
|
||||
export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({
|
||||
app_id: z.string(),
|
||||
slug: z.string(),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery = z.object({
|
||||
node_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Inference result (draft suggestions, nothing persisted)
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
|
||||
|
||||
export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload
|
||||
|
||||
export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({
|
||||
|
||||
@ -483,8 +483,9 @@ export function PaginationPage({
|
||||
aria-current={current ? 'page' : undefined}
|
||||
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
|
||||
className={cn(
|
||||
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
|
||||
'motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal'
|
||||
|
||||
const mockUseMarketplaceTemplateDetail = vi.fn()
|
||||
const mockFetchMarketplaceTemplateDSL = vi.fn()
|
||||
|
||||
vi.mock('@/service/marketplace-templates', () => ({
|
||||
useMarketplaceTemplateDetail: (...args: unknown[]) => mockUseMarketplaceTemplateDetail(...args),
|
||||
fetchMarketplaceTemplateDSL: (...args: unknown[]) => mockFetchMarketplaceTemplateDSL(...args),
|
||||
}))
|
||||
|
||||
describe('ImportFromMarketplaceTemplateModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseMarketplaceTemplateDetail.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
id: 'human-input-writing',
|
||||
template_name: 'Human Input: Writing Assistant',
|
||||
overview: 'Send your creative brief, get a high-quality draft, and review before publishing.',
|
||||
icon: 'technologist',
|
||||
icon_background: '#D1FAE5',
|
||||
icon_file_key: '',
|
||||
publisher_unique_handle: 'langgenius',
|
||||
usage_count: 261,
|
||||
categories: ['operations'],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders marketplace emoji icons without exposing the emoji id as text', () => {
|
||||
render(
|
||||
<ImportFromMarketplaceTemplateModal
|
||||
templateId="human-input-writing"
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Human Input: Writing Assistant')).toBeInTheDocument()
|
||||
expect(screen.queryByText('technologist')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -6,6 +6,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import {
|
||||
fetchMarketplaceTemplateDSL,
|
||||
@ -100,22 +101,13 @@ const ImportFromMarketplaceTemplateModal = ({
|
||||
{template && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{template.icon_file_key
|
||||
? (
|
||||
<img
|
||||
src={`${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`}
|
||||
alt={template.template_name}
|
||||
className="size-10 rounded-lg object-cover"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="flex size-10 items-center justify-center rounded-lg text-xl"
|
||||
style={{ background: template.icon_background || '#F3F4F6' }}
|
||||
>
|
||||
{template.icon || '📄'}
|
||||
</div>
|
||||
)}
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={template.icon_file_key ? 'image' : 'emoji'}
|
||||
icon={template.icon || 'page_facing_up'}
|
||||
background={template.icon_file_key ? undefined : template.icon_background}
|
||||
imageUrl={template.icon_file_key ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` : undefined}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="system-md-semibold text-text-primary">{template.template_name}</div>
|
||||
<div className="flex items-center gap-1 system-xs-regular text-text-tertiary">
|
||||
|
||||
Reference in New Issue
Block a user