diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 57cbd3be926886..2f4d09ae9d5811 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -28,7 +28,6 @@ DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig, ) -from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, @@ -56,7 +55,6 @@ DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_RUNTIME_LAYER_ID = "runtime" DIFY_CONFIG_LAYER_ID = "config" -DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" DIFY_CORE_TOOLS_LAYER_ID = "core_tools" DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge" @@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]: } -def _drive_layer_deps() -> dict[str, str]: - return {"shell": DIFY_SHELL_LAYER_ID} - - def _config_layer_deps() -> dict[str, str]: return {"shell": DIFY_SHELL_LAYER_ID} -def _shell_config_with_drive_ref( - shell_config: DifyShellLayerConfig | None, - drive_config: DifyDriveLayerConfig | None, -) -> DifyShellLayerConfig: - config = shell_config or DifyShellLayerConfig() - if drive_config is None: - return config - return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref}) - - def _markdown_backtick_fence(text: str) -> str: """Choose a fence that will not terminate inside the prompt body.""" longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0) @@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): core_tools: DifyCoreToolsLayerConfig | None = None knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None - # Drive Skills & Files declaration (dify.drive) — an index the agent pulls - # through the back proxy, never inline content. - drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement; a deferred call ends the run and # the workflow pauses via the existing HITL form mechanism (ENG-635). @@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel): core_tools: DifyCoreToolsLayerConfig | None = None knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None - # Drive Skills & Files declaration (dify.drive) — an index the agent pulls - # through the back proxy, never inline content. - drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement (ENG-635). ask_human_config: DifyAskHumanLayerConfig | None = None @@ -307,7 +285,7 @@ def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> Create """Build an Agent App conversation-turn run request. Layer graph: optional Agent Soul system prompt → user prompt → - execution context → optional shell / config / drive / history + execution context → optional shell / config / history (multi-turn) → LLM → optional plugin-direct tools / core-routed tools / knowledge search / ask_human / structured output. Mirrors the workflow-node layer ordering minus the workflow-job / previous-node @@ -345,9 +323,7 @@ def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> Create ] ) - include_shell = ( - run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None - ) + include_shell = run_input.include_shell or run_input.config_layer_config is not None if include_shell: layers.append( RunLayerSpec( @@ -357,16 +333,15 @@ def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> Create config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref), ) ) - # Sandboxed bash workspace (dify.shell). It enters before config/drive - # so eager pulls materialize content in the same filesystem used by - # model commands. + # Sandboxed bash workspace (dify.shell). It enters before config so + # eager pulls materialize content in the same filesystem used by model commands. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, deps=_shell_layer_deps(), metadata=run_input.metadata, - config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + config=run_input.shell_config or DifyShellLayerConfig(), ) ) @@ -381,19 +356,6 @@ def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> Create ) ) - if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): the catalog plus - # prompt-mentioned entries eagerly pulled through the shell layer. - layers.append( - RunLayerSpec( - name=DIFY_DRIVE_LAYER_ID, - type=DIFY_DRIVE_LAYER_TYPE_ID, - deps=_drive_layer_deps(), - metadata=run_input.metadata, - config=run_input.drive_config, - ) - ) - if run_input.include_history: layers.append( RunLayerSpec( @@ -495,7 +457,7 @@ def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) - """Build a workflow Agent Node run request without defining another wire schema. Layer graph mirrors the workflow surface: prompts → execution context → - optional shell / config / drive / history → LLM → optional + optional shell / config / history → LLM → optional plugin-direct tools / core-routed tools / knowledge search / ask_human / structured output. """ @@ -537,9 +499,7 @@ def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) - ] ) - include_shell = ( - run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None - ) + include_shell = run_input.include_shell or run_input.config_layer_config is not None if include_shell: layers.append( RunLayerSpec( @@ -549,16 +509,15 @@ def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) - config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref), ) ) - # Sandboxed bash workspace (dify.shell). It enters before drive so - # drive can materialize mentioned targets with `dify-agent drive pull` - # in the same shell-visible filesystem used by model commands. + # Sandboxed bash workspace (dify.shell). It enters before config so + # eager pulls materialize content in the same filesystem used by model commands. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, deps=_shell_layer_deps(), metadata=run_input.metadata, - config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + config=run_input.shell_config or DifyShellLayerConfig(), ) ) @@ -573,19 +532,6 @@ def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) - ) ) - if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): the catalog plus - # prompt-mentioned entries eagerly pulled through the shell layer. - layers.append( - RunLayerSpec( - name=DIFY_DRIVE_LAYER_ID, - type=DIFY_DRIVE_LAYER_TYPE_ID, - deps=_drive_layer_deps(), - metadata=run_input.metadata, - config=run_input.drive_config, - ) - ) - if run_input.include_history: layers.append( RunLayerSpec( diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index cd87e1f82438f1..5df4bf258f70cd 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -57,7 +57,6 @@ agent_app_feature, agent_app_sandbox, agent_config_inspector, - agent_drive_inspector, annotation, app, audio, @@ -161,7 +160,6 @@ "agent_app_sandbox", "agent_composer", "agent_config_inspector", - "agent_drive_inspector", "agent_providers", "agent_roster", "annotation", diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 76d7863776eba7..66192940fc1eb4 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -182,14 +182,7 @@ def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, AgentComposerService.validate_knowledge_datasets( session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul ) - findings = AgentComposerService.collect_validation_findings( - session=session, - tenant_id=tenant_id, - payload=req_data, - agent_id=AgentComposerService.resolve_workflow_node_agent_id( - session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id - ), - ) + findings = AgentComposerService.collect_validation_findings(payload=req_data) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) @@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource): @with_session(write=False) @model_validate(ComposerSavePayload) def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str): - app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id) + _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id) ComposerConfigValidator.validate_publish_payload(req_data) AgentComposerService.validate_knowledge_datasets( session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul ) - findings = AgentComposerService.collect_validation_findings( - session=session, - tenant_id=tenant_id, - payload=req_data, - agent_id=AgentComposerService.resolve_workflow_node_agent_id( - session=session, - tenant_id=tenant_id, - app_id=app_id, - node_id=node_id, - ), - ) + findings = AgentComposerService.collect_validation_findings(payload=req_data) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) @@ -580,12 +563,7 @@ def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, AgentComposerService.validate_knowledge_datasets( session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul ) - findings = AgentComposerService.collect_validation_findings( - session=session, - tenant_id=tenant_id, - payload=req_data, - agent_id=str(agent_id), - ) + findings = AgentComposerService.collect_validation_findings(payload=req_data) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 325e747b3be362..4fc9c7f6a6e7ae 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -1,21 +1,12 @@ from typing import Any -from uuid import UUID -from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select from sqlalchemy.orm import Session -from controllers.common.schema import ( - query_params_from_model, - query_params_from_request, - register_response_schema_models, - register_schema_models, -) +from controllers.common.schema import query_params_from_model, register_response_schema_models from controllers.common.session import with_session from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( RBACPermission, @@ -24,41 +15,13 @@ model_validate, rbac_permission_required, setup_required, - with_current_tenant_id, - with_current_user, ) from fields.base import ResponseModel from libs.helper import uuid_value from libs.login import login_required -from models import Account -from models.model import App, AppMode, UploadFile -from services.agent.composer_service import AgentComposerService -from services.agent.skill_package_service import SkillManifest, SkillPackageError -from services.agent.skill_standardize_service import SkillStandardizeService -from services.agent.skill_tool_inference_service import ( - SkillToolInferenceError, - SkillToolInferenceResult, - SkillToolInferenceService, -) -from services.agent_drive_service import ( - AgentDriveError, - AgentDriveService, - DriveCommitItem, - DriveFileRef, - normalize_drive_key, -) +from models.model import App, AppMode from services.agent_service import AgentService -_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] -_AGENT_SKILL_UPLOAD_PARAMS = { - "file": { - "in": "formData", - "type": "file", - "required": True, - "description": "Skill package (.zip or .skill).", - } -} - class AgentLogQuery(BaseModel): message_id: str = Field(..., description="Message UUID") @@ -70,27 +33,6 @@ def validate_uuid(cls, value: str) -> str: return uuid_value(value) -class AgentDriveFilePayload(BaseModel): - upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload") - - @field_validator("upload_file_id") - @classmethod - def validate_upload_file_id(cls, value: str) -> str: - return uuid_value(value) - - -class AgentDriveMutationQuery(BaseModel): - node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") - - -class AgentDriveDeleteFileQuery(AgentDriveMutationQuery): - key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") - - -class AgentDriveDeleteFileByAgentQuery(BaseModel): - key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") - - class AgentLogMetaResponse(ResponseModel): status: str executor: str @@ -128,204 +70,7 @@ class AgentLogResponse(ResponseModel): files: list[Any] = Field(default_factory=list) -class AgentUploadedSkillResponse(ResponseModel): - name: str - description: str - path: str - skill_md_key: str - archive_key: str | None = None - - -class AgentSkillUploadResponse(ResponseModel): - skill: AgentUploadedSkillResponse - manifest: SkillManifest - - -class AgentDriveFileResponse(ResponseModel): - name: str - drive_key: str - file_id: str - size: int | None = None - mime_type: str | None = None - - -class AgentDriveFileCommitResponse(ResponseModel): - file: AgentDriveFileResponse - - -class AgentDriveDeleteResponse(ResponseModel): - result: str - removed_keys: list[str] = Field(default_factory=list) - - -register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery) -register_response_schema_models( - console_ns, - AgentDriveDeleteResponse, - AgentDriveFileCommitResponse, - AgentDriveFileResponse, - AgentLogResponse, - AgentUploadedSkillResponse, - AgentSkillUploadResponse, - SkillToolInferenceResult, -) - - -def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None: - if node_id and app_model.mode != AppMode.AGENT: - return AgentComposerService.resolve_workflow_node_agent_id( - session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id - ) - return app_model.bound_agent_id_with_session(session=session) - - -def _agent_not_bound() -> tuple[dict[str, str], int]: - return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 - - -def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App): - """Upload one skill package and commit its normalized files into the agent drive.""" - - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 - - upload = request.files["file"] - content = upload.stream.read() - try: - result = SkillStandardizeService().standardize( - content=content, - filename=upload.filename or "", - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - session=session, - ) - except (SkillPackageError, AgentDriveError) as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - return result, 201 - - -def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True): - payload = AgentDriveFilePayload.model_validate(console_ns.payload or {}) - query = query_params_from_request(AgentDriveMutationQuery) - node_id = query.node_id if allow_node_id else None - agent_id = _resolve_agent_id(session, app_model, node_id) - if not agent_id: - return _agent_not_bound() - - upload_file = session.scalar( - select(UploadFile).where( - UploadFile.id == payload.upload_file_id, - UploadFile.tenant_id == app_model.tenant_id, - ) - ) - if upload_file is None: - return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404 - - try: - key = normalize_drive_key(f"files/{upload_file.name}") - committed = AgentDriveService().commit( - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - items=[ - DriveCommitItem( - key=key, - file_ref=DriveFileRef(kind="upload_file", id=upload_file.id), - # ADD FILE uploads exist solely to live in the drive, so the - # drive owns (and physically cleans) the value on delete. - value_owned_by_drive=True, - ) - ], - session=session, - ) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - - row = committed[0] - return { - "file": { - "name": upload_file.name, - "drive_key": row["key"], - "file_id": upload_file.id, - "size": row.get("size"), - "mime_type": row.get("mime_type"), - }, - }, 201 - - -def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True): - query = query_params_from_request(AgentDriveDeleteFileQuery) - node_id = query.node_id if allow_node_id else None - agent_id = _resolve_agent_id(session, app_model, node_id) - if not agent_id: - return _agent_not_bound() - try: - key = normalize_drive_key(query.key) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - - try: - result = AgentDriveService().commit( - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - items=[DriveCommitItem(key=key, file_ref=None)], - session=session, - ) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - removed_keys = [item["key"] for item in result if item.get("removed")] - return {"result": "success", "removed_keys": removed_keys} - - -def _delete_skill_for_app( - *, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True -): - query = query_params_from_request(AgentDriveMutationQuery) - node_id = query.node_id if allow_node_id else None - agent_id = _resolve_agent_id(session, app_model, node_id) - if not agent_id: - return _agent_not_bound() - if "/" in slug or not slug.strip(): - return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 - - try: - result = AgentDriveService().commit( - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - items=[ - DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None), - DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None), - ], - session=session, - ) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - removed_keys = [item["key"] for item in result if item.get("removed")] - return {"result": "success", "removed_keys": removed_keys} - - -def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str): - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - if "/" in slug or not slug.strip(): - return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 - try: - return SkillToolInferenceService().infer( - tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session - ) - except SkillToolInferenceError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code +register_response_schema_models(console_ns, AgentLogResponse) @console_ns.route("/apps//agent/logs") @@ -344,209 +89,6 @@ class AgentLogApi(Resource): @get_app_model(mode=[AppMode.AGENT_CHAT]) @model_validate(AgentLogQuery) def get(self, req_data: AgentLogQuery, session: Session, app_model: App): - """Get agent logs""" + """Get agent logs.""" return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session) - - -@console_ns.route("/agent//skills/upload") -class AgentSkillUploadByAgentApi(Resource): - @console_ns.doc("upload_agent_skill_by_agent") - @console_ns.doc(description="Upload + standardize a Skill into an Agent App drive") - @console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS}) - @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__]) - @console_ns.response(400, "Invalid skill package or no bound agent") - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - @with_session - def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model) - - -@console_ns.route("/apps//agent/skills/upload") -class AgentSkillUploadApi(Resource): - @console_ns.doc("upload_agent_skill") - @console_ns.doc(description="Upload + standardize a Skill into the agent drive") - @console_ns.doc( - consumes=["multipart/form-data"], - params={ - "app_id": "Application ID", - **query_params_from_model(AgentDriveMutationQuery), - **_AGENT_SKILL_UPLOAD_PARAMS, - }, - ) - @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__]) - @console_ns.response(400, "Invalid skill package or no bound agent") - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_session - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - def post(self, session: Session, current_user: Account, app_model: App): - """Upload a Skill, validate it, and commit drive-backed skill files.""" - return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model) - - -@console_ns.route("/agent//files") -class AgentDriveFilesByAgentApi(Resource): - @console_ns.doc("commit_agent_drive_file_by_agent") - @console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/") - @console_ns.doc(params={"agent_id": "Agent ID"}) - @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) - @console_ns.response( - 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] - ) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - @with_session - def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - return _commit_drive_file_for_app( - session=session, current_user=current_user, app_model=app_model, allow_node_id=False - ) - - @console_ns.doc("delete_agent_drive_file_by_agent") - @console_ns.doc(description="Delete one Agent App drive file by key") - @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)}) - @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - @with_session - def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - return _delete_drive_file_for_app( - session=session, current_user=current_user, app_model=app_model, allow_node_id=False - ) - - -@console_ns.route("/apps//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/ (ENG-625 D3)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) - @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) - @console_ns.response( - 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] - ) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_session - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - def post(self, session: Session, current_user: Account, app_model: App): - """ADD FILE: commit one uploaded file into the bound agent's drive.""" - return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model) - - @console_ns.doc("delete_agent_drive_file") - @console_ns.doc(description="Delete one drive file by key via drive commit-null semantics") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)}) - @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_session - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - def delete(self, session: Session, current_user: Account, app_model: App): - return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model) - - -@console_ns.route("/agent//skills/") -class AgentSkillByAgentApi(Resource): - @console_ns.doc("delete_agent_skill_by_agent") - @console_ns.doc(description="Delete a standardized skill from an Agent App drive") - @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) - @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - @with_session - def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str): - app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - return _delete_skill_for_app( - session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False - ) - - -@console_ns.route("/apps//agent/skills/") -class AgentSkillApi(Resource): - @console_ns.doc("delete_agent_skill") - @console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null") - @console_ns.doc( - params={ - "app_id": "Application ID", - "slug": "Skill slug (single path segment)", - **query_params_from_model(AgentDriveMutationQuery), - } - ) - @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_session - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - def delete(self, session: Session, current_user: Account, app_model: App, slug: str): - return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug) - - -@console_ns.route("/agent//skills//infer-tools") -class AgentSkillInferToolsByAgentApi(Resource): - @console_ns.doc("infer_agent_skill_tools_by_agent") - @console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill") - @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) - @console_ns.response( - 200, - "Inference result (draft suggestions, nothing persisted)", - console_ns.models[SkillToolInferenceResult.__name__], - ) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str): - app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug) - - -@console_ns.route("/apps//agent/skills//infer-tools") -class AgentSkillInferToolsApi(Resource): - @console_ns.doc("infer_agent_skill_tools") - @console_ns.doc( - description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)" - ) - @console_ns.doc( - params={ - "app_id": "Application ID", - "slug": "Skill slug (single path segment)", - **query_params_from_model(AgentDriveMutationQuery), - } - ) - @console_ns.response( - 200, - "Inference result (draft suggestions, nothing persisted)", - console_ns.models[SkillToolInferenceResult.__name__], - ) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - def post(self, session: Session, app_model: App, slug: str): - """Suggest CLI tools/env for a skill. Saving still goes through composer validation.""" - return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug) diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py deleted file mode 100644 index e682953c015a24..00000000000000 --- a/api/controllers/console/app/agent_drive_inspector.py +++ /dev/null @@ -1,434 +0,0 @@ -"""Console read-only inspector for the agent drive (ENG-624). - -``agent-drive`` looks at the *static* drive assets (standardized skills and -committed files); the sibling ``agent-sandbox`` routes look at a *runtime* -sandbox workspace. Unlike the sandbox routes this never proxies to the agent -backend — drive data lives in the API's own DB/storage, served straight from -``AgentDriveService``. Download hands the browser an **external** signed URL -(the inner manifest hands agents internal ones — the two must never mix). -""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from typing import Any -from uuid import UUID - -from flask import Response -from flask_restx import Resource -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from controllers.common.schema import ( - query_params_from_model, - query_params_from_request, - register_response_schema_models, -) -from controllers.common.session import with_session -from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model -from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id -from fields.base import ResponseModel -from libs.login import login_required -from models.model import App, AppMode -from services.agent.composer_service import AgentComposerService -from services.agent_drive_service import AgentDriveError, AgentDriveService - - -class AgentDriveListQuery(BaseModel): - prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") - node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") - - -class AgentDriveListByAgentQuery(BaseModel): - prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") - - -class AgentDriveFileQuery(BaseModel): - key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") - node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") - - -class AgentDriveFileByAgentQuery(BaseModel): - key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") - - -class AgentDriveSkillInspectQuery(BaseModel): - node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") - - -class AgentDriveItemResponse(ResponseModel): - key: str - size: int | None = None - mime_type: str | None = None - hash: str | None = None - file_kind: str - created_at: int | None = None - is_skill: bool | None = None - skill_metadata: str | None = None - - -class AgentDriveListResponse(ResponseModel): - items: list[AgentDriveItemResponse] = Field(default_factory=list) - - -class AgentDriveSkillItemResponse(ResponseModel): - path: str - skill_md_key: str - archive_key: str | None = None - name: str - description: str - size: int | None = None - mime_type: str | None = None - hash: str | None = None - created_at: int | None = None - - -class AgentDriveSkillListResponse(ResponseModel): - items: list[AgentDriveSkillItemResponse] = Field(default_factory=list) - - -class AgentDriveSkillFileResponse(ResponseModel): - path: str - name: str - type: str - drive_key: str | None = None - available_in_drive: bool - - -class AgentDriveSkillMarkdownResponse(ResponseModel): - key: str - size: int | None = None - truncated: bool - binary: bool - text: str | None = None - - -class AgentDriveSkillInspectResponse(ResponseModel): - path: str - skill_md_key: str - archive_key: str | None = None - name: str - description: str - size: int | None = None - mime_type: str | None = None - hash: str | None = None - created_at: int | None = None - source: str - files: list[AgentDriveSkillFileResponse] = Field(default_factory=list) - file_tree: list[dict[str, Any]] = Field(default_factory=list) - skill_md: AgentDriveSkillMarkdownResponse - warnings: list[str] = Field(default_factory=list) - - -class AgentDrivePreviewResponse(ResponseModel): - key: str - size: int | None = None - truncated: bool - binary: bool - text: str | None = None - - -class AgentDriveDownloadResponse(ResponseModel): - url: str - - -register_response_schema_models( - console_ns, - AgentDriveDownloadResponse, - AgentDriveListResponse, - AgentDrivePreviewResponse, - AgentDriveSkillInspectResponse, - AgentDriveSkillListResponse, -) - - -def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None: - """Agent identity for the drive: app-bound agent, or the workflow node binding.""" - if node_id: - return AgentComposerService.resolve_workflow_node_agent_id( - session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id - ) - return app_model.bound_agent_id_with_session(session=session) - - -def _agent_not_bound() -> tuple[dict[str, object], int]: - return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 - - -def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]: - return {"code": exc.code, "message": exc.message}, exc.status_code - - -def _json_response(data: Mapping[str, Any]): - return Response( - response=json.dumps(data, ensure_ascii=False, separators=(",", ":")), - content_type="application/json; charset=utf-8", - ) - - -_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] - - -@console_ns.route("/agent//drive/files") -class AgentDriveListByAgentApi(Resource): - @console_ns.doc("list_agent_drive_files_by_agent") - @console_ns.doc(description="List agent drive entries for an Agent App") - @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)}) - @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def get(self, session: Session, tenant_id: str, agent_id: UUID): - query = query_params_from_request(AgentDriveListByAgentQuery) - resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - try: - items = AgentDriveService().manifest( - tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} - - -@console_ns.route("/agent//drive/skills") -class AgentDriveSkillListByAgentApi(Resource): - @console_ns.doc("list_agent_drive_skills_by_agent") - @console_ns.doc(description="List drive-backed skills for an Agent App") - @console_ns.doc(params={"agent_id": "Agent ID"}) - @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def get(self, session: Session, tenant_id: str, agent_id: UUID): - resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - try: - items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session) - except AgentDriveError as exc: - return _handle(exc) - return {"items": items} - - -@console_ns.route("/agent//drive/skills//inspect") -class AgentDriveSkillInspectByAgentApi(Resource): - @console_ns.doc("inspect_agent_drive_skill_by_agent") - @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI") - @console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"}) - @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str): - resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - try: - return _json_response( - AgentDriveService().inspect_skill( - tenant_id=tenant_id, - agent_id=str(agent_id), - skill_path=skill_path, - session=session, - ) - ) - except AgentDriveError as exc: - return _handle(exc) - - -@console_ns.route("/agent//drive/files/preview") -class AgentDrivePreviewByAgentApi(Resource): - @console_ns.doc("preview_agent_drive_file_by_agent") - @console_ns.doc(description="Truncated text preview of one Agent App drive value") - @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) - @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def get(self, session: Session, tenant_id: str, agent_id: UUID): - query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - try: - return AgentDriveService().preview( - tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - - -@console_ns.route("/agent//drive/files/download") -class AgentDriveDownloadByAgentApi(Resource): - @console_ns.doc("download_agent_drive_file_by_agent") - @console_ns.doc(description="Time-limited external signed URL for one Agent App drive value") - @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) - @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - @with_session(write=False) - def get(self, session: Session, tenant_id: str, agent_id: UUID): - query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id) - try: - url = AgentDriveService().download_url( - tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - return {"url": url} - - -@console_ns.route("/apps//agent/drive/files") -class AgentDriveListApi(Resource): - @console_ns.doc("list_agent_drive_files") - @console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)}) - @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_APP_MODES) - def get(self, session: Session, app_model: App): - query = query_params_from_request(AgentDriveListQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - items = AgentDriveService().manifest( - tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - # the inner manifest exposes file_id for agent-side pulls; the console - # inspector is a pure read surface and does not need value pointers - return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} - - -@console_ns.route("/apps//agent/drive/skills") -class AgentDriveSkillListApi(Resource): - @console_ns.doc("list_agent_drive_skills") - @console_ns.doc(description="List drive-backed skills for the bound agent") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)}) - @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_APP_MODES) - def get(self, session: Session, app_model: App): - query = query_params_from_request(AgentDriveListQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session) - except AgentDriveError as exc: - return _handle(exc) - return {"items": items} - - -@console_ns.route("/apps//agent/drive/skills//inspect") -class AgentDriveSkillInspectApi(Resource): - @console_ns.doc("inspect_agent_drive_skill") - @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI") - @console_ns.doc( - params={ - "app_id": "Application ID", - "skill_path": "Skill path/slug, e.g. tender-analyzer", - **query_params_from_model(AgentDriveSkillInspectQuery), - } - ) - @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_APP_MODES) - def get(self, session: Session, app_model: App, skill_path: str): - query = query_params_from_request(AgentDriveSkillInspectQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - return _json_response( - AgentDriveService().inspect_skill( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - skill_path=skill_path, - session=session, - ) - ) - except AgentDriveError as exc: - return _handle(exc) - - -@console_ns.route("/apps//agent/drive/files/preview") -class AgentDrivePreviewApi(Resource): - @console_ns.doc("preview_agent_drive_file") - @console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) - @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_APP_MODES) - def get(self, session: Session, app_model: App): - query = query_params_from_request(AgentDriveFileQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - return AgentDriveService().preview( - tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - - -@console_ns.route("/apps//agent/drive/files/download") -class AgentDriveDownloadApi(Resource): - @console_ns.doc("download_agent_drive_file") - @console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) - @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_session(write=False) - @get_app_model(mode=_WORKFLOW_APP_MODES) - def get(self, session: Session, app_model: App): - query = query_params_from_request(AgentDriveFileQuery) - agent_id = _resolve_agent_id(session, app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - url = AgentDriveService().download_url( - tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session - ) - except AgentDriveError as exc: - return _handle(exc) - return {"url": url} - - -__all__ = [ - "AgentDriveDownloadApi", - "AgentDriveDownloadByAgentApi", - "AgentDriveListApi", - "AgentDriveListByAgentApi", - "AgentDrivePreviewApi", - "AgentDrivePreviewByAgentApi", - "AgentDriveSkillInspectApi", - "AgentDriveSkillInspectByAgentApi", - "AgentDriveSkillListApi", - "AgentDriveSkillListByAgentApi", -] diff --git a/api/controllers/files/__init__.py b/api/controllers/files/__init__.py index 5d26308e430db6..f8976b86b9fabf 100644 --- a/api/controllers/files/__init__.py +++ b/api/controllers/files/__init__.py @@ -14,12 +14,11 @@ files_ns = Namespace("files", description="File operations", path="/") -from . import agent_drive_archive, image_preview, tool_files, upload +from . import image_preview, tool_files, upload api.add_namespace(files_ns) __all__ = [ - "agent_drive_archive", "api", "bp", "files_ns", diff --git a/api/controllers/files/agent_drive_archive.py b/api/controllers/files/agent_drive_archive.py deleted file mode 100644 index 8ecec2e9a4c93e..00000000000000 --- a/api/controllers/files/agent_drive_archive.py +++ /dev/null @@ -1,69 +0,0 @@ -from urllib.parse import quote - -from flask import Response, request -from flask_restx import Resource -from pydantic import BaseModel, Field -from werkzeug.exceptions import Forbidden, NotFound - -from controllers.common.file_response import enforce_download_for_html -from controllers.common.schema import register_schema_models -from controllers.files import files_ns -from extensions.ext_database import db -from models.agent import AgentDriveFileKind -from services.agent_drive_service import AgentDriveError, AgentDriveService - - -class AgentDriveArchiveMemberQuery(BaseModel): - tenant_id: str = Field(..., description="Tenant ID") - agent_id: str = Field(..., description="Agent ID") - key: str = Field(..., description="Virtual drive key") - archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind") - archive_file_id: str = Field(..., description="Archive file id") - member_path: str = Field(..., description="Zip member path") - timestamp: str = Field(..., description="Unix timestamp") - nonce: str = Field(..., description="Random nonce") - sign: str = Field(..., description="HMAC signature") - as_attachment: bool = Field(default=False, description="Download as attachment") - - -register_schema_models(files_ns, AgentDriveArchiveMemberQuery) - - -@files_ns.route("/agent-drive/archive-member") -class AgentDriveArchiveMemberApi(Resource): - @files_ns.doc("get_agent_drive_archive_member") - @files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters") - def get(self): - args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True)) - if not AgentDriveService.verify_archive_member_signature( - tenant_id=args.tenant_id, - agent_id=args.agent_id, - key=args.key, - archive_file_kind=args.archive_file_kind, - archive_file_id=args.archive_file_id, - member_path=args.member_path, - timestamp=args.timestamp, - nonce=args.nonce, - sign=args.sign, - ): - raise Forbidden("Invalid request.") - try: - payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request( - tenant_id=args.tenant_id, - agent_id=args.agent_id, - key=args.key, - archive_file_kind=args.archive_file_kind, - archive_file_id=args.archive_file_id, - member_path=args.member_path, - session=db.session(), - ) - except AgentDriveError as exc: - raise NotFound(exc.message) from exc - - response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={}) - response.headers["Content-Length"] = str(len(payload)) - if args.as_attachment and filename: - encoded_filename = quote(filename) - response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" - enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="") - return response diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index 32dabf5bb01a7a..5c82f3757e3910 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -23,7 +23,6 @@ from .app import dsl as _app_dsl from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_config as _agent_config -from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -31,7 +30,6 @@ __all__ = [ "_agent_config", - "_agent_drive", "_agent_files", "_agent_llm", "_agent_tools", diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py deleted file mode 100644 index e06720a8e999ad..00000000000000 --- a/api/controllers/inner_api/plugin/agent_drive.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Inner API for the agent drive (agent 网盘) control plane. - -These endpoints are called by the dify-agent server (not the sandbox) with the -inner API key. The drive ref is the URL segment ``agent-``; the -path-like file key travels in the query/body, never as a URL path segment (so -its ``/`` characters do not collide with routing). Drive-owned semantics: -tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the -trusted execution-context user through the same EndUser lookup as plugin file -upload before validating ToolFile ownership. -""" - -from flask import request -from flask_restx import Resource -from pydantic import BaseModel, ValidationError - -from controllers.console.wraps import setup_required -from controllers.inner_api import inner_api_ns -from controllers.inner_api.plugin.wraps import get_user -from controllers.inner_api.wraps import plugin_inner_api_only -from extensions.ext_database import db -from services.agent_drive_service import ( - AgentDriveError, - AgentDriveService, - DriveCommitItem, - parse_agent_drive_ref, -) - - -class _CommitRequest(BaseModel): - tenant_id: str - user_id: str - items: list[DriveCommitItem] - - -def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]: - return {"code": exc.code, "message": exc.message}, exc.status_code - - -@inner_api_ns.route("/drive//manifest") -class AgentDriveManifestApi(Resource): - @setup_required - @plugin_inner_api_only - @inner_api_ns.doc("agent_drive_manifest") - @inner_api_ns.doc(description="List an agent drive (optionally with download URLs)") - def get(self, drive_ref: str): - try: - agent_id = parse_agent_drive_ref(drive_ref) - tenant_id = (request.args.get("tenant_id") or "").strip() - if not tenant_id: - raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400) - include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes") - items = AgentDriveService().manifest( - tenant_id=tenant_id, - agent_id=agent_id, - prefix=request.args.get("prefix", ""), - include_download_url=include_download_url, - session=db.session(), - ) - except AgentDriveError as exc: - return _error_response(exc) - return {"items": items} - - -@inner_api_ns.route("/drive//skills") -class AgentDriveSkillsApi(Resource): - @setup_required - @plugin_inner_api_only - @inner_api_ns.doc("agent_drive_skills") - @inner_api_ns.doc(description="List the skill catalog of an agent drive") - def get(self, drive_ref: str): - try: - agent_id = parse_agent_drive_ref(drive_ref) - tenant_id = (request.args.get("tenant_id") or "").strip() - if not tenant_id: - raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400) - items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session()) - except AgentDriveError as exc: - return _error_response(exc) - return {"items": items} - - -@inner_api_ns.route("/drive//commit") -class AgentDriveCommitApi(Resource): - @setup_required - @plugin_inner_api_only - @inner_api_ns.doc("agent_drive_commit") - @inner_api_ns.doc(description="Commit a batch of file refs into an agent drive") - def post(self, drive_ref: str): - try: - agent_id = parse_agent_drive_ref(drive_ref) - try: - body = _CommitRequest.model_validate(request.get_json(silent=True) or {}) - except ValidationError as exc: - raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc - user = get_user(body.tenant_id, body.user_id) - items = AgentDriveService().commit( - tenant_id=body.tenant_id, - user_id=user.id, - agent_id=agent_id, - items=body.items, - session=db.session(), - ) - except AgentDriveError as exc: - return _error_response(exc) - return {"items": items} diff --git a/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py b/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py new file mode 100644 index 00000000000000..5515c171d3763c --- /dev/null +++ b/api/migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py @@ -0,0 +1,109 @@ +"""remove agent drive + +Revision ID: 89919253ca7a +Revises: 56124e050600 +Create Date: 2026-08-17 17:40:52.081816 + +""" + +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +from models.types import StringUUID + +# revision identifiers, used by Alembic. +revision = "89919253ca7a" +down_revision = "56124e050600" +branch_labels = None +depends_on = None + + +def _rewrite_json_rows(table_name: str, column_name: str, transform) -> None: + # Offline SQL generation cannot run this read-modify-write cleanup. + if op.get_context().as_sql: + return + + connection = op.get_bind() + rows = connection.execute(sa.text(f"SELECT id, {column_name} FROM {table_name}")) + for row_id, raw_value in rows: + if raw_value is None: + continue + value = json.loads(raw_value) + if not transform(value): + continue + connection.execute( + sa.text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"), + {"id": row_id, "value": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}, + ) + + +def _remove_soul_files(value: object) -> bool: + if not isinstance(value, dict) or "files" not in value: + return False + del value["files"] + return True + + +def _remove_node_job_drive_keys(value: object) -> bool: + if not isinstance(value, dict): + return False + changed = False + metadata = value.get("metadata") + if isinstance(metadata, dict): + file_refs = metadata.get("file_refs") + if isinstance(file_refs, list): + for file_ref in file_refs: + if isinstance(file_ref, dict) and "drive_key" in file_ref: + del file_ref["drive_key"] + changed = True + declared_outputs = value.get("declared_outputs") + if isinstance(declared_outputs, list): + for output in declared_outputs: + if not isinstance(output, dict): + continue + check = output.get("check") + if not isinstance(check, dict): + continue + benchmark_file_ref = check.get("benchmark_file_ref") + if isinstance(benchmark_file_ref, dict) and "drive_key" in benchmark_file_ref: + del benchmark_file_ref["drive_key"] + changed = True + return changed + + +def upgrade() -> None: + _rewrite_json_rows("agent_config_snapshots", "config_snapshot", _remove_soul_files) + _rewrite_json_rows("agent_config_drafts", "config_snapshot", _remove_soul_files) + _rewrite_json_rows("workflow_agent_node_bindings", "node_job_config", _remove_node_job_drive_keys) + op.drop_table("agent_drive_files") + + +def downgrade() -> None: + op.create_table( + "agent_drive_files", + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("agent_id", StringUUID(), nullable=False), + sa.Column("key", sa.String(length=512), nullable=False), + sa.Column("file_kind", sa.String(length=32), nullable=False), + sa.Column("file_id", StringUUID(), nullable=False), + sa.Column("value_owned_by_drive", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("is_skill", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True), + sa.Column("size", sa.BigInteger(), nullable=True), + sa.Column("hash", sa.String(length=255), nullable=True), + sa.Column("mime_type", sa.String(length=255), nullable=True), + sa.Column("created_by", StringUUID(), nullable=True), + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"), + sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"), + ) + op.create_index( + "agent_drive_files_tenant_agent_is_skill_key_idx", + "agent_drive_files", + ["tenant_id", "agent_id", "is_skill", "key"], + ) diff --git a/api/models/__init__.py b/api/models/__init__.py index b0c3058f0b7bf0..49ee03c406f33d 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -17,8 +17,6 @@ AgentConfigSnapshot, AgentConfigVersionKind, AgentDebugConversation, - AgentDriveFile, - AgentDriveFileKind, AgentHomeSnapshot, AgentIconType, AgentKind, @@ -168,8 +166,6 @@ "AgentConfigSnapshot", "AgentConfigVersionKind", "AgentDebugConversation", - "AgentDriveFile", - "AgentDriveFileKind", "AgentHomeSnapshot", "AgentIconType", "AgentKind", diff --git a/api/models/agent.py b/api/models/agent.py index a5863dd12592a1..d981194c714ff0 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -536,55 +536,3 @@ class AgentWorkspaceBinding(DefaultFieldsMixin, Base): retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - - -class AgentDriveFileKind(StrEnum): - """Kind of existing file record an agent-drive KV entry points at.""" - - UPLOAD_FILE = "upload_file" - TOOL_FILE = "tool_file" - - -class AgentDriveFile(DefaultFieldsMixin, Base): - """Per-agent path-like KV index into existing file records (agent 网盘 / agent drive). - - A row maps a path-like ``key`` to a *pointer* (``file_kind`` + ``file_id``) at an - existing ``UploadFile`` / ``ToolFile`` — it never stores file bytes. Scope/ownership - is ``tenant_id -> agent-`` (the drive ref; no standalone ``drive_id`` this - phase). ``key`` is opaque/path-like and carries no directory, permission, or - parent-child semantics on the API side; it maps 1:1 to a sandbox-relative path when - synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values - (created by the agent runtime or Skill standardization, not shared with other - business records) have their storage object + record deleted when the KV entry is - overwritten or removed; otherwise only the KV row is dropped. Skills are represented - by the canonical ``/SKILL.md`` row with ``is_skill=True`` and a serialized - ``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by`` - (not a reliable refcount). - """ - - __tablename__ = "agent_drive_files" - __table_args__ = ( - sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"), - UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"), - Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"), - ) - - tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - # drive ref = agent-; this phase has no standalone drive_id. - agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - # path-like opaque key; not a filesystem (no dir/permission/parent semantics). - # Bounded at 512 so the (tenant_id, agent_id, key) unique index stays within - # MySQL's 3072-byte index limit (CHAR(36)*2 + VARCHAR(512) utf8mb4 = 2336). - key: Mapped[str] = mapped_column(String(512), nullable=False) - file_kind: Mapped[AgentDriveFileKind] = mapped_column(EnumText(AgentDriveFileKind, length=32), nullable=False) - # points at UploadFile.id / ToolFile.id (the value), never the bytes. - file_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - value_owned_by_drive: Mapped[bool] = mapped_column( - sa.Boolean, nullable=False, default=False, server_default=sa.text("false") - ) - is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false")) - skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True) - size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True) - hash: Mapped[str | None] = mapped_column(String(255), nullable=True) - mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True) - created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index fc845144e68a01..a81e51d62a58f0 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -150,33 +150,6 @@ class AgentFileRefConfig(AgentFlexibleConfig): transfer_method: str | None = Field(default=None, max_length=64) url: str | None = None remote_url: str | None = None - # Drive key once the file is committed to the agent drive ("files/", - # ENG-625). Files without it are plain upload references and stay invisible - # to the runtime drive manifest. - drive_key: str | None = Field(default=None, max_length=512) - - -class AgentSkillRefConfig(AgentFlexibleConfig): - id: str | None = Field(default=None, max_length=255) - name: str | None = Field(default=None, max_length=255) - description: str | None = None - file_id: str | None = Field(default=None, max_length=255) - path: str | None = None - # Standardization outputs (ENG-594) — previously riding along via - # ``extra="allow"``, promoted to the explicit schema because the runtime - # drive manifest (ENG-623) keys off them. - skill_md_key: str | None = Field(default=None, max_length=512) - skill_md_file_id: str | None = Field(default=None, max_length=255) - full_archive_key: str | None = Field(default=None, max_length=512) - full_archive_file_id: str | None = Field(default=None, max_length=255) - # Zip member path listing from standardization (ENG-371): lets infer-tools - # show the model strong signals like ``scripts/*.sh`` without unpacking. - manifest_files: list[str] | None = None - - -class AgentSoulFilesConfig(BaseModel): - skills: list[AgentSkillRefConfig] = Field(default_factory=list) - files: list[AgentFileRefConfig] = Field(default_factory=list) def validate_config_name(name: str) -> str: @@ -820,7 +793,6 @@ class AgentSoulConfig(BaseModel): config_skills: list[AgentConfigSkillRefConfig] = Field(default_factory=list) config_files: list[AgentConfigFileRefConfig] = Field(default_factory=list) config_note: str = "" - files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig) sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) model: AgentSoulModelConfig | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b403b6713daf5e..a6b8509ed3486c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -972,85 +972,6 @@ Stop a running Agent App chat message generation | 200 | Agent debug conversation refreshed | **application/json**: [AgentDebugConversationRefreshResponse](#agentdebugconversationrefreshresponse)
| | 403 | Insufficient permissions | | -### [GET] /agent/{agent_id}/drive/files -List agent drive entries for an Agent App - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| - -### [GET] /agent/{agent_id}/drive/files/download -Time-limited external signed URL for one Agent App drive value - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| - -### [GET] /agent/{agent_id}/drive/files/preview -Truncated text preview of one Agent App drive value - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| - -### [GET] /agent/{agent_id}/drive/skills -List drive-backed skills for an Agent App - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
| - -### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect -Inspect one drive-backed skill for slash-menu hover/detail UI - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
| - ### [POST] /agent/{agent_id}/features Update an Agent App's presentation features (opener, follow-up, citations, ...) @@ -1096,43 +1017,6 @@ Create or update Agent App message feedback | 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 404 | Agent or message not found | | -### [DELETE] /agent/{agent_id}/files -Delete one Agent App drive file by key - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| key | query | Drive key, e.g. files/sample.pdf | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| - -### [POST] /agent/{agent_id}/files -Commit an uploaded file into the Agent App drive under files/ - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| - ### [GET] /agent/{agent_id}/log-sources #### Parameters @@ -1322,60 +1206,6 @@ Read a text/binary preview file in an Agent App conversation sandbox | ---- | ----------- | ------ | | 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| -### [POST] /agent/{agent_id}/skills/upload -Upload + standardize a Skill into an Agent App drive - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **multipart/form-data**: { **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| -| 400 | Invalid skill package or no bound agent | | - -### [DELETE] /agent/{agent_id}/skills/{slug} -Delete a standardized skill from an Agent App drive - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| slug | path | Skill slug (single path segment) | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| - -### [POST] /agent/{agent_id}/skills/{slug}/infer-tools -Infer CLI tool + ENV suggestions from a standardized Agent App skill - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string (uuid) | -| slug | path | Skill slug (single path segment) | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| - ### [GET] /agent/{agent_id}/statistics/summary #### Parameters @@ -2192,132 +2022,6 @@ Run draft workflow for advanced chat application | ---- | ----------- | ------ | | 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)
| -### [GET] /apps/{app_id}/agent/drive/files -List agent drive entries (read-only inspector; one endpoint for both tabs) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | -| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| - -### [GET] /apps/{app_id}/agent/drive/files/download -Time-limited external signed URL for one drive value (no streaming proxy) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| - -### [GET] /apps/{app_id}/agent/drive/files/preview -Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| - -### [GET] /apps/{app_id}/agent/drive/skills -List drive-backed skills for the bound agent - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | -| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
| - -### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect -Inspect one drive-backed skill for slash-menu hover/detail UI - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
| - -### [DELETE] /apps/{app_id}/agent/files -Delete one drive file by key via drive commit-null semantics - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| key | query | Drive key, e.g. files/sample.pdf | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| - -### [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/ (ENG-625 D3) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| - ### [GET] /apps/{app_id}/agent/logs **Get agent logs** @@ -2338,68 +2042,6 @@ Get agent execution logs for an application | 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)
| | 400 | Invalid request parameters | | -### [POST] /apps/{app_id}/agent/skills/upload -**Upload a Skill, validate it, and commit drive-backed skill files** - -Upload + standardize a Skill into the agent drive - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **multipart/form-data**: { **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| -| 400 | Invalid skill package or no bound agent | | - -### [DELETE] /apps/{app_id}/agent/skills/{slug} -Delete a standardized skill by removing its known drive keys via commit-null - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| slug | path | Skill slug (single path segment) | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| - -### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools -**Suggest CLI tools/env for a skill** - -Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) -Saving still goes through composer validation. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string (uuid) | -| slug | path | Skill slug (single path segment) | Yes | string | -| node_id | query | Workflow node ID (workflow composer variant) | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| - ### [POST] /apps/{app_id}/annotation-reply/{action} Enable or disable annotation reply for an app @@ -13990,135 +13632,6 @@ Stable Agent Soul reference to one normalized skill archive. | debug_conversation_id | string | | Yes | | debug_conversation_message_count | integer | | No | -#### AgentDriveDeleteFileByAgentQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| key | string | Drive key, e.g. files/sample.pdf | Yes | - -#### AgentDriveDeleteResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| removed_keys | [ string ] | | No | -| result | string | | Yes | - -#### AgentDriveDownloadResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| url | string | | Yes | - -#### AgentDriveFileCommitResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes | - -#### AgentDriveFilePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes | - -#### AgentDriveFileResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| drive_key | string | | Yes | -| file_id | string | | Yes | -| mime_type | string | | No | -| name | string | | Yes | -| size | integer | | No | - -#### AgentDriveItemResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| created_at | integer | | No | -| file_kind | string | | Yes | -| hash | string | | No | -| is_skill | boolean | | No | -| key | string | | Yes | -| mime_type | string | | No | -| size | integer | | No | -| skill_metadata | string | | No | - -#### AgentDriveListResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No | - -#### AgentDrivePreviewResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| binary | boolean | | Yes | -| key | string | | Yes | -| size | integer | | No | -| text | string | | No | -| truncated | boolean | | Yes | - -#### AgentDriveSkillFileResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| available_in_drive | boolean | | Yes | -| drive_key | string | | No | -| name | string | | Yes | -| path | string | | Yes | -| type | string | | Yes | - -#### AgentDriveSkillInspectResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| archive_key | string | | No | -| created_at | integer | | No | -| description | string | | Yes | -| file_tree | [ object ] | | No | -| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No | -| hash | string | | No | -| mime_type | string | | No | -| name | string | | Yes | -| path | string | | Yes | -| size | integer | | No | -| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes | -| skill_md_key | string | | Yes | -| source | string | | Yes | -| warnings | [ string ] | | No | - -#### AgentDriveSkillItemResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| archive_key | string | | No | -| created_at | integer | | No | -| description | string | | Yes | -| hash | string | | No | -| mime_type | string | | No | -| name | string | | Yes | -| path | string | | Yes | -| size | integer | | No | -| skill_md_key | string | | Yes | - -#### AgentDriveSkillListResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No | - -#### AgentDriveSkillMarkdownResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| binary | boolean | | Yes | -| key | string | | Yes | -| size | integer | | No | -| text | string | | No | -| truncated | boolean | | Yes | - #### AgentEnvVariableConfig | Name | Type | Description | Required | @@ -14142,7 +13655,6 @@ Stable Agent Soul reference to one normalized skill archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| drive_key | string | | No | | file_id | string | | No | | id | string | | No | | name | string | | No | @@ -14499,13 +14011,6 @@ section may be empty, which is how callers express "no knowledge layer". | status | string | | Yes | | total_tokens | integer | | Yes | -#### AgentLogQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation UUID | Yes | -| message_id | string | Message UUID | Yes | - #### AgentLogResponse | Name | Type | Description | Required | @@ -14763,28 +14268,6 @@ Visibility and lifecycle scope of an Agent record. | ---- | ---- | ----------- | -------- | | result | string | | Yes | -#### AgentSkillRefConfig - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | No | -| file_id | string | | No | -| full_archive_file_id | string | | No | -| full_archive_key | string | | No | -| id | string | | No | -| manifest_files | [ string ] | | No | -| name | string | | No | -| path | string | | No | -| skill_md_file_id | string | | No | -| skill_md_key | string | | No | - -#### AgentSkillUploadResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| manifest | [SkillManifest](#skillmanifest) | | Yes | -| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes | - #### AgentSoulAppFeaturesConfig | Name | Type | Description | Required | @@ -14808,7 +14291,6 @@ Visibility and lifecycle scope of an Agent record. | config_note | string | | No | | config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No | | env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No | -| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No | | human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No | | knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No | | memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No | @@ -14866,13 +14348,6 @@ old Agent tool payloads can be read while new payloads stay explicit. | secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No | | variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No | -#### AgentSoulFilesConfig - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No | -| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No | - #### AgentSoulHumanConfig | Name | Type | Description | Required | @@ -15119,16 +14594,6 @@ Legacy Chat App model config used only for follow-up question generation. | tool_output | object | | Yes | | tool_parameters | object | | Yes | -#### AgentUploadedSkillResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| archive_key | string | | No | -| description | string | | Yes | -| name | string | | Yes | -| path | string | | Yes | -| skill_md_key | string | | Yes | - #### AgentUserSatisfactionRateStatisticResponse | Name | Type | Description | Required | @@ -16145,17 +15610,6 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | content | string | Child chunk text content. | Yes | -#### CliToolSuggestion - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| command | string | | No | -| description | string | | No | -| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No | -| inferred_from | string | | No | -| install_commands | [ string ] | | No | -| name | string | | Yes | - #### CloudPlan Enum representing user plan types in the cloud platform. @@ -17948,14 +17402,6 @@ declaration of an endpoint group | name | string | | Yes | | settings | object | | Yes | -#### EnvSuggestion - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| key | string | | Yes | -| reason | string | | No | -| secret_likely | boolean | | No | - #### EnvironmentVariableItemPayload | Name | Type | Description | Required | @@ -21858,27 +21304,6 @@ Simple provider entity response. | title | string | | Yes | | use_icon_as_answer_icon | boolean | | Yes | -#### SkillManifest - -Validated metadata extracted from a Skill package. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | Yes | -| entry_path | string | | Yes | -| files | [ string ] | | Yes | -| hash | string | | Yes | -| name | string | | Yes | -| size | integer | | Yes | - -#### SkillToolInferenceResult - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No | -| inferable | boolean | | Yes | -| reason | string | | No | - #### SnippetDependencyCheckResponse | Name | Type | Description | Required | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index d29d8ed6b41d3f..064a6dc8e56d37 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -5,7 +5,6 @@ from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from sqlalchemy.sql.elements import ColumnElement from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from libs.helper import to_timestamp @@ -20,7 +19,6 @@ AgentConfigSnapshot, AgentConfigVersionKind, AgentDebugConversation, - AgentDriveFile, AgentIconType, AgentKind, AgentScope, @@ -279,12 +277,7 @@ def save_workflow_composer( state = cls._serialize_workflow_state( session=session, binding=binding, agent=agent, version=version, account_id=account_id ) - state["validation"] = cls.collect_validation_findings( - session=session, - tenant_id=tenant_id, - payload=payload, - agent_id=binding.agent_id, - ) + state["validation"] = cls.collect_validation_findings(payload=payload) session.commit() binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned( tenant_id=tenant_id, @@ -365,16 +358,6 @@ def copy_workflow_composer_from_roster( icon=source_agent.icon, icon_background=source_agent.icon_background, ) - cls._copy_agent_drive_rows( - session=session, - tenant_id=tenant_id, - source_agent_id=source_agent.id, - target_agent_id=inline_agent.id, - account_id=account_id, - agent_soul=agent_soul, - node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), - ) - binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT binding.agent_id = inline_agent.id binding.current_snapshot_id = inline_agent.active_config_snapshot_id @@ -581,12 +564,7 @@ def _save_agent_composer_for_agent( session.flush() state = cls.load_agent_composer(session=session, tenant_id=tenant_id, agent_id=agent.id) - state["validation"] = cls.collect_validation_findings( - session=session, - tenant_id=tenant_id, - payload=payload, - agent_id=agent.id, - ) + state["validation"] = cls.collect_validation_findings(payload=payload) return state @classmethod @@ -1051,12 +1029,9 @@ def _discard_agent_app_build_draft_in_transaction( def collect_validation_findings( cls, *, - session: Session, - tenant_id: str, payload: ComposerSavePayload, - agent_id: str | None = None, ) -> dict[str, Any]: - """ENG-617 soft findings, with DB-backed dataset and drive mention checks.""" + """Collect non-blocking composer validation findings.""" existing_knowledge_set_ids = ( {knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets} if payload.agent_soul is not None @@ -1066,15 +1041,6 @@ def collect_validation_findings( payload, existing_knowledge_set_ids=existing_knowledge_set_ids, ) - if agent_id and payload.agent_soul is not None: - findings["warnings"].extend( - cls._drive_mention_findings( - session=session, - tenant_id=tenant_id, - agent_id=agent_id, - prompt=payload.agent_soul.prompt.system_prompt, - ) - ) return findings @classmethod @@ -1099,21 +1065,6 @@ def validate_knowledge_datasets( + ", ".join(missing_ids) ) - @classmethod - def resolve_bound_agent_id(cls, *, session: Session, tenant_id: str, app_id: str) -> str | None: - """The Agent App's bound roster agent id, if any (validate-endpoint context).""" - return session.scalar( - select(Agent.id) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) - @classmethod def resolve_workflow_node_agent_id( cls, *, session: Session, tenant_id: str, app_id: str, node_id: str @@ -1128,54 +1079,6 @@ def resolve_workflow_node_agent_id( ) return binding.agent_id if binding else None - @classmethod - def _drive_mention_findings( - cls, - *, - session: Session, - tenant_id: str, - agent_id: str, - prompt: str, - ) -> list[dict[str, str | None]]: - """Soft warnings for missing drive-backed prompt mentions.""" - from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions - from services.agent_drive_service import decode_drive_mention_ref - - wanted_keys: dict[str, tuple[str, str]] = {} - for mention in parse_prompt_mentions(prompt): - if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: - continue - decoded_key = decode_drive_mention_ref(mention.ref_id) - if not decoded_key: - continue - wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key) - if not wanted_keys: - return [] - - existing_keys = set( - session.scalars( - select(AgentDriveFile.key).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key.in_(sorted(wanted_keys)), - ) - ) - ) - findings: list[dict[str, str | None]] = [] - for key, (kind, display) in wanted_keys.items(): - if key in existing_keys: - continue - findings.append( - { - "code": "mention_target_missing", - "surface": "agent_soul", - "kind": kind, - "id": key, - "message": f"{kind} '{display}' has no drive entry for key '{key}'.", - } - ) - return findings - @classmethod def get_workflow_candidates( cls, *, session: Session, tenant_id: str, app_id: str, node_id: str, user_id: str @@ -1721,15 +1624,6 @@ def _save_to_roster( operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, ) - cls._copy_agent_drive_rows( - session=session, - tenant_id=tenant_id, - source_agent_id=source_agent.id, - target_agent_id=roster_agent.id, - account_id=account_id, - agent_soul=agent_soul, - node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), - ) binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT binding.agent_id = roster_agent.id binding.current_snapshot_id = roster_agent.active_config_snapshot_id @@ -1801,99 +1695,6 @@ def _create_workflow_only_agent( agent.active_config_is_published = True return agent - @classmethod - def _copy_agent_drive_rows( - cls, - *, - session: Session, - tenant_id: str, - source_agent_id: str, - target_agent_id: str, - account_id: str, - agent_soul: AgentSoulConfig, - node_job: WorkflowNodeJobConfig | None = None, - ) -> None: - exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job) - predicates: list[ColumnElement[bool]] = [] - if exact_keys: - predicates.append(AgentDriveFile.key.in_(sorted(exact_keys))) - predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes)) - if not predicates: - return - - source_rows = list( - session.scalars( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == source_agent_id, - or_(*predicates), - ) - ).all() - ) - if not source_rows: - return - - existing_target_keys = set( - session.scalars( - select(AgentDriveFile.key).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == target_agent_id, - AgentDriveFile.key.in_([row.key for row in source_rows]), - ) - ).all() - ) - for row in source_rows: - if row.key in existing_target_keys: - continue - session.add( - AgentDriveFile( - tenant_id=tenant_id, - agent_id=target_agent_id, - key=row.key, - file_kind=row.file_kind, - file_id=row.file_id, - value_owned_by_drive=row.value_owned_by_drive, - is_skill=row.is_skill, - skill_metadata=row.skill_metadata, - size=row.size, - hash=row.hash, - mime_type=row.mime_type, - created_by=account_id, - ) - ) - - @staticmethod - def _drive_copy_scopes_from_agent_configs( - *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None - ) -> tuple[set[str], set[str]]: - from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions - from services.agent_drive_service import decode_drive_mention_ref - - exact_keys: set[str] = set() - prefixes: set[str] = set() - - for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): - if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: - continue - drive_key = decode_drive_mention_ref(mention.ref_id) - if not drive_key: - continue - if mention.kind == MentionKind.SKILL and "/" in drive_key: - prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/") - else: - exact_keys.add(drive_key) - - if node_job is not None: - for file_ref in node_job.metadata.file_refs or []: - if file_ref.drive_key: - exact_keys.add(file_ref.drive_key) - for output in node_job.declared_outputs: - benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None - if benchmark_ref and benchmark_ref.drive_key: - exact_keys.add(benchmark_ref.drive_key) - - return exact_keys, prefixes - @classmethod def _create_roster_agent_for_composer( cls, diff --git a/api/services/agent/config_skill_normalize_service.py b/api/services/agent/config_skill_normalize_service.py index 8c06dc2bcc83be..7093b49ce1c5a5 100644 --- a/api/services/agent/config_skill_normalize_service.py +++ b/api/services/agent/config_skill_normalize_service.py @@ -1,9 +1,8 @@ """Normalize uploaded config skills into one canonical ToolFile reference. -Config skills are Agent Soul-backed assets, not drive rows. This service keeps -the existing skill package validation rules, enforces the requested stable name, -stores the normalized archive as one ToolFile, and returns the persisted Soul -reference metadata used by ``AgentConfigService``. +This service keeps the existing skill package validation rules, enforces the +requested stable name, stores the normalized archive as one ToolFile, and +returns the persisted Soul reference metadata used by ``AgentConfigService``. """ from __future__ import annotations diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py index 1b7a09cf4935d9..87ce85ddd84f00 100644 --- a/api/services/agent/dsl_service.py +++ b/api/services/agent/dsl_service.py @@ -3,8 +3,7 @@ Agent runtime configuration is split across immutable Soul snapshots and workflow-node bindings, while App and Snippet DSLs must be independent of the source workspace's database identifiers. This module owns that translation. -It deliberately excludes drive payloads and stored credentials from portable -packages; same-workspace copies may use the separate server-side clone path. +It deliberately excludes stored credentials from portable packages. """ from __future__ import annotations @@ -327,7 +326,6 @@ def clone_inline_binding_for_node( node_id: str, source_agent: Agent, source_snapshot: AgentConfigSnapshot, - node_job: WorkflowNodeJobConfig, account_id: str, ) -> tuple[Agent, AgentConfigSnapshot]: """Clone a same-workspace Inline Agent for a pasted target node.""" @@ -350,17 +348,6 @@ def clone_inline_binding_for_node( source=AgentSource.WORKFLOW, operation=AgentConfigRevisionOperation.CREATE_VERSION, ) - from services.agent.composer_service import AgentComposerService - - AgentComposerService._copy_agent_drive_rows( - tenant_id=workflow.tenant_id, - source_agent_id=source_agent.id, - target_agent_id=agent.id, - account_id=account_id, - agent_soul=soul, - node_job=node_job, - session=self.session, - ) return agent, snapshot def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]: diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index a3690f4093a594..3be3a53666eddc 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -66,9 +66,7 @@ class MentionKind(StrEnum): WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}") MAX_MENTIONS_PER_PROMPT = 200 -# Drive keys are validated up to 512 Unicode code points before URL encoding. -# Worst case, one code point becomes 4 UTF-8 bytes and each byte becomes a -# 3-character ``%XX`` escape, so a valid encoded drive key can reach 6144 chars. +# Mention ids are bounded independently of their owning configuration schema. MAX_MENTION_REF_ID_LENGTH = 6144 MAX_MENTION_LABEL_LENGTH = 255 @@ -241,7 +239,7 @@ def _degrade(match: re.Match[str]) -> str: def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver: - """Resolve non-drive soul-surface mentions to canonical display names.""" + """Resolve Soul-surface mentions to canonical display names.""" def _resolve(mention: PromptMention) -> str | None: match mention.kind: diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index fbfd2ababfcb34..f28ff2aa2361eb 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -1,4 +1,4 @@ -"""Validate and normalize uploaded Skill packages for drive standardization. +"""Validate and normalize uploaded Skill packages. A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``, @@ -10,8 +10,7 @@ It does NOT execute or load the skill — the agent backend owns execution. It also does not persist anything into Agent Soul or bind anything to config versions; -``SkillStandardizeService`` consumes the normalized package and commits the -canonical drive rows instead. +``ConfigSkillNormalizeService`` consumes the normalized package for Agent config. """ from __future__ import annotations @@ -63,7 +62,7 @@ class SkillManifest(BaseModel): class NormalizedSkillPackage(BaseModel): - """Canonical skill package bytes and metadata ready to store in agent drive.""" + """Canonical skill package bytes and metadata ready to store as Agent config.""" manifest: SkillManifest archive_bytes: bytes @@ -72,10 +71,10 @@ class NormalizedSkillPackage(BaseModel): class SkillPackageService: - """Validate Skill archives and produce the normalized package stored in drive.""" + """Validate Skill archives and produce a normalized package.""" def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage: - """Return the canonical drive package for an uploaded skill archive. + """Return the canonical package for an uploaded skill archive. The shallowest ``SKILL.md`` defines the skill root. When exactly one depth-2 ``/SKILL.md`` exists, normalization strips that top-level diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py deleted file mode 100644 index 2639f7a9a18329..00000000000000 --- a/api/services/agent/skill_standardize_service.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Standardize an uploaded Skill into the agent drive (ENG-594). - -A validated Skill package is normalized into two **drive-owned** objects committed -to the agent drive (Agent Files §5.4 / §4): - -* ``/SKILL.md`` — the canonical entry, the source of truth for loading. -* ``/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the - complete skill contents. - -The archive's member list is stored in skill metadata and resolved lazily for -inspect/preview/runtime. Upload must not eagerly materialize every archive member -as a separate ToolFile; small archives with many files would otherwise perform -hundreds of storage writes and DB commits inside the request. -""" - -from __future__ import annotations - -import re -from typing import Any - -from sqlalchemy.orm import Session - -from core.tools.tool_file_manager import ToolFileManager -from services.agent.skill_package_service import SkillPackageService -from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata - -_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" -_SKILL_MD_NAME = "SKILL.md" -_SLUG_RE = re.compile(r"[^a-z0-9._-]+") - - -def slugify_skill_name(name: str) -> str: - slug = _SLUG_RE.sub("-", (name or "").strip().lower()).strip("-._") - return slug or "skill" - - -class SkillStandardizeService: - """Persist a normalized skill package into drive-owned files for one agent. - - Instances are intentionally stateful: ``standardize()`` updates - ``last_committed_items`` with the drive commit result for the most recent call. - """ - - def __init__( - self, - *, - package_service: SkillPackageService | None = None, - drive_service: AgentDriveService | None = None, - tool_file_manager: ToolFileManager | None = None, - ) -> None: - self._package = package_service or SkillPackageService() - self._drive = drive_service or AgentDriveService() - self._tool_files = tool_file_manager or ToolFileManager() - self.last_committed_items: list[dict[str, Any]] = [] - - def standardize( - self, - *, - content: bytes, - filename: str, - tenant_id: str, - user_id: str, - agent_id: str, - session: Session, - ) -> dict[str, Any]: - """Create two ToolFiles, commit two drive-owned keys, and return skill metadata. - - This writes ``/SKILL.md`` and ``/.DIFY-SKILL-FULL.zip``, - stores the drive commit rows in ``last_committed_items``, and returns the - console response shape ``{"skill": ..., "manifest": ...}``. - """ - package = self._package.validate_and_normalize(content=content, filename=filename) - manifest = package.manifest - slug = slugify_skill_name(manifest.name) - - # Drive-owned files: canonical SKILL.md and the full archive. The - # archive member tree is preserved in metadata and resolved lazily. - md_tool_file = self._tool_files.create_file_by_raw( - user_id=user_id, - tenant_id=tenant_id, - conversation_id=None, - file_binary=package.skill_md_bytes, - mimetype="text/markdown", - filename=_SKILL_MD_NAME, - ) - archive_tool_file = self._tool_files.create_file_by_raw( - user_id=user_id, - tenant_id=tenant_id, - conversation_id=None, - file_binary=package.archive_bytes, - mimetype="application/zip", - filename=_FULL_ARCHIVE_NAME, - ) - - skill_md_key = f"{slug}/{_SKILL_MD_NAME}" - archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}" - committed_items = self._drive.commit( - tenant_id=tenant_id, - user_id=user_id, - agent_id=agent_id, - items=[ - DriveCommitItem( - key=skill_md_key, - file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id), - value_owned_by_drive=True, - is_skill=True, - skill_metadata=DriveSkillMetadata( - name=manifest.name, - description=manifest.description, - manifest_files=manifest.files, - ), - ), - DriveCommitItem( - key=archive_key, - file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id), - value_owned_by_drive=True, - ), - ], - session=session, - ) - self.last_committed_items = committed_items - - return { - "skill": { - "name": manifest.name, - "description": manifest.description, - "path": slug, - "skill_md_key": skill_md_key, - "archive_key": archive_key, - }, - "manifest": manifest.model_dump(), - } - - -__all__ = ["SkillStandardizeService", "slugify_skill_name"] diff --git a/api/services/agent/skill_tool_inference_service.py b/api/services/agent/skill_tool_inference_service.py deleted file mode 100644 index 7ce53dd4666bb7..00000000000000 --- a/api/services/agent/skill_tool_inference_service.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371). - -Reads the skill's SKILL.md from the agent drive, asks the tenant's default -reasoning model once (a plain LLM call, never an agent run), and returns -*draft* suggestions only — nothing is persisted here. The frontend prefills -the TOOLS box (``inferred from `` badge) and the Pre-Authorize ENV -panel, and saving still goes through the composer's full shell/env/secret/ -dangerous-command validation, so inference opens no bypass. - -ENV suggestions carry only ``key`` + ``reason`` — the model never produces a -value; users fill those in themselves and the runtime injects ``$VAR`` only. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -import json_repair -from pydantic import BaseModel, Field, ValidationError -from sqlalchemy.orm import Session - -from core.errors.error import ProviderTokenNotInitError -from core.model_manager import ModelManager -from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage -from graphon.model_runtime.entities.model_entities import ModelType -from services.agent_drive_service import AgentDriveError, AgentDriveService - -logger = logging.getLogger(__name__) - - -class SkillToolInferenceError(Exception): - """Stable-code error for the infer-tools endpoint.""" - - def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: - self.code = code - self.message = message - self.status_code = status_code - super().__init__(message) - - -class EnvSuggestion(BaseModel): - key: str - reason: str = "" - secret_likely: bool = False - - -class CliToolSuggestion(BaseModel): - name: str - description: str = "" - command: str = "" - install_commands: list[str] = Field(default_factory=list) - env_suggestions: list[EnvSuggestion] = Field(default_factory=list) - inferred_from: str = "" - - -class SkillToolInferenceResult(BaseModel): - inferable: bool - cli_tools: list[CliToolSuggestion] = Field(default_factory=list) - reason: str | None = None - - -_SYSTEM_PROMPT = """\ -You analyze an agent skill document (SKILL.md) and infer which command-line \ -tools the skill depends on at runtime, so a user can pre-install them in the \ -agent's sandbox. - -Rules: -- Only suggest tools the document explicitly uses or clearly requires; never guess. -- For each tool give: name, a one-line reason-style description referencing the \ -document, the base command, and install commands for a Debian-based sandbox \ -(apt-get / pip / npm). -- If a step needs an environment variable (an API key, token, endpoint), add it \ -to env_suggestions with the variable key and the reason. NEVER produce a value. \ -Mark secret_likely=true for credentials. -- If the document describes no external command-line dependency, return \ -{"inferable": false, "cli_tools": [], "reason": ""}. - -Respond with JSON only, matching exactly: -{"inferable": bool, - "cli_tools": [{"name": str, "description": str, "command": str, - "install_commands": [str], "env_suggestions": - [{"key": str, "reason": str, "secret_likely": bool}]}], - "reason": str | null} -""" - - -class SkillToolInferenceService: - """Single-shot LLM inference over a drive-stored SKILL.md.""" - - def __init__(self, *, drive_service: AgentDriveService | None = None) -> None: - self._drive = drive_service or AgentDriveService() - - def infer(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> dict[str, Any]: - skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug, session=session) - - user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}" - - raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) - try: - result = self._parse(raw) - except (ValidationError, ValueError): - logger.warning("skill tool inference output unparsable, retrying once") - raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) - try: - result = self._parse(raw) - except (ValidationError, ValueError) as exc: - raise SkillToolInferenceError( - "inference_failed", - "inference_failed: the model output could not be parsed into tool suggestions.", - status_code=422, - ) from exc - - for tool in result.cli_tools: - tool.inferred_from = slug - return result.model_dump(mode="json") - - def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> str: - try: - preview = self._drive.preview( - tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md", session=session - ) - except AgentDriveError as exc: - if exc.code == "drive_key_not_found": - raise SkillToolInferenceError( - "skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404 - ) from exc - raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc - if preview["binary"] or not preview["text"]: - raise SkillToolInferenceError( - "skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404 - ) - return str(preview["text"]) - - @staticmethod - def _invoke(*, tenant_id: str, user_prompt: str) -> str: - try: - model_manager = ModelManager.for_tenant(tenant_id=tenant_id) - model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM) - except ProviderTokenNotInitError as exc: - raise SkillToolInferenceError( - "default_model_not_configured", - "default_model_not_configured: the workspace has no default reasoning model.", - status_code=400, - ) from exc - try: - response = model_instance.invoke_llm( - prompt_messages=[ - SystemPromptMessage(content=_SYSTEM_PROMPT), - UserPromptMessage(content=user_prompt), - ], - model_parameters={"temperature": 0.1}, - stream=False, - ) - except Exception as exc: - raise SkillToolInferenceError( - "inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422 - ) from exc - return response.message.get_text_content() - - @staticmethod - def _parse(raw: str) -> SkillToolInferenceResult: - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - parsed = json_repair.loads(raw) - if not isinstance(parsed, dict): - raise ValueError("model output is not a JSON object") - return SkillToolInferenceResult.model_validate(parsed) - - -__all__ = [ - "CliToolSuggestion", - "EnvSuggestion", - "SkillToolInferenceError", - "SkillToolInferenceResult", - "SkillToolInferenceService", -] diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index b43f3091f889f7..954a3819774d35 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -186,22 +186,17 @@ def _validate_binding_composer_config_for_publish( node_job=node_job, ) ComposerConfigValidator.validate_publish_payload(payload) - # ENG-623 §4.4: drive-backed refs must point at real drive rows before - # publishing. This stays out of composer save so autosave/save-draft can - # persist incomplete refs and surface them as non-blocking findings. - cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul) + cls._require_config_asset_refs_resolved_for_publish(binding=binding, agent_soul=agent_soul) @classmethod - def _require_drive_refs_resolved_for_publish( + def _require_config_asset_refs_resolved_for_publish( cls, *, - session: Session, binding: WorkflowAgentNodeBinding, agent_soul: AgentSoulConfig, ) -> None: from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions - del session configured_skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing} configured_file_names = {item.name for item in agent_soul.config_files if not item.is_missing} missing_refs: list[str] = [] @@ -359,7 +354,6 @@ def _sync_agent_binding_for_node( node_id=node_id, source_agent_id=agent_id, source_snapshot_id=current_snapshot_id, - node_job=node_job_config, account_id=account_id, ) resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT @@ -422,7 +416,6 @@ def _clone_inline_graph_binding_for_node( node_id: str, source_agent_id: str, source_snapshot_id: str, - node_job: WorkflowNodeJobConfig, account_id: str, ) -> tuple[Agent, str]: source_agent = session.scalar( @@ -456,7 +449,6 @@ def _clone_inline_graph_binding_for_node( node_id=node_id, source_agent=source_agent, source_snapshot=source_snapshot, - node_job=node_job, account_id=account_id, ) return agent, snapshot.id @@ -709,7 +701,6 @@ def restore_agent_node_bindings_to_draft( node_id=source.node_id, source_agent_id=agent_id, source_snapshot_id=snapshot_id, - node_job=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict), account_id=account_id, ) agent_id = agent.id diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py index 76edc3b199c0f1..f17847ab3a1800 100644 --- a/api/services/agent_config_service.py +++ b/api/services/agent_config_service.py @@ -50,7 +50,6 @@ from models.tools import ToolFile from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService from services.agent.skill_package_service import SkillPackageError -from services.agent_drive_service import DriveFileRef class AgentConfigVersionKind(StrEnum): @@ -64,6 +63,13 @@ class AgentConfigMutationSurface(StrEnum): CONSOLE = "console" +class ConfigFileRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["upload_file", "tool_file"] + id: str + + class AgentConfigServiceError(Exception): """Config operation failure mapped to HTTP status at controller boundaries.""" @@ -82,14 +88,14 @@ class ConfigPushFileItem(BaseModel): model_config = ConfigDict(extra="forbid") name: str - file_ref: DriveFileRef | None = None + file_ref: ConfigFileRef | None = None class ConfigPushSkillItem(BaseModel): model_config = ConfigDict(extra="forbid") name: str - file_ref: DriveFileRef | None = None + file_ref: ConfigFileRef | None = None class ConfigPushPayload(BaseModel): @@ -991,7 +997,7 @@ def _validate_source_ref( session: Session, *, tenant_id: str, - file_ref: DriveFileRef, + file_ref: ConfigFileRef, ) -> tuple[int | None, str | None, str | None]: if file_ref.kind == "tool_file": tool_file = self._require_tool_file_source( diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py deleted file mode 100644 index 0e4722d10188f9..00000000000000 --- a/api/services/agent_drive_service.py +++ /dev/null @@ -1,1254 +0,0 @@ -"""Agent 网盘 (agent drive) service — manifest/catalog + commit lifecycle. - -The agent drive is a per-agent path-like KV index over existing UploadFile / -ToolFile records (see ``AgentDriveFile``). This service is the control plane: - -* ``manifest`` lists a drive (optionally with download URLs). Download URLs use - **drive-owned** semantics — tenant-scoped resolution only, NOT a user-level - ``FileAccessScope`` (Agent Files §3.1.2). We reuse the standard - ``file_factory.build_from_mapping`` + ``resolve_file_url`` rebuild, which always - filters by ``tenant_id`` in the builders, so omitting the scope is safe. -* ``commit`` is the single mutation entry point for writes and removals. - ``file_ref=None`` removes an exact key idempotently; otherwise the service - binds the referenced UploadFile/ToolFile to the key. Source ToolFiles must - belong to the current run user. Overwriting a key whose previous value is - ``value_owned_by_drive`` physically cleans the old value (storage + record), - unless another drive entry still references it. Re-committing the same - ``key -> file_ref`` is idempotent and still refreshes skill metadata. -""" - -from __future__ import annotations - -import base64 -import hashlib -import hmac -import io -import json -import logging -import mimetypes -import os -import re -import time -import urllib.parse -import zipfile -from typing import Any, Literal, TypedDict -from urllib.parse import unquote - -from pydantic import BaseModel, ConfigDict, field_validator -from sqlalchemy import func, select -from sqlalchemy.exc import DataError, SQLAlchemyError -from sqlalchemy.orm import Session - -from configs import dify_config -from core.app.file_access.controller import DatabaseFileAccessController -from extensions.ext_storage import storage -from factories import file_factory -from libs.uuid_utils import uuidv7 -from models.agent import Agent, AgentDriveFile, AgentDriveFileKind -from models.model import UploadFile -from models.tools import ToolFile - -logger = logging.getLogger(__name__) - -_MAX_KEY_LENGTH = 512 -_DRIVE_REF_PREFIX = "agent-" -_SKILL_MD_SUFFIX = "/SKILL.md" -_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" -_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member" - - -class AgentDriveError(Exception): - """A drive operation failure mapped to an HTTP status by the controller.""" - - code: str - message: str - status_code: int - - def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: - super().__init__(message) - self.code = code - self.message = message - self.status_code = status_code - - -class DriveFileRef(BaseModel): - model_config = ConfigDict(extra="forbid") - - kind: Literal["upload_file", "tool_file"] - id: str - - -class DriveSkillMetadata(BaseModel): - """Validated skill catalog metadata stored as a JSON string on the drive row.""" - - model_config = ConfigDict(extra="forbid") - - name: str - description: str = "" - # Safe archive member paths captured during skill standardization. The drive - # stores only canonical SKILL.md + full archive, so the UI uses this manifest - # to show the original uploaded package contents. - manifest_files: list[str] | None = None - - @field_validator("name") - @classmethod - def _validate_name(cls, value: str) -> str: - normalized = value.strip() - if not normalized: - raise ValueError("skill metadata name must not be blank") - return normalized - - -class DriveCommitItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - key: str - file_ref: DriveFileRef | None = None - # Drive-owned values may be physically cleaned on overwrite/removal; refs to - # files shared with other business records should set this False. - value_owned_by_drive: bool = True - is_skill: bool = False - skill_metadata: DriveSkillMetadata | None = None - - -class AgentDriveSkillInfo(TypedDict): - path: str - skill_md_key: str - archive_key: str | None - name: str - description: str - size: int | None - mime_type: str | None - hash: str | None - created_at: int | None - - -class AgentDriveSkillFileInfo(TypedDict): - path: str - name: str - type: str - drive_key: str | None - available_in_drive: bool - - -class AgentDriveSkillInspectInfo(TypedDict): - path: str - skill_md_key: str - archive_key: str | None - name: str - description: str - size: int | None - mime_type: str | None - hash: str | None - created_at: int | None - source: str - files: list[AgentDriveSkillFileInfo] - file_tree: list[dict[str, Any]] - skill_md: dict[str, Any] - warnings: list[str] - - -def decode_drive_mention_ref(ref_id: str) -> str: - """Decode the prompt token's URL-encoded drive-key field.""" - - return unquote(ref_id or "") - - -def parse_agent_drive_ref(drive_ref: str) -> str: - """Parse an ``agent-`` URL drive ref into the agent id.""" - if not drive_ref.startswith(_DRIVE_REF_PREFIX): - raise AgentDriveError("invalid_drive_ref", "drive ref must be 'agent-'", status_code=400) - agent_id = drive_ref[len(_DRIVE_REF_PREFIX) :] - if not agent_id: - raise AgentDriveError("invalid_drive_ref", "drive ref must include an agent id", status_code=400) - return agent_id - - -def normalize_drive_key(key: str) -> str: - """Validate + normalize a path-like drive key (Agent Files §6 key safety). - - The key maps back to a sandbox-relative file path, so reject anything that - could escape or break the path: empty, too long, NUL/control chars, absolute - paths, or ``..`` segments. Collapse repeated slashes and strip a leading one. - """ - if not isinstance(key, str) or not key.strip(): - raise AgentDriveError("invalid_key", "drive key must be a non-empty string", status_code=400) - if len(key) > _MAX_KEY_LENGTH: - raise AgentDriveError("invalid_key", f"drive key exceeds {_MAX_KEY_LENGTH} chars", status_code=400) - if "\x00" in key or any(ord(ch) < 0x20 for ch in key): - raise AgentDriveError("invalid_key", "drive key contains control characters", status_code=400) - normalized = re.sub(r"/{2,}", "/", key.strip()).lstrip("/") - segments = normalized.split("/") - if any(segment == ".." for segment in segments): - raise AgentDriveError("invalid_key", "drive key must not contain '..' segments", status_code=400) - if not normalized: - raise AgentDriveError("invalid_key", "drive key must be a non-empty path", status_code=400) - return normalized - - -class AgentDriveService: - """List/commit files in a per-agent drive (tenant_id -> agent-).""" - - def manifest( - self, - *, - tenant_id: str, - agent_id: str, - session: Session, - prefix: str = "", - include_download_url: bool = False, - ) -> list[dict[str, Any]]: - 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) - .order_by(AgentDriveFile.key) - ) - if prefix: - stmt = stmt.where(AgentDriveFile.key.startswith(prefix)) - rows = list(session.scalars(stmt)) - items: list[dict[str, Any]] = [] - for row in rows: - item: dict[str, Any] = { - "key": row.key, - "size": row.size, - "hash": row.hash, - "mime_type": row.mime_type, - "file_kind": row.file_kind.value, - "file_id": row.file_id, - "is_skill": row.is_skill, - "skill_metadata": row.skill_metadata, - "created_at": int(row.created_at.timestamp()) if row.created_at else None, - } - if include_download_url: - item["download_url"] = self._resolve_download_url( - tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id - ) - items.append(item) - return items - - def commit( - self, - *, - tenant_id: str, - user_id: str, - agent_id: str, - items: list[DriveCommitItem], - session: Session, - ) -> list[dict[str, Any]]: - if not items: - raise AgentDriveError("empty_commit", "commit requires at least one item", status_code=400) - committed: list[dict[str, Any]] = [] - pending_storage_deletes: list[str] = [] - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - for item in items: - committed.append( - self._commit_one( - session, - tenant_id=tenant_id, - user_id=user_id, - agent_id=agent_id, - item=item, - pending_storage_deletes=pending_storage_deletes, - ) - ) - session.commit() - for storage_key in pending_storage_deletes: - self._delete_storage(storage_key) - return committed - - def delete( - self, - *, - tenant_id: str, - agent_id: str, - session: Session, - 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] = [] - 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 list_skills(self, *, tenant_id: str, agent_id: str, session: Session) -> list[AgentDriveSkillInfo]: - """Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows.""" - - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - skill_rows = list( - session.scalars( - select(AgentDriveFile) - .where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.is_skill.is_(True), - ) - .order_by(AgentDriveFile.key) - ) - ) - archive_keys = set( - session.scalars( - select(AgentDriveFile.key).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key.in_([self._skill_archive_key(row.key) for row in skill_rows]), - ) - ) - ) - - skills: list[AgentDriveSkillInfo] = [] - for row in skill_rows: - metadata = self._parse_skill_metadata(row.key, row.skill_metadata) - archive_key = self._skill_archive_key(row.key) - skills.append( - { - "path": self._skill_path_from_key(row.key), - "skill_md_key": row.key, - "archive_key": archive_key if archive_key in archive_keys else None, - "name": metadata.name, - "description": metadata.description, - "size": row.size, - "mime_type": row.mime_type, - "hash": row.hash, - "created_at": int(row.created_at.timestamp()) if row.created_at else None, - } - ) - return skills - - def inspect_skill( - self, *, tenant_id: str, agent_id: str, skill_path: str, session: Session - ) -> AgentDriveSkillInspectInfo: - """Return the UI-facing skill inspect view for slash-menu hover/detail.""" - - skill_path = normalize_drive_key(skill_path) - skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}" - skill_path = self._skill_path_from_key(skill_md_key) - catalog = next( - ( - item - for item in self.list_skills(tenant_id=tenant_id, agent_id=agent_id, session=session) - if item["path"] == skill_path - ), - None, - ) - if catalog is None: - raise AgentDriveError("skill_not_found", "no drive-backed skill for this path", status_code=404) - - manifest_files = self._manifest_files_from_skill_metadata( - tenant_id=tenant_id, - agent_id=agent_id, - skill_md_key=skill_md_key, - session=session, - ) - drive_items = self.manifest(tenant_id=tenant_id, agent_id=agent_id, prefix=f"{skill_path}/", session=session) - drive_keys = {item["key"] for item in drive_items} - preview = self.preview(tenant_id=tenant_id, agent_id=agent_id, key=skill_md_key, session=session) - files, warnings = self._skill_file_entries( - skill_path=skill_path, - skill_md_key=skill_md_key, - manifest_files=manifest_files, - drive_keys=drive_keys, - archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False, - ) - return { - **catalog, - "source": "skill_md", - "files": files, - "file_tree": self._build_file_tree(files), - "skill_md": preview, - "warnings": warnings, - } - - def _commit_one( - self, - session: Session, - *, - tenant_id: str, - user_id: str, - agent_id: str, - item: DriveCommitItem, - pending_storage_deletes: list[str], - ) -> dict[str, Any]: - key = normalize_drive_key(item.key) - if item.file_ref is None: - return self._remove_one( - session, - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - pending_storage_deletes=pending_storage_deletes, - ) - - skill_metadata = self._validate_skill_commit_fields(key=key, item=item) - file_kind = AgentDriveFileKind(item.file_ref.kind) - file_id = item.file_ref.id - size, mime_type, file_hash = self._validate_source( - session, - tenant_id=tenant_id, - user_id=user_id, - file_kind=file_kind, - file_id=file_id, - take_ownership=item.value_owned_by_drive, - ) - - existing = session.scalar( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key == key, - ) - ) - if existing is not None: - # Idempotent re-commit of the same value: leave it (do not clean). - if existing.file_kind == file_kind and existing.file_id == file_id: - existing.value_owned_by_drive = item.value_owned_by_drive - existing.is_skill = item.is_skill - existing.skill_metadata = skill_metadata - existing.size = size - existing.mime_type = mime_type - existing.hash = file_hash - return self._row_dict(existing) - # Overwrite: clean the previous drive-owned value if no longer referenced. - if existing.value_owned_by_drive: - self._cleanup_value( - session, - tenant_id=tenant_id, - file_kind=existing.file_kind, - file_id=existing.file_id, - exclude_row_id=existing.id, - pending_storage_deletes=pending_storage_deletes, - ) - existing.file_kind = file_kind - existing.file_id = file_id - existing.value_owned_by_drive = item.value_owned_by_drive - existing.is_skill = item.is_skill - existing.skill_metadata = skill_metadata - existing.size = size - existing.hash = file_hash - existing.mime_type = mime_type - return self._row_dict(existing) - - row = AgentDriveFile( - id=str(uuidv7()), - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - file_kind=file_kind, - file_id=file_id, - value_owned_by_drive=item.value_owned_by_drive, - is_skill=item.is_skill, - skill_metadata=skill_metadata, - size=size, - hash=file_hash, - mime_type=mime_type, - created_by=user_id, - ) - session.add(row) - return self._row_dict(row) - - def _remove_one( - self, - session: Session, - *, - tenant_id: str, - agent_id: str, - key: str, - pending_storage_deletes: list[str], - ) -> dict[str, Any]: - existing = session.scalar( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key == key, - ) - ) - if existing is None: - return {"key": key, "removed": True, "noop": True} - result = { - "key": key, - "removed": True, - "file_kind": existing.file_kind.value, - "file_id": existing.file_id, - "value_owned_by_drive": existing.value_owned_by_drive, - "is_skill": existing.is_skill, - "skill_metadata": existing.skill_metadata, - } - if existing.value_owned_by_drive: - self._cleanup_value( - session, - tenant_id=tenant_id, - file_kind=existing.file_kind, - file_id=existing.file_id, - exclude_row_id=existing.id, - pending_storage_deletes=pending_storage_deletes, - ) - session.delete(existing) - return result - - @staticmethod - def _row_dict(row: AgentDriveFile) -> dict[str, Any]: - return { - "key": row.key, - "file_kind": row.file_kind.value, - "file_id": row.file_id, - "size": row.size, - "mime_type": row.mime_type, - "value_owned_by_drive": row.value_owned_by_drive, - "is_skill": row.is_skill, - "skill_metadata": row.skill_metadata, - } - - @staticmethod - def _skill_path_from_key(key: str) -> str: - if not key.endswith(_SKILL_MD_SUFFIX): - raise AgentDriveError( - "invalid_skill_key", - "skill rows must use the canonical '/SKILL.md' key", - status_code=500, - ) - path = key[: -len(_SKILL_MD_SUFFIX)] - if not path: - raise AgentDriveError( - "invalid_skill_key", - "skill rows must use the canonical '/SKILL.md' key", - status_code=500, - ) - return path - - @classmethod - def _skill_archive_key(cls, key: str) -> str: - return f"{cls._skill_path_from_key(key)}/{_SKILL_ARCHIVE_NAME}" - - @classmethod - def _validate_skill_commit_fields(cls, *, key: str, item: DriveCommitItem) -> str | None: - if not item.is_skill: - if item.skill_metadata is not None: - raise AgentDriveError( - "invalid_skill_metadata", - "skill metadata is only allowed for canonical skill rows", - status_code=400, - ) - return None - cls._skill_path_from_key(key) - if item.skill_metadata is None: - raise AgentDriveError( - "invalid_skill_metadata", - "skill metadata is required for canonical skill rows", - status_code=400, - ) - return json.dumps( - item.skill_metadata.model_dump(mode="json", exclude_none=True), - separators=(",", ":"), - sort_keys=True, - ) - - @staticmethod - def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata: - if raw_metadata is None: - raise AgentDriveError( - "invalid_skill_metadata", - f"skill row '{key}' is missing required metadata", - status_code=500, - ) - try: - return DriveSkillMetadata.model_validate(json.loads(raw_metadata)) - except (ValueError, TypeError) as exc: - raise AgentDriveError( - "invalid_skill_metadata", - f"skill row '{key}' has invalid stored metadata", - status_code=500, - ) from exc - - @staticmethod - def _manifest_files_from_skill_metadata( - *, tenant_id: str, agent_id: str, skill_md_key: str, session: Session - ) -> list[str] | None: - row = session.scalar( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key == skill_md_key, - AgentDriveFile.is_skill.is_(True), - ) - ) - if row is None: - return None - try: - metadata = AgentDriveService._parse_skill_metadata(row.key, row.skill_metadata) - except Exception: - logger.warning("drive skill inspect: malformed skill metadata for %s", skill_md_key, exc_info=True) - return None - return [str(item) for item in (metadata.manifest_files or []) if str(item).strip()] or None - - @classmethod - def _skill_file_entries( - cls, - *, - skill_path: str, - skill_md_key: str, - manifest_files: list[str] | None, - drive_keys: set[str], - archive_available: bool = False, - ) -> tuple[list[AgentDriveSkillFileInfo], list[str]]: - warnings: list[str] = [] - if manifest_files: - paths = sorted({normalize_drive_key(path) for path in manifest_files}) - else: - paths = sorted( - { - key.removeprefix(f"{skill_path}/") - for key in drive_keys - if not key.endswith(f"/{_SKILL_ARCHIVE_NAME}") - } - ) - warnings.append("manifest_files_unavailable") - - files: list[AgentDriveSkillFileInfo] = [] - for path in paths: - if path == _SKILL_ARCHIVE_NAME: - continue - drive_key = f"{skill_path}/{path}" - available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME) - files.append( - { - "path": path, - "name": path.rsplit("/", 1)[-1], - "type": "file", - "drive_key": drive_key if available_in_drive else None, - "available_in_drive": available_in_drive, - } - ) - if "SKILL.md" not in {file["path"] for file in files}: - files.insert( - 0, - { - "path": "SKILL.md", - "name": "SKILL.md", - "type": "file", - "drive_key": skill_md_key, - "available_in_drive": skill_md_key in drive_keys, - }, - ) - return files, warnings - - @staticmethod - def _build_file_tree(files: list[AgentDriveSkillFileInfo]) -> list[dict[str, Any]]: - root: dict[str, Any] = {} - for file in files: - cursor = root - parts = [part for part in file["path"].split("/") if part] - path_parts: list[str] = [] - for part in parts[:-1]: - path_parts.append(part) - directory = cursor.setdefault( - part, - { - "name": part, - "path": "/".join(path_parts), - "type": "directory", - "children": {}, - }, - ) - cursor = directory["children"] - leaf_name = parts[-1] if parts else file["name"] - cursor[leaf_name] = { - "name": leaf_name, - "path": file["path"], - "type": file["type"], - "drive_key": file["drive_key"], - "available_in_drive": file["available_in_drive"], - } - - def serialize(node: dict[str, Any]) -> list[dict[str, Any]]: - result: list[dict[str, Any]] = [] - for item in sorted(node.values(), key=lambda value: (value["type"] != "directory", value["name"])): - if item["type"] == "directory": - children = serialize(item["children"]) - result.append( - { - "name": item["name"], - "path": item["path"], - "type": "directory", - "children": children, - } - ) - else: - result.append(item) - return result - - return serialize(root) - - @staticmethod - def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None: - try: - found_agent_id = session.scalar(select(Agent.id).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) - except (DataError, SQLAlchemyError) as exc: - session.rollback() - raise AgentDriveError( - "agent_not_found", "agent drive does not belong to this tenant", status_code=404 - ) from exc - if found_agent_id is None: - raise AgentDriveError("agent_not_found", "agent drive does not belong to this tenant", status_code=404) - - def _validate_source( - self, - session: Session, - *, - tenant_id: str, - user_id: str, - file_kind: AgentDriveFileKind, - file_id: str, - take_ownership: bool = False, - ) -> tuple[int | None, str | None, str | None]: - """Verify the source file exists for the tenant (and user, for ToolFile). - - Malformed ids (e.g. a non-UUID hitting a UUID column) are treated as a - missing source rather than crashing the commit with a 500. - """ - try: - if file_kind == AgentDriveFileKind.TOOL_FILE: - tool_file = session.scalar( - select(ToolFile) - .where( - ToolFile.id == file_id, - ToolFile.tenant_id == tenant_id, - ToolFile.user_id == user_id, - ) - .with_for_update() - ) - if tool_file is None: - raise AgentDriveError( - "source_not_found", "source ToolFile not found for this tenant/user", status_code=404 - ) - if take_ownership: - tool_file.conversation_id = None - return tool_file.size, tool_file.mimetype, None - upload_file = session.scalar( - select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) - ) - except (DataError, SQLAlchemyError) as exc: - session.rollback() - raise AgentDriveError("source_not_found", "source file ref is invalid", status_code=404) from exc - if upload_file is None: - raise AgentDriveError("source_not_found", "source UploadFile not found for this tenant", status_code=404) - return upload_file.size, upload_file.mime_type, upload_file.hash - - def _cleanup_value( - self, - session: Session, - *, - tenant_id: str, - file_kind: AgentDriveFileKind, - file_id: str, - exclude_row_id: str, - pending_storage_deletes: list[str], - ) -> None: - """Physically delete a drive-owned value, unless another drive entry references it.""" - still_referenced = session.scalar( - select(func.count()) - .select_from(AgentDriveFile) - .where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.file_kind == file_kind, - AgentDriveFile.file_id == file_id, - AgentDriveFile.id != exclude_row_id, - ) - ) - if still_referenced: - return - if file_kind == AgentDriveFileKind.TOOL_FILE: - tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id)) - if tool_file is not None: - pending_storage_deletes.append(tool_file.file_key) - session.delete(tool_file) - return - upload_file = session.scalar( - select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) - ) - if upload_file is not None: - pending_storage_deletes.append(upload_file.key) - session.delete(upload_file) - - @staticmethod - def _delete_storage(storage_key: str | None) -> None: - if not storage_key: - return - try: - storage.delete(storage_key) - except Exception: - # Best-effort: a missing/already-deleted object must not abort the commit. - 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, - for_external: bool = False, - as_attachment: 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: - mapping = {"transfer_method": "local_file", "upload_file_id": file_id} - controller = DatabaseFileAccessController() - # Keep workflow runtime wiring lazy: importing this service is part of - # Agent v2 node bootstrap, while ``core.app.workflow`` re-exports the - # node factory. A module-level import here would close that cycle. - from core.app.workflow.file_runtime import DifyWorkflowFileRuntime - - runtime = DifyWorkflowFileRuntime(file_access_controller=controller) - try: - if file_kind == AgentDriveFileKind.UPLOAD_FILE: - return runtime.resolve_upload_file_url( - upload_file_id=file_id, - for_external=for_external, - as_attachment=as_attachment, - ) - # 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) - url = runtime.resolve_file_url(file=file, for_external=for_external) - if as_attachment and url: - parsed = urllib.parse.urlsplit(url) - query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) - query.append(("as_attachment", "true")) - return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) - return url - 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: - return self._storage_key_for_ref( - session, - tenant_id=tenant_id, - file_kind=row.file_kind, - file_id=row.file_id, - ) - - def _storage_key_for_ref( - self, - session: Session, - *, - tenant_id: str, - file_kind: AgentDriveFileKind, - file_id: str, - ) -> str: - if file_kind == AgentDriveFileKind.TOOL_FILE: - tool_file = session.scalar(select(ToolFile).where(ToolFile.id == 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 == 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 _archive_member_for_key( - self, - session: Session, - *, - tenant_id: str, - agent_id: str, - key: str, - ) -> tuple[AgentDriveFile, str]: - normalized_key = normalize_drive_key(key) - if "/" not in normalized_key: - raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) - skill_path, member_path = normalized_key.split("/", 1) - if member_path in {_SKILL_ARCHIVE_NAME, ""}: - raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404) - - skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}" - skill_row = session.scalar( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key == skill_md_key, - AgentDriveFile.is_skill.is_(True), - ) - ) - if skill_row is None: - raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) - metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata) - manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])} - if member_path not in manifest_files: - raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404) - archive_row = session.scalar( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == tenant_id, - AgentDriveFile.agent_id == agent_id, - AgentDriveFile.key == self._skill_archive_key(skill_md_key), - ) - ) - if archive_row is None: - raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404) - return archive_row, member_path - - def _load_archive_member_bytes( - self, - *, - tenant_id: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - session: Session, - ) -> bytes: - member_path = normalize_drive_key(member_path) - storage_key = self._storage_key_for_ref( - session, - tenant_id=tenant_id, - file_kind=archive_file_kind, - file_id=archive_file_id, - ) - archive_bytes = b"".join(storage.load_stream(storage_key)) - try: - with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: - member = next( - ( - info - for info in archive.infolist() - if not info.is_dir() and normalize_drive_key(info.filename) == member_path - ), - None, - ) - if member is None: - raise AgentDriveError( - "drive_key_not_found", "archive member is missing from the skill archive", status_code=404 - ) - return archive.read(member) - except zipfile.BadZipFile as exc: - raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc - - @classmethod - def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]: - truncated = len(payload) > cls.PREVIEW_MAX_BYTES - sample = payload[: cls.PREVIEW_MAX_BYTES] - if b"\x00" in sample: - return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} - try: - text = sample.decode("utf-8") - except UnicodeDecodeError: - if truncated: - try: - text = sample[:-3].decode("utf-8", errors="strict") - except UnicodeDecodeError: - return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} - else: - return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} - return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text} - - def preview(self, *, tenant_id: str, agent_id: str, key: str, session: Session) -> dict[str, Any]: - """Truncated text preview of one drive value (binary-safe, never 500s on size).""" - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - try: - 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 - response_key = row.key - archive_ref: tuple[AgentDriveFile, str] | None = None - except AgentDriveError: - archive_ref = self._archive_member_for_key( - session, - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - ) - storage_key = None - size = None - response_key = normalize_drive_key(key) - - if archive_ref is not None: - archive_row, member_path = archive_ref - payload = self._load_archive_member_bytes( - tenant_id=tenant_id, - archive_file_kind=archive_row.file_kind, - archive_file_id=archive_row.file_id, - member_path=member_path, - session=session, - ) - return self._preview_bytes(key=response_key, size=len(payload), payload=payload) - - data = bytearray() - assert storage_key is not None - for chunk in storage.load_stream(storage_key): - data.extend(chunk) - if len(data) > self.PREVIEW_MAX_BYTES: - break - return self._preview_bytes(key=response_key, size=size, payload=bytes(data)) - - def preview_archive_member_for_ref( - self, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - session: Session, - ) -> dict[str, Any]: - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - payload = self._load_archive_member_bytes( - tenant_id=tenant_id, - archive_file_kind=archive_file_kind, - archive_file_id=archive_file_id, - member_path=member_path, - session=session, - ) - return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload) - - def download_url(self, *, tenant_id: str, agent_id: str, key: str, session: Session) -> str: - """External signed URL for a browser download of one drive value.""" - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - try: - row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) - except AgentDriveError: - archive_row, member_path = self._archive_member_for_key( - session, - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - ) - return self.sign_archive_member_url( - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - archive_file_kind=archive_row.file_kind, - archive_file_id=archive_row.file_id, - member_path=member_path, - for_external=True, - as_attachment=True, - ) - url = self._resolve_download_url( - tenant_id=tenant_id, - file_kind=row.file_kind, - file_id=row.file_id, - for_external=True, - as_attachment=True, - ) - if url is None: - raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) - return url - - def download_url_archive_member_for_ref( - self, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - session: Session, - for_external: bool = True, - ) -> str: - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - return self.sign_archive_member_url( - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - archive_file_kind=archive_file_kind, - archive_file_id=archive_file_id, - member_path=member_path, - for_external=for_external, - as_attachment=True, - ) - - @staticmethod - def _secret_key() -> bytes: - return dify_config.SECRET_KEY.encode() - - @classmethod - def _archive_member_signature_payload( - cls, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - timestamp: str, - nonce: str, - ) -> str: - return "|".join( - [ - _ARCHIVE_MEMBER_DOWNLOAD_PURPOSE, - tenant_id, - agent_id, - normalize_drive_key(key), - archive_file_kind.value, - archive_file_id, - normalize_drive_key(member_path), - timestamp, - nonce, - ] - ) - - @classmethod - def _sign_archive_member_payload(cls, payload: str) -> str: - digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest() - return base64.urlsafe_b64encode(digest).decode() - - @classmethod - def sign_archive_member_url( - cls, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - for_external: bool, - as_attachment: bool = False, - ) -> str: - base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL) - timestamp = str(int(time.time())) - nonce = os.urandom(16).hex() - payload = cls._archive_member_signature_payload( - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - archive_file_kind=archive_file_kind, - archive_file_id=archive_file_id, - member_path=member_path, - timestamp=timestamp, - nonce=nonce, - ) - query = urllib.parse.urlencode( - { - "tenant_id": tenant_id, - "agent_id": agent_id, - "key": normalize_drive_key(key), - "archive_file_kind": archive_file_kind.value, - "archive_file_id": archive_file_id, - "member_path": normalize_drive_key(member_path), - "timestamp": timestamp, - "nonce": nonce, - "sign": cls._sign_archive_member_payload(payload), - "as_attachment": str(as_attachment).lower(), - } - ) - return f"{base_url}/files/agent-drive/archive-member?{query}" - - @classmethod - def verify_archive_member_signature( - cls, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - timestamp: str, - nonce: str, - sign: str, - ) -> bool: - payload = cls._archive_member_signature_payload( - tenant_id=tenant_id, - agent_id=agent_id, - key=key, - archive_file_kind=archive_file_kind, - archive_file_id=archive_file_id, - member_path=member_path, - timestamp=timestamp, - nonce=nonce, - ) - if sign != cls._sign_archive_member_payload(payload): - return False - current_time = int(time.time()) - return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT - - def load_archive_member_for_signed_request( - self, - *, - tenant_id: str, - agent_id: str, - key: str, - archive_file_kind: AgentDriveFileKind, - archive_file_id: str, - member_path: str, - session: Session, - ) -> tuple[bytes, str, str]: - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - payload = self._load_archive_member_bytes( - tenant_id=tenant_id, - archive_file_kind=archive_file_kind, - archive_file_id=archive_file_id, - member_path=member_path, - session=session, - ) - mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream" - filename = normalize_drive_key(key).rsplit("/", 1)[-1] - return payload, mime_type, filename - - -__all__ = [ - "AgentDriveError", - "AgentDriveService", - "DriveCommitItem", - "DriveFileRef", - "DriveSkillMetadata", - "decode_drive_mention_ref", - "normalize_drive_key", - "parse_agent_drive_ref", -] diff --git a/api/tasks/delete_conversation_task.py b/api/tasks/delete_conversation_task.py index c9bf4ce9f17ddd..4576582c90d8e5 100644 --- a/api/tasks/delete_conversation_task.py +++ b/api/tasks/delete_conversation_task.py @@ -24,7 +24,6 @@ PinnedConversation, SavedMessage, ) -from models.agent import AgentDriveFile, AgentDriveFileKind from models.human_input import HumanInputDelivery, HumanInputFormRecipient from models.tools import ToolConversationVariables, ToolFile @@ -49,9 +48,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool: """Physically remove a soft-deleted conversation and its owned resources. The storage object is deleted before its ``ToolFile`` row so a failed attempt - retains the durable ``file_key`` needed by the next retry. ToolFiles promoted - to Agent Drive are detached from the conversation, and their Drive references - take over lifecycle ownership. + retains the durable ``file_key`` needed by the next retry. """ with session_factory.create_session() as session: @@ -68,25 +65,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool: .with_for_update() ) ) - tool_file_ids = [tool_file.id for tool_file in tool_files] - drive_files = list( - session.scalars( - select(AgentDriveFile) - .where( - AgentDriveFile.file_kind == AgentDriveFileKind.TOOL_FILE, - AgentDriveFile.file_id.in_(tool_file_ids), - ) - .order_by(AgentDriveFile.id) - .with_for_update() - ) - ) - drive_tool_file_ids = {drive_file.file_id for drive_file in drive_files} - for drive_file in drive_files: - drive_file.value_owned_by_drive = True for tool_file in tool_files: - if tool_file.id in drive_tool_file_ids: - tool_file.conversation_id = None - continue _delete_storage_object(tool_file.file_key) session.delete(tool_file) diff --git a/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py b/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py deleted file mode 100644 index b7b9e934cc44b5..00000000000000 --- a/api/tests/test_containers_integration_tests/tasks/test_delete_conversation_task.py +++ /dev/null @@ -1,178 +0,0 @@ -from threading import Event, Thread -from unittest.mock import patch - -from sqlalchemy import event, select -from sqlalchemy.orm import Session - -from models import AppMode, Conversation, ToolFile -from models.agent import AgentDriveFile, AgentDriveFileKind -from models.enums import ConversationFromSource, ConversationStatus -from tasks.delete_conversation_task import _cleanup_conversation_related_data - -TENANT_ID = "11111111-1111-1111-1111-111111111111" -APP_ID = "22222222-2222-2222-2222-222222222222" -ACCOUNT_ID = "33333333-3333-3333-3333-333333333333" -CONVERSATION_ID = "44444444-4444-4444-4444-444444444444" -AGENT_ID = "55555555-5555-5555-5555-555555555555" - - -def test_cleanup_deletes_owned_storage_and_preserves_drive_file( - db_session_with_containers: Session, -) -> None: - conversation = Conversation( - id=CONVERSATION_ID, - app_id=APP_ID, - mode=AppMode.CHAT, - name="Deleted conversation", - inputs={}, - status=ConversationStatus.NORMAL, - from_source=ConversationFromSource.CONSOLE, - from_account_id=ACCOUNT_ID, - is_deleted=True, - ) - owned_file = ToolFile( - user_id=ACCOUNT_ID, - tenant_id=TENANT_ID, - conversation_id=CONVERSATION_ID, - file_key=f"tools/{TENANT_ID}/owned.txt", - mimetype="text/plain", - name="owned.txt", - size=5, - ) - drive_file = ToolFile( - user_id=ACCOUNT_ID, - tenant_id=TENANT_ID, - conversation_id=CONVERSATION_ID, - file_key=f"tools/{TENANT_ID}/drive.txt", - mimetype="text/plain", - name="drive.txt", - size=5, - ) - db_session_with_containers.add_all([conversation, owned_file, drive_file]) - db_session_with_containers.flush() - drive_entry = AgentDriveFile( - tenant_id=TENANT_ID, - agent_id=AGENT_ID, - key="drive.txt", - file_kind=AgentDriveFileKind.TOOL_FILE, - file_id=drive_file.id, - value_owned_by_drive=False, - is_skill=False, - ) - db_session_with_containers.add(drive_entry) - db_session_with_containers.commit() - owned_file_id = owned_file.id - drive_file_id = drive_file.id - - with patch("tasks.delete_conversation_task.storage") as storage_mock: - assert _cleanup_conversation_related_data(CONVERSATION_ID) is True - - storage_mock.delete.assert_called_once_with(f"tools/{TENANT_ID}/owned.txt") - db_session_with_containers.expire_all() - assert db_session_with_containers.get(Conversation, CONVERSATION_ID) is None - assert db_session_with_containers.get(ToolFile, owned_file_id) is None - preserved = db_session_with_containers.get(ToolFile, drive_file_id) - assert preserved is not None - assert preserved.conversation_id is None - preserved_drive_entry = db_session_with_containers.scalar( - select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id) - ) - assert preserved_drive_entry is not None - assert preserved_drive_entry.value_owned_by_drive is True - - -def test_cleanup_preserves_drive_file_committed_while_waiting_for_tool_file_lock( - db_session_with_containers: Session, -) -> None: - conversation = Conversation( - id=CONVERSATION_ID, - app_id=APP_ID, - mode=AppMode.CHAT, - name="Deleted conversation", - inputs={}, - status=ConversationStatus.NORMAL, - from_source=ConversationFromSource.CONSOLE, - from_account_id=ACCOUNT_ID, - is_deleted=True, - ) - drive_file = ToolFile( - user_id=ACCOUNT_ID, - tenant_id=TENANT_ID, - conversation_id=CONVERSATION_ID, - file_key=f"tools/{TENANT_ID}/concurrent-drive.txt", - mimetype="text/plain", - name="concurrent-drive.txt", - size=5, - ) - db_session_with_containers.add_all([conversation, drive_file]) - db_session_with_containers.commit() - drive_file_id = drive_file.id - - engine = db_session_with_containers.get_bind() - drive_session = Session(engine) - locked_file = drive_session.scalar(select(ToolFile).where(ToolFile.id == drive_file_id).with_for_update()) - assert locked_file is not None - drive_session.add( - AgentDriveFile( - tenant_id=TENANT_ID, - agent_id=AGENT_ID, - key="concurrent-drive.txt", - file_kind=AgentDriveFileKind.TOOL_FILE, - file_id=drive_file_id, - value_owned_by_drive=False, - is_skill=False, - ) - ) - drive_session.flush() - - cleanup_result: list[bool] = [] - cleanup_errors: list[BaseException] = [] - - def run_cleanup() -> None: - try: - cleanup_result.append(_cleanup_conversation_related_data(CONVERSATION_ID)) - except BaseException as error: - cleanup_errors.append(error) - - tool_file_lock_started = Event() - - def signal_tool_file_lock( - _connection, - _cursor, - statement: str, - _parameters, - _context, - _executemany, - ) -> None: - normalized_statement = statement.lower() - if "from tool_files" in normalized_statement and "for update" in normalized_statement: - tool_file_lock_started.set() - - event.listen(engine, "before_cursor_execute", signal_tool_file_lock) - cleanup_thread = Thread(target=run_cleanup) - try: - with patch("tasks.delete_conversation_task.storage") as storage_mock: - cleanup_thread.start() - assert tool_file_lock_started.wait(timeout=5) - drive_session.commit() - cleanup_thread.join(timeout=5) - finally: - event.remove(engine, "before_cursor_execute", signal_tool_file_lock) - drive_session.rollback() - drive_session.close() - cleanup_thread.join(timeout=5) - - assert not cleanup_thread.is_alive() - assert cleanup_errors == [] - assert cleanup_result == [True] - storage_mock.delete.assert_not_called() - - db_session_with_containers.expire_all() - preserved = db_session_with_containers.get(ToolFile, drive_file_id) - assert preserved is not None - assert preserved.conversation_id is None - preserved_drive_entry = db_session_with_containers.scalar( - select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id) - ) - assert preserved_drive_entry is not None - assert preserved_drive_entry.value_owned_by_drive is True diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml index 38033743c9a0a5..aa04b37fd7c792 100644 --- a/api/tests/unit_tests/.ruff.toml +++ b/api/tests/unit_tests/.ruff.toml @@ -15,9 +15,7 @@ extend-select = ["ANN401", "ARG"] "controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"] "controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"] "controllers/console/app/test_agent_config_inspector.py" = ["ARG005"] -"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"] "controllers/console/app/test_agent_manage_guard.py" = ["ARG001"] -"controllers/console/app/test_agent_skills.py" = ["ARG005"] "controllers/console/app/test_annotation_security.py" = ["ARG002"] "controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"] "controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"] diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 4f259ade3d6953..978b522d5099a5 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -16,7 +16,6 @@ DifyPluginToolConfig, DifyPluginToolsLayerConfig, ) -from dify_agent.layers.drive import DifyDriveLayerConfig from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID @@ -44,7 +43,7 @@ AgentBackendWorkflowNodeRunInput, redact_for_agent_backend_log, ) -from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID +from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID def _run_input() -> AgentBackendWorkflowNodeRunInput: @@ -363,25 +362,6 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "PROJECT_NAME" -def test_workflow_request_builder_binds_drive_to_shell_when_configured(): - run_input = _run_input() - run_input.include_shell = True - run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1") - - request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) - layers = {layer.name: layer for layer in request.composition.layers} - layer_names = [layer.name for layer in request.composition.layers] - - assert layers[DIFY_SHELL_LAYER_ID].deps == { - "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, - "runtime": "runtime", - } - shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) - assert shell_config.agent_stub_drive_ref == "agent-agent-1" - assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} - assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID) - - def test_agent_app_request_builder_omits_shell_layer_by_default(): request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input()) assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers} @@ -417,24 +397,6 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "APP_ENV" -def test_agent_app_request_builder_binds_drive_to_shell_when_configured(): - run_input = _agent_app_input(include_shell=True) - run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1") - - request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) - layers = {layer.name: layer for layer in request.composition.layers} - layer_names = [layer.name for layer in request.composition.layers] - - assert layers[DIFY_SHELL_LAYER_ID].deps == { - "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, - "runtime": "runtime", - } - shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) - assert shell_config.agent_stub_drive_ref == "agent-agent-1" - assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} - assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID) - - def test_agent_app_request_builder_adds_knowledge_layer_when_configured(): run_input = _agent_app_input() run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index d91005fa377319..9cd219971db1b3 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -15,7 +15,6 @@ from controllers.console.agent.composer import ( AgentComposerApi, AgentComposerCandidatesApi, - AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, WorkflowAgentComposerCopyFromRosterApi, @@ -260,10 +259,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//build-draft", "/agent//build-draft/apply", "/agent//referencing-workflows", - "/agent//drive/files", "/agent//sandbox/files", - "/agent//skills/upload", - "/agent//files", "/agent//api-access", "/agent//api-enable", "/agent//api-keys", @@ -1328,10 +1324,6 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]), ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) - monkeypatch.setattr( - composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None - ) - monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None) monkeypatch.setattr( composer_controller.AgentComposerService, "get_workflow_candidates", @@ -1514,10 +1506,6 @@ def save_agent_composer(**kwargs: object) -> dict: captured["save"] = kwargs return _agent_app_composer_response() - def collect_validation_findings(**kwargs: object) -> dict: - captured["validate"] = kwargs - return {"warnings": [], "knowledge_retrieval_placeholder": []} - def get_agent_app_candidates(**kwargs: object) -> dict: captured["candidates"] = kwargs return _candidates_response("agent_app") @@ -1525,9 +1513,6 @@ def get_agent_app_candidates(**kwargs: object) -> dict: monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", load_agent_composer) monkeypatch.setattr(composer_controller.AgentComposerService, "save_agent_composer", save_agent_composer) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) - monkeypatch.setattr( - composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings - ) monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates) composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id) assert composer["variant"] == "agent_app" @@ -1545,15 +1530,6 @@ def get_agent_app_candidates(**kwargs: object) -> dict: assert saved_composer["variant"] == "agent_app" assert saved_composer["active_config_is_published"] is True assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id - assert unwrap(AgentComposerValidateApi.post)( - AgentComposerValidateApi(), composer_save_payload, MagicMock(), "tenant-1", agent_id - ) == { - "result": "success", - "errors": [], - "warnings": [], - "knowledge_retrieval_placeholder": [], - } - assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id candidates = unwrap(AgentComposerCandidatesApi.get)( AgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, agent_id ) diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py deleted file mode 100644 index ad38a927fcd786..00000000000000 --- a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Unit tests for the console agent drive inspector (ENG-624). - -Handlers are unwrapped past the login/app-model decorators and invoked inside a -bare Flask request context with the drive service mocked — covering agent -resolution, query handling, and error mapping, not auth. -""" - -from __future__ import annotations - -from inspect import unwrap -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from flask import Flask -from sqlalchemy.orm import Session - -from controllers.console.app import agent_drive_inspector as inspector -from controllers.console.app.agent_drive_inspector import ( - AgentDriveDownloadApi, - AgentDriveDownloadByAgentApi, - AgentDriveListApi, - AgentDriveListByAgentApi, - AgentDrivePreviewApi, - AgentDrivePreviewByAgentApi, - AgentDriveSkillInspectApi, - AgentDriveSkillInspectByAgentApi, - AgentDriveSkillListApi, - AgentDriveSkillListByAgentApi, -) -from services.agent_drive_service import AgentDriveError - -_MOD = "controllers.console.app.agent_drive_inspector" -app = Flask(__name__) - - -def _raw(method): - return unwrap(method) - - -_APP = SimpleNamespace( - id="app-1", - tenant_id="tenant-1", - bound_agent_id_with_session=lambda *, session: "agent-1", -) - - -def test_resolve_bound_agent_uses_injected_session(unbound_session: Session): - resolver = MagicMock(return_value="agent-1") - app_model = SimpleNamespace(bound_agent_id_with_session=resolver) - result = inspector._resolve_agent_id(unbound_session, app_model, None) - - assert result == "agent-1" - resolver.assert_called_once_with(session=unbound_session) - assert resolver.call_args.kwargs["session"] is unbound_session - - -def test_list_filters_value_pointers_out_of_console_payload(unbound_session: Session): - raw = _raw(AgentDriveListApi.get) - with app.test_request_context("/?prefix=pdf-toolkit/"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.manifest.return_value = [ - { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "hash": "h", - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tf-1", - "created_at": 1718000000, - } - ] - body = raw(AgentDriveListApi(), unbound_session, _APP) - assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" - assert "file_id" not in body["items"][0] - assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/" - - -def test_list_by_agent_filters_value_pointers_out_of_console_payload(unbound_session: Session): - raw = _raw(AgentDriveListByAgentApi.get) - with app.test_request_context("/?prefix=pdf-toolkit/"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.manifest.return_value = [ - { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "hash": "h", - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tf-1", - "created_at": 1718000000, - } - ] - body = raw(AgentDriveListByAgentApi(), unbound_session, "tenant-1", "agent-1") - assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" - assert "file_id" not in body["items"][0] - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1" - assert drive.return_value.manifest.call_args.kwargs["session"] is unbound_session - - -def test_list_resolves_workflow_node_binding_agent(unbound_session: Session): - raw = _raw(AgentDriveListApi.get) - with app.test_request_context("/?node_id=agent-node-1"): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" - drive.return_value.manifest.return_value = [] - raw(AgentDriveListApi(), unbound_session, _APP) - assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9" - assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1" - - -def test_skill_list_by_agent_calls_service(unbound_session: Session): - raw = _raw(AgentDriveSkillListByAgentApi.get) - with app.test_request_context("/"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.list_skills.return_value = [ - { - "path": "pdf-toolkit", - "skill_md_key": "pdf-toolkit/SKILL.md", - "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", - "name": "PDF Toolkit", - "description": "Work with PDFs.", - "size": 5, - "mime_type": "text/markdown", - "hash": None, - "created_at": 1718000000, - } - ] - body = raw(AgentDriveSkillListByAgentApi(), unbound_session, "tenant-1", "agent-1") - assert body["items"][0]["path"] == "pdf-toolkit" - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1" - assert drive.return_value.list_skills.call_args.kwargs["session"] is unbound_session - - -def test_skill_list_resolves_workflow_node_binding_agent(unbound_session: Session): - raw = _raw(AgentDriveSkillListApi.get) - with app.test_request_context("/?node_id=agent-node-1"): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" - drive.return_value.list_skills.return_value = [] - body = raw(AgentDriveSkillListApi(), unbound_session, _APP) - assert body == {"items": []} - assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9" - - -def test_skill_inspect_by_agent_returns_strict_json_response(unbound_session: Session): - raw = _raw(AgentDriveSkillInspectByAgentApi.get) - payload = { - "path": "pdf-toolkit", - "skill_md_key": "pdf-toolkit/SKILL.md", - "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", - "name": "PDF Toolkit", - "description": "Work with PDFs.", - "size": 5, - "mime_type": "text/markdown", - "hash": None, - "created_at": 1718000000, - "source": "skill_md", - "files": [ - { - "path": "SKILL.md", - "name": "SKILL.md", - "type": "file", - "drive_key": "pdf-toolkit/SKILL.md", - "available_in_drive": True, - } - ], - "file_tree": [], - "skill_md": { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "truncated": False, - "binary": False, - "text": "# PDF Toolkit\nUse it.\n", - }, - "warnings": [], - } - with app.test_request_context("/"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.inspect_skill.return_value = payload - response = raw(AgentDriveSkillInspectByAgentApi(), unbound_session, "tenant-1", "agent-1", "pdf-toolkit") - assert response.status_code == 200 - assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n" - assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data() - assert drive.return_value.inspect_skill.call_args.kwargs["session"] is unbound_session - - -def test_skill_inspect_resolves_workflow_node_binding_agent(unbound_session: Session): - raw = _raw(AgentDriveSkillInspectApi.get) - payload = { - "path": "pdf-toolkit", - "skill_md_key": "pdf-toolkit/SKILL.md", - "archive_key": None, - "name": "PDF Toolkit", - "description": "", - "size": 5, - "mime_type": "text/markdown", - "hash": None, - "created_at": None, - "source": "skill_md", - "files": [], - "file_tree": [], - "skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"}, - "warnings": [], - } - with app.test_request_context("/?node_id=agent-node-1"): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" - drive.return_value.inspect_skill.return_value = payload - response = raw(AgentDriveSkillInspectApi(), unbound_session, _APP, "pdf-toolkit") - assert response.get_json()["path"] == "pdf-toolkit" - assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9" - - -def test_list_400_when_no_agent_bound(unbound_session: Session): - raw = _raw(AgentDriveListApi.get) - resolver = MagicMock(return_value=None) - app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver) - with app.test_request_context("/"): - body, status = raw(AgentDriveListApi(), unbound_session, app_without_agent) - assert status == 400 - assert body["code"] == "agent_not_bound" - resolver.assert_called_once_with(session=unbound_session) - - -def test_preview_passes_through_and_maps_errors(unbound_session: Session): - raw = _raw(AgentDrivePreviewApi.get) - with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.preview.return_value = { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "truncated": False, - "binary": False, - "text": "# hi", - } - body = raw(AgentDrivePreviewApi(), unbound_session, _APP) - assert body["text"] == "# hi" - with app.test_request_context("/?key=ghost/SKILL.md"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.preview.side_effect = AgentDriveError( - "drive_key_not_found", "no drive entry", status_code=404 - ) - body, status = raw(AgentDrivePreviewApi(), unbound_session, _APP) - assert status == 404 - assert body["code"] == "drive_key_not_found" - - -def test_preview_by_agent_passes_through_and_maps_errors(unbound_session: Session): - raw = _raw(AgentDrivePreviewByAgentApi.get) - with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.preview.return_value = { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "truncated": False, - "binary": False, - "text": "# hi", - } - body = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1") - assert body["text"] == "# hi" - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert drive.return_value.preview.call_args.kwargs["session"] is unbound_session - with app.test_request_context("/?key=ghost/SKILL.md"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.preview.side_effect = AgentDriveError( - "drive_key_not_found", "no drive entry", status_code=404 - ) - body, status = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1") - assert status == 404 - assert body["code"] == "drive_key_not_found" - - -def test_download_returns_signed_url_json(unbound_session: Session): - raw = _raw(AgentDriveDownloadApi.get) - with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.download_url.return_value = "https://signed.example/zip" - body = raw(AgentDriveDownloadApi(), unbound_session, _APP) - assert body == {"url": "https://signed.example/zip"} - - -def test_download_by_agent_returns_signed_url_json(unbound_session: Session): - raw = _raw(AgentDriveDownloadByAgentApi.get) - with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.download_url.return_value = "https://signed.example/zip" - body = raw(AgentDriveDownloadByAgentApi(), unbound_session, "tenant-1", "agent-1") - assert body == {"url": "https://signed.example/zip"} - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert drive.return_value.download_url.call_args.kwargs["session"] is unbound_session diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py deleted file mode 100644 index f0496fda088950..00000000000000 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ /dev/null @@ -1,423 +0,0 @@ -"""Unit tests for the console agent Skill endpoints (ENG-370 / ENG-594). - -Handlers are unwrapped past the login/app-model decorators and invoked inside a -bare Flask request context with the services mocked — covering request handling -+ error mapping, not auth. -""" - -from __future__ import annotations - -import io -from datetime import UTC, datetime -from inspect import unwrap -from types import SimpleNamespace -from unittest.mock import MagicMock, patch -from uuid import uuid4 - -import pytest -from flask import Flask -from sqlalchemy.orm import Session - -from controllers.console.app import agent as agent_controller -from controllers.console.app.agent import ( - AgentDriveFilesByAgentApi, - AgentSkillByAgentApi, - AgentSkillInferToolsByAgentApi, - AgentSkillUploadApi, - AgentSkillUploadByAgentApi, -) -from extensions.storage.storage_type import StorageType -from models.enums import CreatorUserRole -from models.model import AppMode, UploadFile -from services.agent.skill_package_service import SkillPackageError -from services.agent_drive_service import AgentDriveError - -_MOD = "controllers.console.app.agent" -app = Flask(__name__) -_TENANT_ID = "00000000-0000-0000-0000-000000000010" -_UPLOAD_FILE_ID = "0fa6f9bc-3416-4476-8857-a13129704dd9" - - -def _raw(method): - return unwrap(method) - - -def _file_ctx(*, files: dict[str, bytes] | None = None): - data = {name: (io.BytesIO(content), name) for name, content in (files or {}).items()} - return app.test_request_context("/", method="POST", data=data, content_type="multipart/form-data") - - -_USER = SimpleNamespace(id="user-1") -_APP = SimpleNamespace( - id="app-1", - tenant_id=_TENANT_ID, - mode=AppMode.AGENT, - bound_agent_id_with_session=lambda *, session: "agent-1", -) -_WORKFLOW_APP = SimpleNamespace( - id="app-1", - tenant_id=_TENANT_ID, - mode=AppMode.WORKFLOW, - bound_agent_id_with_session=lambda *, session: None, -) - - -def _persist_upload(session: Session, *, name: str = "sample.pdf") -> UploadFile: - upload = UploadFile( - tenant_id=_TENANT_ID, - storage_type=StorageType.LOCAL, - key=f"uploads/{name}", - name=name, - size=5, - extension="pdf", - mime_type="application/pdf", - created_by_role=CreatorUserRole.ACCOUNT, - created_by=str(uuid4()), - created_at=datetime.now(UTC), - used=False, - ) - upload.id = _UPLOAD_FILE_ID - session.add(upload) - session.commit() - return upload - - -def test_resolve_bound_agent_uses_injected_session(unbound_session: Session): - resolver = MagicMock(return_value="agent-1") - app_model = SimpleNamespace(bound_agent_id_with_session=resolver) - result = agent_controller._resolve_agent_id(unbound_session, app_model, None) - - assert result == "agent-1" - resolver.assert_called_once_with(session=unbound_session) - assert resolver.call_args.kwargs["session"] is unbound_session - - -def test_upload_standardizes_into_drive_and_returns_skill_ref(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - with _file_ctx(files={"file": b"zip-bytes"}): - with patch(f"{_MOD}.SkillStandardizeService") as svc: - svc.return_value.standardize.return_value = { - "skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}, - "manifest": {"name": "Skill A"}, - } - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP) - assert status == 201 - assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"} - assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" - - -def test_upload_by_agent_resolves_app_and_standardizes_into_drive(unbound_session: Session): - raw = _raw(AgentSkillUploadByAgentApi.post) - with _file_ctx(files={"file": b"zip-bytes"}): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.SkillStandardizeService") as svc, - ): - svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}} - body, status = raw(AgentSkillUploadByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1") - assert status == 201 - assert body["skill"] == {"path": "skill-a"} - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" - - -def test_upload_no_file_is_400(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - with _file_ctx(files={}): - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP) - assert status == 400 - assert body["code"] == "no_file" - - -def test_upload_maps_package_error(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - with _file_ctx(files={"file": b"bad"}): - with patch(f"{_MOD}.SkillStandardizeService") as svc: - svc.return_value.standardize.side_effect = SkillPackageError( - "missing_skill_md", "no SKILL.md", status_code=400 - ) - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP) - assert status == 400 - assert body["code"] == "missing_skill_md" - - -def test_upload_no_bound_agent_is_400(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - resolver = MagicMock(return_value=None) - app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver) - with _file_ctx(files={"file": b"zip"}): - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, app_without_agent) - assert status == 400 - assert body["code"] == "agent_not_bound" - resolver.assert_called_once_with(session=unbound_session) - - -def test_upload_resolves_workflow_node_agent(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - with app.test_request_context( - "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")} - ): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillStandardizeService") as svc: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" - svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _WORKFLOW_APP) - assert status == 201 - assert body["skill"] == {"path": "s"} - assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1" - - -def test_upload_maps_drive_error(unbound_session: Session): - raw = _raw(AgentSkillUploadApi.post) - with _file_ctx(files={"file": b"zip"}): - with patch(f"{_MOD}.SkillStandardizeService") as svc: - svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404) - body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP) - assert status == 404 - assert body["code"] == "source_not_found" - - -def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""): - return app.test_request_context(f"/?{query_string}", method=method, json=payload or {}) - - -def test_files_commit_validates_upload_and_returns_drive_ref(sqlite_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.post) - upload = _persist_upload(sqlite_session, name="sample qna.pdf") - with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}): - with patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.AgentDriveService") as drive: - ns.payload = {"upload_file_id": _UPLOAD_FILE_ID} - drive.return_value.commit.return_value = [ - {"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"} - ] - body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP) - assert status == 201 - assert body["file"]["drive_key"] == "files/sample qna.pdf" - assert body["file"]["file_id"] == upload.id - item = drive.return_value.commit.call_args.kwargs["items"][0] - assert item.value_owned_by_drive is True - assert item.file_ref.kind == "upload_file" - - -def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(sqlite_session: Session): - raw = _raw(AgentDriveFilesByAgentApi.post) - _persist_upload(sqlite_session) - with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=ignored"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.console_ns") as ns, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - ns.payload = {"upload_file_id": _UPLOAD_FILE_ID} - drive.return_value.commit.return_value = [ - {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} - ] - body, status = raw(AgentDriveFilesByAgentApi(), sqlite_session, "tenant-1", _USER, "agent-1") - assert status == 201 - resolve_app.assert_called_once_with(session=sqlite_session, tenant_id="tenant-1", agent_id="agent-1") - - -def test_files_commit_404_when_upload_not_in_tenant(sqlite_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.post) - other_upload = _persist_upload(sqlite_session) - other_upload.tenant_id = str(uuid4()) - sqlite_session.commit() - with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}): - with patch(f"{_MOD}.console_ns") as ns: - ns.payload = {"upload_file_id": _UPLOAD_FILE_ID} - body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP) - assert status == 404 - assert body["code"] == "upload_file_not_found" - - -def test_files_commit_resolves_workflow_node_agent(sqlite_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.post) - _persist_upload(sqlite_session) - with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=agent-node-1"): - with ( - patch(f"{_MOD}.console_ns") as ns, - patch(f"{_MOD}.AgentDriveService") as drive, - patch(f"{_MOD}.AgentComposerService") as composer, - ): - ns.payload = {"upload_file_id": _UPLOAD_FILE_ID} - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" - drive.return_value.commit.return_value = [ - {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} - ] - body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _WORKFLOW_APP) - assert status == 201 - assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1" - - -def test_files_delete_updates_soul_then_drive(unbound_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.delete) - calls: list[str] = [] - with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.commit.side_effect = lambda **kw: ( - calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}] - ) - body = raw(AgentDriveFilesApi(), unbound_session, _USER, _APP) - assert calls == ["drive"] - assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]} - - -def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(unbound_session: Session): - raw = _raw(AgentDriveFilesByAgentApi.delete) - with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}] - body = raw(AgentDriveFilesByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1") - assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]} - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - - -def test_files_delete_resolves_workflow_node_agent(unbound_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.delete) - with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" - drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}] - body = raw(AgentDriveFilesApi(), unbound_session, _USER, _WORKFLOW_APP) - assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]} - assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1" - - -def test_files_delete_survives_drive_failure(unbound_session: Session): - from controllers.console.app.agent import AgentDriveFilesApi - - raw = _raw(AgentDriveFilesApi.delete) - with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.commit.side_effect = RuntimeError("storage down") - with pytest.raises(RuntimeError, match="storage down"): - raw(AgentDriveFilesApi(), unbound_session, _USER, _APP) - - -def test_skill_delete_uses_slug_prefix_and_is_idempotent(unbound_session: Session): - from controllers.console.app.agent import AgentSkillApi - - raw = _raw(AgentSkillApi.delete) - with _json_ctx(method="DELETE"): - with patch(f"{_MOD}.AgentDriveService") as drive: - drive.return_value.commit.return_value = [ - {"key": "tender-analyzer/SKILL.md", "removed": True}, - {"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True}, - ] - body = raw(AgentSkillApi(), unbound_session, _USER, _APP, "tender-analyzer") - assert body == { - "result": "success", - "removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"], - } - - -def test_skill_delete_by_agent_uses_agent_route(unbound_session: Session): - raw = _raw(AgentSkillByAgentApi.delete) - with _json_ctx(method="DELETE", query_string="node_id=ignored"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.AgentDriveService") as drive, - ): - drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}] - body = raw(AgentSkillByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1", "tender-analyzer") - assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]} - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - - -def test_skill_delete_rejects_path_like_slug(unbound_session: Session): - from controllers.console.app.agent import AgentSkillApi - - raw = _raw(AgentSkillApi.delete) - with _json_ctx(method="DELETE"): - body, status = raw(AgentSkillApi(), unbound_session, _USER, _APP, "a/b") - assert status == 400 - assert body["code"] == "drive_key_invalid" - - -def test_infer_tools_returns_draft_suggestions(unbound_session: Session): - from controllers.console.app.agent import AgentSkillInferToolsApi - - raw = _raw(AgentSkillInferToolsApi.post) - with _json_ctx(): - with patch(f"{_MOD}.SkillToolInferenceService") as svc: - svc.return_value.infer.return_value = { - "inferable": True, - "cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}], - "reason": None, - } - body = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe") - assert body["inferable"] is True - assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe" - - -def test_infer_tools_by_agent_uses_agent_route(unbound_session: Session): - raw = _raw(AgentSkillInferToolsByAgentApi.post) - with _json_ctx(query_string="node_id=ignored"): - with ( - patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.SkillToolInferenceService") as svc, - ): - svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None} - body = raw( - AgentSkillInferToolsByAgentApi(), - unbound_session, - "tenant-1", - "agent-1", - "audio-transcribe", - ) - assert body["inferable"] is True - resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1") - assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1" - - -def test_infer_tools_resolves_workflow_node_agent(unbound_session: Session): - from controllers.console.app.agent import AgentSkillInferToolsApi - - raw = _raw(AgentSkillInferToolsApi.post) - with _json_ctx(query_string="node_id=agent-node-1"): - with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillToolInferenceService") as svc: - composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" - svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"} - body = raw(AgentSkillInferToolsApi(), unbound_session, _WORKFLOW_APP, "audio-transcribe") - assert body["inferable"] is False - assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1" - - -def test_infer_tools_maps_inference_errors(unbound_session: Session): - from controllers.console.app.agent import AgentSkillInferToolsApi - from services.agent.skill_tool_inference_service import SkillToolInferenceError - - raw = _raw(AgentSkillInferToolsApi.post) - with _json_ctx(): - with patch(f"{_MOD}.SkillToolInferenceService") as svc: - svc.return_value.infer.side_effect = SkillToolInferenceError( - "default_model_not_configured", "no model", status_code=400 - ) - body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe") - assert status == 400 - assert body["code"] == "default_model_not_configured" - - -def test_infer_tools_rejects_path_like_slug_and_unbound_app(unbound_session: Session): - from controllers.console.app.agent import AgentSkillInferToolsApi - - raw = _raw(AgentSkillInferToolsApi.post) - with _json_ctx(): - body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "a/b") - assert (status, body["code"]) == (400, "drive_key_invalid") - app_without_agent = SimpleNamespace(bound_agent_id_with_session=MagicMock(return_value=None)) - with _json_ctx(): - body, status = raw(AgentSkillInferToolsApi(), unbound_session, app_without_agent, "x") - assert (status, body["code"]) == (400, "agent_not_bound") diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py deleted file mode 100644 index 08b336e1d3149b..00000000000000 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Unit tests for the agent drive inner-API controller (ENG-591). - -Handlers are unwrapped past the auth/setup decorators and invoked inside a bare -Flask request context, with AgentDriveService mocked — so this covers the -controller's request parsing + error mapping, not auth (tested separately). -""" - -from __future__ import annotations - -import inspect -from unittest.mock import ANY, patch - -import pytest -from flask import Flask - -from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi -from models.enums import EndUserType -from models.model import EndUser -from services.agent_drive_service import AgentDriveError - -_MOD = "controllers.inner_api.plugin.agent_drive" -app = Flask(__name__) - - -def _raw(method): - return inspect.unwrap(method) - - -def _end_user(user_id: str) -> EndUser: - return EndUser( - id=user_id, - tenant_id="tenant-1", - type=EndUserType.SERVICE_API, - session_id="session-1", - ) - - -def test_manifest_parses_query_and_returns_items(): - raw = _raw(AgentDriveManifestApi.get) - with app.test_request_context("/?tenant_id=tenant-1&prefix=docs/&include_download_url=true"): - with patch(f"{_MOD}.AgentDriveService") as svc: - svc.return_value.manifest.return_value = [{"key": "docs/a.txt"}] - result = raw(AgentDriveManifestApi(), "agent-agent-1") - assert result == {"items": [{"key": "docs/a.txt"}]} - svc.return_value.manifest.assert_called_once_with( - tenant_id="tenant-1", agent_id="agent-1", prefix="docs/", include_download_url=True, session=ANY - ) - - -def test_manifest_missing_tenant_id_is_400(): - raw = _raw(AgentDriveManifestApi.get) - with app.test_request_context("/"): - body, status = raw(AgentDriveManifestApi(), "agent-agent-1") - assert status == 400 - assert body["code"] == "missing_tenant_id" - - -def test_manifest_bad_drive_ref_is_400(): - raw = _raw(AgentDriveManifestApi.get) - with app.test_request_context("/?tenant_id=tenant-1"): - body, status = raw(AgentDriveManifestApi(), "not-an-agent-ref") - assert status == 400 - assert body["code"] == "invalid_drive_ref" - - -def test_skills_requires_tenant_id_and_returns_items(): - raw = _raw(AgentDriveSkillsApi.get) - - with app.test_request_context("/"): - body, status = raw(AgentDriveSkillsApi(), "agent-agent-1") - assert status == 400 - assert body["code"] == "missing_tenant_id" - - with app.test_request_context("/?tenant_id=tenant-1"): - with patch(f"{_MOD}.AgentDriveService") as svc: - svc.return_value.list_skills.return_value = [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": None, - "name": "Tender Analyzer", - "description": "Parses RFPs.", - } - ] - result = raw(AgentDriveSkillsApi(), "agent-agent-1") - - assert result == { - "items": [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": None, - "name": "Tender Analyzer", - "description": "Parses RFPs.", - } - ] - } - assert svc.return_value.list_skills.call_args.kwargs == { - "tenant_id": "tenant-1", - "agent_id": "agent-1", - "session": ANY, - } - - -def test_commit_parses_body_and_returns_items(): - raw = _raw(AgentDriveCommitApi.post) - payload = { - "tenant_id": "tenant-1", - "user_id": "user-1", - "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}], - } - with app.test_request_context("/", method="POST", json=payload): - with ( - patch(f"{_MOD}.get_user", return_value=_end_user("user-1")) as get_user, - patch(f"{_MOD}.AgentDriveService") as svc, - ): - svc.return_value.commit.return_value = [{"key": "a.txt"}] - result = raw(AgentDriveCommitApi(), "agent-agent-1") - assert result == {"items": [{"key": "a.txt"}]} - assert get_user.call_args.args == ("tenant-1", "user-1") - assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1" - assert svc.return_value.commit.call_args.kwargs["user_id"] == "user-1" - - -def test_commit_canonicalizes_user_before_service_call(): - raw = _raw(AgentDriveCommitApi.post) - payload = { - "tenant_id": "tenant-1", - "user_id": "session-1", - "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}], - } - with app.test_request_context("/", method="POST", json=payload): - with ( - patch(f"{_MOD}.get_user", return_value=_end_user("end-user-1")), - patch(f"{_MOD}.AgentDriveService") as svc, - ): - svc.return_value.commit.return_value = [{"key": "a.txt"}] - result = raw(AgentDriveCommitApi(), "agent-agent-1") - - assert result == {"items": [{"key": "a.txt"}]} - assert svc.return_value.commit.call_args.kwargs["user_id"] == "end-user-1" - - -def test_commit_invalid_body_is_400(): - raw = _raw(AgentDriveCommitApi.post) - with app.test_request_context("/", method="POST", json={"tenant_id": "t"}): # missing user_id/items - body, status = raw(AgentDriveCommitApi(), "agent-agent-1") - assert status == 400 - assert body["code"] == "invalid_request" - - -def test_commit_maps_service_error(): - raw = _raw(AgentDriveCommitApi.post) - payload = { - "tenant_id": "tenant-1", - "user_id": "user-1", - "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}], - } - with app.test_request_context("/", method="POST", json=payload): - with ( - patch(f"{_MOD}.get_user", return_value=_end_user("user-1")), - patch(f"{_MOD}.AgentDriveService") as svc, - ): - svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404) - body, status = raw(AgentDriveCommitApi(), "agent-agent-1") - assert status == 404 - assert body["code"] == "source_not_found" - - -@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveSkillsApi, AgentDriveCommitApi]) -def test_endpoints_have_handlers(api_cls): - assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None)) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 65e3d8f84d3a04..d40db52fbc116c 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -495,7 +495,6 @@ def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeyp "execution_context": "execution_context", "runtime": "runtime", } - assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None def test_config_layer_for_build_draft_marks_config_writable(self): builder = AgentAppRuntimeRequestBuilder( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 292d1adf31b575..a8ac2f9934c62d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -1476,7 +1476,6 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, "runtime": "runtime", } - assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None def test_workflow_run_request_contains_config_layer(): diff --git a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py b/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py deleted file mode 100644 index 691a1c61cc5e30..00000000000000 --- a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[3] - / "migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py" -) - - -def _load_migration_module(): - spec = importlib.util.spec_from_file_location("agent_drive_skill_metadata_refactor", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _create_pre_upgrade_schema(engine: sa.Engine) -> None: - metadata = sa.MetaData() - sa.Table( - "agent_drive_files", - metadata, - sa.Column("tenant_id", sa.String(36), nullable=False), - sa.Column("agent_id", sa.String(36), nullable=False), - sa.Column("key", sa.String(512), nullable=False), - sa.Column("file_kind", sa.String(32), nullable=False), - sa.Column("file_id", sa.String(36), nullable=False), - sa.Column("value_owned_by_drive", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("size", sa.BigInteger(), nullable=True), - sa.Column("hash", sa.String(255), nullable=True), - sa.Column("mime_type", sa.String(255), nullable=True), - sa.Column("created_by", sa.String(36), nullable=True), - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"), - ) - sa.Table( - "agent_config_snapshots", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("config_snapshot", sa.Text(), nullable=False), - ) - metadata.create_all(engine) - - -def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> None: - with engine.begin() as connection: - context = MigrationContext.configure(connection) - operations = Operations(context) - original_op = module.op - module.op = operations - try: - getattr(module, step_name)() - finally: - module.op = original_op - - -def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_upgrade_schema(engine) - snapshot = { - "prompt": {"system_prompt": "Use [§skill:legacy:Legacy§]"}, - "skills_files": {"skills": [{"name": "Legacy"}], "files": [{"name": "u.pdf"}]}, - } - with engine.begin() as connection: - connection.execute( - sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"), - {"id": "snap-1", "config_snapshot": json.dumps(snapshot)}, - ) - - module = _load_migration_module() - _run_migration_step(module, engine, "upgrade") - - inspector = sa.inspect(engine) - columns = {column["name"] for column in inspector.get_columns("agent_drive_files")} - assert {"is_skill", "skill_metadata"}.issubset(columns) - indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")} - assert "agent_drive_files_tenant_agent_is_skill_key_idx" in indexes - - with engine.begin() as connection: - stored_snapshot = connection.execute( - sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"), - {"id": "snap-1"}, - ).scalar_one() - assert json.loads(stored_snapshot) == snapshot - - -def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_upgrade_schema(engine) - with engine.begin() as connection: - connection.execute( - sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"), - {"id": "snap-1", "config_snapshot": json.dumps({"prompt": {"system_prompt": "hello"}})}, - ) - - module = _load_migration_module() - _run_migration_step(module, engine, "upgrade") - _run_migration_step(module, engine, "downgrade") - - inspector = sa.inspect(engine) - columns = {column["name"] for column in inspector.get_columns("agent_drive_files")} - assert "is_skill" not in columns - assert "skill_metadata" not in columns - indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")} - assert "agent_drive_files_tenant_agent_is_skill_key_idx" not in indexes - - with engine.begin() as connection: - stored_snapshot = connection.execute( - sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"), - {"id": "snap-1"}, - ).scalar_one() - assert "skills_files" not in json.loads(stored_snapshot) diff --git a/api/tests/unit_tests/migrations/test_remove_agent_drive.py b/api/tests/unit_tests/migrations/test_remove_agent_drive.py new file mode 100644 index 00000000000000..a6b7ce10d13171 --- /dev/null +++ b/api/tests/unit_tests/migrations/test_remove_agent_drive.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import importlib.util +import json +from io import StringIO +from pathlib import Path +from types import ModuleType + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +_MIGRATION_PATH = ( + Path(__file__).resolve().parents[3] / "migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py" +) + + +def _load_migration_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("remove_agent_drive", _MIGRATION_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load migration module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _create_pre_upgrade_schema(engine: sa.Engine) -> None: + metadata = sa.MetaData() + sa.Table("agent_drive_files", metadata, sa.Column("id", sa.String(36), primary_key=True)) + for table_name in ("agent_config_snapshots", "agent_config_drafts"): + sa.Table( + table_name, + metadata, + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("config_snapshot", sa.Text(), nullable=False), + ) + sa.Table( + "workflow_agent_node_bindings", + metadata, + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("node_job_config", sa.Text(), nullable=False), + ) + metadata.create_all(engine) + + +def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: + migration_step = module.__dict__[step_name] + if not callable(migration_step): + raise TypeError(f"migration step {step_name!r} is not callable") + + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + original_op = module.__dict__["op"] + module.__dict__["op"] = operations + try: + migration_step() + finally: + module.__dict__["op"] = original_op + + +def test_upgrade_removes_agent_drive_schema_and_legacy_json_fields() -> None: + engine = sa.create_engine("sqlite:///:memory:") + _create_pre_upgrade_schema(engine) + soul = { + "files": {"skills": [{"name": "legacy"}]}, + "config_skills": [{"name": "current", "file_id": "tool-1"}], + "prompt": {"system_prompt": "hello"}, + } + node_job = { + "metadata": { + "file_refs": [ + {"id": "upload-1", "drive_key": "files/input.pdf"}, + {"id": "upload-2"}, + ] + }, + "declared_outputs": [ + { + "name": "report", + "type": "file", + "check": {"benchmark_file_ref": {"id": "upload-3", "drive_key": "files/reference.pdf"}}, + } + ], + } + with engine.begin() as connection: + for table_name in ("agent_config_snapshots", "agent_config_drafts"): + connection.execute( + sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :value)"), + {"id": table_name, "value": json.dumps(soul)}, + ) + connection.execute( + sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :value)"), + {"id": "binding-1", "value": json.dumps(node_job)}, + ) + + module = _load_migration_module() + _run_migration_step(module, engine, "upgrade") + + assert "agent_drive_files" not in sa.inspect(engine).get_table_names() + with engine.begin() as connection: + for table_name in ("agent_config_snapshots", "agent_config_drafts"): + stored = connection.execute(sa.text(f"SELECT config_snapshot FROM {table_name}")).scalar_one() + value = json.loads(stored) + assert "files" not in value + assert value["config_skills"] == soul["config_skills"] + assert value["prompt"] == soul["prompt"] + stored_node_job = connection.execute( + sa.text("SELECT node_job_config FROM workflow_agent_node_bindings") + ).scalar_one() + + migrated_node_job = json.loads(stored_node_job) + assert migrated_node_job["metadata"]["file_refs"] == [{"id": "upload-1"}, {"id": "upload-2"}] + assert migrated_node_job["declared_outputs"][0]["check"]["benchmark_file_ref"] == {"id": "upload-3"} + + _run_migration_step(module, engine, "downgrade") + inspector = sa.inspect(engine) + assert "agent_drive_files" in inspector.get_table_names() + assert { + "tenant_id", + "agent_id", + "key", + "file_kind", + "file_id", + "value_owned_by_drive", + "is_skill", + "skill_metadata", + }.issubset({column["name"] for column in inspector.get_columns("agent_drive_files")}) + assert "agent_drive_file_scope_key_unique" in { + constraint["name"] for constraint in inspector.get_unique_constraints("agent_drive_files") + } + assert "agent_drive_files_tenant_agent_is_skill_key_idx" in { + index["name"] for index in inspector.get_indexes("agent_drive_files") + } + + +def test_upgrade_supports_offline_sql_generation() -> None: + module = _load_migration_module() + output = StringIO() + migration_context = MigrationContext.configure( + dialect_name="postgresql", + opts={"as_sql": True, "output_buffer": output}, + ) + operations = Operations(migration_context) + migration_step = module.__dict__["upgrade"] + if not callable(migration_step): + raise TypeError("migration upgrade is not callable") + + original_op = module.__dict__["op"] + module.__dict__["op"] = operations + try: + migration_step() + finally: + module.__dict__["op"] = original_op + + generated_sql = output.getvalue() + assert "DROP TABLE agent_drive_files" in generated_sql + assert "SELECT id" not in generated_sql + + +@pytest.mark.parametrize( + ("table_name", "column_name"), + [ + pytest.param("agent_config_snapshots", "config_snapshot", id="config-snapshot"), + pytest.param("workflow_agent_node_bindings", "node_job_config", id="node-job-config"), + ], +) +def test_upgrade_rejects_invalid_json_without_rewriting(table_name: str, column_name: str) -> None: + engine = sa.create_engine("sqlite:///:memory:") + _create_pre_upgrade_schema(engine) + invalid_json = "not-json" + with engine.begin() as connection: + connection.execute( + sa.text(f"INSERT INTO {table_name} (id, {column_name}) VALUES (:id, :value)"), + {"id": "invalid-row", "value": invalid_json}, + ) + + module = _load_migration_module() + with pytest.raises(json.JSONDecodeError): + _run_migration_step(module, engine, "upgrade") + + with engine.begin() as connection: + stored = connection.execute(sa.text(f"SELECT {column_name} FROM {table_name}")).scalar_one() + assert stored == invalid_json + assert "agent_drive_files" in sa.inspect(engine).get_table_names() diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 76d1a8c5e76f95..2b0337fde192fd 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -39,9 +39,7 @@ project-excludes = [ "controllers/console/agent/test_agent_controllers.py", "controllers/console/app/test_agent_app_sandbox.py", "controllers/console/app/test_agent_config_inspector.py", - "controllers/console/app/test_agent_drive_inspector.py", "controllers/console/app/test_agent_manage_guard.py", - "controllers/console/app/test_agent_skills.py", "controllers/console/app/test_annotation_api.py", "controllers/console/app/test_annotation_security.py", "controllers/console/app/test_app_apis.py", @@ -146,7 +144,6 @@ project-excludes = [ "controllers/files/test_upload.py", "controllers/inner_api/app/test_dsl.py", "controllers/inner_api/plugin/test_agent_config.py", - "controllers/inner_api/plugin/test_agent_drive.py", "controllers/inner_api/plugin/test_plugin.py", "controllers/inner_api/plugin/test_plugin_wraps.py", "controllers/inner_api/test_auth_wraps.py", @@ -723,7 +720,6 @@ project-excludes = [ "libs/test_workspace_member_helper.py", "libs/test_workspace_permission.py", "libs/test_yarl.py", - "migrations/test_agent_drive_skill_metadata_refactor.py", "migrations/test_uuidv7_pg18_migration.py", "models/test_account_models.py", "models/test_agent.py", @@ -819,7 +815,6 @@ project-excludes = [ "services/test_agent_app_feature_service.py", "services/test_agent_app_sandbox_service.py", "services/test_agent_config_service.py", - "services/test_agent_drive_service.py", "services/test_annotation_service.py", "services/test_api_token_service.py", "services/test_app_generate_service.py", diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index efc29ffb602533..f18d58e3032562 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -44,24 +44,6 @@ def test_workflow_variant_rejects_agent_app_only_fields(): ) -def test_workflow_variant_accepts_agent_soul_files_section(): - payload = ComposerSavePayload.model_validate( - { - "variant": ComposerVariant.WORKFLOW, - "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, - "agent_soul": { - "schema_version": 1, - "prompt": {"system_prompt": "jjjj"}, - "files": {"skills": [], "files": []}, - }, - } - ) - - assert payload.agent_soul is not None - assert payload.agent_soul.files.skills == [] - assert payload.agent_soul.files.files == [] - - def test_agent_app_variant_rejects_workflow_node_job(): with pytest.raises(ValueError): ComposerSavePayload.model_validate( diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py index fa188ecb8f8bd7..545db0a93b3d83 100644 --- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -18,7 +18,7 @@ WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSkillRefConfig, AgentSoulConfig from services.agent.dsl_entities import ( AGENT_NODE_JOB_DSL_KEY, AGENT_PACKAGE_REF_KEY, @@ -465,44 +465,41 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, ) -def test_clone_inline_binding_copies_soul_and_drive_rows(monkeypatch: pytest.MonkeyPatch) -> None: +def test_clone_inline_binding_copies_soul() -> None: session = Mock() service = AgentDslService(session) target_agent = SimpleNamespace(id="target-agent") target_snapshot = SimpleNamespace(id="target-snapshot") service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot)) - copy_rows = Mock() - monkeypatch.setattr("services.agent.composer_service.AgentComposerService._copy_agent_drive_rows", copy_rows) source_agent = _agent() - source_snapshot = SimpleNamespace( - config_snapshot_dict=AgentSoulConfig(config_note="source").model_dump(mode="json") + source_soul = AgentSoulConfig( + config_note="source", + config_skills=[AgentConfigSkillRefConfig(name="summarizer", file_id="skill-file-1")], + config_files=[AgentConfigFileRefConfig(name="brief.pdf", file_kind="upload_file", file_id="config-file-1")], ) + source_snapshot = SimpleNamespace(config_snapshot_dict=source_soul.model_dump(mode="json")) workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1") - node_job = WorkflowNodeJobConfig(workflow_prompt="work") result = service.clone_inline_binding_for_node( workflow=workflow, node_id="target-node", source_agent=source_agent, source_snapshot=source_snapshot, - node_job=node_job, account_id="account-1", ) assert result == (target_agent, target_snapshot) create_kwargs = service._create_workflow_only_agent.call_args.kwargs assert create_kwargs["metadata"].name == source_agent.name - assert create_kwargs["soul"].config_note == "source" + cloned_soul = create_kwargs["soul"] + assert cloned_soul.config_note == "source" + assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_skills] == [ + ("summarizer", "tool_file", "skill-file-1") + ] + assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_files] == [ + ("brief.pdf", "upload_file", "config-file-1") + ] assert create_kwargs["source"] == AgentSource.WORKFLOW - copy_rows.assert_called_once_with( - tenant_id="tenant-1", - source_agent_id="agent-1", - target_agent_id="target-agent", - account_id="account-1", - agent_soul=create_kwargs["soul"], - node_job=node_job, - session=session, - ) def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 5dbce1c2549473..2042dbee7949bc 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -20,8 +20,6 @@ AgentConfigSnapshot, AgentConfigVersionKind, AgentDebugConversation, - AgentDriveFile, - AgentDriveFileKind, AgentHomeSnapshot, AgentKind, AgentScope, @@ -2379,7 +2377,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk scope=AgentScope.WORKFLOW_ONLY, ) create_roster_calls = [] - copy_drive_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) def fake_create_roster_agent_for_composer(**kwargs): @@ -2391,11 +2388,6 @@ def fake_create_roster_agent_for_composer(**kwargs): "_create_roster_agent_for_composer", fake_create_roster_agent_for_composer, ) - monkeypatch.setattr( - AgentComposerService, - "_copy_agent_drive_rows", - lambda **kwargs: copy_drive_calls.append(kwargs), - ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -2496,17 +2488,6 @@ def fake_create_roster_agent_for_composer(**kwargs): assert create_roster_calls[1]["role"] == "Copied role" assert create_roster_calls[1]["icon"] == "copied" assert create_roster_calls[1]["icon_background"] == "#E0F2FE" - copy_drive_calls[0].pop("session", None) - assert copy_drive_calls == [ - { - "tenant_id": "tenant-1", - "source_agent_id": "roster-agent-1", - "target_agent_id": "roster-agent-1", - "account_id": "account-1", - "agent_soul": payload.agent_soul, - "node_job": payload.node_job, - } - ] def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): @@ -2914,11 +2895,7 @@ def fake_create_workflow_only_agent(**kwargs): captured["create"] = kwargs return inline_agent - def fake_copy_drive_rows(**kwargs): - captured["drive"] = kwargs - monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) - monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", @@ -2950,9 +2927,6 @@ def fake_copy_drive_rows(**kwargs): assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me" assert create_kwargs["name"] == "Nadia" assert create_kwargs["role"] == "Clarifies tenders" - drive_kwargs = captured["drive"] - assert drive_kwargs["source_agent_id"] == "roster-agent-1" - assert drive_kwargs["target_agent_id"] == "inline-agent-1" def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot( @@ -3196,191 +3170,6 @@ def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding( ) -def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): - session = sqlite_session - skill_row = AgentDriveFile( - tenant_id="tenant-1", - agent_id="roster-agent-1", - key="tender-analyzer/SKILL.md", - file_kind="tool_file", - file_id="tool-file-1", - value_owned_by_drive=True, - is_skill=True, - skill_metadata='{"name":"Tender Analyzer"}', - size=10, - mime_type="text/markdown", - ) - script_row = AgentDriveFile( - tenant_id="tenant-1", - agent_id="roster-agent-1", - key="tender-analyzer/scripts/run.sh", - file_kind="tool_file", - file_id="tool-file-2", - value_owned_by_drive=True, - size=20, - mime_type="text/x-shellscript", - ) - file_row = AgentDriveFile( - tenant_id="tenant-1", - agent_id="roster-agent-1", - key="files/qna.pdf", - file_kind="upload_file", - file_id="upload-file-1", - value_owned_by_drive=False, - size=30, - mime_type="application/pdf", - ) - session.add_all([skill_row, script_row, file_row]) - session.commit() - agent_soul = AgentSoulConfig.model_validate( - { - "prompt": { - "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]", - }, - } - ) - node_job = WorkflowNodeJobConfig.model_validate( - {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}} - ) - - AgentComposerService._copy_agent_drive_rows( - session=session, - tenant_id="tenant-1", - source_agent_id="roster-agent-1", - target_agent_id="inline-agent-1", - account_id="account-1", - agent_soul=agent_soul, - node_job=node_job, - ) - - session.flush() - copied = list( - session.scalars( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == "tenant-1", - AgentDriveFile.agent_id == "inline-agent-1", - ) - ) - ) - assert {row.key for row in copied} == { - "tender-analyzer/SKILL.md", - "tender-analyzer/scripts/run.sh", - "files/qna.pdf", - } - assert {row.agent_id for row in copied} == {"inline-agent-1"} - copied_by_key = {row.key: row for row in copied} - assert copied_by_key["tender-analyzer/SKILL.md"].file_id == "tool-file-1" - assert copied_by_key["tender-analyzer/SKILL.md"].is_skill is True - assert copied_by_key["files/qna.pdf"].value_owned_by_drive is False - - -def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -): - session = sqlite_session - agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}}) - - AgentComposerService._copy_agent_drive_rows( - session=session, - tenant_id="tenant-1", - source_agent_id="roster-agent-1", - target_agent_id="inline-agent-1", - account_id="account-1", - agent_soul=agent_soul, - ) - - assert not session.new - - -def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): - session = sqlite_session - source_row = AgentDriveFile( - tenant_id="tenant-1", - agent_id="roster-agent-1", - key="files/qna.pdf", - file_kind="upload_file", - file_id="upload-file-1", - value_owned_by_drive=False, - size=30, - mime_type="application/pdf", - ) - target_row = AgentDriveFile( - tenant_id="tenant-1", - agent_id="inline-agent-1", - key=source_row.key, - file_kind=source_row.file_kind, - file_id=source_row.file_id, - value_owned_by_drive=source_row.value_owned_by_drive, - size=source_row.size, - mime_type=source_row.mime_type, - ) - session.add_all([source_row, target_row]) - session.commit() - agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}}) - - AgentComposerService._copy_agent_drive_rows( - session=session, - tenant_id="tenant-1", - source_agent_id="roster-agent-1", - target_agent_id="inline-agent-1", - account_id="account-1", - agent_soul=agent_soul, - ) - - session.flush() - target_rows = list( - session.scalars( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == "tenant-1", - AgentDriveFile.agent_id == "inline-agent-1", - ) - ) - ) - assert [row.key for row in target_rows] == ["files/qna.pdf"] - - -def test_drive_copy_scopes_include_declared_output_benchmark_files(): - agent_soul = AgentSoulConfig.model_validate( - { - "prompt": { - "system_prompt": ( - "[§file:files/source.pdf:source.pdf§] " - "[§knowledge:dataset-1:Docs§] " - "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]" - ) - }, - } - ) - node_job = WorkflowNodeJobConfig.model_validate( - { - "declared_outputs": [ - { - "name": "qna_report", - "type": "file", - "check": { - "enabled": True, - "prompt": "Compare the generated file with the benchmark.", - "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"}, - }, - }, - { - "name": "summary", - "type": "string", - "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}}, - }, - ], - } - ) - - exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs( - agent_soul=agent_soul, - node_job=node_job, - ) - - assert exact_keys == {"files/source.pdf", "files/expected.pdf"} - assert prefixes == {"tender-analyzer/"} - - def test_composer_create_agents_syncs_active_config_has_model( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, @@ -5835,7 +5624,7 @@ def test_publish_validation_rejects_agent_soul_publish_only_errors(self, sqlite_ draft_workflow=self._agent_workflow(), ) - def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_session: Session): + def test_publish_validation_rejects_dangling_agent_soul_config_refs(self, sqlite_session: Session): session = sqlite_session binding = self._agent_binding() agent_soul = AgentSoulConfig.model_validate( @@ -5845,7 +5634,7 @@ def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_ "model_provider": "openai", "model": "gpt-4o", }, - "prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."}, + "prompt": {"system_prompt": "Use [§skill:research:Research§]."}, } ) agent = self._publish_agent() @@ -7045,135 +6834,6 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc assert {entry["granularity"] for entry in entries[1:]} == {"tool"} -# ── ENG-623 §4.4: drive-backed prompt mention validation ───────────────────── - - -def _drive_soul(**overrides): - from services.entities.agent_entities import AgentSoulConfig - - base = { - "prompt": { - "system_prompt": ( - "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]." - ) - }, - } - base.update(overrides) - return AgentSoulConfig.model_validate(base) - - -def _session_with_drive_keys(sqlite_session: Session, existing_keys: list[str]) -> Session: - session = sqlite_session - session.add_all( - [ - AgentDriveFile( - id=f"drive-file-{index}", - tenant_id="tenant-1", - agent_id="agent-1", - key=key, - file_kind=AgentDriveFileKind.UPLOAD_FILE, - file_id=f"upload-{index}", - ) - for index, key in enumerate(existing_keys, start=1) - ] - ) - session.commit() - return session - - -def test_drive_mention_findings_reports_missing_keys(sqlite_session: Session): - session = _session_with_drive_keys(sqlite_session, ["tender-analyzer/SKILL.md"]) - - findings = AgentComposerService._drive_mention_findings( - session=session, - tenant_id="tenant-1", - agent_id="agent-1", - prompt=_drive_soul().prompt.system_prompt, - ) - - assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")] - assert findings[0]["kind"] == "file" - assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry") - - -def test_drive_mention_findings_clean_when_all_keys_exist(sqlite_session: Session): - session = _session_with_drive_keys( - sqlite_session, - ["tender-analyzer/SKILL.md", "files/sample.pdf"], - ) - - assert ( - AgentComposerService._drive_mention_findings( - session=session, - tenant_id="tenant-1", - agent_id="agent-1", - prompt=_drive_soul().prompt.system_prompt, - ) - == [] - ) - - -def test_drive_mention_findings_skips_prompt_without_drive_mentions(sqlite_session: Session): - session = sqlite_session - # No drive-backed mention at all -> no DB roundtrip, no findings. - soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."}) - findings = AgentComposerService._drive_mention_findings( - session=session, - tenant_id="tenant-1", - agent_id="agent-1", - prompt=soul.prompt.system_prompt, - ) - assert findings == [] - - -def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context( - sqlite_session: Session, -): - from services.entities.agent_entities import ComposerSavePayload - - session = _session_with_drive_keys(sqlite_session, []) - payload = ComposerSavePayload.model_validate( - { - "variant": "agent_app", - "save_strategy": "save_to_current_version", - "agent_soul": _drive_soul().model_dump(mode="json"), - } - ) - - findings = AgentComposerService.collect_validation_findings( - session=session, tenant_id="tenant-1", payload=payload, agent_id="agent-1" - ) - - codes = {w["code"] for w in findings["warnings"]} - assert codes >= {"mention_target_missing"} - assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == { - "tender-analyzer/SKILL.md", - "files/sample.pdf", - } - # without agent context the drive check is skipped entirely - findings_no_agent = AgentComposerService.collect_validation_findings( - session=session, tenant_id="tenant-1", payload=payload - ) - assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"]) - - -# ── ENG-623/625: resolver helpers + save-path drive guard ──────────────────── - - -def test_resolve_bound_agent_id_queries_active_roster_agent(sqlite_session: Session): - session = sqlite_session - session.add( - _agent( - agent_id="agent-9", - tenant_id="t-1", - source=AgentSource.ROSTER, - app_id="app-1", - ) - ) - session.commit() - assert AgentComposerService.resolve_bound_agent_id(session=session, tenant_id="t-1", app_id="app-1") == "agent-9" - - def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): @@ -7207,129 +6867,3 @@ def boom(cls, **kwargs): AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n") == "agent-7" ) - - -def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -): - payload = ComposerSavePayload.model_validate( - { - "variant": "workflow", - "save_strategy": "node_job_only", - "agent_soul": _drive_soul().model_dump(mode="json"), - "soul_lock": {"locked": False}, - } - ) - binding = WorkflowAgentNodeBinding( - tenant_id="t-1", - app_id="app-1", - workflow_id="wf-1", - workflow_version="draft", - node_id="n-1", - binding_type=WorkflowAgentBindingType.INLINE_AGENT, - agent_id="agent-1", - current_snapshot_id="version-1", - ) - session = sqlite_session - monkeypatch.setattr( - AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) - ) - monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) - monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) - monkeypatch.setattr( - AgentComposerService, - "_get_agent_if_present", - classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), - ) - monkeypatch.setattr( - AgentComposerService, - "_get_version_if_present", - classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), - ) - monkeypatch.setattr( - AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) - ) - guarded: dict[str, str] = {} - - def fake_collect(cls, *, session, tenant_id, payload, agent_id=None): - guarded["tenant_id"] = tenant_id - guarded["agent_id"] = agent_id - return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]} - - monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect)) - - result = AgentComposerService.save_workflow_composer( - session=session, - tenant_id="t-1", - app_id="app-1", - node_id="n-1", - account_id="acc-1", - payload=payload, - ) - - assert result == { - "state": "ok", - "validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}, - } - assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} - - -def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -): - payload = ComposerSavePayload.model_validate( - { - "variant": "workflow", - "save_strategy": "node_job_only", - "agent_soul": _drive_soul().model_dump(mode="json"), - "soul_lock": {"locked": False}, - } - ) - binding = WorkflowAgentNodeBinding( - tenant_id="t-1", - app_id="app-1", - workflow_id="wf-1", - workflow_version="draft", - node_id="n-1", - binding_type=WorkflowAgentBindingType.ROSTER_AGENT, - agent_id="agent-1", - current_snapshot_id="version-1", - ) - session = sqlite_session - monkeypatch.setattr( - AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) - ) - monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) - monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) - monkeypatch.setattr( - AgentComposerService, - "_get_agent_if_present", - classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), - ) - monkeypatch.setattr( - AgentComposerService, - "_get_version_if_present", - classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), - ) - monkeypatch.setattr( - AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) - ) - captured: dict[str, str | None] = {} - - def fake_collect(cls, *, session, tenant_id, payload, agent_id=None): - captured["agent_id"] = agent_id - return {"warnings": []} - - monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect)) - - result = AgentComposerService.save_workflow_composer( - session=session, - tenant_id="t-1", - app_id="app-1", - node_id="n-1", - account_id="acc-1", - payload=payload, - ) - - assert result == {"state": "ok", "validation": {"warnings": []}} - assert captured["agent_id"] == "agent-1" diff --git a/api/tests/unit_tests/services/agent/test_prompt_mentions.py b/api/tests/unit_tests/services/agent/test_prompt_mentions.py index 48e4978a3bc568..c6fb262572cb73 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -7,8 +7,6 @@ from __future__ import annotations -from urllib.parse import quote - import pytest from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowPreviousNodeOutputRef @@ -65,12 +63,6 @@ def test_parse_skips_oversized_id_or_label(): assert parse_prompt_mentions(f"[§skill:{long_id}§]") == [] -def test_parse_accepts_long_unicode_encoded_drive_key_within_drive_limit(): - encoded_drive_key = quote("你" * 512) - mentions = parse_prompt_mentions(f"[§skill:{encoded_drive_key}:Long Skill§]") - assert [(mention.kind, mention.ref_id) for mention in mentions] == [(MentionKind.SKILL, encoded_drive_key)] - - # ── expand + scrub ──────────────────────────────────────────────────────────── diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py deleted file mode 100644 index 922c018c729e7f..00000000000000 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Unit tests for Skill standardization into the agent drive (ENG-594).""" - -from __future__ import annotations - -import io -import zipfile -from unittest.mock import MagicMock - -import pytest -from sqlalchemy import select -from sqlalchemy.orm import Session - -from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource -from models.tools import ToolFile -from services.agent.skill_standardize_service import SkillStandardizeService, slugify_skill_name -from services.agent_drive_service import DriveSkillMetadata - -_TENANT_ID = "11111111-1111-1111-1111-111111111111" -_AGENT_ID = "22222222-2222-2222-2222-222222222222" -_USER_ID = "33333333-3333-3333-3333-333333333333" - -_SKILL_MD = b"""--- -name: PDF Toolkit -description: Work with PDFs. ---- - -# PDF Toolkit -""" - - -def _zip(members: dict[str, bytes]) -> bytes: - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - for name, data in members.items(): - archive.writestr(name, data) - return buffer.getvalue() - - -def test_slugify_skill_name(): - assert slugify_skill_name("PDF Toolkit") == "pdf-toolkit" - assert slugify_skill_name(" Weird/Name!! ") == "weird-name" - assert slugify_skill_name("") == "skill" - - -@pytest.mark.parametrize("sqlite_session", [(Agent, ToolFile, AgentDriveFile)], indirect=True) -def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(sqlite_session: Session): - content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"}) - - agent = Agent( - id=_AGENT_ID, - tenant_id=_TENANT_ID, - name="Drive Agent", - scope=AgentScope.ROSTER, - source=AgentSource.AGENT_APP, - ) - md_tool_file = ToolFile( - user_id=_USER_ID, - tenant_id=_TENANT_ID, - conversation_id=None, - file_key="tools/skill-md", - mimetype="text/markdown", - name="SKILL.md", - size=len(_SKILL_MD), - ) - archive_tool_file = ToolFile( - user_id=_USER_ID, - tenant_id=_TENANT_ID, - conversation_id=None, - file_key="tools/skill-archive", - mimetype="application/zip", - name=".DIFY-SKILL-FULL.zip", - size=len(content), - ) - sqlite_session.add_all([agent, md_tool_file, archive_tool_file]) - sqlite_session.commit() - - tool_files = MagicMock() - tool_files.create_file_by_raw.side_effect = [md_tool_file, archive_tool_file] - - service = SkillStandardizeService(tool_file_manager=tool_files) - result = service.standardize( - content=content, - filename="skill.zip", - tenant_id=_TENANT_ID, - user_id=_USER_ID, - agent_id=_AGENT_ID, - session=sqlite_session, - ) - assert not sqlite_session.in_transaction() - - # ToolFiles: SKILL.md and the full archive. Archive members stay lazy. - assert tool_files.create_file_by_raw.call_count == 2 - md_call, zip_call = tool_files.create_file_by_raw.call_args_list - assert md_call.kwargs["mimetype"] == "text/markdown" - assert md_call.kwargs["file_binary"] == _SKILL_MD - assert zip_call.kwargs["mimetype"] == "application/zip" - assert zip_call.kwargs["file_binary"] != content - with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive: - assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [ - "SKILL.md", - "scripts/run.py", - ] - - # Committed as drive-owned with the standardized keys. Member paths are - # carried in metadata for inspect/preview/runtime lazy resolution. - rows = { - row.key: row - for row in sqlite_session.scalars( - select(AgentDriveFile).where( - AgentDriveFile.tenant_id == _TENANT_ID, - AgentDriveFile.agent_id == _AGENT_ID, - ) - ) - } - assert set(rows) == {"pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"} - skill_row = rows["pdf-toolkit/SKILL.md"] - archive_row = rows["pdf-toolkit/.DIFY-SKILL-FULL.zip"] - assert skill_row.file_kind == AgentDriveFileKind.TOOL_FILE - assert skill_row.file_id == md_tool_file.id - assert skill_row.value_owned_by_drive is True - assert skill_row.is_skill is True - assert skill_row.skill_metadata is not None - skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata) - assert skill_metadata.name == "PDF Toolkit" - assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"] - assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE - assert archive_row.file_id == archive_tool_file.id - assert archive_row.value_owned_by_drive is True - assert archive_row.is_skill is False - assert len(service.last_committed_items) == 2 - - # The returned upload response carries only the drive-derived fields the UI needs. - skill = result["skill"] - assert skill["path"] == "pdf-toolkit" - assert skill["name"] == "PDF Toolkit" - assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip" - assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md" - assert result["manifest"]["entry_path"] == "SKILL.md" - assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"] - assert "_committed_items" not in result diff --git a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py deleted file mode 100644 index 68c07abb377fea..00000000000000 --- a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Unit tests for skill → CLI tool inference (ENG-371).""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest -from sqlalchemy.orm import Session - -from services.agent.skill_tool_inference_service import ( - SkillToolInferenceError, - SkillToolInferenceService, -) -from services.agent_drive_service import AgentDriveError - -_MOD = "services.agent.skill_tool_inference_service" - -_SKILL_MD_PREVIEW = { - "key": "audio-transcribe/SKILL.md", - "size": 100, - "truncated": False, - "binary": False, - "text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.", -} - - -def _service(preview=_SKILL_MD_PREVIEW): - drive = MagicMock() - drive.preview.return_value = preview - return SkillToolInferenceService(drive_service=drive), drive - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session): - service, drive = _service() - raw = ( - '{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",' - ' "description": "transcoding for step 2", "command": "ffmpeg",' - ' "install_commands": ["apt-get install -y ffmpeg"],' - ' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}' - ) - with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): - result = service.infer( - tenant_id="t-1", - agent_id="a-1", - slug="audio-transcribe", - session=sqlite_session, - ) - - assert result["inferable"] is True - tool = result["cli_tools"][0] - assert tool["name"] == "ffmpeg" - assert tool["inferred_from"] == "audio-transcribe" - assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}] - drive.preview.assert_called_once_with( - tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session - ) - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session): - service, _ = _service() - captured: dict[str, str] = {} - - def fake_invoke(*, tenant_id, user_prompt): - captured["prompt"] = user_prompt - return '{"inferable": false, "cli_tools": [], "reason": "none"}' - - with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)): - service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session) - - assert "Files inside the skill package" not in captured["prompt"] - assert "ffmpeg" in captured["prompt"] # SKILL.md body present - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session): - service, _ = _service() - raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}' - with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): - result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session) - assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"} - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session): - service, _ = _service() - calls: list[int] = [] - - def bad_invoke(**kwargs): - calls.append(1) - return "not json at all ][" - - with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)): - with pytest.raises(SkillToolInferenceError) as exc_info: - service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session) - - assert len(calls) == 2 # one retry - assert exc_info.value.code == "inference_failed" - assert exc_info.value.status_code == 422 - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session): - service, _ = _service() - raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}' - with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): - result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session) - assert result["inferable"] is True - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_missing_skill_maps_to_404(sqlite_session: Session): - drive = MagicMock() - drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404) - service = SkillToolInferenceService(drive_service=drive) - - with pytest.raises(SkillToolInferenceError) as exc_info: - service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session) - assert exc_info.value.code == "skill_not_found" - assert exc_info.value.status_code == 404 - assert not sqlite_session.in_transaction() - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_binary_skill_md_maps_to_404(sqlite_session: Session): - service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None}) - with pytest.raises(SkillToolInferenceError) as exc_info: - service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session) - assert exc_info.value.code == "skill_not_found" - assert not sqlite_session.in_transaction() - - -# ── real-path coverage: _invoke / passthrough ──────────────────────────────── - - -def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch): - import services.agent.skill_tool_inference_service as module - from core.errors.error import ProviderTokenNotInitError - - fake_manager = MagicMock() - fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default") - monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) - - with pytest.raises(SkillToolInferenceError) as exc_info: - SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") - assert exc_info.value.code == "default_model_not_configured" - assert exc_info.value.status_code == 400 - - -def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch): - import services.agent.skill_tool_inference_service as module - - fake_manager = MagicMock() - fake_instance = MagicMock() - fake_manager.get_default_model_instance.return_value = fake_instance - monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) - - fake_instance.invoke_llm.side_effect = RuntimeError("provider down") - with pytest.raises(SkillToolInferenceError) as exc_info: - SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") - assert exc_info.value.code == "inference_failed" - assert exc_info.value.status_code == 422 - - fake_instance.invoke_llm.side_effect = None - fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}' - raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") - assert raw == '{"inferable": false}' - call = fake_instance.invoke_llm.call_args.kwargs - assert call["model_parameters"] == {"temperature": 0.1} - assert call["stream"] is False - - -@pytest.mark.parametrize("sqlite_session", [()], indirect=True) -def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session): - drive = MagicMock() - drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404) - service = SkillToolInferenceService(drive_service=drive) - - with pytest.raises(SkillToolInferenceError) as exc_info: - service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session) - assert exc_info.value.code == "agent_not_found" - assert not sqlite_session.in_transaction() diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py index 70cd220877ae69..87f376e1c4b0f0 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py +++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py @@ -6,7 +6,6 @@ from sqlalchemy.orm import Session from models.agent import Agent, WorkflowAgentBindingType, WorkflowAgentNodeBinding -from models.agent_config_entities import WorkflowNodeJobConfig from models.enums import AppStatus from models.model import App, AppMode from models.workflow import Workflow, WorkflowType @@ -326,7 +325,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M target_snapshot = SimpleNamespace(id="target-snapshot") clone = Mock(return_value=(target_agent, target_snapshot)) monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) - node_job = WorkflowNodeJobConfig(workflow_prompt="work") result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node( session=session, @@ -334,7 +332,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M node_id="target-node", source_agent_id="source-agent", source_snapshot_id="source-snapshot", - node_job=node_job, account_id="account-1", ) @@ -344,7 +341,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M node_id="target-node", source_agent=source_agent, source_snapshot=source_snapshot, - node_job=node_job, account_id="account-1", ) @@ -361,7 +357,6 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul node_id="target-node", source_agent_id="source-agent", source_snapshot_id="source-snapshot", - node_job=WorkflowNodeJobConfig(), account_id="account-1", ) diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py deleted file mode 100644 index e917745b4e7fe7..00000000000000 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ /dev/null @@ -1,952 +0,0 @@ -"""Unit tests for the agent drive service (ENG-591). - -Pure helpers (key safety / drive-ref parsing) plus the commit/manifest lifecycle -exercised against the project's in-memory SQLite engine with seeded ToolFiles. -""" - -from __future__ import annotations - -import datetime -import io -import zipfile -from collections.abc import Generator -from unittest.mock import patch - -import pytest -from sqlalchemy import delete, event, select -from sqlalchemy.exc import DataError -from sqlalchemy.orm import Session - -from core.db.session_factory import session_factory -from extensions.storage.storage_type import StorageType -from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource -from models.enums import CreatorUserRole -from models.model import UploadFile -from models.tools import ToolFile -from services.agent_drive_service import ( - AgentDriveError, - AgentDriveService, - DriveCommitItem, - DriveSkillMetadata, - normalize_drive_key, - parse_agent_drive_ref, -) - -TENANT = "11111111-1111-1111-1111-111111111111" -AGENT = "22222222-2222-2222-2222-222222222222" -USER = "33333333-3333-3333-3333-333333333333" - - -# ── pure helpers ────────────────────────────────────────────────────────────── - - -def test_parse_agent_drive_ref(): - assert parse_agent_drive_ref("agent-abc") == "abc" - for bad in ["abc", "agent-", ""]: - with pytest.raises(AgentDriveError): - parse_agent_drive_ref(bad) - - -def test_normalize_drive_key_ok_and_collapses_slashes(): - assert normalize_drive_key("a/b/c.txt") == "a/b/c.txt" - assert normalize_drive_key("/a//b.txt") == "a/b.txt" - assert normalize_drive_key("skill-name/SKILL.md") == "skill-name/SKILL.md" - - -@pytest.mark.parametrize("bad", ["", " ", "a/../b", "../etc", "a/\x00b", "a" * 1100]) -def test_normalize_drive_key_rejects_unsafe(bad: str): - with pytest.raises(AgentDriveError): - normalize_drive_key(bad) - - -# ── service lifecycle (in-memory ORM) ───────────────────────────────────────── - - -@pytest.fixture(autouse=True) -def _tables() -> Generator[None, None, None]: - engine = session_factory.get_session_maker().kw["bind"] - for model in (Agent, ToolFile, UploadFile, AgentDriveFile): - model.__table__.create(bind=engine, checkfirst=True) - _seed_agent() - yield - with session_factory.create_session() as session: - session.execute(delete(AgentDriveFile)) - session.execute(delete(UploadFile)) - session.execute(delete(ToolFile)) - session.execute(delete(Agent)) - session.commit() - AgentDriveFile.__table__.drop(bind=engine, checkfirst=True) - - -def _seed_agent(*, tenant_id: str = TENANT, agent_id: str = AGENT) -> None: - agent = Agent( - id=agent_id, - tenant_id=tenant_id, - name="Drive Agent", - scope=AgentScope.ROSTER, - source=AgentSource.AGENT_APP, - ) - with session_factory.create_session() as session: - session.add(agent) - session.commit() - - -def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt", conversation_id: str | None = None) -> str: - tool_file = ToolFile( - user_id=user_id, - tenant_id=TENANT, - conversation_id=conversation_id, - file_key=f"tools/{TENANT}/{name}", - mimetype="text/plain", - name=name, - size=5, - ) - with session_factory.create_session() as session: - session.add(tool_file) - session.commit() - return tool_file.id - - -def _zip_bytes(members: dict[str, bytes]) -> bytes: - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - for name, data in members.items(): - archive.writestr(name, data) - return buffer.getvalue() - - -def _commit(key: str, tool_file_id: str, *, owned: bool = True): - return AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key=key, - file_ref={"kind": "tool_file", "id": tool_file_id}, - value_owned_by_drive=owned, - ) - ], - session=session_factory.create_session(), - ) - - -def test_commit_then_manifest_lists_the_entry(): - tf = _seed_tool_file() - _commit("data/report.txt", tf) - - items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - assert [i["key"] for i in items] == ["data/report.txt"] - assert items[0]["file_kind"] == "tool_file" - assert items[0]["file_id"] == tf - assert items[0]["mime_type"] == "text/plain" - - # prefix filter - assert ( - AgentDriveService().manifest( - tenant_id=TENANT, agent_id=AGENT, prefix="data/", session=session_factory.create_session() - ) - != [] - ) - assert ( - AgentDriveService().manifest( - tenant_id=TENANT, agent_id=AGENT, prefix="other/", session=session_factory.create_session() - ) - == [] - ) - - -def test_commit_owned_tool_file_detaches_conversation_ownership(): - conversation_id = "44444444-4444-4444-4444-444444444444" - tool_file_id = _seed_tool_file(conversation_id=conversation_id) - - _commit("data/report.txt", tool_file_id, owned=True) - - with session_factory.create_session() as session: - tool_file = session.get(ToolFile, tool_file_id) - assert tool_file is not None - assert tool_file.conversation_id is None - - -def test_commit_shared_tool_file_keeps_conversation_ownership(): - conversation_id = "44444444-4444-4444-4444-444444444444" - tool_file_id = _seed_tool_file(conversation_id=conversation_id) - - _commit("data/report.txt", tool_file_id, owned=False) - - with session_factory.create_session() as session: - tool_file = session.get(ToolFile, tool_file_id) - assert tool_file is not None - assert tool_file.conversation_id == conversation_id - - -def test_commit_skill_row_persists_metadata_and_lists_catalog() -> None: - tf = _seed_tool_file(name="SKILL.md") - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="tender-analyzer/SKILL.md", - file_ref={"kind": "tool_file", "id": tf}, - is_skill=True, - skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="Parses RFPs."), - ) - ], - session=session_factory.create_session(), - ) - - with session_factory.create_session() as session: - row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md")) - assert row is not None - assert row.is_skill is True - assert row.skill_metadata == '{"description":"Parses RFPs.","name":"Tender Analyzer"}' - - skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - assert len(skills) == 1 - assert skills[0]["path"] == "tender-analyzer" - assert skills[0]["skill_md_key"] == "tender-analyzer/SKILL.md" - assert skills[0]["archive_key"] is None - assert skills[0]["name"] == "Tender Analyzer" - assert skills[0]["description"] == "Parses RFPs." - assert skills[0]["size"] == 5 - assert skills[0]["mime_type"] == "text/plain" - - -def test_commit_rejects_skill_row_without_skill_metadata() -> None: - tf = _seed_tool_file(name="SKILL.md") - - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="tender-analyzer/SKILL.md", - file_ref={"kind": "tool_file", "id": tf}, - is_skill=True, - ) - ], - session=session_factory.create_session(), - ) - - assert exc_info.value.code == "invalid_skill_metadata" - - -@pytest.mark.parametrize("raw_metadata", [None, '{"description":"oops"}']) -def test_list_skills_raises_controlled_error_for_invalid_stored_metadata(raw_metadata: str | None) -> None: - tf = _seed_tool_file(name="SKILL.md") - - with session_factory.create_session() as session: - session.add( - AgentDriveFile( - id="44444444-4444-4444-4444-444444444444", - tenant_id=TENANT, - agent_id=AGENT, - key="broken-skill/SKILL.md", - file_kind=AgentDriveFileKind.TOOL_FILE, - file_id=tf, - value_owned_by_drive=True, - is_skill=True, - skill_metadata=raw_metadata, - size=5, - mime_type="text/plain", - created_by=USER, - ) - ) - session.commit() - - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - - assert exc_info.value.code == "invalid_skill_metadata" - - -def test_commit_rejects_non_skill_row_with_skill_metadata() -> None: - tf = _seed_tool_file() - with pytest.raises(AgentDriveError, match="skill metadata"): - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="files/report.txt", - file_ref={"kind": "tool_file", "id": tf}, - skill_metadata=DriveSkillMetadata(name="Bad", description=""), - ) - ], - session=session_factory.create_session(), - ) - - -def test_commit_rejects_non_canonical_skill_key() -> None: - tf = _seed_tool_file(name="README.md") - with pytest.raises(AgentDriveError, match="canonical"): - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="tender-analyzer/README.md", - file_ref={"kind": "tool_file", "id": tf}, - is_skill=True, - skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description=""), - ) - ], - session=session_factory.create_session(), - ) - - -def test_commit_rejects_tool_file_not_owned_by_user(): - other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999") - with pytest.raises(AgentDriveError) as exc_info: - _commit("x.txt", other) - assert exc_info.value.status_code == 404 - assert exc_info.value.code == "source_not_found" - - -def test_commit_rejects_agent_from_another_tenant(): - tf = _seed_tool_file() - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().commit( - tenant_id="99999999-9999-9999-9999-999999999999", - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="x.txt", - file_ref={"kind": "tool_file", "id": tf}, - value_owned_by_drive=True, - ) - ], - session=session_factory.create_session(), - ) - assert exc_info.value.status_code == 404 - assert exc_info.value.code == "agent_not_found" - - -def test_overwrite_cleans_old_drive_owned_value(): - tf1 = _seed_tool_file(name="v1.txt") - tf2 = _seed_tool_file(name="v2.txt") - _commit("doc.txt", tf1, owned=True) - - with patch("services.agent_drive_service.storage") as storage_mock: - _commit("doc.txt", tf2, owned=True) - storage_mock.delete.assert_called_once() - - # old ToolFile physically removed; key now points at tf2 - with session_factory.create_session() as session: - assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is None - assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None - rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt"))) - assert len(rows) == 1 - assert rows[0].file_id == tf2 - - -def test_batch_failure_does_not_delete_old_storage_before_commit(): - tf1 = _seed_tool_file(name="v1.txt") - tf2 = _seed_tool_file(name="v2.txt") - _commit("doc.txt", tf1, owned=True) - - with patch("services.agent_drive_service.storage") as storage_mock: - with session_factory.create_session() as session: - with pytest.raises(AgentDriveError): - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="doc.txt", - file_ref={"kind": "tool_file", "id": tf2}, - value_owned_by_drive=True, - ), - DriveCommitItem( - key="bad.txt", - file_ref={"kind": "tool_file", "id": "44444444-4444-4444-4444-444444444444"}, - value_owned_by_drive=True, - ), - ], - session=session, - ) - session.rollback() - storage_mock.delete.assert_not_called() - - with session_factory.create_session() as session: - row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt")) - assert row is not None - assert row.file_id == tf1 - assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is not None - assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None - - -def test_validate_source_db_error_maps_to_404(): - """A database UUID failure maps to 404 and rolls back the real transaction.""" - - rollback_events: list[Session] = [] - - def raise_data_error(_orm_execute_state: object) -> None: - raise DataError("bad uuid", {}, Exception("invalid input syntax for uuid")) - - def record_rollback(session: Session) -> None: - rollback_events.append(session) - - with session_factory.create_session() as session: - session.begin() - event.listen(session, "do_orm_execute", raise_data_error) - event.listen(session, "after_rollback", record_rollback) - try: - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService()._validate_source( - session, - tenant_id=TENANT, - user_id="not-a-uuid", - file_kind=AgentDriveFileKind.TOOL_FILE, - file_id="also-bad", - ) - finally: - event.remove(session, "do_orm_execute", raise_data_error) - event.remove(session, "after_rollback", record_rollback) - - assert exc_info.value.status_code == 404 - assert exc_info.value.code == "source_not_found" - assert rollback_events == [session] - assert not session.in_transaction() - - -def test_recommit_same_value_is_idempotent_and_keeps_value(): - tf = _seed_tool_file() - _commit("a.txt", tf) - _commit("a.txt", tf) # no error, no cleanup - - with session_factory.create_session() as session: - assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None - rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "a.txt"))) - assert len(rows) == 1 - - -def test_recommit_same_skill_value_updates_metadata_without_cleaning_backing_file() -> None: - tf = _seed_tool_file(name="SKILL.md") - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="tender-analyzer/SKILL.md", - file_ref={"kind": "tool_file", "id": tf}, - value_owned_by_drive=True, - is_skill=True, - skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="v1"), - ) - ], - session=session_factory.create_session(), - ) - - with patch("services.agent_drive_service.storage") as storage_mock: - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="tender-analyzer/SKILL.md", - file_ref={"kind": "tool_file", "id": tf}, - value_owned_by_drive=False, - is_skill=True, - skill_metadata=DriveSkillMetadata(name="Tender Analyzer v2", description="v2"), - ) - ], - session=session_factory.create_session(), - ) - storage_mock.delete.assert_not_called() - - with session_factory.create_session() as session: - row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md")) - assert row is not None - assert row.file_id == tf - assert row.value_owned_by_drive is False - assert row.skill_metadata == '{"description":"v2","name":"Tender Analyzer v2"}' - assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None - - -def _seed_upload_file(*, name: str = "u.txt") -> str: - upload = UploadFile( - tenant_id=TENANT, - storage_type=StorageType.LOCAL, - key=f"upload_files/{TENANT}/{name}", - name=name, - size=7, - extension="txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, - created_by=USER, - created_at=datetime.datetime.now(tz=datetime.UTC), - used=False, - ) - with session_factory.create_session() as session: - session.add(upload) - session.commit() - return upload.id - - -def _commit_upload(key: str, upload_file_id: str, *, owned: bool = True): - return AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key=key, - file_ref={"kind": "upload_file", "id": upload_file_id}, - value_owned_by_drive=owned, - ) - ], - session=session_factory.create_session(), - ) - - -def test_commit_upload_file_source_and_manifest(): - uf = _seed_upload_file() - _commit_upload("docs/u.txt", uf) - - items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - assert items[0]["file_kind"] == "upload_file" - assert items[0]["file_id"] == uf - assert items[0]["mime_type"] == "text/plain" - - -def test_commit_rejects_missing_upload_file(): - with pytest.raises(AgentDriveError) as exc_info: - _commit_upload("x.txt", "44444444-4444-4444-4444-444444444444") - assert exc_info.value.status_code == 404 - assert exc_info.value.code == "source_not_found" - - -def test_overwrite_cleans_old_upload_file_value(): - u1 = _seed_upload_file(name="v1.txt") - u2 = _seed_upload_file(name="v2.txt") - _commit_upload("doc.txt", u1, owned=True) - - with patch("services.agent_drive_service.storage") as storage_mock: - _commit_upload("doc.txt", u2, owned=True) - storage_mock.delete.assert_called_once() - - with session_factory.create_session() as session: - assert session.scalar(select(UploadFile).where(UploadFile.id == u1)) is None - assert session.scalar(select(UploadFile).where(UploadFile.id == u2)) is not None - - -def test_manifest_includes_internal_download_url(): - tf = _seed_tool_file() - _commit("data/r.txt", tf) - - with ( - patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()), - patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls, - ): - runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1" - items = AgentDriveService().manifest( - tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session() - ) - - assert items[0]["download_url"] == "http://internal/files/x?sign=1" - # drive-owned resolution: internal URL (for_external=False) - assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False - - -def test_manifest_download_url_none_when_unresolvable(): - tf = _seed_tool_file() - _commit("data/r.txt", tf) - - with patch( - "services.agent_drive_service.file_factory.build_from_mapping", - side_effect=ValueError("not found"), - ): - items = AgentDriveService().manifest( - tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session() - ) - assert items[0]["download_url"] is None - - -# ── ENG-625 D5: delete ──────────────────────────────────────────────────────── - - -def test_delete_by_key_cleans_drive_owned_value(): - tf = _seed_tool_file(name="doomed.txt") - _commit("files/doomed.txt", tf, owned=True) - - with patch("services.agent_drive_service.storage") as storage_mock: - removed = AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)], - session=session_factory.create_session(), - ) - storage_mock.delete.assert_called_once() - - assert removed == [ - { - "key": "files/doomed.txt", - "file_kind": "tool_file", - "file_id": tf, - "value_owned_by_drive": True, - "is_skill": False, - "skill_metadata": None, - "removed": True, - } - ] - with session_factory.create_session() as session: - assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None - assert list(session.scalars(select(AgentDriveFile))) == [] - - -def test_commit_null_batch_removes_multiple_skill_keys(): - md = _seed_tool_file(name="SKILL.md") - zf = _seed_tool_file(name="full.zip") - _commit("tender-analyzer/SKILL.md", md, owned=True) - _commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True) - other = _seed_tool_file(name="other.txt") - _commit("files/other.txt", other, owned=True) - - with patch("services.agent_drive_service.storage"): - removed = AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem(key="tender-analyzer/SKILL.md", file_ref=None), - DriveCommitItem(key="tender-analyzer/.DIFY-SKILL-FULL.zip", file_ref=None), - ], - session=session_factory.create_session(), - ) - - assert sorted(item["key"] for item in removed) == [ - "tender-analyzer/.DIFY-SKILL-FULL.zip", - "tender-analyzer/SKILL.md", - ] - with session_factory.create_session() as session: - # both skill ToolFiles physically removed, the unrelated file untouched - assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None - assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None - assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None - keys = [row.key for row in session.scalars(select(AgentDriveFile))] - assert keys == ["files/other.txt"] - - -def test_commit_null_is_idempotent_for_missing_keys(): - removed = AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[DriveCommitItem(key="files/never-there.txt", file_ref=None)], - session=session_factory.create_session(), - ) - assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}] - - -def test_commit_null_keeps_shared_value_records(): - tf = _seed_tool_file(name="shared.txt") - _commit("files/shared.txt", tf, owned=False) - - with patch("services.agent_drive_service.storage") as storage_mock: - removed = AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[DriveCommitItem(key="files/shared.txt", file_ref=None)], - session=session_factory.create_session(), - ) - storage_mock.delete.assert_not_called() - - assert removed[0]["key"] == "files/shared.txt" - with session_factory.create_session() as session: - # only the KV row dropped; the shared ToolFile survives - assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None - - -def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles(): - """ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill - overwrites /SKILL.md and /.DIFY-SKILL-FULL.zip, physically - cleaning both old drive-owned ToolFiles.""" - old_md = _seed_tool_file(name="SKILL.md") - old_zip = _seed_tool_file(name="full-v1.zip") - _commit("pdf-toolkit/SKILL.md", old_md, owned=True) - _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True) - - new_md = _seed_tool_file(name="SKILL-v2.md") - new_zip = _seed_tool_file(name="full-v2.zip") - with patch("services.agent_drive_service.storage") as storage_mock: - _commit("pdf-toolkit/SKILL.md", new_md, owned=True) - _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True) - assert storage_mock.delete.call_count == 2 - - with session_factory.create_session() as session: - assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None - assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None - rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))} - assert rows == { - "pdf-toolkit/SKILL.md": new_md, - "pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip, - } - - -# ── ENG-624: console drive inspector (service layer) ───────────────────────── - - -def test_preview_returns_text_with_truncation_flags(): - tf = _seed_tool_file(name="SKILL.md") - _commit("pdf-toolkit/SKILL.md", tf) - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"]) - result = AgentDriveService().preview( - tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md", session=session_factory.create_session() - ) - - assert result == { - "key": "pdf-toolkit/SKILL.md", - "size": 5, - "truncated": False, - "binary": False, - "text": "# PDF Toolkit\nUse responsibly.\n", - } - - -def test_preview_marks_binary_and_oversized_content(): - tf = _seed_tool_file(name="blob.bin") - _commit("files/blob.bin", tf) - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"]) - binary = AgentDriveService().preview( - tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session() - ) - assert binary["binary"] is True - assert binary["text"] is None - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)]) - oversized = AgentDriveService().preview( - tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session() - ) - assert oversized["truncated"] is True - assert oversized["binary"] is False - assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES - - -def test_preview_unknown_key_is_404(): - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().preview( - tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md", session=session_factory.create_session() - ) - assert exc_info.value.code == "drive_key_not_found" - assert exc_info.value.status_code == 404 - - -def test_preview_rejects_cross_tenant_agent(): - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().preview( - tenant_id="99999999-9999-9999-9999-999999999999", - agent_id=AGENT, - key="pdf-toolkit/SKILL.md", - session=session_factory.create_session(), - ) - assert exc_info.value.code == "agent_not_found" - - -def test_download_url_signs_external_audience(): - tf = _seed_tool_file(name="full.zip") - _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf) - - with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver: - url = AgentDriveService().download_url( - tenant_id=TENANT, - agent_id=AGENT, - key="pdf-toolkit/.DIFY-SKILL-FULL.zip", - session=session_factory.create_session(), - ) - - assert url == "https://signed.example/x" - # console downloads are for browsers: external signing, never the internal URL - assert resolver.call_args.kwargs["for_external"] is True - assert resolver.call_args.kwargs["as_attachment"] is True - - -def test_upload_file_download_url_uses_attachment_filename(): - upload_file_id = _seed_upload_file(name="report.pdf") - _commit_upload("files/report.pdf", upload_file_id) - - with patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls: - runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf" - url = AgentDriveService().download_url( - tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf", session=session_factory.create_session() - ) - - assert url == "https://files.example/report.pdf" - assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True - assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True - - -def test_manifest_items_carry_created_at_for_inspector(): - tf = _seed_tool_file() - _commit("files/x.txt", tf) - items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int) - - -# ── DIFY-2517: skill catalog / inspect ─────────────────────────────────────── - - -def _commit_skill(*, manifest_files: list[str] | None = None) -> None: - md = _seed_tool_file(name="SKILL.md") - zf = _seed_tool_file(name="full.zip") - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="pdf-toolkit/SKILL.md", - file_ref={"kind": "tool_file", "id": md}, - value_owned_by_drive=True, - is_skill=True, - skill_metadata=DriveSkillMetadata( - name="PDF Toolkit", - description="Work with PDFs.", - manifest_files=manifest_files, - ), - ), - DriveCommitItem( - key="pdf-toolkit/.DIFY-SKILL-FULL.zip", - file_ref={"kind": "tool_file", "id": zf}, - value_owned_by_drive=True, - ), - ], - session=session_factory.create_session(), - ) - - -def test_list_skills_uses_canonical_skill_rows(): - _commit_skill(manifest_files=["SKILL.md", "scripts/run.py"]) - - skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session()) - - created_at = skills[0].pop("created_at") - assert skills == [ - { - "path": "pdf-toolkit", - "skill_md_key": "pdf-toolkit/SKILL.md", - "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", - "name": "PDF Toolkit", - "description": "Work with PDFs.", - "size": 5, - "mime_type": "text/plain", - "hash": None, - } - ] - assert created_at is None or isinstance(created_at, int) - - -def test_inspect_skill_returns_manifest_files_and_file_tree(): - _commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"]) - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"]) - result = AgentDriveService().inspect_skill( - tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session() - ) - - assert result["source"] == "skill_md" - assert result["warnings"] == [] - assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"] - assert result["files"][0]["available_in_drive"] is True - assert result["files"][1]["available_in_drive"] is True - assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md" - assert result["file_tree"][0]["name"] == "references" - assert result["file_tree"][1]["name"] == "scripts" - assert result["file_tree"][2]["name"] == "SKILL.md" - assert result["skill_md"]["text"] == "# PDF Toolkit\n" - - -def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing(): - _commit_skill(manifest_files=None) - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"]) - result = AgentDriveService().inspect_skill( - tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session() - ) - - assert result["warnings"] == ["manifest_files_unavailable"] - assert [file["path"] for file in result["files"]] == ["SKILL.md"] - - -def test_preview_skill_archive_member_from_manifest_without_drive_row(): - _commit_skill(manifest_files=["SKILL.md", "references/guide.md"]) - archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"}) - - with patch("services.agent_drive_service.storage") as storage_mock: - storage_mock.load_stream.return_value = iter([archive]) - result = AgentDriveService().preview( - tenant_id=TENANT, - agent_id=AGENT, - key="pdf-toolkit/references/guide.md", - session=session_factory.create_session(), - ) - - assert result == { - "key": "pdf-toolkit/references/guide.md", - "size": len(b"Guide content\n"), - "truncated": False, - "binary": False, - "text": "Guide content\n", - } - - -def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row(): - _commit_skill(manifest_files=["SKILL.md", "references/guide.md"]) - - with patch.object( - AgentDriveService, - "sign_archive_member_url", - return_value="https://signed.example/member", - ) as sign: - url = AgentDriveService().download_url( - tenant_id=TENANT, - agent_id=AGENT, - key="pdf-toolkit/references/guide.md", - session=session_factory.create_session(), - ) - - assert url == "https://signed.example/member" - kwargs = sign.call_args.kwargs - assert kwargs["key"] == "pdf-toolkit/references/guide.md" - assert kwargs["member_path"] == "references/guide.md" - assert kwargs["for_external"] is True - - -def test_skill_metadata_rejects_non_canonical_rows(): - tf = _seed_tool_file(name="not-skill.md") - with pytest.raises(AgentDriveError) as exc_info: - AgentDriveService().commit( - tenant_id=TENANT, - user_id=USER, - agent_id=AGENT, - items=[ - DriveCommitItem( - key="files/not-skill.md", - file_ref={"kind": "tool_file", "id": tf}, - value_owned_by_drive=True, - is_skill=True, - skill_metadata=DriveSkillMetadata(name="Bad"), - ) - ], - session=session_factory.create_session(), - ) - assert exc_info.value.code == "invalid_skill_key" diff --git a/api/tests/unit_tests/tasks/test_delete_conversation_task.py b/api/tests/unit_tests/tasks/test_delete_conversation_task.py index d928549975ba46..e3e5d84488cb6f 100644 --- a/api/tests/unit_tests/tasks/test_delete_conversation_task.py +++ b/api/tests/unit_tests/tasks/test_delete_conversation_task.py @@ -27,7 +27,7 @@ PinnedConversation, SavedMessage, ) -from models.agent import AgentConfigDraftType, AgentDriveFile, AgentDriveFileKind +from models.agent import AgentConfigDraftType from models.enums import ( ConversationFromSource, ConversationStatus, @@ -93,14 +93,13 @@ def _tool_file(*, name: str, conversation_id: str | None = CONVERSATION_ID) -> T ) -def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_session: Session) -> None: +def test_cleanup_removes_owned_resources(sqlite_session: Session) -> None: conversation = _conversation(CONVERSATION_ID, deleted=True) other_conversation = _conversation(OTHER_CONVERSATION_ID, deleted=False) message = _message() owned_file = _tool_file(name="owned.txt") - drive_file = _tool_file(name="drive.txt") other_file = _tool_file(name="other.txt", conversation_id=OTHER_CONVERSATION_ID) - sqlite_session.add_all([conversation, other_conversation, message, owned_file, drive_file, other_file]) + sqlite_session.add_all([conversation, other_conversation, message, owned_file, other_file]) sqlite_session.flush() message_chain = MessageChain(message_id=MESSAGE_ID, type=MessageChainType.SYSTEM, input=None, output=None) @@ -202,15 +201,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id=CONVERSATION_ID, ), - AgentDriveFile( - tenant_id=TENANT_ID, - agent_id=AGENT_ID, - key="drive.txt", - file_kind=AgentDriveFileKind.TOOL_FILE, - file_id=drive_file.id, - value_owned_by_drive=False, - is_skill=False, - ), HumanInputFormRecipient( form_id=form.id, delivery_id=delivery.id, @@ -230,7 +220,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio form_id = form.id owned_file_id = owned_file.id owned_file_key = owned_file.file_key - drive_file_id = drive_file.id other_file_id = other_file.id with patch("tasks.delete_conversation_task.storage") as storage_mock: @@ -245,12 +234,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio ) assert sqlite_session.scalar(select(HumanInputForm).where(HumanInputForm.id == form_id)) is None assert sqlite_session.get(ToolFile, owned_file_id) is None - preserved_drive_file = sqlite_session.get(ToolFile, drive_file_id) - assert preserved_drive_file is not None - assert preserved_drive_file.conversation_id is None - preserved_drive_entry = sqlite_session.scalar(select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)) - assert preserved_drive_entry is not None - assert preserved_drive_entry.value_owned_by_drive is True assert sqlite_session.get(ToolFile, other_file_id) is not None assert sqlite_session.get(Conversation, OTHER_CONVERSATION_ID) is not None diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main.go b/dify-agent-runtime/cmd/dify-agent-cli/main.go index 08bf33f3589c70..6f28da7432f5d8 100644 --- a/dify-agent-runtime/cmd/dify-agent-cli/main.go +++ b/dify-agent-runtime/cmd/dify-agent-cli/main.go @@ -1,6 +1,6 @@ // dify-agent-cli is the Go replacement for the Python dify-agent CLI. // It communicates with the Agent Stub server via HTTP to provide -// connect, file, drive, and config operations inside the sandbox container. +// connect, file, and config operations inside the sandbox container. package main import ( @@ -17,7 +17,6 @@ import ( var knownRootCommands = map[string]struct{}{ "config": {}, "connect": {}, - "drive": {}, "file": {}, } @@ -76,7 +75,6 @@ func newRootCommand() *cobra.Command { root.AddCommand( newConnectCommand(), newFileCommand(), - newDriveCommand(), newConfigCommand(), ) return root @@ -142,60 +140,6 @@ func newFileCommand() *cobra.Command { return cmd } -func newDriveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "drive", - Short: "List, pull, or push agent drive files through the Agent Stub.", - } - - var listJSON bool - list := &cobra.Command{ - Use: "list [REMOTE_PREFIX]", - Short: "List drive files visible to the current sandbox execution.", - Args: cobra.MaximumNArgs(1), - RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error { - prefix := "" - if len(args) > 0 { - prefix = args[0] - } - return agentcli.RunDriveList(env, prefix, listJSON) - }), - } - list.Flags().BoolVar(&listJSON, "json", false, "Emit the drive manifest as JSON.") - - var pullTo string - var pullJSON bool - pull := &cobra.Command{ - Use: "pull [REMOTE]...", - Short: "Pull one or more drive keys/prefixes into one local directory tree.", - RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error { - localBase := pullTo - if localBase == "" { - localBase = agentcli.ReadDriveBase() - } - return agentcli.RunDrivePull(env, args, localBase, pullJSON) - }), - } - pull.Flags().StringVar(&pullTo, "to", "", "Local base directory for pulled drive files.") - pull.Flags().BoolVar(&pullJSON, "json", false, "Emit the pull result as JSON.") - - var pushKind string - var pushJSON bool - push := &cobra.Command{ - Use: "push LOCAL_PATH REMOTE_PATH", - Short: "Upload one local file or directory into the agent drive.", - Args: cobra.ExactArgs(2), - RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error { - return agentcli.RunDrivePush(env, args[0], args[1], pushKind) - }), - } - push.Flags().StringVar(&pushKind, "kind", "", "Directory upload kind: skill or dir.") - push.Flags().BoolVar(&pushJSON, "json", false, "Accepted for consistency; drive push output is already emitted as JSON.") - - cmd.AddCommand(list, pull, push) - return cmd -} - func newConfigCommand() *cobra.Command { cmd := &cobra.Command{ Use: "config", diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go index 11326c9a8c51e2..2007058cb2a842 100644 --- a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go +++ b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go @@ -38,7 +38,7 @@ func TestCommandHelp(t *testing.T) { { name: "root", args: []string{"--help"}, - want: []string{"Usage:", "dify-agent", "config", "connect", "drive", "file"}, + want: []string{"Usage:", "dify-agent", "config", "connect", "file"}, }, { name: "connect", @@ -70,26 +70,6 @@ func TestCommandHelp(t *testing.T) { args: []string{"file", "public-url", "--help"}, want: []string{"dify-agent file public-url", "Create a browser-visible download URL"}, }, - { - name: "drive", - args: []string{"drive", "--help"}, - want: []string{"dify-agent drive", "list", "pull", "push"}, - }, - { - name: "drive list", - args: []string{"drive", "list", "--help"}, - want: []string{"dify-agent drive list", "List drive files", "--json"}, - }, - { - name: "drive pull", - args: []string{"drive", "pull", "--help"}, - want: []string{"dify-agent drive pull", "Pull one or more drive", "--to", "--json"}, - }, - { - name: "drive push", - args: []string{"drive", "push", "--help"}, - want: []string{"dify-agent drive push", "Upload one local file or directory", "--kind", "--json"}, - }, { name: "config", args: []string{"config", "--help"}, diff --git a/dify-agent-runtime/docker/Dockerfile b/dify-agent-runtime/docker/Dockerfile index ca058727e450ba..b0ed2e499faa7f 100644 --- a/dify-agent-runtime/docker/Dockerfile +++ b/dify-agent-runtime/docker/Dockerfile @@ -71,9 +71,8 @@ COPY --from=go-builder /bin/shellctl-runner /usr/local/bin/shellctl-runner COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent RUN useradd --create-home --shell /bin/sh dify \ - && mkdir -p /mnt/drive \ && chown dify:dify /home \ - && chown -R dify:dify /home/dify /mnt/drive + && chown -R dify:dify /home/dify USER dify WORKDIR /home/dify diff --git a/dify-agent-runtime/internal/agentcli/archive.go b/dify-agent-runtime/internal/agentcli/archive.go index 3a458009d71c10..e4b1632d03e93c 100644 --- a/dify-agent-runtime/internal/agentcli/archive.go +++ b/dify-agent-runtime/internal/agentcli/archive.go @@ -135,3 +135,26 @@ func extractZip(archivePath string, targetDir string) error { } return nil } + +func shouldSkipDir(name string) bool { + skip := map[string]bool{ + ".git": true, "__pycache__": true, ".pytest_cache": true, + ".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true, + } + return skip[name] +} + +func buildSkillArchive(dirPath string) (string, error) { + tmpFile, err := os.CreateTemp("", "skill-archive-*.zip") + if err != nil { + return "", fmt.Errorf("create temp archive: %w", err) + } + archivePath := tmpFile.Name() + _ = tmpFile.Close() + + if err := createZipArchive(archivePath, dirPath); err != nil { + _ = os.Remove(archivePath) + return "", err + } + return archivePath, nil +} diff --git a/dify-agent-runtime/internal/agentcli/client.go b/dify-agent-runtime/internal/agentcli/client.go index 8e84991ab726f5..e58efd50cff04b 100644 --- a/dify-agent-runtime/internal/agentcli/client.go +++ b/dify-agent-runtime/internal/agentcli/client.go @@ -10,10 +10,6 @@ type StubClient interface { CreateToolFileUploadURL(ctx context.Context, filename, mimetype string) (string, error) CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error) - // Drive operations (HTTP-only control-plane) - GetDriveManifest(ctx context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error) - CommitDrive(ctx context.Context, items []DriveCommitItem) ([]byte, error) - // Config operations (HTTP-only control-plane) GetConfigManifest(ctx context.Context) ([]byte, error) CreateConfigDownloadURL(ctx context.Context, kind, name string) (*FileDownloadResponse, error) diff --git a/dify-agent-runtime/internal/agentcli/client_http.go b/dify-agent-runtime/internal/agentcli/client_http.go index 14abb3f368b0de..495bf0a822c442 100644 --- a/dify-agent-runtime/internal/agentcli/client_http.go +++ b/dify-agent-runtime/internal/agentcli/client_http.go @@ -127,45 +127,6 @@ func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod return &resp, nil } -func (c *httpStubClient) GetDriveManifest(_ context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error) { - params := map[string]string{ - "prefix": prefix, - } - if includeDownloadURL { - params["include_download_url"] = "true" - } else { - params["include_download_url"] = "false" - } - - body, statusCode, err := c.http.getJSON("/drive/manifest", params) - if err != nil { - return nil, err - } - if err := checkHTTPError(body, statusCode, "drive manifest"); err != nil { - return nil, err - } - - var manifest DriveManifestResponse - if err := json.Unmarshal(body, &manifest); err != nil { - return nil, fmt.Errorf("parse drive manifest: %w", err) - } - return &manifest, nil -} - -func (c *httpStubClient) CommitDrive(_ context.Context, items []DriveCommitItem) ([]byte, error) { - payload := map[string]any{ - "items": items, - } - body, statusCode, err := c.http.postJSON("/drive/commit", payload) - if err != nil { - return nil, err - } - if err := checkHTTPError(body, statusCode, "drive commit"); err != nil { - return nil, err - } - return body, nil -} - func (c *httpStubClient) GetConfigManifest(_ context.Context) ([]byte, error) { body, statusCode, err := c.http.getJSON("/config/manifest", nil) if err != nil { diff --git a/dify-agent-runtime/internal/agentcli/config.go b/dify-agent-runtime/internal/agentcli/config.go index 69c038a8e2f450..17b4b27b731e2c 100644 --- a/dify-agent-runtime/internal/agentcli/config.go +++ b/dify-agent-runtime/internal/agentcli/config.go @@ -10,6 +10,11 @@ import ( const defaultConfigBase = ".dify_conf" +type ConfigFileRef struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + // RunConfigManifest executes the `config manifest` command. func RunConfigManifest(env *Environment) error { client, err := NewStubClient(env) @@ -216,8 +221,8 @@ func RunConfigSkillsPush(env *Environment, paths []string) error { defer func() { _ = client.Close() }() type skillPushItem struct { - Name string `json:"name"` - FileRef *DriveFileRef `json:"file_ref"` + Name string `json:"name"` + FileRef *ConfigFileRef `json:"file_ref"` } var skills []skillPushItem @@ -243,7 +248,7 @@ func RunConfigSkillsPush(env *Environment, paths []string) error { defer func() { _ = os.Remove(archivePath) }() name := filepath.Base(absPath) - fileRef, err := uploadAndPrepareConfigItem(client, archivePath) + fileRef, err := uploadConfigFile(client, archivePath) if err != nil { return fmt.Errorf("upload config skill %q: %w", name, err) } @@ -280,8 +285,8 @@ func RunConfigFilesPush(env *Environment, paths []string) error { defer func() { _ = client.Close() }() type filePushItem struct { - Name string `json:"name"` - FileRef *DriveFileRef `json:"file_ref"` + Name string `json:"name"` + FileRef *ConfigFileRef `json:"file_ref"` } var files []filePushItem @@ -296,7 +301,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error { } name := filepath.Base(absPath) - fileRef, err := uploadAndPrepareConfigItem(client, absPath) + fileRef, err := uploadConfigFile(client, absPath) if err != nil { return fmt.Errorf("upload config file %q: %w", name, err) } @@ -320,7 +325,7 @@ func RunConfigFilesPush(env *Environment, paths []string) error { return nil } -func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileRef, error) { +func uploadConfigFile(client StubClient, filePath string) (*ConfigFileRef, error) { filename := filepath.Base(filePath) mimetype := guessMIMEType(filename) uploadURL, err := client.CreateToolFileUploadURL(context.Background(), filename, mimetype) @@ -331,16 +336,16 @@ func uploadAndPrepareConfigItem(client StubClient, filePath string) (*DriveFileR if err != nil { return nil, fmt.Errorf("upload data: %w", err) } - - var uploadResult map[string]any + var uploadResult struct { + ID string `json:"id"` + } if err := json.Unmarshal(uploadBody, &uploadResult); err != nil { return nil, fmt.Errorf("parse upload result: %w", err) } - toolFileID, _ := uploadResult["id"].(string) - if toolFileID == "" { + if uploadResult.ID == "" { return nil, fmt.Errorf("upload response is missing id") } - return &DriveFileRef{Kind: "tool_file", ID: toolFileID}, nil + return &ConfigFileRef{Kind: "tool_file", ID: uploadResult.ID}, nil } // RunConfigSkillsDelete executes the `config skills delete` command. diff --git a/dify-agent-runtime/internal/agentcli/config_test.go b/dify-agent-runtime/internal/agentcli/config_test.go index 833932ff545429..b38d565e287aa6 100644 --- a/dify-agent-runtime/internal/agentcli/config_test.go +++ b/dify-agent-runtime/internal/agentcli/config_test.go @@ -13,6 +13,117 @@ import ( "testing" ) +type configPushCapture struct { + payload map[string]any + upload []byte +} + +func newConfigPushServer(t *testing.T) (*httptest.Server, *configPushCapture) { + t.Helper() + capture := &configPushCapture{} + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/agent-stub/files/upload-request": + _ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/uploads/config-asset"}) + case "/uploads/config-asset": + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing upload", http.StatusBadRequest) + return + } + defer func() { _ = file.Close() }() + capture.upload, err = io.ReadAll(file) + if err != nil { + http.Error(w, "bad upload", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"id": "tool-file-1"}) + case "/agent-stub/config/push": + if err := json.NewDecoder(r.Body).Decode(&capture.payload); err != nil { + http.Error(w, "bad config", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"result": "success"}) + default: + http.NotFound(w, r) + } + })) + return server, capture +} + +func assertConfigPushItem(t *testing.T, payload map[string]any, key string, name string) { + t.Helper() + items, ok := payload[key].([]any) + if !ok || len(items) != 1 { + t.Fatalf("%s = %#v, want one item", key, payload[key]) + } + item, ok := items[0].(map[string]any) + if !ok || item["name"] != name { + t.Fatalf("%s item = %#v", key, items[0]) + } + fileRef, ok := item["file_ref"].(map[string]any) + if !ok || fileRef["kind"] != "tool_file" || fileRef["id"] != "tool-file-1" { + t.Fatalf("file_ref = %#v", item["file_ref"]) + } +} + +func TestConfigFilesPushUploadsFileAndPushesToolFileRef(t *testing.T) { + server, capture := newConfigPushServer(t) + defer server.Close() + + filePath := filepath.Join(t.TempDir(), "guide.txt") + if err := os.WriteFile(filePath, []byte("guide"), 0o644); err != nil { + t.Fatalf("write config file: %v", err) + } + if err := RunConfigFilesPush( + &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}, + []string{filePath}, + ); err != nil { + t.Fatalf("push config file: %v", err) + } + + if string(capture.upload) != "guide" { + t.Fatalf("uploaded file = %q", capture.upload) + } + assertConfigPushItem(t, capture.payload, "files", "guide.txt") +} + +func TestConfigSkillsPushUploadsArchiveAndPushesToolFileRef(t *testing.T) { + server, capture := newConfigPushServer(t) + defer server.Close() + + skillDir := filepath.Join(t.TempDir(), "alpha") + if err := os.Mkdir(skillDir, 0o755); err != nil { + t.Fatalf("create skill directory: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Alpha\n"), 0o644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + if err := RunConfigSkillsPush( + &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}, + []string{skillDir}, + ); err != nil { + t.Fatalf("push config skill: %v", err) + } + + archive, err := zip.NewReader(bytes.NewReader(capture.upload), int64(len(capture.upload))) + if err != nil { + t.Fatalf("open uploaded skill archive: %v", err) + } + foundSkillMD := false + for _, file := range archive.File { + if file.Name == "SKILL.md" { + foundSkillMD = true + break + } + } + if !foundSkillMD { + t.Fatalf("uploaded skill archive does not contain SKILL.md") + } + assertConfigPushItem(t, capture.payload, "skills", "alpha") +} + func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) { skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n", "reference.md": "guide"}) tests := []struct { diff --git a/dify-agent-runtime/internal/agentcli/drive.go b/dify-agent-runtime/internal/agentcli/drive.go deleted file mode 100644 index 8d5e19f265a768..00000000000000 --- a/dify-agent-runtime/internal/agentcli/drive.go +++ /dev/null @@ -1,353 +0,0 @@ -package agentcli - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -// DriveItem represents one item in a drive manifest. -type DriveItem struct { - Key string `json:"key"` - Size *int64 `json:"size,omitempty"` - MimeType string `json:"mime_type,omitempty"` - Hash string `json:"hash,omitempty"` - DownloadURL *string `json:"download_url,omitempty"` -} - -// DriveManifestResponse is the drive manifest from the Agent Stub. -type DriveManifestResponse struct { - Items []DriveItem `json:"items"` -} - -// DrivePullResultItem represents one pulled drive file. -type DrivePullResultItem struct { - Key string `json:"key"` - LocalPath string `json:"local_path"` -} - -// DrivePullResult is the JSON output for `dify-agent drive pull --json`. -type DrivePullResult struct { - Items []DrivePullResultItem `json:"items"` -} - -// DriveCommitItem represents one file to commit into the drive. -type DriveCommitItem struct { - Key string `json:"key"` - FileRef DriveFileRef `json:"file_ref"` -} - -// DriveFileRef is the reference to an uploaded file. -type DriveFileRef struct { - Kind string `json:"kind"` - ID string `json:"id"` -} - -// DriveCommitResponse is the response from a drive commit. -type DriveCommitResponse struct { - Items []DriveItem `json:"items"` -} - -// RunDriveList executes the `drive list` command. -func RunDriveList(env *Environment, pathPrefix string, jsonOutput bool) error { - client, err := NewStubClient(env) - if err != nil { - return err - } - defer func() { _ = client.Close() }() - - manifest, err := client.GetDriveManifest(context.Background(), pathPrefix, false) - if err != nil { - return err - } - - if jsonOutput { - out, _ := json.Marshal(manifest) - fmt.Println(string(out)) - return nil - } - - for _, item := range manifest.Items { - size := "-" - if item.Size != nil { - size = fmt.Sprintf("%d", *item.Size) - } - mimeType := item.MimeType - if mimeType == "" { - mimeType = "-" - } - hash := item.Hash - if hash == "" { - hash = "-" - } - fmt.Printf("%s\t%s\t%s\t%s\n", size, mimeType, hash, item.Key) - } - return nil -} - -// RunDrivePull executes the `drive pull` command. -func RunDrivePull(env *Environment, targets []string, localBase string, jsonOutput bool) error { - client, err := NewStubClient(env) - if err != nil { - return err - } - defer func() { _ = client.Close() }() - - if localBase == "" { - localBase = ReadDriveBase() - } - resolvedBase, err := filepath.Abs(localBase) - if err != nil { - return fmt.Errorf("resolve drive base: %w", err) - } - - if len(targets) == 0 { - targets = []string{""} - } - - ctx := context.Background() - resultItems := []DrivePullResultItem{} - - for _, target := range targets { - manifest, err := client.GetDriveManifest(ctx, target, true) - if err != nil { - return err - } - - if len(manifest.Items) == 0 { - continue - } - - localPath := resolveDriveDestination(resolvedBase, target) - resultItems = append(resultItems, DrivePullResultItem{Key: target, LocalPath: localPath}) - - for _, item := range manifest.Items { - if item.DownloadURL == nil || *item.DownloadURL == "" { - return fmt.Errorf("drive manifest item is missing download_url: %s", item.Key) - } - - destPath := resolveDriveDestination(resolvedBase, item.Key) - destDir := filepath.Dir(destPath) - if err := os.MkdirAll(destDir, 0o755); err != nil { - return fmt.Errorf("create directory: %w", err) - } - - data, err := client.DownloadFromURL(*item.DownloadURL) - if err != nil { - return fmt.Errorf("download %s: %w", item.Key, err) - } - - if err := os.WriteFile(destPath, data, 0o644); err != nil { - return fmt.Errorf("write %s: %w", destPath, err) - } - } - } - - if jsonOutput { - out, _ := json.Marshal(DrivePullResult{Items: resultItems}) - fmt.Println(string(out)) - return nil - } - - for _, item := range resultItems { - fmt.Println(item.LocalPath) - } - return nil -} - -// RunDrivePush executes the `drive push` command. -func RunDrivePush(env *Environment, localPath string, drivePath string, kind string) error { - absPath, err := filepath.Abs(localPath) - if err != nil { - return fmt.Errorf("resolve path: %w", err) - } - - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("local path not found: %s", absPath) - } - - client, err := NewStubClient(env) - if err != nil { - return err - } - defer func() { _ = client.Close() }() - - if info.IsDir() { - if kind == "" { - return fmt.Errorf("directory drive push requires --kind skill or --kind dir") - } - if kind == "file" { - return fmt.Errorf("--kind file requires a file") - } - if kind == "dir" { - return pushDirectory(client, absPath, drivePath) - } - return pushSkillDirectory(client, absPath, drivePath) - } - - // Single file push - if kind == "skill" { - return fmt.Errorf("--kind skill requires a directory containing SKILL.md") - } - if kind == "dir" { - return fmt.Errorf("--kind dir requires a directory") - } - - commitItem, err := uploadAndPrepareCommitItem(client, absPath, drivePath) - if err != nil { - return err - } - - return commitDriveItems(client, []DriveCommitItem{*commitItem}) -} - -func pushDirectory(client StubClient, dirPath string, drivePath string) error { - var items []DriveCommitItem - - err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - if shouldSkipDir(info.Name()) { - return filepath.SkipDir - } - return nil - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("drive push does not support symlinked files: %s", path) - } - - relPath, _ := filepath.Rel(dirPath, path) - driveKey := joinDriveKey(drivePath, filepath.ToSlash(relPath)) - commitItem, err := uploadAndPrepareCommitItem(client, path, driveKey) - if err != nil { - return err - } - items = append(items, *commitItem) - return nil - }) - if err != nil { - return err - } - - if len(items) == 0 { - return fmt.Errorf("directory has no regular files: %s", dirPath) - } - - return commitDriveItems(client, items) -} - -func pushSkillDirectory(client StubClient, dirPath string, drivePath string) error { - skillMDPath := filepath.Join(dirPath, "SKILL.md") - if _, err := os.Stat(skillMDPath); os.IsNotExist(err) { - return fmt.Errorf("--kind skill requires a directory containing SKILL.md") - } - - // Upload SKILL.md - skillMDItem, err := uploadAndPrepareCommitItem(client, skillMDPath, joinDriveKey(drivePath, "SKILL.md")) - if err != nil { - return err - } - - // Build and upload archive - archivePath, err := buildSkillArchive(dirPath) - if err != nil { - return err - } - defer func() { _ = os.Remove(archivePath) }() - - archiveItem, err := uploadAndPrepareCommitItem(client, archivePath, joinDriveKey(drivePath, ".DIFY-SKILL-FULL.zip")) - if err != nil { - return err - } - - return commitDriveItems(client, []DriveCommitItem{*skillMDItem, *archiveItem}) -} - -func uploadAndPrepareCommitItem(client StubClient, filePath string, driveKey string) (*DriveCommitItem, error) { - filename := filepath.Base(filePath) - mimetype := guessMIMEType(filename) - ctx := context.Background() - - // Request upload URL - uploadURL, err := client.CreateFileUploadURL(ctx, filename, mimetype) - if err != nil { - return nil, err - } - - // Upload - uploadBody, err := client.UploadFileToURL(uploadURL, filePath, filename, mimetype) - if err != nil { - return nil, err - } - - var uploadResult map[string]any - if err := json.Unmarshal(uploadBody, &uploadResult); err != nil { - return nil, fmt.Errorf("parse upload result: %w", err) - } - - toolFileID, _ := uploadResult["id"].(string) - if toolFileID == "" { - return nil, fmt.Errorf("upload response is missing id") - } - - return &DriveCommitItem{ - Key: driveKey, - FileRef: DriveFileRef{Kind: "tool_file", ID: toolFileID}, - }, nil -} - -func commitDriveItems(client StubClient, items []DriveCommitItem) error { - body, err := client.CommitDrive(context.Background(), items) - if err != nil { - return err - } - fmt.Println(string(body)) - return nil -} - -func resolveDriveDestination(basePath string, key string) string { - if key == "" { - return basePath - } - return filepath.Join(basePath, filepath.FromSlash(key)) -} - -func joinDriveKey(base string, child string) string { - stripped := strings.TrimRight(base, "/") - child = strings.TrimLeft(child, "/") - if stripped == "" { - return child - } - return stripped + "/" + child -} - -func shouldSkipDir(name string) bool { - skip := map[string]bool{ - ".git": true, "__pycache__": true, ".pytest_cache": true, - ".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true, - } - return skip[name] -} - -// buildSkillArchive creates a zip archive of the skill directory. -func buildSkillArchive(dirPath string) (string, error) { - // Create temp file for archive - tmpFile, err := os.CreateTemp("", "skill-archive-*.zip") - if err != nil { - return "", fmt.Errorf("create temp archive: %w", err) - } - archivePath := tmpFile.Name() - _ = tmpFile.Close() - - if err := createZipArchive(archivePath, dirPath); err != nil { - _ = os.Remove(archivePath) - return "", err - } - return archivePath, nil -} diff --git a/dify-agent-runtime/internal/agentcli/env.go b/dify-agent-runtime/internal/agentcli/env.go index 04d6f5cf16d6eb..f81a3963315523 100644 --- a/dify-agent-runtime/internal/agentcli/env.go +++ b/dify-agent-runtime/internal/agentcli/env.go @@ -15,9 +15,6 @@ import ( const ( EnvAPIBaseURL = envvar.EnvAgentStubAPIBaseURL EnvAuthJWE = envvar.EnvAgentStubAuthJWE - EnvDriveBase = envvar.EnvAgentStubDriveBase - - DefaultDriveBase = envvar.DefaultDriveBase ) // Environment holds validated Agent Stub connection parameters. @@ -65,14 +62,6 @@ func HasEnvironment() bool { return os.Getenv(EnvAPIBaseURL) != "" && os.Getenv(EnvAuthJWE) != "" } -// ReadDriveBase returns the configured drive base or the default. -func ReadDriveBase() string { - if v := strings.TrimSpace(os.Getenv(EnvDriveBase)); v != "" { - return v - } - return DefaultDriveBase -} - // ParseEndpoint parses an Agent Stub URL and normalizes it. func ParseEndpoint(rawURL string) (*Endpoint, error) { stripped := strings.TrimSpace(rawURL) diff --git a/dify-agent-runtime/internal/agentcli/env_test.go b/dify-agent-runtime/internal/agentcli/env_test.go index d5b333c82e3c2b..4347fcaf199e1b 100644 --- a/dify-agent-runtime/internal/agentcli/env_test.go +++ b/dify-agent-runtime/internal/agentcli/env_test.go @@ -140,17 +140,3 @@ func TestReadEnvironment_Valid(t *testing.T) { t.Errorf("AuthJWE = %q, want %q", env.AuthJWE, "test-token") } } - -func TestReadDriveBase_Default(t *testing.T) { - t.Setenv(EnvDriveBase, "") - if got := ReadDriveBase(); got != DefaultDriveBase { - t.Errorf("ReadDriveBase() = %q, want %q", got, DefaultDriveBase) - } -} - -func TestReadDriveBase_Custom(t *testing.T) { - t.Setenv(EnvDriveBase, "/custom/drive") - if got := ReadDriveBase(); got != "/custom/drive" { - t.Errorf("ReadDriveBase() = %q, want %q", got, "/custom/drive") - } -} diff --git a/dify-agent-runtime/internal/envvar/envvar.go b/dify-agent-runtime/internal/envvar/envvar.go index 40f9f9a97adac7..6c14eb056664d8 100644 --- a/dify-agent-runtime/internal/envvar/envvar.go +++ b/dify-agent-runtime/internal/envvar/envvar.go @@ -28,13 +28,6 @@ const ( // EnvAgentStubAuthJWE is the per-request JWE token for Agent Stub auth. EnvAgentStubAuthJWE = "DIFY_AGENT_STUB_AUTH_JWE" - - // EnvAgentStubDriveBase is the sandbox-local drive directory for the agent. - EnvAgentStubDriveBase = "DIFY_AGENT_STUB_DRIVE_BASE" - - // DefaultDriveBase is the default Agent Stub drive mount point. - // currently unused. - DefaultDriveBase = "/mnt/drive" ) // PathIsolationEnabled returns whether Landlock filesystem isolation is active. diff --git a/dify-agent-runtime/internal/landlock/config.go b/dify-agent-runtime/internal/landlock/config.go index 46e10e5547497f..234174840e2c06 100644 --- a/dify-agent-runtime/internal/landlock/config.go +++ b/dify-agent-runtime/internal/landlock/config.go @@ -19,7 +19,7 @@ type Config struct { var ( // DefaultRWPaths are directories granted read-write access besides HOME. - // Agent-specific paths (e.g. drive base) are added dynamically by the runner. + // Agent-specific paths are added dynamically by the runner. DefaultRWPaths = []string{} // DefaultROPaths are directories granted read-only + execute access. diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 44c20ad0432cb6..d89310a9e415dd 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -21,7 +21,7 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi # Dify API inner endpoints -# Base URL for Dify API inner endpoints used by Agent Stub config/file/drive requests. +# Base URL for Dify API inner endpoints used by Agent Stub config and file requests. DIFY_AGENT_INNER_API_URL=http://localhost:5001 # Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY. DIFY_AGENT_INNER_API_KEY= diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index 9520b6e29cd64c..63f76ecce1d255 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -28,7 +28,6 @@ RunLayerSpec( | Config field | Meaning | | --- | --- | -| `agent_stub_drive_ref` | Optional Drive ref used by shell-visible Agent Stub commands. | | `cli_tools` | CLI bootstrap declarations with install commands and scoped environment metadata. | | `env` | Normal environment variables exported to Shell commands. | | `secret_refs` | Names of secret environment variables supplied by the backend environment. | diff --git a/dify-agent/src/dify_agent/agent_stub/_constants.py b/dify-agent/src/dify_agent/agent_stub/_constants.py deleted file mode 100644 index d21e073f5efbd6..00000000000000 --- a/dify-agent/src/dify_agent/agent_stub/_constants.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Zero-side-effect Agent Stub constants shared across client-safe modules.""" - -from __future__ import annotations - -from typing import Final - - -AGENT_STUB_DRIVE_BASE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_DRIVE_BASE" -DEFAULT_AGENT_STUB_DRIVE_BASE: Final[str] = "/mnt/drive" - - -__all__ = [ - "AGENT_STUB_DRIVE_BASE_ENV_VAR", - "DEFAULT_AGENT_STUB_DRIVE_BASE", -] diff --git a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py deleted file mode 100644 index b59395496d18ec..00000000000000 --- a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Shared drive download materialization helpers. - -This module centralizes the safety-critical filesystem logic used by both the -sandbox-visible CLI and the runtime drive layer. It owns path resolution under -one local drive base, overwrite-via-temp-file semantics, payload size checks, -and safe extraction of downloaded skill archives so those invariants cannot -drift between the two call sites. -""" - -from __future__ import annotations - -import stat -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from tempfile import TemporaryDirectory -from typing import Final -from uuid import uuid4 -from zipfile import BadZipFile, ZipFile, ZipInfo - - -SKILL_ARCHIVE_FILENAME: Final[str] = ".DIFY-SKILL-FULL.zip" - - -@dataclass(frozen=True, slots=True) -class DriveDownloadPayload: - """One downloaded drive payload ready to materialize under a local base.""" - - key: str - payload: bytes - size: int | None = None - - -class DriveMaterializationValidationError(ValueError): - """Raised when one drive key or archive entry is structurally unsafe.""" - - -class DriveMaterializationTransferError(RuntimeError): - """Raised when one downloaded payload cannot be safely materialized.""" - - -def materialize_drive_downloads( - *, - base_path: Path, - downloads: list[DriveDownloadPayload], -) -> list[Path]: - """Write downloaded drive payloads under one local base and extract skills. - - The helper preserves caller-provided order in the returned list of paths. - Skill archives are extracted and deleted only after every payload has been - written successfully so partial extraction cannot outlive a later failure in - the same batch. The returned path for an archive is the path where it was - downloaded before successful extraction. - """ - - resolved_base_path = base_path.expanduser().resolve() - try: - _ = resolved_base_path.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise DriveMaterializationTransferError(f"failed to prepare drive base {resolved_base_path}") from exc - - written_paths: list[Path] = [] - archive_paths: list[Path] = [] - for download in downloads: - if download.size is not None and len(download.payload) != download.size: - raise DriveMaterializationTransferError(f"downloaded drive file size mismatch for {download.key}") - destination = resolve_drive_destination(resolved_base_path, download.key) - try: - destination.parent.mkdir(parents=True, exist_ok=True) - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - _ = temp_path.write_bytes(download.payload) - _ = temp_path.replace(destination) - except OSError as exc: - raise DriveMaterializationTransferError(f"failed to materialize drive file {download.key}") from exc - written_paths.append(destination) - if destination.name == SKILL_ARCHIVE_FILENAME: - archive_paths.append(destination) - - for archive_path in sorted(archive_paths): - extract_skill_archive(archive_path) - _delete_extracted_archive(archive_path) - return written_paths - - -def resolve_drive_destination(base_path: Path, drive_key: str) -> Path: - """Resolve one drive key under a local base and reject path traversal.""" - - destination = (base_path / Path(drive_key)).resolve() - try: - destination.relative_to(base_path) - except ValueError as exc: - raise DriveMaterializationValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc - return destination - - -def extract_archive_to_directory(archive_path: Path, *, target_dir: Path) -> None: - """Safely extract one downloaded archive into one resolved target directory.""" - - resolved_target_dir = target_dir.resolve() - try: - with TemporaryDirectory(dir=resolved_target_dir, prefix=".dify-skill-extract-") as staging_dir_name: - staging_dir = Path(staging_dir_name).resolve() - with ZipFile(archive_path) as archive: - for zip_info in archive.infolist(): - destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename) - if _is_zip_symlink(zip_info): - raise DriveMaterializationValidationError( - f"skill archive contains unsupported symlink entry: {zip_info.filename}" - ) - if zip_info.is_dir(): - destination.mkdir(parents=True, exist_ok=True) - continue - destination.parent.mkdir(parents=True, exist_ok=True) - with archive.open(zip_info) as source_file: - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - _ = temp_path.write_bytes(source_file.read()) - _ = temp_path.replace(destination) - for staged_path in sorted(staging_dir.rglob("*")): - if staged_path.is_dir(): - continue - relative_path = staged_path.relative_to(staging_dir) - destination = (resolved_target_dir / relative_path).resolve() - destination.parent.mkdir(parents=True, exist_ok=True) - _ = staged_path.replace(destination) - except DriveMaterializationValidationError: - raise - except (BadZipFile, OSError) as exc: - raise DriveMaterializationTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc - - -def extract_skill_archive(archive_path: Path) -> None: - """Safely extract one downloaded skill archive into its containing directory.""" - - extract_archive_to_directory(archive_path, target_dir=archive_path.parent.resolve()) - - -def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path: - normalized_name = entry_name.replace("\\", "/") - pure_path = PurePosixPath(normalized_name) - if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute(): - raise DriveMaterializationValidationError(f"skill archive contains unsafe absolute path: {entry_name}") - if any(part in {"", ".", ".."} for part in pure_path.parts): - raise DriveMaterializationValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}") - destination = (target_dir / Path(*pure_path.parts)).resolve() - try: - destination.relative_to(target_dir) - except ValueError as exc: - raise DriveMaterializationValidationError( - f"skill archive entry resolves outside the skill directory: {entry_name}" - ) from exc - return destination - - -def _is_zip_symlink(zip_info: ZipInfo) -> bool: - file_mode = zip_info.external_attr >> 16 - return stat.S_ISLNK(file_mode) - - -def _delete_extracted_archive(archive_path: Path) -> None: - try: - archive_path.unlink(missing_ok=True) - except OSError as exc: - raise DriveMaterializationTransferError( - f"failed to delete extracted skill archive: {archive_path.name}" - ) from exc - - -__all__ = [ - "DriveDownloadPayload", - "DriveMaterializationTransferError", - "DriveMaterializationValidationError", - "SKILL_ARCHIVE_FILENAME", - "extract_archive_to_directory", - "extract_skill_archive", - "materialize_drive_downloads", - "resolve_drive_destination", -] diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py index 9bd7d1f994acc0..d776f92ec832e2 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -1,7 +1,5 @@ """Client-safe protocol exports for the Dify Agent Stub package.""" -from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE - from .agent_stub import ( AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_PROTOCOL_VERSION, @@ -20,12 +18,6 @@ AgentStubConfigPushSkillItem, AgentStubConfigSkillItem, AgentStubConfigVersionInfo, - AgentStubDriveCommitItem, - AgentStubDriveCommitRequest, - AgentStubDriveCommitResponse, - AgentStubDriveFileRef, - AgentStubDriveItem, - AgentStubDriveManifestResponse, AgentStubEndpoint, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, @@ -39,9 +31,6 @@ agent_stub_config_push_url, agent_stub_config_skill_inspect_url, agent_stub_connections_url, - agent_stub_drive_base_for_ref, - agent_stub_drive_commit_url, - agent_stub_drive_manifest_url, agent_stub_file_download_request_url, agent_stub_file_upload_request_url, is_canonical_dify_file_reference, @@ -51,10 +40,8 @@ __all__ = [ "AGENT_STUB_AUTH_JWE_ENV_VAR", - "AGENT_STUB_DRIVE_BASE_ENV_VAR", "AGENT_STUB_PROTOCOL_VERSION", "AGENT_STUB_API_BASE_URL_ENV_VAR", - "DEFAULT_AGENT_STUB_DRIVE_BASE", "AgentStubConnectRequest", "AgentStubConnectResponse", "AgentStubConfigDownloadSource", @@ -69,12 +56,6 @@ "AgentStubConfigPushSkillItem", "AgentStubConfigSkillItem", "AgentStubConfigVersionInfo", - "AgentStubDriveCommitItem", - "AgentStubDriveCommitRequest", - "AgentStubDriveCommitResponse", - "AgentStubDriveFileRef", - "AgentStubDriveItem", - "AgentStubDriveManifestResponse", "AgentStubEndpoint", "AgentStubFileDownloadRequest", "AgentStubFileDownloadResponse", @@ -88,9 +69,6 @@ "agent_stub_config_push_url", "agent_stub_config_skill_inspect_url", "agent_stub_connections_url", - "agent_stub_drive_base_for_ref", - "agent_stub_drive_commit_url", - "agent_stub_drive_manifest_url", "agent_stub_file_download_request_url", "agent_stub_file_upload_request_url", "is_canonical_dify_file_reference", diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py index 3b0c8c419bb684..8a65c19d94d713 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -18,9 +18,6 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue, model_validator -from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE - - AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1 AGENT_STUB_API_BASE_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_API_BASE_URL" AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE" @@ -39,17 +36,6 @@ class AgentStubEndpoint: path: str -def agent_stub_drive_base_for_ref(drive_ref: str | None) -> str: - """Return the fixed sandbox-local Agent Stub drive base for one drive ref.""" - normalized_ref = (drive_ref or "").strip() - if not normalized_ref: - return DEFAULT_AGENT_STUB_DRIVE_BASE - drive_ref_parts = normalized_ref.split("/") - if normalized_ref.startswith("/") or any(part in {"", ".", ".."} for part in drive_ref_parts): - raise ValueError("Agent Stub drive_ref must be a safe relative path") - return f"{DEFAULT_AGENT_STUB_DRIVE_BASE.rstrip('/')}/{'/'.join(drive_ref_parts)}" - - def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint: """Parse an HTTP(S) Agent Stub endpoint and normalize its API root.""" stripped = url.strip() @@ -103,16 +89,6 @@ def agent_stub_file_download_request_url(base_url: str) -> str: return f"{_require_http_base_url(base_url)}/files/download-request" -def agent_stub_drive_manifest_url(base_url: str) -> str: - """Return the stable HTTP drive-manifest endpoint URL for one base URL.""" - return f"{_require_http_base_url(base_url)}/drive/manifest" - - -def agent_stub_drive_commit_url(base_url: str) -> str: - """Return the stable HTTP drive-commit endpoint URL for one base URL.""" - return f"{_require_http_base_url(base_url)}/drive/commit" - - def agent_stub_config_manifest_url(base_url: str) -> str: """Return the stable HTTP config-manifest endpoint URL for one base URL.""" return f"{_require_http_base_url(base_url)}/config/manifest" @@ -270,70 +246,6 @@ class AgentStubFileDownloadResponse(BaseModel): download_url: str -class AgentStubDriveFileRef(BaseModel): - """Trusted file reference used by Agent Stub drive commit requests.""" - - kind: Literal["upload_file", "tool_file"] - id: str - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - -class AgentStubDriveCommitItem(BaseModel): - """One drive key to file binding committed through the Agent Stub.""" - - key: str - file_ref: AgentStubDriveFileRef | None = None - value_owned_by_drive: bool = True - is_skill: bool = False - skill_metadata: dict[str, str] | None = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - -class AgentStubDriveCommitRequest(BaseModel): - """Request body for one Agent Stub drive commit batch.""" - - items: list[AgentStubDriveCommitItem] - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - -class AgentStubDriveItem(BaseModel): - """One manifest or commit item returned by the Agent Stub drive API. - - Known stable fields stay typed, while extra response metadata from the Dify - API is preserved for forward compatibility. - """ - - key: str - size: int | None = None - hash: str | None = None - mime_type: str | None = None - file_kind: Literal["upload_file", "tool_file"] | None = None - file_id: str | None = None - created_at: int | None = None - download_url: str | None = None - value_owned_by_drive: bool | None = None - removed: bool | None = None - is_skill: bool | None = None - skill_metadata: str | None = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") - - -class AgentStubDriveManifestResponse(BaseModel): - """Response body for one Agent Stub drive manifest request.""" - - items: list[AgentStubDriveItem] - - -class AgentStubDriveCommitResponse(BaseModel): - """Response body for one Agent Stub drive commit request.""" - - items: list[AgentStubDriveItem] - - class AgentStubConfigVersionInfo(BaseModel): id: str kind: Literal["snapshot", "draft", "build_draft"] @@ -424,10 +336,8 @@ def _require_http_base_url(base_url: str) -> str: __all__ = [ "AGENT_STUB_AUTH_JWE_ENV_VAR", - "AGENT_STUB_DRIVE_BASE_ENV_VAR", "AGENT_STUB_PROTOCOL_VERSION", "AGENT_STUB_API_BASE_URL_ENV_VAR", - "DEFAULT_AGENT_STUB_DRIVE_BASE", "AgentStubConnectRequest", "AgentStubConnectResponse", "AgentStubEndpoint", @@ -445,12 +355,6 @@ def _require_http_base_url(base_url: str) -> str: "AgentStubConfigSkillItem", "AgentStubConfigSkillItemsResponse", "AgentStubConfigVersionInfo", - "AgentStubDriveCommitItem", - "AgentStubDriveCommitRequest", - "AgentStubDriveCommitResponse", - "AgentStubDriveFileRef", - "AgentStubDriveItem", - "AgentStubDriveManifestResponse", "AgentStubFileDownloadRequest", "AgentStubFileDownloadResponse", "AgentStubFileMapping", @@ -463,9 +367,6 @@ def _require_http_base_url(base_url: str) -> str: "agent_stub_config_push_url", "agent_stub_config_skill_inspect_url", "agent_stub_connections_url", - "agent_stub_drive_base_for_ref", - "agent_stub_drive_commit_url", - "agent_stub_drive_manifest_url", "agent_stub_file_download_request_url", "agent_stub_file_upload_request_url", "is_canonical_dify_file_reference", diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py deleted file mode 100644 index 8c86e5bc806510..00000000000000 --- a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Server-side Dify API client for Agent Stub drive endpoints. - -The Agent Stub drive API is an HTTP-only control plane over the existing Dify -agent drive inner APIs. Sandbox callers never send trusted tenant, agent, or -user ids directly; this module receives an authenticated ``AgentStubPrincipal``, -derives ``agent-`` from execution context, injects trusted identity -fields into the Dify inner request, and normalizes transport, HTTP, JSON, and -schema failures into ``AgentStubDriveRequestError`` for the route layer. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Protocol - -import httpx -from pydantic import ValidationError - -from dify_agent.agent_stub.protocol.agent_stub import ( - AgentStubDriveCommitRequest, - AgentStubDriveCommitResponse, - AgentStubDriveManifestResponse, -) -from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal -from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig - - -class AgentStubDriveRequestHandler(Protocol): - """Trusted control-plane bridge from sandbox drive calls to Dify inner APIs.""" - - async def get_manifest( - self, - *, - principal: AgentStubPrincipal, - prefix: str, - include_download_url: bool, - ) -> AgentStubDriveManifestResponse: ... - - async def commit( - self, - *, - principal: AgentStubPrincipal, - request: AgentStubDriveCommitRequest, - ) -> AgentStubDriveCommitResponse: ... - - -class AgentStubDriveRequestError(RuntimeError): - """Raised when the Agent Stub cannot complete one drive control-plane call.""" - - status_code: int - detail: object - - def __init__(self, status_code: int, detail: object) -> None: - self.status_code = status_code - self.detail = detail - super().__init__(str(detail)) - - -@dataclass(slots=True) -class DifyApiAgentStubDriveRequestHandler: - """Call Dify API inner drive endpoints on behalf of authenticated sandboxes. - - Manifest requests require ``tenant_id`` and ``agent_id`` from execution - context and forward query parameters to - ``/inner/api/drive/agent-/manifest``. Commit requests additionally - require ``user_id`` and post a raw JSON payload to - ``/inner/api/drive/agent-/commit``. Dify drive endpoints return - raw ``{"items": [...]}`` payloads instead of plugin-style ``data`` envelopes, - so this module validates the raw success payload directly. - """ - - inner_api_url: str - inner_api_key: str - timeout: httpx.Timeout | float = 30.0 - - async def get_manifest( - self, - *, - principal: AgentStubPrincipal, - prefix: str, - include_download_url: bool, - ) -> AgentStubDriveManifestResponse: - """Request one drive manifest from Dify's inner drive manifest endpoint.""" - execution_context = self._require_agent_context(principal.execution_context) - payload = await self._get_inner_api( - f"/inner/api/drive/{self._drive_ref(execution_context)}/manifest", - { - "tenant_id": execution_context.tenant_id, - "prefix": prefix, - "include_download_url": str(include_download_url).lower(), - }, - ) - try: - return AgentStubDriveManifestResponse.model_validate(payload) - except ValidationError as exc: - raise AgentStubDriveRequestError(502, "Dify API drive manifest response is invalid") from exc - - async def commit( - self, - *, - principal: AgentStubPrincipal, - request: AgentStubDriveCommitRequest, - ) -> AgentStubDriveCommitResponse: - """Commit one drive batch through Dify's inner drive commit endpoint.""" - execution_context = self._require_user_context(self._require_agent_context(principal.execution_context)) - payload = await self._post_inner_api( - f"/inner/api/drive/{self._drive_ref(execution_context)}/commit", - { - "tenant_id": execution_context.tenant_id, - "user_id": execution_context.user_id, - "items": [item.model_dump(mode="json", exclude_none=True) for item in request.items], - }, - ) - try: - return AgentStubDriveCommitResponse.model_validate(payload) - except ValidationError as exc: - raise AgentStubDriveRequestError(502, "Dify API drive commit response is invalid") from exc - - def _require_agent_context( - self, execution_context: DifyExecutionContextLayerConfig - ) -> DifyExecutionContextLayerConfig: - if execution_context.agent_id is None: - raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations") - return execution_context - - def _require_user_context( - self, execution_context: DifyExecutionContextLayerConfig - ) -> DifyExecutionContextLayerConfig: - if execution_context.user_id is None: - raise AgentStubDriveRequestError(400, "execution context user_id is required for drive commit") - return execution_context - - @staticmethod - def _drive_ref(execution_context: DifyExecutionContextLayerConfig) -> str: - agent_id = execution_context.agent_id - if agent_id is None: - raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations") - return f"agent-{agent_id}" - - async def _get_inner_api(self, path: str, params: Mapping[str, str]) -> object: - url = f"{self.inner_api_url.rstrip('/')}{path}" - async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client: - try: - response = await client.get( - url, - params=dict(params), - headers={"X-Inner-Api-Key": self.inner_api_key}, - ) - except httpx.TimeoutException as exc: - raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc - except httpx.RequestError as exc: - raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc - return self._normalize_payload(response) - - async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> object: - url = f"{self.inner_api_url.rstrip('/')}{path}" - async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client: - try: - response = await client.post( - url, - json=dict(payload), - headers={"X-Inner-Api-Key": self.inner_api_key}, - ) - except httpx.TimeoutException as exc: - raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc - except httpx.RequestError as exc: - raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc - return self._normalize_payload(response) - - def _normalize_payload(self, response: httpx.Response) -> object: - raw_payload = self._parse_json(response) - if response.is_error: - detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload - raise AgentStubDriveRequestError(response.status_code, detail) - return raw_payload - - @staticmethod - def _parse_json(response: httpx.Response) -> object: - try: - return response.json() - except ValueError as exc: - raise AgentStubDriveRequestError(502, "Dify API drive request returned invalid JSON") from exc - - -__all__ = [ - "AgentStubDriveRequestError", - "AgentStubDriveRequestHandler", - "DifyApiAgentStubDriveRequestHandler", -] diff --git a/dify-agent/src/dify_agent/agent_stub/server/app.py b/dify-agent/src/dify_agent/agent_stub/server/app.py index ba16abf18c77ed..2e417ffa1164cc 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/app.py +++ b/dify-agent/src/dify_agent/agent_stub/server/app.py @@ -2,7 +2,7 @@ The standalone stub server is only a convenience wrapper around the shared router. It reuses the main ``ServerSettings`` model and derives the Agent Stub -token codec plus optional file and drive request bridges from the same helper +token codec plus optional file and config request bridges from the same helper methods that the standard run server uses before mounting ``create_agent_stub_router(...)``. """ @@ -24,7 +24,6 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI: token_codec=resolved_settings.create_agent_stub_token_codec(), file_request_handler=resolved_settings.create_agent_stub_file_request_handler(), config_request_handler=resolved_settings.create_agent_stub_config_request_handler(), - drive_request_handler=resolved_settings.create_agent_stub_drive_request_handler(), ) ) return app diff --git a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py index badf83dc41ce59..23cc1ee7d72f01 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py +++ b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py @@ -1,6 +1,6 @@ """Shared Agent Stub HTTP control-plane service. -This layer owns authenticated delegation for file, config, and drive operations. +This layer owns authenticated delegation for file and config operations. The HTTP adapter validates transport DTOs before calling into this service. """ @@ -15,16 +15,12 @@ AgentStubConfigManifestResponse, AgentStubConfigPushRequest, AgentStubConfigPushResponse, - AgentStubDriveCommitRequest, - AgentStubDriveCommitResponse, - AgentStubDriveManifestResponse, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, AgentStubFileUploadRequest, AgentStubFileUploadResponse, ) from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestError, AgentStubConfigRequestHandler -from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import ( AgentStubPrincipal, @@ -71,7 +67,6 @@ class AgentStubControlPlaneService: token_codec: AgentStubTokenCodec | None file_request_handler: AgentStubFileRequestHandler | None = None config_request_handler: AgentStubConfigRequestHandler | None = None - drive_request_handler: AgentStubDriveRequestHandler | None = None connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4())) async def connect(self, *, authorization: str | None) -> AgentStubConnectResponse: @@ -115,25 +110,6 @@ async def create_file_download_request( except AgentStubFileRequestError as exc: raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc - async def get_drive_manifest( - self, - *, - prefix: str, - include_download_url: bool, - authorization: str | None, - ) -> AgentStubDriveManifestResponse: - """Authenticate and delegate one drive manifest request.""" - principal = self._authenticate(authorization) - handler = self._require_drive_request_handler() - try: - return await handler.get_manifest( - principal=principal, - prefix=prefix, - include_download_url=include_download_url, - ) - except AgentStubDriveRequestError as exc: - raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc - async def get_config_manifest( self, *, @@ -198,20 +174,6 @@ async def update_config_note( except AgentStubConfigRequestError as exc: raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc - async def commit_drive( - self, - *, - request: AgentStubDriveCommitRequest, - authorization: str | None, - ) -> AgentStubDriveCommitResponse: - """Authenticate and delegate one drive commit request.""" - principal = self._authenticate(authorization) - handler = self._require_drive_request_handler() - try: - return await handler.commit(principal=principal, request=request) - except AgentStubDriveRequestError as exc: - raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc - def _authenticate(self, authorization: str | None, *, expose_expiration: bool = False) -> AgentStubPrincipal: token_codec = self.token_codec if token_codec is None: @@ -238,11 +200,6 @@ def _require_config_request_handler(self) -> AgentStubConfigRequestHandler: raise AgentStubConfigurationError(503, "Agent Stub config API is not configured") return self.config_request_handler - def _require_drive_request_handler(self) -> AgentStubDriveRequestHandler: - if self.drive_request_handler is None: - raise AgentStubConfigurationError(503, "Agent Stub drive API is not configured") - return self.drive_request_handler - __all__ = [ "AgentStubAuthenticationError", diff --git a/dify-agent/src/dify_agent/agent_stub/server/router.py b/dify-agent/src/dify_agent/agent_stub/server/router.py index 5c77202093d30e..2472608360422a 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/router.py +++ b/dify-agent/src/dify_agent/agent_stub/server/router.py @@ -1,7 +1,7 @@ """Embeddable router factory for Dify Agent stub endpoints. Both the standalone stub server and the standard run server mount the same -router so the Agent Stub protocol, token validation, and file/drive +router so the Agent Stub protocol, token validation, and file/config control-plane behavior stay identical regardless of hosting mode. The factory is intentionally settings-agnostic: callers must pass already constructed token-codec and request-handler dependencies rather than having this module read @@ -13,7 +13,6 @@ from fastapi import APIRouter from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler -from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec @@ -23,14 +22,12 @@ def create_agent_stub_router( *, token_codec: AgentStubTokenCodec | None, file_request_handler: AgentStubFileRequestHandler | None = None, - drive_request_handler: AgentStubDriveRequestHandler | None = None, config_request_handler: AgentStubConfigRequestHandler | None = None, ) -> APIRouter: """Build the embeddable stub router from pre-built server dependencies.""" return create_agent_stub_http_router( token_codec, file_request_handler, - drive_request_handler, config_request_handler, ) diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py index 5bcaa978b6a1be..6778a5905560d8 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py @@ -2,7 +2,7 @@ The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It keeps FastAPI-specific request parsing and HTTPException translation here while -the service owns auth and file/config/drive delegation. +the service owns auth and file/config delegation. """ from __future__ import annotations @@ -17,16 +17,12 @@ AgentStubConfigNoteUpdateRequest, AgentStubConfigPushRequest, AgentStubConfigPushResponse, - AgentStubDriveCommitRequest, - AgentStubDriveCommitResponse, - AgentStubDriveManifestResponse, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, AgentStubFileUploadRequest, AgentStubFileUploadResponse, ) from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler -from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec @@ -35,7 +31,6 @@ def create_agent_stub_http_router( token_codec: AgentStubTokenCodec | None, file_request_handler: AgentStubFileRequestHandler | None = None, - drive_request_handler: AgentStubDriveRequestHandler | None = None, config_request_handler: AgentStubConfigRequestHandler | None = None, ) -> APIRouter: """Create HTTP routes bound to the application's Agent Stub dependencies.""" @@ -44,7 +39,6 @@ def create_agent_stub_http_router( token_codec=token_codec, file_request_handler=file_request_handler, config_request_handler=config_request_handler, - drive_request_handler=drive_request_handler, ) @router.post("/connections", response_model=AgentStubConnectResponse) @@ -132,31 +126,6 @@ async def update_config_note( except AgentStubControlPlaneError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - @router.get("/drive/manifest", response_model=AgentStubDriveManifestResponse) - async def get_drive_manifest( - prefix: str = "", - include_download_url: bool = False, - authorization: str | None = Header(default=None, alias="Authorization"), - ) -> AgentStubDriveManifestResponse: - try: - return await service.get_drive_manifest( - prefix=prefix, - include_download_url=include_download_url, - authorization=authorization, - ) - except AgentStubControlPlaneError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - @router.post("/drive/commit", response_model=AgentStubDriveCommitResponse) - async def commit_drive( - request: AgentStubDriveCommitRequest, - authorization: str | None = Header(default=None, alias="Authorization"), - ) -> AgentStubDriveCommitResponse: - try: - return await service.commit_drive(request=request, authorization=authorization) - except AgentStubControlPlaneError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - return router diff --git a/dify-agent/src/dify_agent/agent_stub/shell_env.py b/dify-agent/src/dify_agent/agent_stub/shell_env.py index dd81ee4e75aa80..ce1f6174515556 100644 --- a/dify-agent/src/dify_agent/agent_stub/shell_env.py +++ b/dify-agent/src/dify_agent/agent_stub/shell_env.py @@ -1,9 +1,8 @@ """Client-safe shell environment helpers for Agent Stub forwarding. Only user-visible ``shell.run`` commands receive these variables. Internal -lifecycle commands remain free of Agent Stub credentials and drive-base -defaults so workspace setup and cleanup cannot accidentally inherit -user-facing forwarding state. The module stays server-extra-free because the +lifecycle commands remain free of Agent Stub credentials so workspace setup +and cleanup cannot accidentally inherit user-facing forwarding state. The module stays server-extra-free because the shell runtime and provider factory use it in sandbox-visible paths. """ @@ -11,11 +10,9 @@ from typing import Protocol -from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR from dify_agent.agent_stub.protocol.agent_stub import ( AGENT_STUB_API_BASE_URL_ENV_VAR, AGENT_STUB_AUTH_JWE_ENV_VAR, - agent_stub_drive_base_for_ref, normalize_agent_stub_api_base_url, ) from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig @@ -30,29 +27,21 @@ def __call__(self, execution_context: DifyExecutionContextLayerConfig, *, sessio def build_shell_agent_stub_env( *, agent_stub_api_base_url: str | None, - agent_stub_drive_ref: str | None = None, execution_context: DifyExecutionContextLayerConfig | None, token_factory: ShellAgentStubTokenFactory | None, session_id: str | None, ) -> dict[str, str] | None: - """Build the shell-visible Agent Stub environment for one user command. - - ``agent_stub_drive_ref`` is the storage reference from the bound - ``dify.drive`` layer. The sandbox-local base is fixed by the Agent Stub - contract and derived here at shell-run injection time. - """ + """Build the shell-visible Agent Stub environment for one user command.""" if agent_stub_api_base_url is None or execution_context is None or token_factory is None: return None return { AGENT_STUB_API_BASE_URL_ENV_VAR: normalize_agent_stub_api_base_url(agent_stub_api_base_url), AGENT_STUB_AUTH_JWE_ENV_VAR: token_factory(execution_context, session_id=session_id), - AGENT_STUB_DRIVE_BASE_ENV_VAR: agent_stub_drive_base_for_ref(agent_stub_drive_ref), } __all__ = [ "AGENT_STUB_AUTH_JWE_ENV_VAR", - "AGENT_STUB_DRIVE_BASE_ENV_VAR", "AGENT_STUB_API_BASE_URL_ENV_VAR", "ShellAgentStubTokenFactory", "build_shell_agent_stub_env", diff --git a/dify-agent/src/dify_agent/layers/_agent_cli_help.json b/dify-agent/src/dify_agent/layers/_agent_cli_help.json index c8942b2ae112de..ebc8364be4c4ed 100644 --- a/dify-agent/src/dify_agent/layers/_agent_cli_help.json +++ b/dify-agent/src/dify_agent/layers/_agent_cli_help.json @@ -15,10 +15,6 @@ "config skills pull": "Pull one or all visible config skills into ./.dify_conf/skills by default.\n\nUsage:\n dify-agent config skills pull [NAME]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local directory for pulled config skills.", "config skills push": "Upload one or more local skill directories into the current config manifest.\n\nUsage:\n dify-agent config skills push PATH... [flags]\n\nFlags:\n -h, --help help for push", "connect": "Establish one Agent Stub connection using the current environment.\n\nUsage:\n dify-agent connect [ARGV]... [flags]\n\nFlags:\n -h, --help help for connect\n --json Emit the connection response as JSON.", - "drive": "List, pull, or push agent drive files through the Agent Stub.\n\nUsage:\n dify-agent drive [command]\n\nAvailable Commands:\n list List drive files visible to the current sandbox execution.\n pull Pull one or more drive keys/prefixes into one local directory tree.\n push Upload one local file or directory into the agent drive.\n\nFlags:\n -h, --help help for drive\n\nUse \"dify-agent drive [command] --help\" for more information about a command.", - "drive list": "List drive files visible to the current sandbox execution.\n\nUsage:\n dify-agent drive list [REMOTE_PREFIX] [flags]\n\nFlags:\n -h, --help help for list\n --json Emit the drive manifest as JSON.", - "drive pull": "Pull one or more drive keys/prefixes into one local directory tree.\n\nUsage:\n dify-agent drive pull [REMOTE]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local base directory for pulled drive files.", - "drive push": "Upload one local file or directory into the agent drive.\n\nUsage:\n dify-agent drive push LOCAL_PATH REMOTE_PATH [flags]\n\nFlags:\n -h, --help help for push\n --json Accepted for consistency; drive push output is already emitted as JSON.\n --kind string Directory upload kind: skill or dir.", "file": "Upload or download workflow files through the Agent Stub.\n\nUsage:\n dify-agent file [command]\n\nAvailable Commands:\n download Download one workflow file mapping into the local sandbox directory.\n public-url Create a browser-visible download URL for an existing ToolFile reference.\n upload Upload one sandbox-local file as a ToolFile output reference.\n\nFlags:\n -h, --help help for file\n\nUse \"dify-agent file [command] --help\" for more information about a command.", "file download": "Download one workflow file mapping into the local sandbox directory.\n\nUsage:\n dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [flags]\n\nFlags:\n -h, --help help for download\n --to string Local directory for the downloaded file.", "file public-url": "Create a browser-visible download URL for an existing ToolFile reference.\n\nUsage:\n dify-agent file public-url REFERENCE [flags]\n\nFlags:\n -h, --help help for public-url", diff --git a/dify-agent/src/dify_agent/layers/drive/__init__.py b/dify-agent/src/dify_agent/layers/drive/__init__.py deleted file mode 100644 index a38f77ed65a145..00000000000000 --- a/dify-agent/src/dify_agent/layers/drive/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Client-safe exports for the Dify drive runtime catalog DTOs. - -The layer implementation lives in the sibling ``layer`` module. Keep this -package root import-safe for client code that only builds run requests. -""" - -from dify_agent.layers.drive.configs import ( - DIFY_DRIVE_LAYER_TYPE_ID, - DifyDriveLayerConfig, - DifyDriveSkillConfig, -) - -__all__ = [ - "DIFY_DRIVE_LAYER_TYPE_ID", - "DifyDriveLayerConfig", - "DifyDriveSkillConfig", -] diff --git a/dify-agent/src/dify_agent/layers/drive/configs.py b/dify-agent/src/dify_agent/layers/drive/configs.py deleted file mode 100644 index 20fd514baf22cc..00000000000000 --- a/dify-agent/src/dify_agent/layers/drive/configs.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Client-safe DTOs for the Dify drive declaration layer. - -The drive layer carries the runtime drive catalog plus the prompt-mentioned -targets that must be pulled eagerly when the layer enters. It is still config -only: skills are declared as metadata, not content, and plain files are listed -only when the prompt explicitly mentions their drive keys. - -The API backend catalogs and writes this config; the Agent backend consumes it -by running sandbox-visible ``dify-agent drive pull`` commands through the shell -layer so materialized files live in the same filesystem that model shell jobs -use. -""" - -from typing import Final - -from pydantic import BaseModel, ConfigDict, Field - -from agenton.layers import LayerConfig - - -DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive" - - -class DifyDriveSkillConfig(BaseModel): - """Runtime declaration of one standardized skill — metadata, not content.""" - - model_config = ConfigDict(extra="forbid") - - name: str - # The model judges from this description whether the skill is worth loading. - description: str - # "/SKILL.md" — the canonical entry document in the drive. - skill_md_key: str - # "/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill. - archive_key: str | None = None - path: str - - -class DifyDriveLayerConfig(LayerConfig): - """Drive runtime catalog plus eager-pull instructions for mentioned targets.""" - - # "agent-" — storage addressing, deliberately explicit instead of - # derived from execution context so a shared (non-agent-bound) drive stays - # possible later. - drive_ref: str - skills: list[DifyDriveSkillConfig] = Field(default_factory=list) - mentioned_skill_keys: list[str] = Field(default_factory=list) - mentioned_file_keys: list[str] = Field(default_factory=list) - - -__all__ = [ - "DIFY_DRIVE_LAYER_TYPE_ID", - "DifyDriveLayerConfig", - "DifyDriveSkillConfig", -] diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py deleted file mode 100644 index 8ac4b91c18962b..00000000000000 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Runtime Dify drive layer with shell-backed eager pulls. - -The API backend sends the full drive skill catalog plus the ordered drive keys -mentioned in the prompt. When the layer enters a run context it eagerly pulls -those mentioned skills/files through the already-active shell layer by running -the sandbox-visible ``dify-agent drive pull`` command, then contributes a -concise prompt block describing what was loaded. It also contributes a suffix -prompt with the remaining skill catalog plus agent-visible ``dify-agent file`` -usage captured from the real CLI. Drive commands remain internal for now and -are not exposed to the model. -""" - -from __future__ import annotations - -import shlex -from dataclasses import dataclass, field -from pathlib import Path -from typing import ClassVar - -from typing_extensions import Self, override - -from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer -from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref -from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT -from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig -from dify_agent.layers.shell.layer import DifyShellLayer - -_AGENT_STUB_FILE_HELP_COMMANDS = ( - "dify-agent file --help", - "dify-agent file upload --help", - "dify-agent file download --help", -) - - -class DifyDriveLayerError(RuntimeError): - """Raised when one eager-pull drive operation fails.""" - - -class DifyDriveDeps(LayerDeps): - shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable] - - -@dataclass(slots=True) -class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]): - """Drive runtime layer that materializes prompt-mentioned targets via shell.""" - - type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID - - config: DifyDriveLayerConfig - _loaded_skill_bodies: dict[str, str] = field(default_factory=dict) - _pulled_file_paths: dict[str, str] = field(default_factory=dict) - _agent_stub_cli_help: dict[str, str] = field(default_factory=dict) - - @classmethod - @override - def from_config(cls, config: DifyDriveLayerConfig) -> Self: - return cls(config=DifyDriveLayerConfig.model_validate(config)) - - @property - @override - def prefix_prompts(self) -> list[str]: - return [self.build_prompt_context()] - - @property - @override - def suffix_prompts(self) -> list[str]: - return [self.build_suffix_prompt()] - - @override - async def on_context_create(self) -> None: - await self._load_agent_stub_cli_help() - await self._pull_mentioned_targets() - - @override - async def on_context_resume(self) -> None: - await self._load_agent_stub_cli_help() - await self._pull_mentioned_targets() - - def build_prompt_context(self) -> str: - sections: list[str] = [] - - loaded_skill_sections: list[str] = [] - for skill_key in self.config.mentioned_skill_keys: - body = self._loaded_skill_bodies.get(skill_key) - if body is None: - continue - skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None) - if skill is None: - continue - pulled_skill_path = self._pulled_file_paths.get(skill_key) - if pulled_skill_path is None: - continue - local_path = Path(pulled_skill_path).parent - loaded_skill_sections.append(f"Path: {skill.path}\nLocal path: {local_path}\nSKILL.md:\n{body}") - if loaded_skill_sections: - sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections)) - - mentioned_files = [ - f"- {key} -> {self._pulled_file_paths[key]}" - for key in self.config.mentioned_file_keys - if key in self._pulled_file_paths - ] - if mentioned_files: - sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files)) - - if not sections: - return "" - return "\n\n".join(sections) - - def build_suffix_prompt(self) -> str: - sections: list[str] = [] - mentioned_skill_keys = set(self.config.mentioned_skill_keys) - other_skills = [ - f"- {skill.path}: {skill.name} — {skill.description}" - for skill in self.config.skills - if skill.skill_md_key not in mentioned_skill_keys - ] - if other_skills: - sections.append("Other available skills:\n" + "\n".join(other_skills)) - if cli_help := self._format_agent_stub_cli_help(): - sections.append(cli_help) - return "\n\n".join(sections) - - def _format_agent_stub_cli_help(self) -> str: - command_sections = [ - _format_command_output(command, self._agent_stub_cli_help[command]) - for command in _AGENT_STUB_FILE_HELP_COMMANDS - if command in self._agent_stub_cli_help - ] - if not command_sections: - return "" - return ( - "Agent Stub file CLI reference for installed `dify-agent`:\n" - + "\n\n".join(command_sections) - + f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}" - ) - - async def _load_agent_stub_cli_help(self) -> None: - self._agent_stub_cli_help = {} - for command in _AGENT_STUB_FILE_HELP_COMMANDS: - result = await self.deps.shell.run_remote_script(command, timeout=10.0) - if result.exit_code != 0 or not result.output_complete: - continue - output = result.output.strip() - if output: - self._agent_stub_cli_help[command] = output - - async def _pull_mentioned_targets(self) -> None: - self._loaded_skill_bodies = {} - self._pulled_file_paths = {} - targets = self._mentioned_pull_targets() - if not targets: - return - - script = self._build_shell_pull_script(targets=targets) - result = await self.deps.shell.run_remote_script_complete(script, inject_agent_stub_env=True) - if result.exit_code != 0: - raise DifyDriveLayerError( - "drive mentioned pull failed in shell: " - + f"{result.status} exit_code={result.exit_code} " - + f"output_complete={result.output_complete} " - + f"incomplete_reason={result.incomplete_reason} " - + f"output_path={result.output_path}\n{result.output}" - ) - try: - written_paths, skill_bodies = self._parse_shell_pull_output(result.output) - self._record_pulled_paths(written_paths) - for skill_key in self.config.mentioned_skill_keys: - body = skill_bodies.get(skill_key) - if body is None: - raise DifyDriveLayerError(f"missing pulled SKILL.md content for mentioned skill {skill_key}") - self._loaded_skill_bodies[skill_key] = body - except DifyDriveLayerError: - if result.output_complete: - raise - raise DifyDriveLayerError( - "drive mentioned pull output incomplete before required SKILL.md content was captured: " - + f"reason={result.incomplete_reason} output_path={result.output_path}\n{result.output}" - ) from None - - def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str: - pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets)) - base_path = agent_stub_drive_base_for_ref(self.config.drive_ref) - lines = [ - "set -eu", - f"base={shlex.quote(base_path)}", - "dify-agent drive pull " + " ".join(shlex.quote(target) for target in pull_targets) + ' --to "$base"', - ] - for skill_key in self.config.mentioned_skill_keys: - skill_path = self._shell_local_path(skill_key) - lines.extend( - [ - f"test -f {shlex.quote(skill_path)}", - f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(skill_key)} {shlex.quote(skill_path)}", - f"printf '__DIFY_DRIVE_SKILL_BEGIN__\\t%s\\n' {shlex.quote(skill_key)}", - f"cat {shlex.quote(skill_path)}", - f"printf '\\n__DIFY_DRIVE_SKILL_END__\\t%s\\n' {shlex.quote(skill_key)}", - ] - ) - for file_key in self.config.mentioned_file_keys: - file_path = self._shell_local_path(file_key) - lines.extend( - [ - f"test -e {shlex.quote(file_path)}", - f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(file_key)} {shlex.quote(file_path)}", - ] - ) - return "\n".join(lines) - - def _parse_shell_pull_output(self, output: str) -> tuple[dict[str, str], dict[str, str]]: - written_paths: dict[str, str] = {} - skill_bodies: dict[str, str] = {} - current_skill_key: str | None = None - current_skill_body: list[str] = [] - - for line in output.splitlines(keepends=True): - stripped_line = line.rstrip("\n") - if current_skill_key is not None: - if stripped_line == f"__DIFY_DRIVE_SKILL_END__\t{current_skill_key}": - skill_bodies[current_skill_key] = "".join(current_skill_body) - current_skill_key = None - current_skill_body = [] - continue - current_skill_body.append(line) - continue - - if stripped_line.startswith("__DIFY_DRIVE_MENTIONED_PATH__\t"): - parts = stripped_line.split("\t", 2) - if len(parts) != 3: - raise DifyDriveLayerError("drive mentioned pull emitted an invalid path marker") - _marker, key, path = parts - written_paths[key] = path - continue - if stripped_line.startswith("__DIFY_DRIVE_SKILL_BEGIN__\t"): - current_skill_key = stripped_line.split("\t", 1)[1] - current_skill_body = [] - - if current_skill_key is not None: - raise DifyDriveLayerError(f"drive mentioned pull omitted SKILL.md end marker for {current_skill_key}") - return written_paths, skill_bodies - - def _record_pulled_paths(self, written_paths: dict[str, str]) -> None: - self._pulled_file_paths = written_paths - for file_key in self.config.mentioned_file_keys: - if file_key not in written_paths: - raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}") - for skill_key in self.config.mentioned_skill_keys: - if skill_key not in written_paths: - raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}") - - def _mentioned_pull_targets(self) -> list[tuple[str, bool]]: - return [(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys] + [ - (file_key, True) for file_key in self.config.mentioned_file_keys - ] - - def _shell_local_path(self, drive_key: str) -> str: - return f"{agent_stub_drive_base_for_ref(self.config.drive_ref).rstrip('/')}/{drive_key.lstrip('/')}" - - @staticmethod - def _skill_prefix(skill_key: str) -> str: - return f"{skill_key.rsplit('/', 1)[0]}/" - - -def _format_command_output(command: str, output: str) -> str: - return f"Command:\n$ {command}\nOutput:\n{output}" - - -__all__ = ["DifyDriveLayer", "DifyDriveLayerError"] diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py index 821e02cc89c239..77d7d05a3f77de 100644 --- a/dify-agent/src/dify_agent/layers/shell/configs.py +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -4,8 +4,7 @@ provider factory. The Sandbox dependency supplies the active shellctl data plane. Public config carries product-level Agent Soul settings that affect the workspace itself: CLI tool bootstrap commands, normal environment variables, -secret environment variable names, and the Agent Stub drive ref used by -shell-visible drive commands. Sandbox selection is a deployment concern. +secret environment variable names. Sandbox selection is a deployment concern. """ import re @@ -73,8 +72,6 @@ class DifyShellLayerConfig(LayerConfig): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - # Optional because shell can be used without a drive layer. - agent_stub_drive_ref: str | None = Field(default=None, max_length=1024) cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list) env: list[DifyShellEnvVarConfig] = Field(default_factory=list) secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list) diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index e4f4987e19c576..be7c73bdd56df4 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -545,7 +545,6 @@ def _build_shell_command_env( execution_context = execution_context_layer.config if execution_context_layer is not None else None agent_stub_env = build_shell_agent_stub_env( agent_stub_api_base_url=self.agent_stub_api_base_url, - agent_stub_drive_ref=self.config.agent_stub_drive_ref, execution_context=execution_context, token_factory=self.agent_stub_token_factory, session_id=None, diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 771a0ecb473c94..5563d43a7067a4 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -46,7 +46,6 @@ from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer -from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig @@ -79,7 +78,6 @@ def create_default_layer_providers( LayerProvider.from_layer_type(DifyOutputLayer), LayerProvider.from_layer_type(DifyAskHumanLayer), LayerProvider.from_layer_type(DifyConfigLayer), - LayerProvider.from_layer_type(DifyDriveLayer), LayerProvider.from_factory( layer_type=DifyExecutionContextLayer, create=lambda config: DifyExecutionContextLayer.from_config_with_settings( diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 586d77b33ea318..0378e04baa5f46 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -60,7 +60,6 @@ def issue_agent_stub_token( agent_stub_token_factory = issue_agent_stub_token agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler() agent_stub_config_request_handler = resolved_settings.create_agent_stub_config_request_handler() - agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler() runtime_backend_profile = resolved_settings.build_runtime_backend_profile() layer_providers = create_default_layer_providers( plugin_daemon_url=resolved_settings.plugin_daemon_url, @@ -146,7 +145,6 @@ def get_scheduler() -> RunScheduler: token_codec=agent_stub_token_codec, file_request_handler=agent_stub_file_request_handler, config_request_handler=agent_stub_config_request_handler, - drive_request_handler=agent_stub_drive_request_handler, ) ) return app diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 197592c8815683..866ce832d93cf2 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -19,7 +19,6 @@ from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler -from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS @@ -243,20 +242,6 @@ def create_agent_stub_config_request_handler(self) -> DifyApiAgentStubConfigRequ timeout=self.create_outbound_http_timeout(), ) - def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None: - """Return the Dify API drive bridge when both Dify API settings are configured. - - Drive manifest and commit requests should honor the same outbound timeout - settings as the server's other trusted Dify API HTTP calls. - """ - if self.inner_api_key is None: - return None - return DifyApiAgentStubDriveRequestHandler( - inner_api_url=self.inner_api_url, - inner_api_key=self.inner_api_key, - timeout=self.create_outbound_http_timeout(), - ) - def create_outbound_http_timeout(self) -> httpx.Timeout: """Build one shared outbound HTTP timeout object from server settings.""" return httpx.Timeout( diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py index d49a66e44eff14..431dda4541998a 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py @@ -8,18 +8,11 @@ from pydantic import ValidationError from dify_agent.agent_stub.protocol.agent_stub import ( - AgentStubDriveCommitItem, - AgentStubDriveCommitRequest, - AgentStubDriveFileRef, - AgentStubDriveManifestResponse, AgentStubConfigDownloadSource, AgentStubFileDownloadRequest, AgentStubFileMapping, AgentStubFileUploadRequest, agent_stub_connections_url, - agent_stub_drive_base_for_ref, - agent_stub_drive_commit_url, - agent_stub_drive_manifest_url, agent_stub_file_download_request_url, agent_stub_file_upload_request_url, normalize_agent_stub_api_base_url, @@ -62,34 +55,6 @@ def test_agent_stub_file_upload_request_rejects_client_max_size() -> None: ) -def test_agent_stub_drive_request_urls_handle_trailing_slash() -> None: - assert agent_stub_drive_manifest_url("https://agent.example.com/agent-stub/") == ( - "https://agent.example.com/agent-stub/drive/manifest" - ) - assert agent_stub_drive_commit_url("https://agent.example.com/agent-stub") == ( - "https://agent.example.com/agent-stub/drive/commit" - ) - - -def test_agent_stub_drive_base_for_ref_uses_fixed_mount_with_drive_ref() -> None: - assert agent_stub_drive_base_for_ref("agent-1") == "/mnt/drive/agent-1" - assert agent_stub_drive_base_for_ref("shared/drive") == "/mnt/drive/shared/drive" - - -def test_agent_stub_drive_base_for_ref_uses_default_without_drive_ref() -> None: - assert agent_stub_drive_base_for_ref(None) == "/mnt/drive" - assert agent_stub_drive_base_for_ref(" ") == "/mnt/drive" - - -@pytest.mark.parametrize( - "drive_ref", - ["/agent-1", "../agent-1", "agent-1/..", "agent-1/./files", "agent-1//files"], -) -def test_agent_stub_drive_base_for_ref_rejects_unsafe_refs(drive_ref: str) -> None: - with pytest.raises(ValueError, match="safe relative path"): - _ = agent_stub_drive_base_for_ref(drive_ref) - - def test_normalize_agent_stub_api_base_url_rejects_query_and_fragment() -> None: with pytest.raises(ValueError, match="query string or fragment"): _ = normalize_agent_stub_api_base_url("https://agent.example.com/agent-stub?x=1") @@ -198,35 +163,6 @@ def test_agent_stub_config_download_source_rejects_invalid_names_and_identity_fi _ = AgentStubConfigDownloadSource.model_validate(source) -def test_agent_stub_drive_commit_request_validates_file_refs() -> None: - request = AgentStubDriveCommitRequest( - items=[ - AgentStubDriveCommitItem( - key="skills/example/SKILL.md", - file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), - ) - ] - ) - - assert request.items[0].file_ref is not None - assert request.items[0].file_ref.kind == "tool_file" - - with pytest.raises(ValidationError, match="tool_file"): - _ = AgentStubDriveFileRef(kind="bad_kind", id="tool-file-1") # pyright: ignore[reportArgumentType] - - item_without_file_ref = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"}) - assert item_without_file_ref.file_ref is None - - -def test_agent_stub_drive_manifest_response_preserves_extra_item_fields() -> None: - response = AgentStubDriveManifestResponse.model_validate( - {"items": [{"key": "skills/example/SKILL.md", "name": "SKILL.md"}]} - ) - - assert response.items[0].model_extra == {"name": "SKILL.md"} - assert response.items[0].model_dump(mode="json")["name"] == "SKILL.md" - - @pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"]) def test_agent_stub_file_mapping_rejects_non_remote_with_url( transfer_method: Literal["tool_file", "local_file", "datasource_file"], diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py index 8206a466e87a94..1da2d399b52631 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py @@ -39,8 +39,6 @@ def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None: assert "/agent-stub/connections" in created_paths assert "/agent-stub/files/upload-request" in created_paths assert "/agent-stub/files/download-request" in created_paths - assert "/agent-stub/drive/manifest" in created_paths - assert "/agent-stub/drive/commit" in created_paths assert created_paths == module_paths @@ -91,57 +89,3 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.status_code == 200 assert response.json() == {"upload_url": "https://files.example.com/files/upload/for-plugin?sign=1"} - - -def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None: - settings = ServerSettings( - agent_stub_api_base_url="https://agent.example.com/agent-stub", - server_secret_key=_base64url_secret(b"1" * 32), - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - sandbox_files_base_url="https://files.example.com", - ) - token_codec = settings.create_agent_stub_token_codec() - assert token_codec is not None - token = token_codec.encode_connection_token( - _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1 - ) - - original_async_client = httpx.AsyncClient - - def handler(request: httpx.Request) -> httpx.Response: - assert str(request.url) == ( - "https://api.example.com/inner/api/drive/agent-agent-1/manifest" - "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false" - ) - assert request.headers["X-Inner-Api-Key"] == "inner-secret" - return httpx.Response( - 200, - json={ - "items": [ - { - "key": "skills/example/SKILL.md", - "size": 12, - "hash": "sha256:abc", - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tool-file-1", - } - ] - }, - ) - - monkeypatch.setattr( - "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", - lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), - ) - - client = TestClient(create_agent_stub_app(settings)) - response = client.get( - "/agent-stub/drive/manifest", - headers={"Authorization": f"Bearer {token}"}, - params={"prefix": "skills/"}, - ) - - assert response.status_code == 200 - assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py deleted file mode 100644 index c636fc0a0fdfde..00000000000000 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import asyncio -import json - -import httpx - -from dify_agent.agent_stub.protocol.agent_stub import ( - AgentStubDriveCommitItem, - AgentStubDriveCommitRequest, - AgentStubDriveFileRef, -) -from dify_agent.agent_stub.server.agent_stub_drive import ( - AgentStubDriveRequestError, - DifyApiAgentStubDriveRequestHandler, -) -from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal -from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig - - -def _principal() -> AgentStubPrincipal: - return AgentStubPrincipal( - execution_context=DifyExecutionContextLayerConfig( - tenant_id="tenant-1", - user_id="user-1", - user_from="account", - workflow_id="workflow-1", - agent_id="agent-1", - agent_mode="workflow_run", - invoke_from="service-api", - ), - session_id="session-1", - scope=["agent_stub:connect"], - token_id="token-1", - ) - - -def _patch_async_client(monkeypatch, handler) -> None: - original_async_client = httpx.AsyncClient - monkeypatch.setattr( - "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", - lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), - ) - - -def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifest(monkeypatch) -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "GET" - assert str(request.url) == ( - "https://api.example.com/inner/api/drive/agent-agent-1/manifest" - "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=true" - ) - assert request.headers["X-Inner-Api-Key"] == "inner-secret" - return httpx.Response( - 200, - json={ - "items": [ - { - "key": "skills/example/SKILL.md", - "name": "SKILL.md", - "size": 12, - "hash": "sha256:abc", - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tool-file-1", - "created_at": 123, - "download_url": "https://files.example.com/download", - } - ] - }, - ) - - _patch_async_client(monkeypatch, handler) - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - - async def scenario() -> None: - response = await drive_handler.get_manifest( - principal=_principal(), - prefix="skills/", - include_download_url=True, - ) - assert response.items[0].download_url == "https://files.example.com/download" - assert response.items[0].model_extra == {"name": "SKILL.md"} - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(monkeypatch) -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "POST" - assert str(request.url) == "https://api.example.com/inner/api/drive/agent-agent-1/commit" - assert json.loads(request.content) == { - "tenant_id": "tenant-1", - "user_id": "user-1", - "items": [ - { - "key": "skills/example/SKILL.md", - "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, - "value_owned_by_drive": True, - "is_skill": False, - } - ], - } - return httpx.Response( - 200, - json={ - "items": [ - { - "key": "skills/example/SKILL.md", - "size": 12, - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tool-file-1", - "value_owned_by_drive": True, - } - ] - }, - ) - - _patch_async_client(monkeypatch, handler) - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - - async def scenario() -> None: - response = await drive_handler.commit( - principal=_principal(), - request=AgentStubDriveCommitRequest( - items=[ - AgentStubDriveCommitItem( - key="skills/example/SKILL.md", - file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), - ) - ] - ), - ) - assert response.items[0].value_owned_by_drive is True - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None: - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - principal = _principal() - principal.execution_context = principal.execution_context.model_copy(update={"agent_id": None}) - - async def scenario() -> None: - try: - await drive_handler.get_manifest(principal=principal, prefix="", include_download_url=False) - except AgentStubDriveRequestError as exc: - assert exc.status_code == 400 - assert "agent_id" in str(exc) - else: - raise AssertionError("expected AgentStubDriveRequestError") - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_rejects_missing_user_id_for_commit() -> None: - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - principal = _principal() - principal.execution_context = principal.execution_context.model_copy(update={"user_id": None}) - - async def scenario() -> None: - try: - await drive_handler.commit( - principal=principal, - request=AgentStubDriveCommitRequest( - items=[ - AgentStubDriveCommitItem( - key="skills/example/SKILL.md", - file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), - ) - ] - ), - ) - except AgentStubDriveRequestError as exc: - assert exc.status_code == 400 - assert "user_id" in str(exc) - else: - raise AssertionError("expected AgentStubDriveRequestError") - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_maps_invalid_json_response(monkeypatch) -> None: - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}) - - _patch_async_client(monkeypatch, handler) - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - - async def scenario() -> None: - try: - await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False) - except AgentStubDriveRequestError as exc: - assert exc.status_code == 502 - assert exc.detail == "Dify API drive request returned invalid JSON" - else: - raise AssertionError("expected AgentStubDriveRequestError") - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_rejects_malformed_success_payload(monkeypatch) -> None: - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"unexpected": []}) - - _patch_async_client(monkeypatch, handler) - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - - async def scenario() -> None: - try: - await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False) - except AgentStubDriveRequestError as exc: - assert exc.status_code == 502 - assert exc.detail == "Dify API drive manifest response is invalid" - else: - raise AssertionError("expected AgentStubDriveRequestError") - - asyncio.run(scenario()) - - -def test_dify_api_agent_stub_drive_handler_preserves_non_2xx_detail(monkeypatch) -> None: - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(404, json={"code": "source_not_found", "message": "missing file"}) - - _patch_async_client(monkeypatch, handler) - drive_handler = DifyApiAgentStubDriveRequestHandler( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - ) - - async def scenario() -> None: - try: - await drive_handler.commit( - principal=_principal(), - request=AgentStubDriveCommitRequest( - items=[ - AgentStubDriveCommitItem( - key="skills/example/SKILL.md", - file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), - ) - ] - ), - ) - except AgentStubDriveRequestError as exc: - assert exc.status_code == 404 - assert exc.detail == {"code": "source_not_found", "message": "missing file"} - else: - raise AssertionError("expected AgentStubDriveRequestError") - - asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py index ef6a4bf68491af..2fdda63efc3732 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py @@ -8,14 +8,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from dify_agent.agent_stub.protocol.agent_stub import ( - AgentStubDriveCommitResponse, - AgentStubDriveItem, - AgentStubDriveManifestResponse, - AgentStubFileDownloadResponse, - AgentStubFileUploadResponse, -) -from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler +from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadResponse, AgentStubFileUploadResponse from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router from dify_agent.agent_stub.server.tokens.agent_stub import AGENT_STUB_TOKEN_TTL_SECONDS, AgentStubTokenCodec @@ -344,137 +337,3 @@ async def create_download_request(self, *, principal, request): assert response.status_code == 400 assert response.json()["detail"] == {"detail": "bad request", "code": "inner_api_error"} - - -def test_agent_stub_drive_manifest_route_forwards_authenticated_request() -> None: - codec = _token_codec() - token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) - - class FakeDriveHandler: - async def get_manifest(self, *, principal, prefix, include_download_url): - assert principal.execution_context.user_id == "user-1" - assert prefix == "skills/" - assert include_download_url is True - return AgentStubDriveManifestResponse( - items=[ - AgentStubDriveItem( - key="skills/example/SKILL.md", - size=12, - hash="sha256:abc", - mime_type="text/markdown", - file_kind="tool_file", - file_id="tool-file-1", - created_at=123, - download_url="https://files.example.com/download", - ) - ] - ) - - async def commit(self, *, principal, request): - del principal, request - raise AssertionError("unexpected commit request") - - drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) - app = FastAPI() - app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) - client = TestClient(app) - - response = client.get( - "/agent-stub/drive/manifest", - headers={"Authorization": f"Bearer {token}"}, - params={"prefix": "skills/", "include_download_url": "true"}, - ) - - assert response.status_code == 200 - assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" - - -def test_agent_stub_drive_commit_route_forwards_authenticated_request() -> None: - codec = _token_codec() - token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) - - class FakeDriveHandler: - async def commit(self, *, principal, request): - assert principal.execution_context.user_id == "user-1" - assert request.items[0].file_ref.id == "tool-file-1" - return AgentStubDriveCommitResponse( - items=[ - AgentStubDriveItem( - key="skills/example/SKILL.md", - size=12, - hash=None, - mime_type="text/markdown", - file_kind="tool_file", - file_id="tool-file-1", - value_owned_by_drive=True, - ) - ] - ) - - async def get_manifest(self, *, principal, prefix, include_download_url): - del principal, prefix, include_download_url - raise AssertionError("unexpected manifest request") - - drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) - app = FastAPI() - app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) - client = TestClient(app) - - response = client.post( - "/agent-stub/drive/commit", - headers={"Authorization": f"Bearer {token}"}, - json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]}, - ) - - assert response.status_code == 200 - assert response.json()["items"][0]["file_id"] == "tool-file-1" - - -def test_agent_stub_drive_routes_return_503_when_drive_api_is_unconfigured() -> None: - codec = _token_codec() - token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) - app = FastAPI() - app.include_router(create_agent_stub_http_router(codec, None, None)) - client = TestClient(app) - - manifest_response = client.get( - "/agent-stub/drive/manifest", - headers={"Authorization": f"Bearer {token}"}, - ) - commit_response = client.post( - "/agent-stub/drive/commit", - headers={"Authorization": f"Bearer {token}"}, - json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]}, - ) - - assert manifest_response.status_code == 503 - assert commit_response.status_code == 503 - assert manifest_response.json()["detail"] == "Agent Stub drive API is not configured" - assert commit_response.json()["detail"] == "Agent Stub drive API is not configured" - - -def test_agent_stub_drive_route_preserves_structured_handler_error_details() -> None: - codec = _token_codec() - token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) - - class FakeDriveHandler: - async def get_manifest(self, *, principal, prefix, include_download_url): - del principal, prefix, include_download_url - raise AgentStubDriveRequestError(400, {"code": "invalid_key", "message": "bad request"}) - - async def commit(self, *, principal, request): - del principal, request - raise AssertionError("unexpected commit request") - - drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) - app = FastAPI() - app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) - client = TestClient(app) - - response = client.get( - "/agent-stub/drive/manifest", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == {"code": "invalid_key", "message": "bad request"} diff --git a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py index ce9435a4b46817..c66f0c88a47fbf 100644 --- a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py @@ -19,7 +19,7 @@ def _shell_layer() -> DifyShellLayer: return DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), + DifyShellLayerConfig(), ) diff --git a/dify-agent/tests/local/dify_agent/layers/drive/__init__.py b/dify-agent/tests/local/dify_agent/layers/drive/__init__.py deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py deleted file mode 100644 index 05ddad3543c1c0..00000000000000 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Contract tests for the dify.drive declaration layer (ENG-623).""" - -import pytest -from pydantic import ValidationError - -from dify_agent.layers.drive import ( - DIFY_DRIVE_LAYER_TYPE_ID, - DifyDriveLayerConfig, - DifyDriveSkillConfig, -) -from dify_agent.layers.drive.layer import DifyDriveLayer - - -def test_type_id_is_frozen_contract() -> None: - assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive" - assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID - - -def test_layer_config_round_trips_manifest_entries() -> None: - config = DifyDriveLayerConfig.model_validate( - { - "drive_ref": "agent-019e9112", - "skills": [ - { - "path": "tender-analyzer", - "name": "Tender Analyzer", - "description": "Parses RFP documents step by step.", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", - } - ], - "mentioned_skill_keys": ["tender-analyzer/SKILL.md"], - "mentioned_file_keys": ["files/sample.pdf"], - } - ) - - dumped = config.model_dump(mode="json") - assert dumped["drive_ref"] == "agent-019e9112" - assert "drive_base" not in dumped - assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md" - assert dumped["mentioned_file_keys"] == ["files/sample.pdf"] - assert "content" not in DifyDriveSkillConfig.model_fields - - -def test_layer_config_rejects_unknown_fields() -> None: - with pytest.raises(ValidationError): - DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"}) - - -def test_drive_layer_is_registered_and_constructible_from_config() -> None: - layer = DifyDriveLayer.from_config( - DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]), - ) - - assert isinstance(layer, DifyDriveLayer) - assert layer.config.drive_ref == "agent-1" - assert not hasattr(layer, "local_drive_base") diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py deleted file mode 100644 index c4cfb74346fc49..00000000000000 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Behavior tests for the runtime Dify drive layer.""" - -from __future__ import annotations - -from typing import Literal - -import pytest - -from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig -from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _AGENT_FILE_UPLOAD_REPLY_HINT -from dify_agent.layers.shell import DifyShellLayerConfig -from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer - - -def _shell_layer() -> DifyShellLayer: - return DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), - ) - - -def _build_layer() -> DifyDriveLayer: - layer = DifyDriveLayer.from_config( - DifyDriveLayerConfig( - drive_ref="agent-1", - skills=[ - DifyDriveSkillConfig( - path="tender-analyzer", - name="Tender Analyzer", - description="Parses RFPs.", - skill_md_key="tender-analyzer/SKILL.md", - archive_key="tender-analyzer/.DIFY-SKILL-FULL.zip", - ), - DifyDriveSkillConfig( - path="other-skill", - name="Other Skill", - description="Fallback catalog entry.", - skill_md_key="other-skill/SKILL.md", - archive_key=None, - ), - ], - mentioned_skill_keys=["tender-analyzer/SKILL.md"], - mentioned_file_keys=["files/report.pdf"], - ) - ) - layer.bind_deps({"shell": _shell_layer()}) - return layer - - -def _remote_result( - output: str, - *, - exit_code: int | None = 0, - output_complete: bool = True, - incomplete_reason: Literal["output_limit", "timeout"] | None = None, -) -> CompleteRemoteCommandResult: - return CompleteRemoteCommandResult( - job_id="remote-drive-pull", - status="exited", - done=True, - exit_code=exit_code, - output=output, - output_complete=output_complete, - incomplete_reason=incomplete_reason, - offset=len(output), - output_path="/tmp/output.log", - ) - - -def _pulled_output() -> str: - return ( - "/mnt/drive/agent-1/tender-analyzer\n" - "/mnt/drive/agent-1/files/report.pdf\n" - "__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n" - "__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n" - "# Tender Analyzer\n" - "Use carefully.\n" - "__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n" - "__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n" - ) - - -def _file_help_output(command: str) -> str: - return f"Usage: {command.removesuffix(' --help')} [OPTIONS]\n\nAgent Stub file command help.\n" - - -def _patch_file_help(monkeypatch: pytest.MonkeyPatch) -> list[str]: - captured_scripts: list[str] = [] - - async def fake_run_remote_script( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> CompleteRemoteCommandResult: - del self, timeout, inject_agent_stub_env - captured_scripts.append(script) - return _remote_result(_file_help_output(script)) - - monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) - return captured_scripts - - -def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None: - layer = _build_layer() - layer._agent_stub_cli_help = { - "dify-agent file --help": _file_help_output("dify-agent file --help"), - "dify-agent file upload --help": _file_help_output("dify-agent file upload --help"), - "dify-agent file download --help": _file_help_output("dify-agent file download --help"), - } - - assert len(layer.suffix_prompts) == 1 - prompt = layer.suffix_prompts[0] - assert "Other available skills" in prompt - assert "other-skill: Other Skill" in prompt - assert "Agent Stub file CLI reference for installed `dify-agent`" in prompt - assert "$ dify-agent file upload --help" in prompt - assert "$ dify-agent file download --help" in prompt - assert prompt.index("$ dify-agent file upload --help") < prompt.index("$ dify-agent file download --help") - assert _AGENT_FILE_UPLOAD_REPLY_HINT in prompt - assert "dify-agent drive" not in prompt - - -@pytest.mark.anyio -async def test_on_context_create_pulls_mentioned_targets_through_shell(monkeypatch: pytest.MonkeyPatch) -> None: - layer = _build_layer() - captured: dict[str, object] = {} - help_scripts = _patch_file_help(monkeypatch) - - async def fake_run_remote_script_complete( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> CompleteRemoteCommandResult: - del self, timeout - captured["script"] = script - captured["inject_agent_stub_env"] = inject_agent_stub_env - return _remote_result(_pulled_output()) - - monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - - await layer.on_context_create() - - assert help_scripts == [ - "dify-agent file --help", - "dify-agent file upload --help", - "dify-agent file download --help", - ] - assert "dify-agent file download --help" in layer._agent_stub_cli_help - script = captured["script"] - assert isinstance(script, str) - assert captured["inject_agent_stub_env"] is True - assert 'dify-agent drive pull tender-analyzer/ files/report.pdf --to "$base"' in script - prompt = layer.build_prompt_context() - assert "Loaded mentioned skills" in prompt - assert "# Tender Analyzer\nUse carefully." in prompt - - -@pytest.mark.anyio -async def test_on_context_create_raises_when_shell_pull_fails(monkeypatch: pytest.MonkeyPatch) -> None: - layer = _build_layer() - _patch_file_help(monkeypatch) - - async def fake_run_remote_script_complete( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> CompleteRemoteCommandResult: - del self, script, timeout, inject_agent_stub_env - return _remote_result("permission denied\n", exit_code=1) - - monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - - with pytest.raises(DifyDriveLayerError) as exc_info: - await layer.on_context_create() - - message = str(exc_info.value) - assert "drive mentioned pull failed in shell: exited exit_code=1" in message - assert "output_complete=True" in message - assert "output_path=/tmp/output.log" in message - - -@pytest.mark.anyio -async def test_on_context_create_raises_when_required_skill_marker_is_missing_from_complete_output( - monkeypatch: pytest.MonkeyPatch, -) -> None: - layer = _build_layer() - _patch_file_help(monkeypatch) - - async def fake_run_remote_script_complete( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> CompleteRemoteCommandResult: - del self, script, timeout, inject_agent_stub_env - return _remote_result("__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n") - - monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - - with pytest.raises(DifyDriveLayerError, match="missing pulled SKILL.md"): - await layer.on_context_create() - - -@pytest.mark.anyio -async def test_on_context_create_reports_incomplete_capture_when_required_marker_is_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - layer = _build_layer() - _patch_file_help(monkeypatch) - - async def fake_run_remote_script_complete( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> CompleteRemoteCommandResult: - del self, script, timeout, inject_agent_stub_env - output = ( - "__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n" - ) - return _remote_result(output, output_complete=False, incomplete_reason="output_limit") - - monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - - with pytest.raises(DifyDriveLayerError) as exc_info: - await layer.on_context_create() - - message = str(exc_info.value) - assert "output incomplete before required SKILL.md content was captured" in message - assert "reason=output_limit" in message diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py index 30405baff662ad..a4dbdb1641b8e9 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -27,7 +27,6 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None: config = DifyShellLayerConfig() assert config.model_dump() == { - "agent_stub_drive_ref": None, "cli_tools": [], "env": [], "secret_refs": [], @@ -50,7 +49,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: ], env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")], secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")], - agent_stub_drive_ref="agent-1", ) assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"] @@ -58,7 +56,6 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: assert config.cli_tools[0].secret_refs[0].ref == "credential-2" assert config.env[0].name == "PROJECT_NAME" assert config.secret_refs[0].ref == "credential-1" - assert config.agent_stub_drive_ref == "agent-1" def test_shell_layer_config_rejects_invalid_env_names() -> None: diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py index afff9519fc9d94..d79e615b73e75d 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py @@ -75,6 +75,8 @@ def _validator_for(schema): sys.modules["jsonschema.protocols"] = jsonschema_protocols_module sys.modules["jsonschema.validators"] = jsonschema_validators_module +from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig +from dify_agent.layers.config.layer import DifyConfigLayer from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig @@ -100,6 +102,18 @@ def _runtime_backend_profile() -> RuntimeBackendProfile: ) +def test_default_layer_providers_register_config_layer() -> None: + providers = create_default_layer_providers() + + config_provider = next(provider for provider in providers if provider.type_id == DIFY_CONFIG_LAYER_TYPE_ID) + config = DifyConfigLayerConfig(agent_id="agent-1") + layer = config_provider.create_layer(config) + + assert isinstance(layer, DifyConfigLayer) + assert layer.type_id == DIFY_CONFIG_LAYER_TYPE_ID + assert layer.config == config + + def test_default_layer_providers_register_runtime_layer() -> None: profile = _runtime_backend_profile() diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 66983fd1cc51e9..14d49f9f3be0a9 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -292,10 +292,6 @@ def fake_create_dify_api_inner_http_client(_settings: ServerSettings) -> FakePlu getattr(route, "path", None) == "/agent-stub/files/download-request" for route in create_app(settings).routes ) - assert any( - getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes - ) - assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes) route_paths = create_app(settings).openapi()["paths"] assert { "/execution-bindings/files/list", @@ -378,65 +374,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert fake_redis.closed is True -def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypatch: pytest.MonkeyPatch) -> None: - fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) - settings = ServerSettings( - redis_url="redis://example.invalid/0", - agent_stub_api_base_url="https://agent.example.com/agent-stub", - server_secret_key=_base64url_secret(b"1" * 32), - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - sandbox_files_base_url="https://files.example.com", - ) - token_codec = settings.create_agent_stub_token_codec() - assert token_codec is not None - token = token_codec.encode_connection_token( - _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1 - ) - - original_async_client = httpx.AsyncClient - - def handler(request: httpx.Request) -> httpx.Response: - assert str(request.url) == ( - "https://api.example.com/inner/api/drive/agent-agent-1/manifest" - "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false" - ) - assert request.headers["X-Inner-Api-Key"] == "inner-secret" - return httpx.Response( - 200, - json={ - "items": [ - { - "key": "skills/example/SKILL.md", - "size": 12, - "hash": "sha256:abc", - "mime_type": "text/markdown", - "file_kind": "tool_file", - "file_id": "tool-file-1", - } - ] - }, - ) - - monkeypatch.setattr( - "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", - lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), - ) - - with TestClient(create_app(settings)) as client: - response = client.get( - "/agent-stub/drive/manifest", - headers={"Authorization": f"Bearer {token}"}, - params={"prefix": "skills/"}, - ) - - assert response.status_code == 200 - assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" - assert FakeRunScheduler.created[0].shutdown_called is True - assert fake_http_client.is_closed is True - assert fake_redis.closed is True - - def test_create_plugin_daemon_http_client_uses_generic_outbound_httpx_construction_args( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dify-agent/tests/local/dify_agent/server/test_binding_files.py b/dify-agent/tests/local/dify_agent/server/test_binding_files.py index 21e93ea334bce9..4975729a9ddab3 100644 --- a/dify-agent/tests/local/dify_agent/server/test_binding_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_binding_files.py @@ -420,7 +420,6 @@ def issue_token(execution_context: DifyExecutionContextLayerConfig, *, session_i "HOME": "/home/agent", "DIFY_AGENT_STUB_API_BASE_URL": "http://stub/agent-stub", "DIFY_AGENT_STUB_AUTH_JWE": "secret-jwe", - "DIFY_AGENT_STUB_DRIVE_BASE": "/mnt/drive", } assert timeout == pytest.approx(60.0, rel=0, abs=0.01) assert issued_tokens == [(context, None)] diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 29908851d85732..c04d87fa76447e 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -2,13 +2,10 @@ from pathlib import Path import secrets -from typing import cast -import httpx import pytest from pydantic import ValidationError -from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.server.settings import ServerSettings @@ -271,32 +268,6 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_ assert handler.max_upload_size_bytes == 72 * 1024 * 1024 -def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None: - assert ServerSettings().create_agent_stub_drive_request_handler() is None - - -def test_server_settings_create_agent_stub_drive_request_handler_returns_handler_when_configured() -> None: - settings = ServerSettings( - inner_api_url="https://api.example.com", - inner_api_key="inner-secret", - outbound_http_connect_timeout=11, - outbound_http_read_timeout=22, - outbound_http_write_timeout=33, - outbound_http_pool_timeout=44, - ) - - handler = settings.create_agent_stub_drive_request_handler() - - assert isinstance(handler, DifyApiAgentStubDriveRequestHandler) - assert handler.inner_api_url == "https://api.example.com" - assert handler.inner_api_key == "inner-secret" - timeout = cast(httpx.Timeout, handler.timeout) - assert timeout.connect == 11 - assert timeout.read == 22 - assert timeout.write == 33 - assert timeout.pool == 44 - - def test_build_runtime_backend_profile_returns_none_when_local_endpoint_is_unset( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py index 3ece328472a8b4..ba1493701fbf7c 100644 --- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -68,7 +68,6 @@ def requirement_name(requirement: str) -> str: agent_cli_help_module = importlib.import_module("dify_agent.layers._agent_cli_help") agent_stub_shell_env_module = importlib.import_module("dify_agent.agent_stub.shell_env") shell_module = importlib.import_module("dify_agent.layers.shell") - drive_module = importlib.import_module("dify_agent.layers.drive") execution_context_module = importlib.import_module("dify_agent.layers.execution_context") plugin_module = importlib.import_module("dify_agent.layers.dify_plugin") ask_human_module = importlib.import_module("dify_agent.layers.ask_human") @@ -90,7 +89,6 @@ def requirement_name(requirement: str) -> str: assert "Usage:" in agent_cli_help_module.render_agent_stub_cli_help(("config",)) assert agent_stub_shell_env_module.build_shell_agent_stub_env is not None assert shell_module.DifyShellLayerConfig is not None - assert drive_module.DifyDriveLayerConfig is not None assert execution_context_module.DifyExecutionContextLayerConfig is not None assert plugin_module.DifyPluginLLMLayerConfig is not None assert ask_human_module.DifyAskHumanLayerConfig is not None diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 4407d408f4141e..f23233aa3a2e56 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -104,7 +104,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> blocked_imports=[ "anthropic", "dify_agent.adapters.llm", - "dify_agent.layers.drive.layer", "dify_agent.layers.execution_context.layer", "dify_agent.layers.ask_human.layer", "dify_agent.layers.dify_plugin.llm_layer", @@ -125,7 +124,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], imports=[ "dify_agent.protocol", - "dify_agent.layers.drive", "dify_agent.layers.execution_context", "dify_agent.layers.ask_human", "dify_agent.layers.dify_plugin", @@ -135,7 +133,6 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], assertions=[ "assert hasattr(dify_agent_protocol, 'CreateRunRequest')", - "assert hasattr(dify_agent_layers_drive, 'DifyDriveLayerConfig')", "assert hasattr(dify_agent_layers_execution_context, 'DifyExecutionContextLayerConfig')", "assert hasattr(dify_agent_layers_ask_human, 'DifyAskHumanLayerConfig')", "assert hasattr(dify_agent_layers_dify_plugin, 'DifyPluginLLMLayerConfig')", diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 1e27c084aa4cca..0b0eac00f59c78 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -36,7 +36,7 @@ Agent v2 state belongs under `world.agentBuilder`: - `fixtures` stores resolved models and seeded resources. - `accessPoint`, `configure`, `speechToText`, and `workflow` store per-scenario state. -Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, drive files, and tool credentials in the existing typed cleanup fields. +Do not add Agent v2 fields to the top level of `DifyWorld`. Store created Agent IDs, config assets, and tool credentials in the existing typed cleanup fields. ## Setup boundary diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/config-assets.ts similarity index 75% rename from e2e/features/agent-v2/support/agent-drive.ts rename to e2e/features/agent-v2/support/config-assets.ts index c76e7b75c9b446..abc4be6e6eb14d 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/config-assets.ts @@ -3,9 +3,6 @@ import type { AgentConfigFileUploadResponse, AgentConfigSkillRefConfig, AgentConfigSkillUploadResponse, - AgentDriveSkillItemResponse, - AgentDriveSkillListResponse, - AgentSkillUploadResponse, } from '@dify/contracts/api/console/agent/types.gen' import type { ConsoleClient } from '../../../support/api/console-client' import { Buffer } from 'node:buffer' @@ -31,46 +28,28 @@ const createSingleFileZip = ({ content, entryName }: { content: Buffer; entryNam const localHeader = Buffer.alloc(30) localHeader.writeUInt32LE(0x04034b50, 0) localHeader.writeUInt16LE(20, 4) - localHeader.writeUInt16LE(0, 6) - localHeader.writeUInt16LE(0, 8) - localHeader.writeUInt16LE(0, 10) - localHeader.writeUInt16LE(0, 12) localHeader.writeUInt32LE(checksum, 14) localHeader.writeUInt32LE(content.length, 18) localHeader.writeUInt32LE(content.length, 22) localHeader.writeUInt16LE(entryNameBuffer.length, 26) - localHeader.writeUInt16LE(0, 28) const centralDirectoryOffset = localHeader.length + entryNameBuffer.length + content.length const centralDirectoryHeader = Buffer.alloc(46) centralDirectoryHeader.writeUInt32LE(0x02014b50, 0) centralDirectoryHeader.writeUInt16LE(20, 4) centralDirectoryHeader.writeUInt16LE(20, 6) - centralDirectoryHeader.writeUInt16LE(0, 8) - centralDirectoryHeader.writeUInt16LE(0, 10) - centralDirectoryHeader.writeUInt16LE(0, 12) - centralDirectoryHeader.writeUInt16LE(0, 14) centralDirectoryHeader.writeUInt32LE(checksum, 16) centralDirectoryHeader.writeUInt32LE(content.length, 20) centralDirectoryHeader.writeUInt32LE(content.length, 24) centralDirectoryHeader.writeUInt16LE(entryNameBuffer.length, 28) - centralDirectoryHeader.writeUInt16LE(0, 30) - centralDirectoryHeader.writeUInt16LE(0, 32) - centralDirectoryHeader.writeUInt16LE(0, 34) - centralDirectoryHeader.writeUInt16LE(0, 36) - centralDirectoryHeader.writeUInt32LE(0, 38) - centralDirectoryHeader.writeUInt32LE(0, 42) const centralDirectorySize = centralDirectoryHeader.length + entryNameBuffer.length const endOfCentralDirectory = Buffer.alloc(22) endOfCentralDirectory.writeUInt32LE(0x06054b50, 0) - endOfCentralDirectory.writeUInt16LE(0, 4) - endOfCentralDirectory.writeUInt16LE(0, 6) endOfCentralDirectory.writeUInt16LE(1, 8) endOfCentralDirectory.writeUInt16LE(1, 10) endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12) endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16) - endOfCentralDirectory.writeUInt16LE(0, 20) return Buffer.concat([ localHeader, @@ -113,25 +92,6 @@ const toSkillArchiveUpload = async ({ const createUploadFile = (content: Buffer, name: string, type: string) => new File([Uint8Array.from(content)], name, { type }) -export async function uploadAgentDriveSkill( - client: ConsoleClient, - { - agentId, - fileName, - filePath, - }: { - agentId: string - fileName: string - filePath: string - }, -): Promise { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - return client.agent.byAgentId.skills.upload.post({ - body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, - params: { agent_id: agentId }, - }) -} - export async function uploadAgentConfigFileToDraft( client: ConsoleClient, { @@ -195,13 +155,3 @@ export async function uploadAgentConfigSkillToDraft( size: skill.size, } } - -export async function getAgentDriveSkills( - client: ConsoleClient, - agentId: string, -): Promise { - const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({ - params: { agent_id: agentId }, - }) - return body.items ?? [] -} diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts index e0ab6620d972ae..6636a2ee52429d 100644 --- a/e2e/features/agent-v2/support/fixtures/agents.ts +++ b/e2e/features/agent-v2/support/fixtures/agents.ts @@ -110,33 +110,6 @@ export async function requirePreseededWorkflow( } } -export async function requirePreseededAgentDriveSkill( - world: DifyWorld, - client: ConsoleClient, - agentName: string, - skillName: string, -): Promise { - const agent = await requirePreseededAgent(world, client, agentName) - - const response = await client.agent.byAgentId.drive.skills.get({ - params: { agent_id: agent.id }, - }) - const skill = response.items?.find((item) => item.name === skillName) - - if (!skill) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, - ) - } - - return { - id: skill.path, - kind: 'skill', - name: skill.name, - } -} - export async function requirePreseededFullConfigAgentCoreConfiguration( world: DifyWorld, client: ConsoleClient, @@ -146,13 +119,6 @@ export async function requirePreseededFullConfigAgentCoreConfiguration( const agent = await requirePreseededAgent(world, client, agentName) - await requirePreseededAgentDriveSkill( - world, - client, - agentName, - agentBuilderPreseededResources.summarySkill, - ) - const jsonTool = await requirePreseededTool( world, client, @@ -225,13 +191,6 @@ export async function requirePreseededToolStatesAgentConfiguration( ): Promise { const agent = await requirePreseededAgent(world, client, agentName) - await requirePreseededAgentDriveSkill( - world, - client, - agentName, - agentBuilderPreseededResources.summarySkill, - ) - const jsonTool = await requirePreseededTool( world, client, diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts index 6e30a3e0871cd0..809e7b8a77fdb1 100644 --- a/e2e/features/agent-v2/support/fixtures/common.ts +++ b/e2e/features/agent-v2/support/fixtures/common.ts @@ -51,9 +51,7 @@ export const matchesNameOrLabel = (value: string, name: string, label?: unknown) export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) => items.some((item) => { const record = asRecord(item) - const values = [record.name, record.drive_key, record.reference, record.file_id, record.id].map( - asString, - ) + const values = [record.name, record.reference, record.file_id, record.id].map(asString) return values.some((value) => value === expectedName || value.endsWith(`/${expectedName}`)) }) diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index fd0ec8f8728140..9ad0fc7be85032 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -17,17 +17,12 @@ import { agentBuilderFixedInputs, agentBuilderPreseededResources, } from './agent-builder-resources' -import { - getAgentDriveSkills, - uploadAgentConfigFileToDraft, - uploadAgentConfigSkillToDraft, - uploadAgentDriveSkill, -} from './agent-drive' import { createAgentSoulConfigWithKnowledgeDataset, createAgentSoulConfigWithModel, normalAgentSoulConfig, } from './agent-soul' +import { uploadAgentConfigFileToDraft, uploadAgentConfigSkillToDraft } from './config-assets' import { isRecord, matchesNameOrLabel } from './fixtures/common' import { splitToolDisplayName } from './fixtures/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' @@ -627,17 +622,6 @@ const saveSeededAgentComposer = async ( } } -const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => { - const skills = await getAgentDriveSkills(client, agentId) - if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return - - await uploadAgentDriveSkill(client, { - agentId, - fileName: agentBuilderTestMaterials.summarySkill, - filePath: getAgentBuilderTestMaterialPath('summarySkill'), - }) -} - const seedFullConfigAgent = async (context: SeedContext) => { const title = agentBuilderPreseededResources.fullConfigAgent const model = getStableModelResource(context) @@ -669,7 +653,6 @@ const seedFullConfigAgent = async (context: SeedContext) => { fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(context.consoleClient, agentId) await saveSeededAgentComposer(context.consoleClient, { agentId, @@ -712,7 +695,6 @@ const seedToolStatesAgent = async (context: SeedContext) => { fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(context.consoleClient, agent.id) await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index de28838dc27acf..5de92e425621ff 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -13,7 +13,6 @@ import { agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../../agent-v2/support/agent-builder-resources' -import { uploadAgentConfigFileToDraft } from '../../agent-v2/support/agent-drive' import { createAgentSoulConfigWithModel, normalAgentPrompt, @@ -21,6 +20,7 @@ import { updatedAgentPrompt, updatedAgentSoulConfig, } from '../../agent-v2/support/agent-soul' +import { uploadAgentConfigFileToDraft } from '../../agent-v2/support/config-assets' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath, diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 5e59b9aa47b33e..4867eb83034ef2 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -3,8 +3,8 @@ import type { AgentComposerEnvVariable } from '../../agent-v2/support/agent-soul import type { DifyWorld } from '../../support/world' import { zPostAgentByAgentIdConfigFilesResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' -import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/agent-drive' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' +import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/config-assets' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath, diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index 174fcc8820add6..8fdaae41822c72 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -9,7 +9,6 @@ import { getAgentConfigurePath, saveAgentComposerDraft, } from '../../agent-v2/support/agent' -import { getAgentDriveSkills, uploadAgentDriveSkill } from '../../agent-v2/support/agent-drive' import { concurrentFirstAgentPrompt, concurrentSecondAgentPrompt, @@ -19,10 +18,6 @@ import { normalAgentSoulConfig, updatedAgentPrompt, } from '../../agent-v2/support/agent-soul' -import { - agentBuilderTestMaterials, - getAgentBuilderTestMaterialPath, -} from '../../agent-v2/support/test-materials' import { expectNormalAgentPromptDraft, getCurrentAgentId, @@ -137,30 +132,6 @@ Given('the Agent v2 composer draft is publishable', async function (this: DifyWo ) }) -Given( - 'the e2e-summary-skill Skill is available to the Agent v2 test agent', - async function (this: DifyWorld) { - const agentId = getCurrentAgentId(this) - const upload = await uploadAgentDriveSkill(this.getConsoleClient(), { - agentId, - fileName: agentBuilderTestMaterials.summarySkill, - filePath: getAgentBuilderTestMaterialPath('summarySkill'), - }) - this.createdAgentDriveFiles.push({ agentId, key: upload.skill.skill_md_key }) - if (upload.skill.archive_key) - this.createdAgentDriveFiles.push({ agentId, key: upload.skill.archive_key }) - }, -) - -Then( - 'the Agent v2 test agent should include drive skill {string}', - async function (this: DifyWorld, skillName: string) { - const skills = await getAgentDriveSkills(this.getConsoleClient(), getCurrentAgentId(this)) - - expect(skills.map((skill) => skill.name)).toContain(skillName) - }, -) - When('I open the Agent v2 configure page', async function (this: DifyWorld) { await this.getPage().goto(getAgentConfigurePath(getCurrentAgentId(this))) }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 006277d5649724..9716b1af122394 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -170,15 +170,6 @@ After( }) }, })), - ...this.createdAgentDriveFiles.toReversed().map((file) => ({ - label: `Delete Agent drive file ${file.key}`, - run: async () => { - await this.getConsoleClient().agent.byAgentId.files.delete({ - params: { agent_id: file.agentId }, - query: { key: file.key }, - }) - }, - })), ...this.createdAppIds.toReversed().map((id) => ({ label: `Delete app ${id}`, run: async () => { diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 3cbabd84542b8b..04d73fa8dded79 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -10,10 +10,6 @@ import { runCleanupTasks } from '../../support/cleanup' import { apiURL, baseURL, defaultLocale } from '../../test-env' export type ScenarioCleanup = () => Promise | void -export type CreatedAgentDriveFile = { - agentId: string - key: string -} export type CreatedAgentConfigFile = { agentId: string name: string @@ -95,7 +91,6 @@ export class DifyWorld extends World { createdDatasetIds: string[] = [] createdAgentConfigFiles: CreatedAgentConfigFile[] = [] createdAgentConfigSkills: CreatedAgentConfigSkill[] = [] - createdAgentDriveFiles: CreatedAgentDriveFile[] = [] createdBuiltinToolCredentials: CreatedBuiltinToolCredential[] = [] agentBuilder: AgentBuilderWorldState = createAgentBuilderWorldState() scenarioCleanups: ScenarioCleanup[] = [] @@ -120,7 +115,6 @@ export class DifyWorld extends World { this.createdDatasetIds = [] this.createdAgentConfigFiles = [] this.createdAgentConfigSkills = [] - this.createdAgentDriveFiles = [] this.createdBuiltinToolCredentials = [] this.agentBuilder = createAgentBuilderWorldState() this.scenarioCleanups = [] diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index 7d10e128fa06e7..fe1094a123ab5e 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -13,13 +13,8 @@ import { zDeleteAgentByAgentIdConfigSkillsByNamePath, zDeleteAgentByAgentIdConfigSkillsByNameQuery, zDeleteAgentByAgentIdConfigSkillsByNameResponse, - zDeleteAgentByAgentIdFilesPath, - zDeleteAgentByAgentIdFilesQuery, - zDeleteAgentByAgentIdFilesResponse, zDeleteAgentByAgentIdPath, zDeleteAgentByAgentIdResponse, - zDeleteAgentByAgentIdSkillsBySlugPath, - zDeleteAgentByAgentIdSkillsBySlugResponse, zGetAgentByAgentIdApiAccessPath, zGetAgentByAgentIdApiAccessResponse, zGetAgentByAgentIdApiKeysPath, @@ -64,19 +59,6 @@ import { zGetAgentByAgentIdConfigSkillsPath, zGetAgentByAgentIdConfigSkillsQuery, zGetAgentByAgentIdConfigSkillsResponse, - zGetAgentByAgentIdDriveFilesDownloadPath, - zGetAgentByAgentIdDriveFilesDownloadQuery, - zGetAgentByAgentIdDriveFilesDownloadResponse, - zGetAgentByAgentIdDriveFilesPath, - zGetAgentByAgentIdDriveFilesPreviewPath, - zGetAgentByAgentIdDriveFilesPreviewQuery, - zGetAgentByAgentIdDriveFilesPreviewResponse, - zGetAgentByAgentIdDriveFilesQuery, - zGetAgentByAgentIdDriveFilesResponse, - zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath, - zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse, - zGetAgentByAgentIdDriveSkillsPath, - zGetAgentByAgentIdDriveSkillsResponse, zGetAgentByAgentIdLogsByConversationIdMessagesPath, zGetAgentByAgentIdLogsByConversationIdMessagesQuery, zGetAgentByAgentIdLogsByConversationIdMessagesResponse, @@ -151,20 +133,12 @@ import { zPostAgentByAgentIdFeedbacksBody, zPostAgentByAgentIdFeedbacksPath, zPostAgentByAgentIdFeedbacksResponse, - zPostAgentByAgentIdFilesBody, - zPostAgentByAgentIdFilesPath, - zPostAgentByAgentIdFilesResponse, zPostAgentByAgentIdPublishBody, zPostAgentByAgentIdPublishPath, zPostAgentByAgentIdPublishResponse, zPostAgentByAgentIdSandboxFilesDownloadBody, zPostAgentByAgentIdSandboxFilesDownloadPath, zPostAgentByAgentIdSandboxFilesDownloadResponse, - zPostAgentByAgentIdSkillsBySlugInferToolsPath, - zPostAgentByAgentIdSkillsBySlugInferToolsResponse, - zPostAgentByAgentIdSkillsUploadBody, - zPostAgentByAgentIdSkillsUploadPath, - zPostAgentByAgentIdSkillsUploadResponse, zPostAgentByAgentIdVersionsByVersionIdRestorePath, zPostAgentByAgentIdVersionsByVersionIdRestoreResponse, zPostAgentResponse, @@ -863,128 +837,6 @@ export const debugConversation = { refresh, } -/** - * Time-limited external signed URL for one Agent App drive value - */ -export const get19 = oc - .route({ - description: 'Time-limited external signed URL for one Agent App drive value', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentByAgentIdDriveFilesDownload', - path: '/agent/{agent_id}/drive/files/download', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAgentByAgentIdDriveFilesDownloadPath, - query: zGetAgentByAgentIdDriveFilesDownloadQuery, - }), - ) - .output(zGetAgentByAgentIdDriveFilesDownloadResponse) - -export const download4 = { - get: get19, -} - -/** - * Truncated text preview of one Agent App drive value - */ -export const get20 = oc - .route({ - description: 'Truncated text preview of one Agent App drive value', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentByAgentIdDriveFilesPreview', - path: '/agent/{agent_id}/drive/files/preview', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAgentByAgentIdDriveFilesPreviewPath, - query: zGetAgentByAgentIdDriveFilesPreviewQuery, - }), - ) - .output(zGetAgentByAgentIdDriveFilesPreviewResponse) - -export const preview3 = { - get: get20, -} - -/** - * List agent drive entries for an Agent App - */ -export const get21 = oc - .route({ - description: 'List agent drive entries for an Agent App', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentByAgentIdDriveFiles', - path: '/agent/{agent_id}/drive/files', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAgentByAgentIdDriveFilesPath, - query: zGetAgentByAgentIdDriveFilesQuery.optional(), - }), - ) - .output(zGetAgentByAgentIdDriveFilesResponse) - -export const files3 = { - get: get21, - download: download4, - preview: preview3, -} - -/** - * Inspect one drive-backed skill for slash-menu hover/detail UI - */ -export const get22 = oc - .route({ - description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentByAgentIdDriveSkillsBySkillPathInspect', - path: '/agent/{agent_id}/drive/skills/{skill_path}/inspect', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath })) - .output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse) - -export const inspect2 = { - get: get22, -} - -export const bySkillPath = { - inspect: inspect2, -} - -/** - * List drive-backed skills for an Agent App - */ -export const get23 = oc - .route({ - description: 'List drive-backed skills for an Agent App', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentByAgentIdDriveSkills', - path: '/agent/{agent_id}/drive/skills', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentByAgentIdDriveSkillsPath })) - .output(zGetAgentByAgentIdDriveSkillsResponse) - -export const skills2 = { - get: get23, - bySkillPath, -} - -export const drive = { - files: files3, - skills: skills2, -} - /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ @@ -1027,45 +879,7 @@ export const feedbacks = { post: post14, } -/** - * Delete one Agent App drive file by key - */ -export const delete5 = oc - .route({ - description: 'Delete one Agent App drive file by key', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAgentByAgentIdFiles', - path: '/agent/{agent_id}/files', - tags: ['console'], - }) - .input( - z.object({ params: zDeleteAgentByAgentIdFilesPath, query: zDeleteAgentByAgentIdFilesQuery }), - ) - .output(zDeleteAgentByAgentIdFilesResponse) - -/** - * Commit an uploaded file into the Agent App drive under files/ - */ -export const post15 = oc - .route({ - description: 'Commit an uploaded file into the Agent App drive under files/', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAgentByAgentIdFiles', - path: '/agent/{agent_id}/files', - successStatus: 201, - tags: ['console'], - }) - .input(z.object({ body: zPostAgentByAgentIdFilesBody, params: zPostAgentByAgentIdFilesPath })) - .output(zPostAgentByAgentIdFilesResponse) - -export const files4 = { - delete: delete5, - post: post15, -} - -export const get24 = oc +export const get19 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1077,10 +891,10 @@ export const get24 = oc .output(zGetAgentByAgentIdLogSourcesResponse) export const logSources = { - get: get24, + get: get19, } -export const get25 = oc +export const get20 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1097,14 +911,14 @@ export const get25 = oc .output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse) export const messages = { - get: get25, + get: get20, } export const byConversationId = { messages, } -export const get26 = oc +export const get21 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1118,14 +932,14 @@ export const get26 = oc .output(zGetAgentByAgentIdLogsResponse) export const logs = { - get: get26, + get: get21, byConversationId, } /** * Get Agent App message details by ID */ -export const get27 = oc +export const get22 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -1138,14 +952,14 @@ export const get27 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get27, + get: get22, } export const messages2 = { byMessageId: byMessageId2, } -export const post16 = oc +export const post15 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1157,13 +971,13 @@ export const post16 = oc .output(zPostAgentByAgentIdPublishResponse) export const publish = { - post: post16, + post: post15, } /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get28 = oc +export const get23 = oc .route({ description: "List workflow apps that reference this Agent App's bound Agent (read-only)", inputStructure: 'detailed', @@ -1176,13 +990,13 @@ export const get28 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get28, + get: get23, } /** * Create a ToolFile from one Agent App Binding file and return its download URL */ -export const post17 = oc +export const post16 = oc .route({ description: 'Create a ToolFile from one Agent App Binding file and return its download URL', inputStructure: 'detailed', @@ -1199,14 +1013,14 @@ export const post17 = oc ) .output(zPostAgentByAgentIdSandboxFilesDownloadResponse) -export const download5 = { - post: post17, +export const download4 = { + post: post16, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get29 = oc +export const get24 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -1224,13 +1038,13 @@ export const get29 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get29, + get: get24, } /** * List a directory in an Agent App conversation sandbox */ -export const get30 = oc +export const get25 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -1247,16 +1061,16 @@ export const get30 = oc ) .output(zGetAgentByAgentIdSandboxFilesResponse) -export const files5 = { - get: get30, - download: download5, +export const files3 = { + get: get25, + download: download4, read, } /** * Get basic information for an Agent App conversation sandbox */ -export const get31 = oc +export const get26 = oc .route({ description: 'Get basic information for an Agent App conversation sandbox', inputStructure: 'detailed', @@ -1269,80 +1083,11 @@ export const get31 = oc .output(zGetAgentByAgentIdSandboxResponse) export const sandbox = { - get: get31, - files: files5, -} - -/** - * Upload + standardize a Skill into an Agent App drive - */ -export const post18 = oc - .route({ - description: 'Upload + standardize a Skill into an Agent App drive', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAgentByAgentIdSkillsUpload', - path: '/agent/{agent_id}/skills/upload', - successStatus: 201, - tags: ['console'], - }) - .input( - z.object({ - body: zPostAgentByAgentIdSkillsUploadBody, - params: zPostAgentByAgentIdSkillsUploadPath, - }), - ) - .output(zPostAgentByAgentIdSkillsUploadResponse) - -export const upload2 = { - post: post18, -} - -/** - * Infer CLI tool + ENV suggestions from a standardized Agent App skill - */ -export const post19 = oc - .route({ - description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAgentByAgentIdSkillsBySlugInferTools', - path: '/agent/{agent_id}/skills/{slug}/infer-tools', - tags: ['console'], - }) - .input(z.object({ params: zPostAgentByAgentIdSkillsBySlugInferToolsPath })) - .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) - -export const inferTools = { - post: post19, -} - -/** - * Delete a standardized skill from an Agent App drive - */ -export const delete6 = oc - .route({ - description: 'Delete a standardized skill from an Agent App drive', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAgentByAgentIdSkillsBySlug', - path: '/agent/{agent_id}/skills/{slug}', - tags: ['console'], - }) - .input(z.object({ params: zDeleteAgentByAgentIdSkillsBySlugPath })) - .output(zDeleteAgentByAgentIdSkillsBySlugResponse) - -export const bySlug = { - delete: delete6, - inferTools, -} - -export const skills3 = { - upload: upload2, - bySlug, + get: get26, + files: files3, } -export const get32 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1359,14 +1104,14 @@ export const get32 = oc .output(zGetAgentByAgentIdStatisticsSummaryResponse) export const summary = { - get: get32, + get: get27, } export const statistics = { summary, } -export const post20 = oc +export const post17 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1378,10 +1123,10 @@ export const post20 = oc .output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse) export const restore = { - post: post20, + post: post17, } -export const get33 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1393,11 +1138,11 @@ export const get33 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get33, + get: get28, restore, } -export const get34 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1409,11 +1154,11 @@ export const get34 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get34, + get: get29, byVersionId, } -export const delete7 = oc +export const delete5 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1425,7 +1170,7 @@ export const delete7 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get35 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1448,8 +1193,8 @@ export const put3 = oc .output(zPutAgentByAgentIdResponse) export const byAgentId = { - delete: delete7, - get: get35, + delete: delete5, + get: get30, put: put3, apiAccess, apiEnable, @@ -1462,22 +1207,19 @@ export const byAgentId = { config, copy, debugConversation, - drive, features, feedbacks, - files: files4, logSources, logs, messages: messages2, publish, referencingWorkflows, sandbox, - skills: skills3, statistics, versions, } -export const get36 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1488,7 +1230,7 @@ export const get36 = oc .input(z.object({ query: zGetAgentQuery.optional() })) .output(zGetAgentResponse) -export const post21 = oc +export const post18 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1501,8 +1243,8 @@ export const post21 = oc .output(zPostAgentResponse) export const agent = { - get: get36, - post: post21, + get: get31, + post: post18, inviteOptions, byAgentId, } diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index ddef2e3addface..3991a7dfee0873 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -290,45 +290,6 @@ export type AgentDebugConversationRefreshResponse = { debug_conversation_message_count?: number } -export type AgentDriveListResponse = { - items?: Array -} - -export type AgentDriveDownloadResponse = { - url: string -} - -export type AgentDrivePreviewResponse = { - binary: boolean - key: string - size?: number | null - text?: string | null - truncated: boolean -} - -export type AgentDriveSkillListResponse = { - items?: Array -} - -export type AgentDriveSkillInspectResponse = { - archive_key?: string | null - created_at?: number | null - description: string - file_tree?: Array<{ - [key: string]: unknown - }> - files?: Array - hash?: string | null - mime_type?: string | null - name: string - path: string - size?: number | null - skill_md: AgentDriveSkillMarkdownResponse - skill_md_key: string - source: string - warnings?: Array -} - export type AgentAppFeaturesPayload = { opening_statement?: string | null retriever_resource?: AgentFeatureToggleConfig | null @@ -345,19 +306,6 @@ export type MessageFeedbackPayload = { rating?: 'dislike' | 'like' | null } -export type AgentDriveDeleteResponse = { - removed_keys?: Array - result: string -} - -export type AgentDriveFilePayload = { - upload_file_id: string -} - -export type AgentDriveFileCommitResponse = { - file: AgentDriveFileResponse -} - export type AgentLogSourceListResponse = { data: Array groups: Array @@ -451,17 +399,6 @@ export type SandboxReadResponse = { truncated: boolean } -export type AgentSkillUploadResponse = { - manifest: SkillManifest - skill: AgentUploadedSkillResponse -} - -export type SkillToolInferenceResult = { - cli_tools?: Array - inferable: boolean - reason?: string | null -} - export type AgentStatisticSummaryEnvelopeResponse = { charts: AgentStatisticChartsResponse source: string @@ -649,7 +586,6 @@ export type AgentSoulConfig = { config_note?: string config_skills?: Array env?: AgentSoulEnvConfig - files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig knowledge?: AgentSoulKnowledgeConfig memory?: AgentSoulMemoryConfig @@ -820,45 +756,6 @@ export type AgentConfigSkillMarkdownResponse = { truncated: boolean } -export type AgentDriveItemResponse = { - created_at?: number | null - file_kind: string - hash?: string | null - is_skill?: boolean | null - key: string - mime_type?: string | null - size?: number | null - skill_metadata?: string | null -} - -export type AgentDriveSkillItemResponse = { - archive_key?: string | null - created_at?: number | null - description: string - hash?: string | null - mime_type?: string | null - name: string - path: string - size?: number | null - skill_md_key: string -} - -export type AgentDriveSkillFileResponse = { - available_in_drive: boolean - drive_key?: string | null - name: string - path: string - type: string -} - -export type AgentDriveSkillMarkdownResponse = { - binary: boolean - key: string - size?: number | null - text?: string | null - truncated: boolean -} - export type AgentFeatureToggleConfig = { enabled?: boolean [key: string]: unknown @@ -886,14 +783,6 @@ export type AgentTextToSpeechFeatureConfig = { [key: string]: unknown } -export type AgentDriveFileResponse = { - drive_key: string - file_id: string - mime_type?: string | null - name: string - size?: number | null -} - export type AgentLogSourceResponse = { app_icon?: string | null app_icon_background?: string | null @@ -1038,32 +927,6 @@ export type SandboxFileEntryResponse = { type: 'dir' | 'file' | 'other' | 'symlink' } -export type SkillManifest = { - description: string - entry_path: string - files: Array - hash: string - name: string - size: number -} - -export type AgentUploadedSkillResponse = { - archive_key?: string | null - description: string - name: string - path: string - skill_md_key: string -} - -export type CliToolSuggestion = { - command?: string - description?: string - env_suggestions?: Array - inferred_from?: string - install_commands?: Array - name: string -} - export type AgentStatisticChartsResponse = { average_response_time?: Array average_session_interactions?: Array @@ -1183,11 +1046,6 @@ export type AgentSoulEnvConfig = { variables?: Array } -export type AgentSoulFilesConfig = { - files?: Array - skills?: Array -} - export type AgentSoulHumanConfig = { contacts?: Array tools?: Array @@ -1403,12 +1261,6 @@ export type HumanInputFormSubmissionData = { export type ExecutionContentType = 'human_input' -export type EnvSuggestion = { - key: string - reason?: string - secret_likely?: boolean -} - export type AgentAverageResponseTimeStatisticResponse = { date: string latency: number @@ -1518,35 +1370,6 @@ export type AgentEnvVariableConfig = { [key: string]: unknown } -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type 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 | null - name?: string | null - path?: string | null - skill_md_file_id?: string | null - skill_md_key?: string | null - [key: string]: unknown -} - export type AgentHumanToolConfig = { description?: string | null enabled?: boolean @@ -1665,6 +1488,20 @@ export type DeclaredOutputFileConfig = { mime_types?: Array } +export type AgentFileRefConfig = { + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + export type AgentCliToolAuthorizationStatus = | 'allowed' | 'authorized' @@ -2786,93 +2623,6 @@ export type PostAgentByAgentIdDebugConversationRefreshResponses = { export type PostAgentByAgentIdDebugConversationRefreshResponse = PostAgentByAgentIdDebugConversationRefreshResponses[keyof PostAgentByAgentIdDebugConversationRefreshResponses] -export type GetAgentByAgentIdDriveFilesData = { - body?: never - path: { - agent_id: string - } - query?: { - prefix?: string - } - url: '/agent/{agent_id}/drive/files' -} - -export type GetAgentByAgentIdDriveFilesResponses = { - 200: AgentDriveListResponse -} - -export type GetAgentByAgentIdDriveFilesResponse = - GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses] - -export type GetAgentByAgentIdDriveFilesDownloadData = { - body?: never - path: { - agent_id: string - } - query: { - key: string - } - url: '/agent/{agent_id}/drive/files/download' -} - -export type GetAgentByAgentIdDriveFilesDownloadResponses = { - 200: AgentDriveDownloadResponse -} - -export type GetAgentByAgentIdDriveFilesDownloadResponse = - GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses] - -export type GetAgentByAgentIdDriveFilesPreviewData = { - body?: never - path: { - agent_id: string - } - query: { - key: string - } - url: '/agent/{agent_id}/drive/files/preview' -} - -export type GetAgentByAgentIdDriveFilesPreviewResponses = { - 200: AgentDrivePreviewResponse -} - -export type GetAgentByAgentIdDriveFilesPreviewResponse = - GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] - -export type GetAgentByAgentIdDriveSkillsData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agent/{agent_id}/drive/skills' -} - -export type GetAgentByAgentIdDriveSkillsResponses = { - 200: AgentDriveSkillListResponse -} - -export type GetAgentByAgentIdDriveSkillsResponse = - GetAgentByAgentIdDriveSkillsResponses[keyof GetAgentByAgentIdDriveSkillsResponses] - -export type GetAgentByAgentIdDriveSkillsBySkillPathInspectData = { - body?: never - path: { - agent_id: string - skill_path: string - } - query?: never - url: '/agent/{agent_id}/drive/skills/{skill_path}/inspect' -} - -export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses = { - 200: AgentDriveSkillInspectResponse -} - -export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponse = - GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses[keyof GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses] - export type PostAgentByAgentIdFeaturesData = { body: AgentAppFeaturesPayload path: { @@ -2914,40 +2664,6 @@ export type PostAgentByAgentIdFeedbacksResponses = { export type PostAgentByAgentIdFeedbacksResponse = PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses] -export type DeleteAgentByAgentIdFilesData = { - body?: never - path: { - agent_id: string - } - query: { - key: string - } - url: '/agent/{agent_id}/files' -} - -export type DeleteAgentByAgentIdFilesResponses = { - 200: AgentDriveDeleteResponse -} - -export type DeleteAgentByAgentIdFilesResponse = - DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses] - -export type PostAgentByAgentIdFilesData = { - body: AgentDriveFilePayload - path: { - agent_id: string - } - query?: never - url: '/agent/{agent_id}/files' -} - -export type PostAgentByAgentIdFilesResponses = { - 201: AgentDriveFileCommitResponse -} - -export type PostAgentByAgentIdFilesResponse = - PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] - export type GetAgentByAgentIdLogSourcesData = { body?: never path: { @@ -3157,62 +2873,6 @@ export type GetAgentByAgentIdSandboxFilesReadResponses = { export type GetAgentByAgentIdSandboxFilesReadResponse = GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses] -export type PostAgentByAgentIdSkillsUploadData = { - body: { - file: Blob | File - } - path: { - agent_id: string - } - query?: never - url: '/agent/{agent_id}/skills/upload' -} - -export type PostAgentByAgentIdSkillsUploadErrors = { - 400: unknown -} - -export type PostAgentByAgentIdSkillsUploadResponses = { - 201: AgentSkillUploadResponse -} - -export type PostAgentByAgentIdSkillsUploadResponse = - PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses] - -export type DeleteAgentByAgentIdSkillsBySlugData = { - body?: never - path: { - agent_id: string - slug: string - } - query?: never - url: '/agent/{agent_id}/skills/{slug}' -} - -export type DeleteAgentByAgentIdSkillsBySlugResponses = { - 200: AgentDriveDeleteResponse -} - -export type DeleteAgentByAgentIdSkillsBySlugResponse = - DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses] - -export type PostAgentByAgentIdSkillsBySlugInferToolsData = { - body?: never - path: { - agent_id: string - slug: string - } - query?: never - url: '/agent/{agent_id}/skills/{slug}/infer-tools' -} - -export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { - 200: SkillToolInferenceResult -} - -export type PostAgentByAgentIdSkillsBySlugInferToolsResponse = - PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] - export type GetAgentByAgentIdStatisticsSummaryData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d3fc03ef81eeb0..92aeaa3de4893b 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -144,24 +144,6 @@ export const zAgentDebugConversationRefreshResponse = z.object({ debug_conversation_message_count: z.int().optional().default(0), }) -/** - * AgentDriveDownloadResponse - */ -export const zAgentDriveDownloadResponse = z.object({ - url: z.string(), -}) - -/** - * AgentDrivePreviewResponse - */ -export const zAgentDrivePreviewResponse = z.object({ - binary: z.boolean(), - key: z.string(), - size: z.int().nullish(), - text: z.string().nullish(), - truncated: z.boolean(), -}) - /** * MessageFeedbackPayload */ @@ -171,21 +153,6 @@ export const zMessageFeedbackPayload = z.object({ rating: z.enum(['dislike', 'like']).nullish(), }) -/** - * AgentDriveDeleteResponse - */ -export const zAgentDriveDeleteResponse = z.object({ - removed_keys: z.array(z.string()).optional(), - result: z.string(), -}) - -/** - * AgentDriveFilePayload - */ -export const zAgentDriveFilePayload = z.object({ - upload_file_id: z.string(), -}) - /** * AgentPublishPayload */ @@ -652,91 +619,6 @@ export const zAgentConfigSkillInspectResponse = z.object({ warnings: z.array(z.string()).optional(), }) -/** - * AgentDriveItemResponse - */ -export const zAgentDriveItemResponse = z.object({ - created_at: z.int().nullish(), - file_kind: z.string(), - hash: z.string().nullish(), - is_skill: z.boolean().nullish(), - key: z.string(), - mime_type: z.string().nullish(), - size: z.int().nullish(), - skill_metadata: z.string().nullish(), -}) - -/** - * AgentDriveListResponse - */ -export const zAgentDriveListResponse = z.object({ - items: z.array(zAgentDriveItemResponse).optional(), -}) - -/** - * AgentDriveSkillItemResponse - */ -export const zAgentDriveSkillItemResponse = z.object({ - archive_key: z.string().nullish(), - created_at: z.int().nullish(), - description: z.string(), - hash: z.string().nullish(), - mime_type: z.string().nullish(), - name: z.string(), - path: z.string(), - size: z.int().nullish(), - skill_md_key: z.string(), -}) - -/** - * AgentDriveSkillListResponse - */ -export const zAgentDriveSkillListResponse = z.object({ - items: z.array(zAgentDriveSkillItemResponse).optional(), -}) - -/** - * AgentDriveSkillFileResponse - */ -export const zAgentDriveSkillFileResponse = z.object({ - available_in_drive: z.boolean(), - drive_key: z.string().nullish(), - name: z.string(), - path: z.string(), - type: z.string(), -}) - -/** - * AgentDriveSkillMarkdownResponse - */ -export const zAgentDriveSkillMarkdownResponse = z.object({ - binary: z.boolean(), - key: z.string(), - size: z.int().nullish(), - text: z.string().nullish(), - truncated: z.boolean(), -}) - -/** - * AgentDriveSkillInspectResponse - */ -export const zAgentDriveSkillInspectResponse = z.object({ - archive_key: z.string().nullish(), - created_at: z.int().nullish(), - description: z.string(), - file_tree: z.array(z.record(z.string(), z.unknown())).optional(), - files: z.array(zAgentDriveSkillFileResponse).optional(), - hash: z.string().nullish(), - mime_type: z.string().nullish(), - name: z.string(), - path: z.string(), - size: z.int().nullish(), - skill_md: zAgentDriveSkillMarkdownResponse, - skill_md_key: z.string(), - source: z.string(), - warnings: z.array(z.string()).optional(), -}) - /** * AgentFeatureToggleConfig */ @@ -754,24 +636,6 @@ export const zAgentTextToSpeechFeatureConfig = z.object({ voice: z.string().nullish(), }) -/** - * 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({ - file: zAgentDriveFileResponse, -}) - /** * AgentLogSourceResponse */ @@ -920,39 +784,6 @@ export const zSandboxListResponse = z.object({ truncated: z.boolean().optional().default(false), }) -/** - * 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(), -}) - -/** - * AgentUploadedSkillResponse - */ -export const zAgentUploadedSkillResponse = z.object({ - archive_key: z.string().nullish(), - description: z.string(), - name: z.string(), - path: z.string(), - skill_md_key: z.string(), -}) - -/** - * AgentSkillUploadResponse - */ -export const zAgentSkillUploadResponse = z.object({ - manifest: zSkillManifest, - skill: zAgentUploadedSkillResponse, -}) - /** * AgentStatisticSummaryResponse */ @@ -1423,36 +1254,6 @@ export const zFeedback = z.object({ */ export const zExecutionContentType = z.enum(['human_input']) -/** - * 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(), -}) - /** * AgentAverageResponseTimeStatisticResponse */ @@ -1611,55 +1412,6 @@ export const zAgentEnvVariableConfig = z.object({ variable: z.string().max(255).nullish(), }) -/** - * 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(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * 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(), - 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(), -}) - -/** - * AgentSoulFilesConfig - */ -export const zAgentSoulFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), -}) - /** * AgentHumanToolConfig */ @@ -1768,6 +1520,30 @@ export const zDeclaredOutputFileConfig = z.object({ mime_types: z.array(z.string()).optional(), }) +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + /** * AgentCliToolAuthorizationStatus * @@ -2470,7 +2246,6 @@ export const zAgentSoulConfig = z.object({ config_note: z.string().optional().default(''), config_skills: z.array(zAgentConfigSkillRefConfig).optional(), env: zAgentSoulEnvConfig.optional(), - files: zAgentSoulFilesConfig.optional(), human: zAgentSoulHumanConfig.optional(), knowledge: zAgentSoulKnowledgeConfig.optional(), memory: zAgentSoulMemoryConfig.optional(), @@ -3297,65 +3072,6 @@ export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({ export const zPostAgentByAgentIdDebugConversationRefreshResponse = zAgentDebugConversationRefreshResponse -export const zGetAgentByAgentIdDriveFilesPath = z.object({ - agent_id: z.uuid(), -}) - -export const zGetAgentByAgentIdDriveFilesQuery = z.object({ - prefix: z.string().optional().default(''), -}) - -/** - * Drive entries - */ -export const zGetAgentByAgentIdDriveFilesResponse = zAgentDriveListResponse - -export const zGetAgentByAgentIdDriveFilesDownloadPath = z.object({ - agent_id: z.uuid(), -}) - -export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({ - key: z.string().min(1), -}) - -/** - * Signed URL - */ -export const zGetAgentByAgentIdDriveFilesDownloadResponse = zAgentDriveDownloadResponse - -export const zGetAgentByAgentIdDriveFilesPreviewPath = z.object({ - agent_id: z.uuid(), -}) - -export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({ - key: z.string().min(1), -}) - -/** - * Preview - */ -export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewResponse - -export const zGetAgentByAgentIdDriveSkillsPath = z.object({ - agent_id: z.uuid(), -}) - -/** - * Drive skills - */ -export const zGetAgentByAgentIdDriveSkillsResponse = zAgentDriveSkillListResponse - -export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath = z.object({ - agent_id: z.uuid(), - skill_path: z.string(), -}) - -/** - * Drive skill inspect view - */ -export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse = - zAgentDriveSkillInspectResponse - export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload export const zPostAgentByAgentIdFeaturesPath = z.object({ @@ -3378,30 +3094,6 @@ export const zPostAgentByAgentIdFeedbacksPath = z.object({ */ export const zPostAgentByAgentIdFeedbacksResponse = zSimpleResultResponse -export const zDeleteAgentByAgentIdFilesPath = z.object({ - agent_id: z.uuid(), -}) - -export const zDeleteAgentByAgentIdFilesQuery = z.object({ - key: z.string().min(1), -}) - -/** - * File removed - */ -export const zDeleteAgentByAgentIdFilesResponse = zAgentDriveDeleteResponse - -export const zPostAgentByAgentIdFilesBody = zAgentDriveFilePayload - -export const zPostAgentByAgentIdFilesPath = z.object({ - agent_id: z.uuid(), -}) - -/** - * File committed into the agent drive - */ -export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse - export const zGetAgentByAgentIdLogSourcesPath = z.object({ agent_id: z.uuid(), }) @@ -3543,39 +3235,6 @@ export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({ */ export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse -export const zPostAgentByAgentIdSkillsUploadBody = z.object({ - file: z.custom((value) => value instanceof Blob || value instanceof File), -}) - -export const zPostAgentByAgentIdSkillsUploadPath = z.object({ - agent_id: z.uuid(), -}) - -/** - * Skill uploaded into drive - */ -export const zPostAgentByAgentIdSkillsUploadResponse = zAgentSkillUploadResponse - -export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({ - agent_id: z.uuid(), - slug: z.string(), -}) - -/** - * Skill removed - */ -export const zDeleteAgentByAgentIdSkillsBySlugResponse = zAgentDriveDeleteResponse - -export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ - agent_id: z.uuid(), - slug: z.string(), -}) - -/** - * Inference result (draft suggestions, nothing persisted) - */ -export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult - export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({ agent_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index ede274a1f60381..4c2fcd0df73e8d 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -9,12 +9,6 @@ import { zDeleteAppsByAppIdAgentConfigSkillsByNamePath, zDeleteAppsByAppIdAgentConfigSkillsByNameQuery, zDeleteAppsByAppIdAgentConfigSkillsByNameResponse, - zDeleteAppsByAppIdAgentFilesPath, - zDeleteAppsByAppIdAgentFilesQuery, - zDeleteAppsByAppIdAgentFilesResponse, - zDeleteAppsByAppIdAgentSkillsBySlugPath, - zDeleteAppsByAppIdAgentSkillsBySlugQuery, - zDeleteAppsByAppIdAgentSkillsBySlugResponse, zDeleteAppsByAppIdAnnotationsByAnnotationIdPath, zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse, zDeleteAppsByAppIdAnnotationsPath, @@ -79,21 +73,6 @@ import { zGetAppsByAppIdAgentConfigSkillsPath, zGetAppsByAppIdAgentConfigSkillsQuery, zGetAppsByAppIdAgentConfigSkillsResponse, - zGetAppsByAppIdAgentDriveFilesDownloadPath, - zGetAppsByAppIdAgentDriveFilesDownloadQuery, - zGetAppsByAppIdAgentDriveFilesDownloadResponse, - zGetAppsByAppIdAgentDriveFilesPath, - zGetAppsByAppIdAgentDriveFilesPreviewPath, - zGetAppsByAppIdAgentDriveFilesPreviewQuery, - zGetAppsByAppIdAgentDriveFilesPreviewResponse, - zGetAppsByAppIdAgentDriveFilesQuery, - zGetAppsByAppIdAgentDriveFilesResponse, - zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath, - zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery, - zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse, - zGetAppsByAppIdAgentDriveSkillsPath, - zGetAppsByAppIdAgentDriveSkillsQuery, - zGetAppsByAppIdAgentDriveSkillsResponse, zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, @@ -313,17 +292,6 @@ import { zPostAppsByAppIdAgentConfigSkillsUploadPath, zPostAppsByAppIdAgentConfigSkillsUploadQuery, zPostAppsByAppIdAgentConfigSkillsUploadResponse, - zPostAppsByAppIdAgentFilesBody, - zPostAppsByAppIdAgentFilesPath, - zPostAppsByAppIdAgentFilesQuery, - zPostAppsByAppIdAgentFilesResponse, - zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, - zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery, - zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse, - zPostAppsByAppIdAgentSkillsUploadBody, - zPostAppsByAppIdAgentSkillsUploadPath, - zPostAppsByAppIdAgentSkillsUploadQuery, - zPostAppsByAppIdAgentSkillsUploadResponse, zPostAppsByAppIdAnnotationReplyByActionBody, zPostAppsByAppIdAnnotationReplyByActionPath, zPostAppsByAppIdAnnotationReplyByActionResponse, @@ -1159,195 +1127,12 @@ export const config = { skills, } -/** - * Time-limited external signed URL for one drive value (no streaming proxy) - */ -export const get16 = oc - .route({ - description: 'Time-limited external signed URL for one drive value (no streaming proxy)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentDriveFilesDownload', - path: '/apps/{app_id}/agent/drive/files/download', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentDriveFilesDownloadPath, - query: zGetAppsByAppIdAgentDriveFilesDownloadQuery, - }), - ) - .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) - -export const download4 = { - get: get16, -} - -/** - * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) - */ -export const get17 = oc - .route({ - description: - 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentDriveFilesPreview', - path: '/apps/{app_id}/agent/drive/files/preview', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentDriveFilesPreviewPath, - query: zGetAppsByAppIdAgentDriveFilesPreviewQuery, - }), - ) - .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) - -export const preview4 = { - get: get17, -} - -/** - * List agent drive entries (read-only inspector; one endpoint for both tabs) - */ -export const get18 = oc - .route({ - description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentDriveFiles', - path: '/apps/{app_id}/agent/drive/files', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentDriveFilesPath, - query: zGetAppsByAppIdAgentDriveFilesQuery.optional(), - }), - ) - .output(zGetAppsByAppIdAgentDriveFilesResponse) - -export const files3 = { - get: get18, - download: download4, - preview: preview4, -} - -/** - * Inspect one drive-backed skill for slash-menu hover/detail UI - */ -export const get19 = oc - .route({ - description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentDriveSkillsBySkillPathInspect', - path: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath, - query: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery.optional(), - }), - ) - .output(zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse) - -export const inspect2 = { - get: get19, -} - -export const bySkillPath = { - inspect: inspect2, -} - -/** - * List drive-backed skills for the bound agent - */ -export const get20 = oc - .route({ - description: 'List drive-backed skills for the bound agent', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentDriveSkills', - path: '/apps/{app_id}/agent/drive/skills', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentDriveSkillsPath, - query: zGetAppsByAppIdAgentDriveSkillsQuery.optional(), - }), - ) - .output(zGetAppsByAppIdAgentDriveSkillsResponse) - -export const skills2 = { - get: get20, - bySkillPath, -} - -export const drive = { - files: files3, - skills: skills2, -} - -/** - * Delete one drive file by key via drive commit-null semantics - */ -export const delete3 = oc - .route({ - description: 'Delete one drive file by key via drive commit-null semantics', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAppsByAppIdAgentFiles', - path: '/apps/{app_id}/agent/files', - tags: ['console'], - }) - .input( - z.object({ - params: zDeleteAppsByAppIdAgentFilesPath, - query: zDeleteAppsByAppIdAgentFilesQuery, - }), - ) - .output(zDeleteAppsByAppIdAgentFilesResponse) - -/** - * ADD FILE: commit one uploaded file into the bound agent's drive - * - * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) - */ -export const post11 = oc - .route({ - description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentFiles', - path: '/apps/{app_id}/agent/files', - successStatus: 201, - summary: "ADD FILE: commit one uploaded file into the bound agent's drive", - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentFilesBody, - params: zPostAppsByAppIdAgentFilesPath, - query: zPostAppsByAppIdAgentFilesQuery.optional(), - }), - ) - .output(zPostAppsByAppIdAgentFilesResponse) - -export const files4 = { - delete: delete3, - post: post11, -} - /** * Get agent logs * * Get agent execution logs for an application */ -export const get21 = oc +export const get16 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -1361,109 +1146,18 @@ export const get21 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get21, -} - -/** - * Upload a Skill, validate it, and commit drive-backed skill files - * - * Upload + standardize a Skill into the agent drive - */ -export const post12 = oc - .route({ - description: 'Upload + standardize a Skill into the agent drive', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentSkillsUpload', - path: '/apps/{app_id}/agent/skills/upload', - successStatus: 201, - summary: 'Upload a Skill, validate it, and commit drive-backed skill files', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentSkillsUploadBody, - params: zPostAppsByAppIdAgentSkillsUploadPath, - query: zPostAppsByAppIdAgentSkillsUploadQuery.optional(), - }), - ) - .output(zPostAppsByAppIdAgentSkillsUploadResponse) - -export const upload2 = { - post: post12, -} - -/** - * 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. - */ -export const post13 = oc - .route({ - description: - "Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.", - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentSkillsBySlugInferTools', - path: '/apps/{app_id}/agent/skills/{slug}/infer-tools', - summary: 'Suggest CLI tools/env for a skill', - tags: ['console'], - }) - .input( - z.object({ - params: zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, - query: zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery.optional(), - }), - ) - .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) - -export const inferTools = { - post: post13, -} - -/** - * Delete a standardized skill by removing its known drive keys via commit-null - */ -export const delete4 = oc - .route({ - description: 'Delete a standardized skill by removing its known drive keys via commit-null', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAppsByAppIdAgentSkillsBySlug', - path: '/apps/{app_id}/agent/skills/{slug}', - tags: ['console'], - }) - .input( - z.object({ - params: zDeleteAppsByAppIdAgentSkillsBySlugPath, - query: zDeleteAppsByAppIdAgentSkillsBySlugQuery.optional(), - }), - ) - .output(zDeleteAppsByAppIdAgentSkillsBySlugResponse) - -export const bySlug = { - delete: delete4, - inferTools, -} - -export const skills3 = { - upload: upload2, - bySlug, + get: get16, } export const agent = { config, - drive, - files: files4, logs, - skills: skills3, } /** * Get status of annotation reply action job */ -export const get22 = oc +export const get17 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1476,7 +1170,7 @@ export const get22 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get22, + get: get17, } export const status = { @@ -1486,7 +1180,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post14 = oc +export const post11 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1504,7 +1198,7 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post14, + post: post11, status, } @@ -1515,7 +1209,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get23 = oc +export const get18 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1528,13 +1222,13 @@ export const get23 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get23, + get: get18, } /** * Update annotation settings for an app */ -export const post15 = oc +export const post12 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1552,7 +1246,7 @@ export const post15 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post15, + post: post12, } export const annotationSettings = { @@ -1562,7 +1256,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post16 = oc +export const post13 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1575,13 +1269,13 @@ export const post16 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post16, + post: post13, } /** * Get status of batch import job */ -export const get24 = oc +export const get19 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1594,7 +1288,7 @@ export const get24 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get24, + get: get19, } export const batchImportStatus = { @@ -1604,7 +1298,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get25 = oc +export const get20 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1617,13 +1311,13 @@ export const get25 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get25, + get: get20, } /** * Export all annotations for an app with CSV injection protection */ -export const get26 = oc +export const get21 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1636,13 +1330,13 @@ export const get26 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get26, + get: get21, } /** * Get hit histories for an annotation */ -export const get27 = oc +export const get22 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1660,10 +1354,10 @@ export const get27 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get27, + get: get22, } -export const delete5 = oc +export const delete3 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1678,7 +1372,7 @@ export const delete5 = oc /** * Update or delete an annotation */ -export const post17 = oc +export const post14 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1696,12 +1390,12 @@ export const post17 = oc .output(zPostAppsByAppIdAnnotationsByAnnotationIdResponse) export const byAnnotationId = { - delete: delete5, - post: post17, + delete: delete3, + post: post14, hitHistories, } -export const delete6 = oc +export const delete4 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1716,7 +1410,7 @@ export const delete6 = oc /** * Get annotations for an app with pagination */ -export const get28 = oc +export const get23 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1736,7 +1430,7 @@ export const get28 = oc /** * Create a new annotation for an app */ -export const post18 = oc +export const post15 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1752,9 +1446,9 @@ export const post18 = oc .output(zPostAppsByAppIdAnnotationsResponse) export const annotations = { - delete: delete6, - get: get28, - post: post18, + delete: delete4, + get: get23, + post: post15, batchImport, batchImportStatus, count: count2, @@ -1765,7 +1459,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post19 = oc +export const post16 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1778,13 +1472,13 @@ export const post19 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post19, + post: post16, } /** * Transcript audio to text for chat messages */ -export const post20 = oc +export const post17 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1799,13 +1493,13 @@ export const post20 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post20, + post: post17, } /** * Delete a chat conversation */ -export const delete7 = oc +export const delete5 = oc .route({ description: 'Delete a chat conversation', inputStructure: 'detailed', @@ -1821,7 +1515,7 @@ export const delete7 = oc /** * Get chat conversation details */ -export const get29 = oc +export const get24 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1834,14 +1528,14 @@ export const get29 = oc .output(zGetAppsByAppIdChatConversationsByConversationIdResponse) export const byConversationId = { - delete: delete7, - get: get29, + delete: delete5, + get: get24, } /** * Get chat conversations with pagination, filtering and summary */ -export const get30 = oc +export const get25 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1859,14 +1553,14 @@ export const get30 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get30, + get: get25, byConversationId, } /** * Get suggested questions for a message */ -export const get31 = oc +export const get26 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1879,7 +1573,7 @@ export const get31 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get31, + get: get26, } export const byMessageId = { @@ -1889,7 +1583,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post21 = oc +export const post18 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1902,7 +1596,7 @@ export const post21 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post21, + post: post18, } export const byTaskId = { @@ -1912,7 +1606,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get32 = oc +export const get27 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1927,7 +1621,7 @@ export const get32 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get32, + get: get27, byMessageId, byTaskId, } @@ -1935,7 +1629,7 @@ export const chatMessages = { /** * Delete a completion conversation */ -export const delete8 = oc +export const delete6 = oc .route({ description: 'Delete a completion conversation', inputStructure: 'detailed', @@ -1951,7 +1645,7 @@ export const delete8 = oc /** * Get completion conversation details with messages */ -export const get33 = oc +export const get28 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1964,14 +1658,14 @@ export const get33 = oc .output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse) export const byConversationId2 = { - delete: delete8, - get: get33, + delete: delete6, + get: get28, } /** * Get completion conversations with pagination and filtering */ -export const get34 = oc +export const get29 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1989,14 +1683,14 @@ export const get34 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get34, + get: get29, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post22 = oc +export const post19 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -2009,7 +1703,7 @@ export const post22 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post22, + post: post19, } export const byTaskId2 = { @@ -2019,7 +1713,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post23 = oc +export const post20 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -2037,14 +1731,14 @@ export const post23 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post23, + post: post20, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get35 = oc +export const get30 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -2062,7 +1756,7 @@ export const get35 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get35, + get: get30, } /** @@ -2072,7 +1766,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post24 = oc +export const post21 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -2092,7 +1786,7 @@ export const post24 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post24, + post: post21, } /** @@ -2100,7 +1794,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post25 = oc +export const post22 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -2115,7 +1809,7 @@ export const post25 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post25, + post: post22, } /** @@ -2123,7 +1817,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get36 = oc +export const get31 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -2139,13 +1833,13 @@ export const get36 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get36, + get: get31, } /** * Export user feedback data for Google Sheets */ -export const get37 = oc +export const get32 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -2163,13 +1857,13 @@ export const get37 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get37, + get: get32, } /** * Create or update message feedback (like/dislike) */ -export const post26 = oc +export const post23 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -2182,14 +1876,14 @@ export const post26 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post26, + post: post23, export: export3, } /** * Update application icon */ -export const post27 = oc +export const post24 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -2202,13 +1896,13 @@ export const post27 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post27, + post: post24, } /** * Get message details by ID */ -export const get38 = oc +export const get33 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -2221,7 +1915,7 @@ export const get38 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get38, + get: get33, } export const messages = { @@ -2233,7 +1927,7 @@ export const messages = { * * Update application model configuration */ -export const post28 = oc +export const post25 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -2249,13 +1943,13 @@ export const post28 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post28, + post: post25, } /** * Check if app name is available */ -export const post29 = oc +export const post26 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -2268,13 +1962,13 @@ export const post29 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post29, + post: post26, } /** * Publish app to Creators Platform */ -export const post30 = oc +export const post27 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2287,13 +1981,13 @@ export const post30 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post30, + post: post27, } /** * Refresh MCP server configuration and regenerate server code */ -export const post31 = oc +export const post28 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -2306,13 +2000,13 @@ export const post31 = oc .output(zPostAppsByAppIdServerRefreshResponse) export const refresh = { - post: post31, + post: post28, } /** * Get MCP server configuration for an application */ -export const get39 = oc +export const get34 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -2327,7 +2021,7 @@ export const get39 = oc /** * Create MCP server configuration for an application */ -export const post32 = oc +export const post29 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -2356,8 +2050,8 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get39, - post: post32, + get: get34, + post: post29, put, refresh, } @@ -2365,7 +2059,7 @@ export const server = { /** * Reset access token for application site */ -export const post33 = oc +export const post30 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2378,13 +2072,13 @@ export const post33 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post33, + post: post30, } /** * Update application site configuration */ -export const post34 = oc +export const post31 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2397,14 +2091,14 @@ export const post34 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post34, + post: post31, accessTokenReset, } /** * Enable or disable app site */ -export const post35 = oc +export const post32 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2417,13 +2111,13 @@ export const post35 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post35, + post: post32, } /** * Remove the current account's star from an application */ -export const delete9 = oc +export const delete7 = oc .route({ description: "Remove the current account's star from an application", inputStructure: 'detailed', @@ -2438,7 +2132,7 @@ export const delete9 = oc /** * Star an application for the current account */ -export const post36 = oc +export const post33 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2451,14 +2145,14 @@ export const post36 = oc .output(zPostAppsByAppIdStarResponse) export const star = { - delete: delete9, - post: post36, + delete: delete7, + post: post33, } /** * Get average response time statistics for an application */ -export const get40 = oc +export const get35 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2476,13 +2170,13 @@ export const get40 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get40, + get: get35, } /** * Get average session interaction statistics for an application */ -export const get41 = oc +export const get36 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2500,13 +2194,13 @@ export const get41 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get41, + get: get36, } /** * Get daily conversation statistics for an application */ -export const get42 = oc +export const get37 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2524,13 +2218,13 @@ export const get42 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get42, + get: get37, } /** * Get daily terminal/end-user statistics for an application */ -export const get43 = oc +export const get38 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2548,13 +2242,13 @@ export const get43 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get43, + get: get38, } /** * Get daily message statistics for an application */ -export const get44 = oc +export const get39 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2572,13 +2266,13 @@ export const get44 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get44, + get: get39, } /** * Get daily token cost statistics for an application */ -export const get45 = oc +export const get40 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2596,13 +2290,13 @@ export const get45 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get45, + get: get40, } /** * Get tokens per second statistics for an application */ -export const get46 = oc +export const get41 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2620,13 +2314,13 @@ export const get46 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get46, + get: get41, } /** * Get user satisfaction rate statistics for an application */ -export const get47 = oc +export const get42 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2644,7 +2338,7 @@ export const get47 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get47, + get: get42, } export const statistics = { @@ -2661,7 +2355,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get48 = oc +export const get43 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2679,13 +2373,13 @@ export const get48 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get48, + get: get43, } /** * Convert text to speech for chat messages */ -export const post37 = oc +export const post34 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2700,7 +2394,7 @@ export const post37 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post37, + post: post34, voices, } @@ -2709,7 +2403,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get49 = oc +export const get44 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2725,7 +2419,7 @@ export const get49 = oc /** * Update app tracing configuration */ -export const post38 = oc +export const post35 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2738,8 +2432,8 @@ export const post38 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get49, - post: post38, + get: get44, + post: post35, } /** @@ -2747,7 +2441,7 @@ export const trace = { * * Delete an existing tracing configuration for an application */ -export const delete10 = oc +export const delete8 = oc .route({ description: 'Delete an existing tracing configuration for an application', inputStructure: 'detailed', @@ -2769,7 +2463,7 @@ export const delete10 = oc /** * Get tracing configuration for an application */ -export const get50 = oc +export const get45 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2808,7 +2502,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post39 = oc +export const post36 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2825,16 +2519,16 @@ export const post39 = oc .output(zPostAppsByAppIdTraceConfigResponse) export const traceConfig = { - delete: delete10, - get: get50, + delete: delete8, + get: get45, patch, - post: post39, + post: post36, } /** * Update app trigger (enable/disable) */ -export const post40 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2852,13 +2546,13 @@ export const post40 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post40, + post: post37, } /** * Get app triggers list */ -export const get51 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2871,7 +2565,7 @@ export const get51 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get51, + get: get46, } /** @@ -2879,7 +2573,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get52 = oc +export const get47 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2898,7 +2592,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get52, + get: get47, } /** @@ -2906,7 +2600,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get53 = oc +export const get48 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2925,7 +2619,7 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get53, + get: get48, } /** @@ -2933,7 +2627,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get54 = oc +export const get49 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2952,7 +2646,7 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get54, + get: get49, } /** @@ -2960,7 +2654,7 @@ export const count3 = { * * Stop running workflow task */ -export const post41 = oc +export const post38 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2974,7 +2668,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post41, + post: post38, } export const byTaskId3 = { @@ -2988,7 +2682,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get55 = oc +export const get50 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -3001,7 +2695,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get55, + get: get50, } /** @@ -3009,7 +2703,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get56 = oc +export const get51 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -3023,7 +2717,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get56, + get: get51, } /** @@ -3031,7 +2725,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get57 = oc +export const get52 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -3045,7 +2739,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get57, + get: get52, export: export4, nodeExecutions, } @@ -3053,7 +2747,7 @@ export const byRunId = { /** * Create a ToolFile from one workflow Agent Binding file and return its download URL */ -export const post42 = oc +export const post39 = oc .route({ description: 'Create a ToolFile from one workflow Agent Binding file and return its download URL', @@ -3071,14 +2765,14 @@ export const post42 = oc ) .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse) -export const download5 = { - post: post42, +export const download4 = { + post: post39, } /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get58 = oc +export const get53 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -3096,13 +2790,13 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read = { - get: get58, + get: get53, } /** * List a directory in a workflow Agent node sandbox */ -export const get59 = oc +export const get54 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -3119,14 +2813,14 @@ export const get59 = oc ) .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) -export const files5 = { - get: get59, - download: download5, +export const files3 = { + get: get54, + download: download4, read, } export const sandbox = { - files: files5, + files: files3, } export const byNodeId4 = { @@ -3146,7 +2840,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get60 = oc +export const get55 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -3165,7 +2859,7 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get60, + get: get55, count: count3, tasks, byRunId, @@ -3177,7 +2871,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get61 = oc +export const get56 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -3191,7 +2885,7 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get61, + get: get56, } /** @@ -3199,7 +2893,7 @@ export const mentionUsers = { * * Delete a comment reply */ -export const delete11 = oc +export const delete9 = oc .route({ description: 'Delete a comment reply', inputStructure: 'detailed', @@ -3237,7 +2931,7 @@ export const put2 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse) export const byReplyId = { - delete: delete11, + delete: delete9, put: put2, } @@ -3246,7 +2940,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post43 = oc +export const post40 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3266,7 +2960,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post43, + post: post40, byReplyId, } @@ -3275,7 +2969,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post44 = oc +export const post41 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3289,7 +2983,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post44, + post: post41, } /** @@ -3297,7 +2991,7 @@ export const resolve = { * * Delete a workflow comment */ -export const delete12 = oc +export const delete10 = oc .route({ description: 'Delete a workflow comment', inputStructure: 'detailed', @@ -3316,7 +3010,7 @@ export const delete12 = oc * * Get a specific workflow comment */ -export const get62 = oc +export const get57 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3353,8 +3047,8 @@ export const put3 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse) export const byCommentId = { - delete: delete12, - get: get62, + delete: delete10, + get: get57, put: put3, replies, resolve, @@ -3365,7 +3059,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get63 = oc +export const get58 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3383,7 +3077,7 @@ export const get63 = oc * * Create a new workflow comment */ -export const post45 = oc +export const post42 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3403,8 +3097,8 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get63, - post: post45, + get: get58, + post: post42, mentionUsers, byCommentId, } @@ -3412,7 +3106,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get64 = oc +export const get59 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3430,13 +3124,13 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get64, + get: get59, } /** * Get workflow daily runs statistics */ -export const get65 = oc +export const get60 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3454,13 +3148,13 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get65, + get: get60, } /** * Get workflow daily terminals statistics */ -export const get66 = oc +export const get61 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3478,13 +3172,13 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get66, + get: get61, } /** * Get workflow daily token cost statistics */ -export const get67 = oc +export const get62 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3502,7 +3196,7 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get67, + get: get62, } export const statistics2 = { @@ -3522,7 +3216,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get68 = oc +export const get63 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3541,7 +3235,7 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get68, + get: get63, } /** @@ -3549,7 +3243,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get69 = oc +export const get64 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3563,14 +3257,14 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get69, + get: get64, byBlockType, } /** * Get conversation variables for workflow */ -export const get70 = oc +export const get65 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3585,7 +3279,7 @@ export const get70 = oc /** * Update conversation variables for workflow draft */ -export const post46 = oc +export const post43 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3603,8 +3297,8 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get70, - post: post46, + get: get65, + post: post43, } /** @@ -3612,7 +3306,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get71 = oc +export const get66 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3628,7 +3322,7 @@ export const get71 = oc /** * Update environment variables for workflow draft */ -export const post47 = oc +export const post44 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3646,14 +3340,14 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get71, - post: post47, + get: get66, + post: post44, } /** * Update draft workflow features */ -export const post48 = oc +export const post45 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3671,7 +3365,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post48, + post: post45, } /** @@ -3679,7 +3373,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post49 = oc +export const post46 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3698,7 +3392,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post49, + post: post46, } /** @@ -3706,7 +3400,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post50 = oc +export const post47 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3724,8 +3418,8 @@ export const post50 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview5 = { - post: post50, +export const preview4 = { + post: post47, } /** @@ -3733,7 +3427,7 @@ export const preview5 = { * * Submit human input form preview for workflow */ -export const post51 = oc +export const post48 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3752,11 +3446,11 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post51, + post: post48, } export const form2 = { - preview: preview5, + preview: preview4, run: run5, } @@ -3778,7 +3472,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post52 = oc +export const post49 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3797,7 +3491,7 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post52, + post: post49, } export const byNodeId6 = { @@ -3817,7 +3511,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post53 = oc +export const post50 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3836,7 +3530,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post53, + post: post50, } export const byNodeId7 = { @@ -3851,7 +3545,7 @@ export const loop2 = { nodes: nodes6, } -export const get72 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3865,10 +3559,10 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get72, + get: get67, } -export const post54 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3885,10 +3579,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) export const copyFromRoster = { - post: post54, + post: post51, } -export const post55 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3905,10 +3599,10 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post55, + post: post52, } -export const post56 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3925,10 +3619,10 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post56, + post: post53, } -export const post57 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3945,10 +3639,10 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post57, + post: post54, } -export const get73 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3981,7 +3675,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get73, + get: get68, put: put4, candidates, copyFromRoster, @@ -3993,7 +3687,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get74 = oc +export const get69 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -4006,7 +3700,7 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get74, + get: get69, } /** @@ -4014,7 +3708,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post58 = oc +export const post55 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -4033,7 +3727,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post58, + post: post55, } /** @@ -4041,7 +3735,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post59 = oc +export const post56 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -4055,7 +3749,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post59, + post: post56, } export const trigger = { @@ -4065,7 +3759,7 @@ export const trigger = { /** * Delete all variables for a specific node */ -export const delete13 = oc +export const delete11 = oc .route({ description: 'Delete all variables for a specific node', inputStructure: 'detailed', @@ -4081,7 +3775,7 @@ export const delete13 = oc /** * Get variables for a specific node */ -export const get75 = oc +export const get70 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -4094,8 +3788,8 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) export const variables = { - delete: delete13, - get: get75, + delete: delete11, + get: get70, } export const byNodeId8 = { @@ -4115,7 +3809,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post60 = oc +export const post57 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -4134,13 +3828,13 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post60, + post: post57, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get76 = oc +export const get71 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -4153,13 +3847,13 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get76, + get: get71, } /** * Full value for one declared output, including signed download URL for files. */ -export const get77 = oc +export const get72 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -4175,18 +3869,18 @@ export const get77 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview6 = { - get: get77, +export const preview5 = { + get: get72, } export const byOutputName = { - preview: preview6, + preview: preview5, } /** * One node's declared outputs for a draft workflow run. */ -export const get78 = oc +export const get73 = oc .route({ description: "One node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4199,14 +3893,14 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get78, + get: get73, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get79 = oc +export const get74 = oc .route({ description: "Snapshot of every node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4219,7 +3913,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get79, + get: get74, events, byNodeId: byNodeId9, } @@ -4235,7 +3929,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get80 = oc +export const get75 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -4248,7 +3942,7 @@ export const get80 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get80, + get: get75, } /** @@ -4256,7 +3950,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post61 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -4275,7 +3969,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post61, + post: post58, } /** @@ -4283,7 +3977,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post62 = oc +export const post59 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -4302,7 +3996,7 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post62, + post: post59, } export const trigger2 = { @@ -4332,7 +4026,7 @@ export const reset = { /** * Delete a workflow variable */ -export const delete14 = oc +export const delete12 = oc .route({ description: 'Delete a workflow variable', inputStructure: 'detailed', @@ -4348,7 +4042,7 @@ export const delete14 = oc /** * Get a specific workflow variable */ -export const get81 = oc +export const get76 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -4381,8 +4075,8 @@ export const patch2 = oc .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) export const byVariableId = { - delete: delete14, - get: get81, + delete: delete12, + get: get76, patch: patch2, reset, } @@ -4390,7 +4084,7 @@ export const byVariableId = { /** * Delete all draft workflow variables */ -export const delete15 = oc +export const delete13 = oc .route({ description: 'Delete all draft workflow variables', inputStructure: 'detailed', @@ -4408,7 +4102,7 @@ export const delete15 = oc * * Get draft workflow variables */ -export const get82 = oc +export const get77 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4427,8 +4121,8 @@ export const get82 = oc .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) export const variables2 = { - delete: delete15, - get: get82, + delete: delete13, + get: get77, byVariableId, } @@ -4437,7 +4131,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get83 = oc +export const get78 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4455,7 +4149,7 @@ export const get83 = oc * * Sync draft workflow configuration */ -export const post63 = oc +export const post60 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4474,8 +4168,8 @@ export const post63 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get83, - post: post63, + get: get78, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4495,7 +4189,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get84 = oc +export const get79 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4511,7 +4205,7 @@ export const get84 = oc /** * Publish workflow */ -export const post64 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4529,14 +4223,14 @@ export const post64 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get84, - post: post64, + get: get79, + post: post61, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get85 = oc +export const get80 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4549,13 +4243,13 @@ export const get85 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get85, + get: get80, } /** * Full value for one declared output of a published run. */ -export const get86 = oc +export const get81 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4575,18 +4269,18 @@ export const get86 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview7 = { - get: get86, +export const preview6 = { + get: get81, } export const byOutputName2 = { - preview: preview7, + preview: preview6, } /** * One node's declared outputs for a published workflow run. */ -export const get87 = oc +export const get82 = oc .route({ description: "One node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4599,14 +4293,14 @@ export const get87 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get87, + get: get82, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get88 = oc +export const get83 = oc .route({ description: "Snapshot of every node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4619,7 +4313,7 @@ export const get88 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get88, + get: get83, events: events2, byNodeId: byNodeId10, } @@ -4639,7 +4333,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get89 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4657,7 +4351,7 @@ export const get89 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get89, + get: get84, } export const triggers2 = { @@ -4667,7 +4361,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post65 = oc +export const post62 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4680,13 +4374,13 @@ export const post65 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post65, + post: post62, } /** * Delete workflow */ -export const delete16 = oc +export const delete14 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4723,7 +4417,7 @@ export const patch3 = oc .output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { - delete: delete16, + delete: delete14, patch: patch3, restore, } @@ -4733,7 +4427,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get90 = oc +export const get85 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4752,7 +4446,7 @@ export const get90 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get90, + get: get85, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4766,7 +4460,7 @@ export const workflows3 = { * * Delete application */ -export const delete17 = oc +export const delete15 = oc .route({ description: 'Delete application', inputStructure: 'detailed', @@ -4785,7 +4479,7 @@ export const delete17 = oc * * Get application details */ -export const get91 = oc +export const get86 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4817,8 +4511,8 @@ export const put6 = oc .output(zPutAppsByAppIdResponse) export const byAppId2 = { - delete: delete17, - get: get91, + delete: delete15, + get: get86, put: put6, advancedChat, agent, @@ -4864,7 +4558,7 @@ export const byAppId2 = { * * Delete an API key for an app */ -export const delete18 = oc +export const delete16 = oc .route({ description: 'Delete an API key for an app', inputStructure: 'detailed', @@ -4879,7 +4573,7 @@ export const delete18 = oc .output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse) export const byApiKeyId = { - delete: delete18, + delete: delete16, } /** @@ -4887,7 +4581,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get92 = oc +export const get87 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4905,7 +4599,7 @@ export const get92 = oc * * Create a new API key for an app */ -export const post66 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4920,8 +4614,8 @@ export const post66 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get92, - post: post66, + get: get87, + post: post63, byApiKeyId, } @@ -4934,7 +4628,7 @@ export const byResourceId = { * * Get list of applications with pagination and filtering */ -export const get93 = oc +export const get88 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4952,7 +4646,7 @@ export const get93 = oc * * Create a new application */ -export const post67 = oc +export const post64 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4967,8 +4661,8 @@ export const post67 = oc .output(zPostAppsResponse) export const apps = { - get: get93, - post: post67, + get: get88, + post: post64, imports, recent, starred, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 03d2a09d35c6f6..0edb77f8ec8c89 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -258,75 +258,12 @@ export type AgentConfigSkillInspectResponse = { warnings?: Array } -export type AgentDriveListResponse = { - items?: Array -} - -export type AgentDriveDownloadResponse = { - url: string -} - -export type AgentDrivePreviewResponse = { - binary: boolean - key: string - size?: number | null - text?: string | null - truncated: boolean -} - -export type AgentDriveSkillListResponse = { - items?: Array -} - -export type AgentDriveSkillInspectResponse = { - archive_key?: string | null - created_at?: number | null - description: string - file_tree?: Array<{ - [key: string]: unknown - }> - files?: Array - hash?: string | null - mime_type?: string | null - name: string - path: string - size?: number | null - skill_md: AgentDriveSkillMarkdownResponse - skill_md_key: string - source: string - warnings?: Array -} - -export type AgentDriveDeleteResponse = { - removed_keys?: Array - result: string -} - -export type AgentDriveFilePayload = { - upload_file_id: string -} - -export type AgentDriveFileCommitResponse = { - file: AgentDriveFileResponse -} - export type AgentLogResponse = { files?: Array iterations: Array meta: AgentLogMetaResponse } -export type AgentSkillUploadResponse = { - manifest: SkillManifest - skill: AgentUploadedSkillResponse -} - -export type SkillToolInferenceResult = { - cli_tools?: Array - inferable: boolean - reason?: string | null -} - export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string @@ -1510,53 +1447,6 @@ export type AgentConfigSkillMarkdownResponse = { truncated: boolean } -export type AgentDriveItemResponse = { - created_at?: number | null - file_kind: string - hash?: string | null - is_skill?: boolean | null - key: string - mime_type?: string | null - size?: number | null - skill_metadata?: string | null -} - -export type AgentDriveSkillItemResponse = { - archive_key?: string | null - created_at?: number | null - description: string - hash?: string | null - mime_type?: string | null - name: string - path: string - size?: number | null - skill_md_key: string -} - -export type AgentDriveSkillFileResponse = { - available_in_drive: boolean - drive_key?: string | null - name: string - path: string - type: string -} - -export type AgentDriveSkillMarkdownResponse = { - binary: boolean - key: string - size?: number | null - text?: string | null - truncated: boolean -} - -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 @@ -1578,32 +1468,6 @@ export type AgentLogMetaResponse = { total_tokens: number } -export type SkillManifest = { - description: string - entry_path: string - files: Array - hash: string - name: string - size: number -} - -export type AgentUploadedSkillResponse = { - archive_key?: string | null - description: string - name: string - path: string - skill_md_key: string -} - -export type CliToolSuggestion = { - command?: string - description?: string - env_suggestions?: Array - inferred_from?: string - install_commands?: Array - name: string -} - export type AnnotationSettingEmbeddingModelResponse = { embedding_model_name?: string | null embedding_provider_name?: string | null @@ -2064,7 +1928,6 @@ export type AgentSoulConfig = { config_note?: string config_skills?: Array env?: AgentSoulEnvConfig - files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig knowledge?: AgentSoulKnowledgeConfig memory?: AgentSoulMemoryConfig @@ -2292,12 +2155,6 @@ export type AgentToolCallResponse = { } } -export type EnvSuggestion = { - key: string - reason?: string - secret_likely?: boolean -} - export type SimpleModelConfig = { model?: JsonValue | null pre_prompt?: string | null @@ -2477,11 +2334,6 @@ export type AgentSoulEnvConfig = { variables?: Array } -export type AgentSoulFilesConfig = { - files?: Array - skills?: Array -} - export type AgentSoulHumanConfig = { contacts?: Array tools?: Array @@ -2779,35 +2631,6 @@ export type AgentEnvVariableConfig = { [key: string]: unknown } -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type 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 | null - name?: string | null - path?: string | null - skill_md_file_id?: string | null - skill_md_key?: string | null - [key: string]: unknown -} - export type AgentHumanToolConfig = { description?: string | null enabled?: boolean @@ -2883,6 +2706,20 @@ export type AgentSoulDifyToolConfig = { tool_name?: string | null } +export type AgentFileRefConfig = { + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' export type DeclaredOutputRetryConfig = { @@ -3946,138 +3783,6 @@ export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponses = { export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponse = GetAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameInspectResponses] -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 GetAppsByAppIdAgentDriveSkillsData = { - body?: never - path: { - app_id: string - } - query?: { - node_id?: string - prefix?: string - } - url: '/apps/{app_id}/agent/drive/skills' -} - -export type GetAppsByAppIdAgentDriveSkillsResponses = { - 200: AgentDriveSkillListResponse -} - -export type GetAppsByAppIdAgentDriveSkillsResponse = - GetAppsByAppIdAgentDriveSkillsResponses[keyof GetAppsByAppIdAgentDriveSkillsResponses] - -export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectData = { - body?: never - path: { - app_id: string - skill_path: string - } - query?: { - node_id?: string - } - url: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect' -} - -export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses = { - 200: AgentDriveSkillInspectResponse -} - -export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse = - GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses[keyof GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses] - -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: { @@ -4101,68 +3806,6 @@ export type GetAppsByAppIdAgentLogsResponses = { export type GetAppsByAppIdAgentLogsResponse = GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses] -export type PostAppsByAppIdAgentSkillsUploadData = { - body: { - file: Blob | File - } - path: { - app_id: string - } - query?: { - node_id?: string - } - url: '/apps/{app_id}/agent/skills/upload' -} - -export type PostAppsByAppIdAgentSkillsUploadErrors = { - 400: unknown -} - -export type PostAppsByAppIdAgentSkillsUploadResponses = { - 201: AgentSkillUploadResponse -} - -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: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 12f765715d6b99..91074f82e8beb4 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -142,39 +142,6 @@ export const zAgentConfigSkillFilePreviewResponse = z.object({ truncated: z.boolean(), }) -/** - * AgentDriveDownloadResponse - */ -export const zAgentDriveDownloadResponse = z.object({ - url: z.string(), -}) - -/** - * AgentDrivePreviewResponse - */ -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({ - removed_keys: z.array(z.string()).optional(), - result: z.string(), -}) - -/** - * AgentDriveFilePayload - */ -export const zAgentDriveFilePayload = z.object({ - upload_file_id: z.string(), -}) - /** * AnnotationReplyPayload */ @@ -1239,109 +1206,6 @@ export const zAgentConfigSkillInspectResponse = z.object({ warnings: z.array(z.string()).optional(), }) -/** - * AgentDriveItemResponse - */ -export const zAgentDriveItemResponse = z.object({ - created_at: z.int().nullish(), - file_kind: z.string(), - hash: z.string().nullish(), - is_skill: z.boolean().nullish(), - key: z.string(), - mime_type: z.string().nullish(), - size: z.int().nullish(), - skill_metadata: z.string().nullish(), -}) - -/** - * AgentDriveListResponse - */ -export const zAgentDriveListResponse = z.object({ - items: z.array(zAgentDriveItemResponse).optional(), -}) - -/** - * AgentDriveSkillItemResponse - */ -export const zAgentDriveSkillItemResponse = z.object({ - archive_key: z.string().nullish(), - created_at: z.int().nullish(), - description: z.string(), - hash: z.string().nullish(), - mime_type: z.string().nullish(), - name: z.string(), - path: z.string(), - size: z.int().nullish(), - skill_md_key: z.string(), -}) - -/** - * AgentDriveSkillListResponse - */ -export const zAgentDriveSkillListResponse = z.object({ - items: z.array(zAgentDriveSkillItemResponse).optional(), -}) - -/** - * AgentDriveSkillFileResponse - */ -export const zAgentDriveSkillFileResponse = z.object({ - available_in_drive: z.boolean(), - drive_key: z.string().nullish(), - name: z.string(), - path: z.string(), - type: z.string(), -}) - -/** - * AgentDriveSkillMarkdownResponse - */ -export const zAgentDriveSkillMarkdownResponse = z.object({ - binary: z.boolean(), - key: z.string(), - size: z.int().nullish(), - text: z.string().nullish(), - truncated: z.boolean(), -}) - -/** - * AgentDriveSkillInspectResponse - */ -export const zAgentDriveSkillInspectResponse = z.object({ - archive_key: z.string().nullish(), - created_at: z.int().nullish(), - description: z.string(), - file_tree: z.array(z.record(z.string(), z.unknown())).optional(), - files: z.array(zAgentDriveSkillFileResponse).optional(), - hash: z.string().nullish(), - mime_type: z.string().nullish(), - name: z.string(), - path: z.string(), - size: z.int().nullish(), - skill_md: zAgentDriveSkillMarkdownResponse, - skill_md_key: z.string(), - source: z.string(), - warnings: z.array(z.string()).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({ - file: zAgentDriveFileResponse, -}) - /** * AgentLogMetaResponse */ @@ -1355,39 +1219,6 @@ 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(), -}) - -/** - * AgentUploadedSkillResponse - */ -export const zAgentUploadedSkillResponse = z.object({ - archive_key: z.string().nullish(), - description: z.string(), - name: z.string(), - path: z.string(), - skill_md_key: z.string(), -}) - -/** - * AgentSkillUploadResponse - */ -export const zAgentSkillUploadResponse = z.object({ - manifest: zSkillManifest, - skill: zAgentUploadedSkillResponse, -}) - /** * AnnotationSettingEmbeddingModelResponse */ @@ -2486,36 +2317,6 @@ 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 */ @@ -3236,55 +3037,6 @@ export const zAgentEnvVariableConfig = z.object({ variable: z.string().max(255).nullish(), }) -/** - * 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(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * 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(), - 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(), -}) - -/** - * AgentSoulFilesConfig - */ -export const zAgentSoulFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), -}) - /** * AgentHumanToolConfig */ @@ -3350,6 +3102,30 @@ export const zAgentSoulSandboxConfig = z.object({ provider: z.string().nullish(), }) +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + /** * OutputErrorStrategy * @@ -4162,7 +3938,6 @@ export const zAgentSoulConfig = z.object({ config_note: z.string().optional().default(''), config_skills: z.array(zAgentConfigSkillRefConfig).optional(), env: zAgentSoulEnvConfig.optional(), - files: zAgentSoulFilesConfig.optional(), human: zAgentSoulHumanConfig.optional(), knowledge: zAgentSoulKnowledgeConfig.optional(), memory: zAgentSoulMemoryConfig.optional(), @@ -4921,106 +4696,6 @@ export const zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery = z.object({ export const zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse = zAgentConfigSkillInspectResponse -export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ - app_id: z.uuid(), -}) - -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.uuid(), -}) - -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.uuid(), -}) - -export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ - key: z.string().min(1), - node_id: z.string().optional(), -}) - -/** - * Preview - */ -export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse - -export const zGetAppsByAppIdAgentDriveSkillsPath = z.object({ - app_id: z.uuid(), -}) - -export const zGetAppsByAppIdAgentDriveSkillsQuery = z.object({ - node_id: z.string().optional(), - prefix: z.string().optional().default(''), -}) - -/** - * Drive skills - */ -export const zGetAppsByAppIdAgentDriveSkillsResponse = zAgentDriveSkillListResponse - -export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath = z.object({ - app_id: z.uuid(), - skill_path: z.string(), -}) - -export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery = z.object({ - node_id: z.string().optional(), -}) - -/** - * Drive skill inspect view - */ -export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse = - zAgentDriveSkillInspectResponse - -export const zDeleteAppsByAppIdAgentFilesPath = z.object({ - app_id: z.uuid(), -}) - -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.uuid(), -}) - -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.uuid(), }) @@ -5035,51 +4710,6 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ */ export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse -export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({ - file: z.custom((value) => value instanceof Blob || value instanceof File), -}) - -export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ - app_id: z.uuid(), -}) - -export const zPostAppsByAppIdAgentSkillsUploadQuery = z.object({ - node_id: z.string().optional(), -}) - -/** - * Skill uploaded into drive - */ -export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse - -export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({ - app_id: z.uuid(), - 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.uuid(), - 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({ diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 67e5d738e3e25c..5dc2a624c1edf0 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -405,7 +405,6 @@ export type AgentSoulConfig = { config_note?: string config_skills?: Array env?: AgentSoulEnvConfig - files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig knowledge?: AgentSoulKnowledgeConfig memory?: AgentSoulMemoryConfig @@ -600,11 +599,6 @@ export type AgentSoulEnvConfig = { variables?: Array } -export type AgentSoulFilesConfig = { - files?: Array - skills?: Array -} - export type AgentSoulHumanConfig = { contacts?: Array tools?: Array @@ -857,35 +851,6 @@ export type AgentEnvVariableConfig = { [key: string]: unknown } -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type 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 | null - name?: string | null - path?: string | null - skill_md_file_id?: string | null - skill_md_key?: string | null - [key: string]: unknown -} - export type AgentHumanToolConfig = { description?: string | null enabled?: boolean @@ -961,6 +926,20 @@ export type AgentSoulDifyToolConfig = { tool_name?: string | null } +export type AgentFileRefConfig = { + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' export type DeclaredOutputRetryConfig = { diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 958018613d80bf..994adf7aa0f45d 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -789,55 +789,6 @@ export const zAgentEnvVariableConfig = z.object({ variable: z.string().max(255).nullish(), }) -/** - * 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(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * 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(), - 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(), -}) - -/** - * AgentSoulFilesConfig - */ -export const zAgentSoulFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), -}) - /** * AgentHumanToolConfig */ @@ -903,6 +854,30 @@ export const zAgentSoulSandboxConfig = z.object({ provider: z.string().nullish(), }) +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + /** * OutputErrorStrategy * @@ -1553,7 +1528,6 @@ export const zAgentSoulConfig = z.object({ config_note: z.string().optional().default(''), config_skills: z.array(zAgentConfigSkillRefConfig).optional(), env: zAgentSoulEnvConfig.optional(), - files: zAgentSoulFilesConfig.optional(), human: zAgentSoulHumanConfig.optional(), knowledge: zAgentSoulKnowledgeConfig.optional(), memory: zAgentSoulMemoryConfig.optional(),