diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index b9032c521eb..172d63858a5 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -32,6 +32,8 @@ from clients.agent_backend.factory import create_agent_backend_run_client from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario from clients.agent_backend.request_builder import ( AGENT_SOUL_PROMPT_LAYER_ID, + DIFY_CONFIG_LAYER_ID, + DIFY_CORE_TOOLS_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, @@ -47,6 +49,8 @@ from clients.agent_backend.request_builder import ( __all__ = [ "AGENT_SOUL_PROMPT_LAYER_ID", + "DIFY_CONFIG_LAYER_ID", + "DIFY_CORE_TOOLS_LAYER_ID", "DIFY_EXECUTION_CONTEXT_LAYER_ID", "DIFY_KNOWLEDGE_BASE_LAYER_ID", "DIFY_PLUGIN_TOOLS_LAYER_ID", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index fb45a783031..0ed8af042dd 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -20,6 +20,8 @@ from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig +from dify_agent.layers.config import DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig +from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID, DifyCoreToolsLayerConfig from dify_agent.layers.dify_plugin import ( DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, @@ -54,8 +56,10 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" +DIFY_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" DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" @@ -86,6 +90,10 @@ 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, @@ -159,7 +167,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + 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; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -206,7 +216,9 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + 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; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -243,8 +255,9 @@ class AgentBackendRunRequestBuilder: Layer graph: optional Agent Soul system prompt → user prompt → execution context → optional history (multi-turn) → LLM → optional - plugin tools / knowledge search → optional structured output. Mirrors the workflow-node - layer ordering minus the workflow-job / previous-node prompt. + plugin-direct tools / core-routed tools / knowledge search → + optional structured output. Mirrors the workflow-node layer ordering + minus the workflow-job / previous-node prompt. """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: @@ -274,11 +287,13 @@ class AgentBackendRunRequestBuilder: ] ) - include_shell = run_input.include_shell or run_input.drive_config is not None + include_shell = ( + run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None + ) if include_shell: - # 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/drive + # so eager pulls materialize content in the same filesystem used by + # model commands. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, @@ -289,6 +304,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.config_layer_config is not None: + layers.append( + RunLayerSpec( + name=DIFY_CONFIG_LAYER_ID, + type=DIFY_CONFIG_LAYER_TYPE_ID, + deps=_config_layer_deps(), + metadata=run_input.metadata, + config=run_input.config_layer_config, + ) + ) + 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. @@ -338,6 +364,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.core_tools is not None and run_input.core_tools.tools: + layers.append( + RunLayerSpec( + name=DIFY_CORE_TOOLS_LAYER_ID, + type=DIFY_CORE_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.core_tools, + ) + ) + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( @@ -403,7 +440,8 @@ class AgentBackendRunRequestBuilder: non-plugin layer graph that produced the snapshot. Plugin layers (``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the composition and the snapshot before submission because their configs - require credentials that are not persisted between runs. + may carry credentials or runtime-only declarations that are not + persisted between runs. """ if not runtime_layer_specs: raise ValueError( @@ -436,8 +474,9 @@ class AgentBackendRunRequestBuilder: """Build a workflow Agent Node run request without defining another wire schema. Layer graph mirrors the workflow surface: prompts → execution context → - optional drive/history → LLM → optional plugin tools / knowledge search - → optional auxiliary layers such as ask_human, shell, and structured output. + optional drive/history → LLM → optional plugin-direct tools / + core-routed tools / knowledge search → optional auxiliary layers such + as ask_human, shell, and structured output. """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: @@ -473,7 +512,9 @@ class AgentBackendRunRequestBuilder: ] ) - include_shell = run_input.include_shell or run_input.drive_config is not None + include_shell = ( + run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None + ) if include_shell: # Sandboxed bash workspace (dify.shell). It enters before drive so # drive can materialize mentioned targets with `dify-agent drive pull` @@ -488,6 +529,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.config_layer_config is not None: + layers.append( + RunLayerSpec( + name=DIFY_CONFIG_LAYER_ID, + type=DIFY_CONFIG_LAYER_TYPE_ID, + deps=_config_layer_deps(), + metadata=run_input.metadata, + config=run_input.config_layer_config, + ) + ) + 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. @@ -539,6 +591,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.core_tools is not None and run_input.core_tools.tools: + layers.append( + RunLayerSpec( + name=DIFY_CORE_TOOLS_LAYER_ID, + type=DIFY_CORE_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.core_tools, + ) + ) + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index cd8d6e0ab46..f5dd5407df3 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -54,6 +54,7 @@ from .app import ( agent_app_access, agent_app_feature, agent_app_sandbox, + agent_config_inspector, agent_drive_inspector, annotation, app, @@ -157,6 +158,7 @@ __all__ = [ "agent_app_feature", "agent_app_sandbox", "agent_composer", + "agent_config_inspector", "agent_drive_inspector", "agent_providers", "agent_roster", diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 7413be95d75..d089772e3ab 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -57,8 +57,9 @@ class WorkflowAgentComposerApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + @with_current_user_id @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, node_id: str): + def get(self, tenant_id: str, account_id: str, app_model: App, node_id: str): query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True)) return dump_response( WorkflowAgentComposerResponse, @@ -66,6 +67,7 @@ class WorkflowAgentComposerApi(Resource): tenant_id=tenant_id, app_id=app_model.id, node_id=node_id, + account_id=account_id, snapshot_id=query.snapshot_id, ), ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 3b33dc68c72..ec7f32c7609 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -248,12 +248,16 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite): backing_app_id: str | None = None hidden_app_backed: bool = False debug_conversation_id: str | None = None + debug_conversation_has_messages: bool = False + debug_conversation_message_count: int = 0 role: str | None = None active_config_is_published: bool = False class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_id: str + debug_conversation_has_messages: bool = False + debug_conversation_message_count: int = 0 class AgentPublishPayload(BaseModel): @@ -372,11 +376,17 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: s payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent) payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id) payload["id"] = agent.id - payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id( + debug_conversation_id = roster_service.get_or_create_agent_app_debug_conversation_id( tenant_id=app_model.tenant_id, agent_id=agent.id, account_id=current_user.id, ) + message_count = roster_service.count_agent_app_debug_conversation_messages( + conversation_id=debug_conversation_id, + ) + payload["debug_conversation_id"] = debug_conversation_id + payload["debug_conversation_has_messages"] = message_count > 0 + payload["debug_conversation_message_count"] = message_count payload["role"] = agent.role or "" payload["active_config_is_published"] = roster_service.active_config_is_published( tenant_id=app_model.tenant_id, @@ -635,9 +645,11 @@ class AgentDebugConversationRefreshApi(Resource): agent_id=str(agent_id), account_id=current_user.id, ) - return AgentDebugConversationRefreshResponse(debug_conversation_id=debug_conversation_id).model_dump( - mode="json" - ) + return AgentDebugConversationRefreshResponse( + debug_conversation_id=debug_conversation_id, + debug_conversation_has_messages=False, + debug_conversation_message_count=0, + ).model_dump(mode="json") @console_ns.route("/agent//publish") diff --git a/api/controllers/console/app/agent_config_inspector.py b/api/controllers/console/app/agent_config_inspector.py new file mode 100644 index 00000000000..83824d6434f --- /dev/null +++ b/api/controllers/console/app/agent_config_inspector.py @@ -0,0 +1,1249 @@ +"""Console API for Agent Soul-backed config assets.""" + +from __future__ import annotations + +import inspect +import io +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Literal +from uuid import UUID + +from flask import Response, request, send_file, url_for +from flask_restx import Resource +from pydantic import BaseModel, Field + +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) +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, + RBACResourceScope, + account_initialization_required, + edit_permission_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, + with_current_user, +) +from extensions.ext_database import db +from fields.base import ResponseModel +from libs.login import login_required +from models.account import Account +from models.model import App, AppMode +from services.agent.composer_service import AgentComposerService +from services.agent.errors import AgentVersionNotFoundError +from services.agent_config_service import ( + AgentConfigService, + AgentConfigServiceError, + AgentConfigVersionKind, + ConfigPushFileItem, + ConfigPushPayload, + ConfigPushSkillItem, +) + + +class AgentConfigQuery(BaseModel): + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + version_id: str | None = Field(default=None, description="Published snapshot ID for read-only version view") + draft_type: Literal["draft", "debug_build"] | None = Field( + default=None, + description="Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft", + ) + + +class AgentConfigByAgentQuery(BaseModel): + version_id: str | None = Field(default=None, description="Published snapshot ID for read-only version view") + draft_type: Literal["draft", "debug_build"] | None = Field( + default=None, + description="Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft", + ) + + +class AgentConfigFileUploadPayload(BaseModel): + upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload") + + +_AGENT_CONFIG_SKILL_UPLOAD_PARAMS = { + "file": { + "in": "formData", + "type": "file", + "required": True, + "description": "Skill package (.zip or .skill).", + } +} + + +class AgentConfigSkillFileQuery(AgentConfigQuery): + path: str = Field(..., description="Normalized zip member path inside the skill package") + + +class AgentConfigSkillFileByAgentQuery(AgentConfigByAgentQuery): + path: str = Field(..., description="Normalized zip member path inside the skill package") + + +class AgentConfigVersionResponse(ResponseModel): + id: str + kind: Literal["snapshot", "draft", "build_draft"] + writable: bool + + +class AgentConfigSkillItemResponse(ResponseModel): + id: str + name: str + file_id: str | None = None + description: str = "" + size: int | None = None + mime_type: str | None = None + hash: str | None = None + + +class AgentConfigFileItemResponse(ResponseModel): + id: str + name: str + file_id: str | None = None + size: int | None = None + mime_type: str | None = None + hash: str | None = None + + +class AgentConfigSkillItemsResponse(ResponseModel): + items: list[AgentConfigSkillItemResponse] = Field(default_factory=list) + + +class AgentConfigFileItemsResponse(ResponseModel): + items: list[AgentConfigFileItemResponse] = Field(default_factory=list) + + +class AgentConfigSkillUploadResponse(ResponseModel): + skill: AgentConfigSkillItemResponse + config_version: AgentConfigVersionResponse + + +class AgentConfigFileUploadResponse(ResponseModel): + file: AgentConfigFileItemResponse + config_version: AgentConfigVersionResponse + + +class AgentConfigManifestResponse(ResponseModel): + agent_id: str + config_version: AgentConfigVersionResponse + skills: AgentConfigSkillItemsResponse = Field(default_factory=AgentConfigSkillItemsResponse) + files: AgentConfigFileItemsResponse = Field(default_factory=AgentConfigFileItemsResponse) + env_keys: list[str] = Field(default_factory=list) + note: str = "" + + +class AgentConfigSkillListResponse(ResponseModel): + agent_id: str + config_version: AgentConfigVersionResponse + items: list[AgentConfigSkillItemResponse] = Field(default_factory=list) + + +class AgentConfigFileListResponse(ResponseModel): + agent_id: str + config_version: AgentConfigVersionResponse + items: list[AgentConfigFileItemResponse] = Field(default_factory=list) + + +class AgentConfigSkillFileResponse(ResponseModel): + path: str + name: str + type: Literal["file", "directory"] + previewable: bool + downloadable: bool + + +class AgentConfigSkillMarkdownResponse(ResponseModel): + path: Literal["SKILL.md"] + size: int | None = None + truncated: bool + binary: Literal[False] + text: str + + +class AgentConfigSkillInspectResponse(ResponseModel): + id: str + name: str + description: str = "" + size: int | None = None + mime_type: str | None = None + hash: str | None = None + source: Literal["config_skill_zip"] + files: list[AgentConfigSkillFileResponse] = Field(default_factory=list) + file_tree: list[dict[str, Any]] | None = None + skill_md: AgentConfigSkillMarkdownResponse + warnings: list[str] = Field(default_factory=list) + + +class AgentConfigFilePreviewResponse(ResponseModel): + name: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class AgentConfigSkillFilePreviewResponse(ResponseModel): + path: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class AgentConfigDownloadResponse(ResponseModel): + url: str + + +class AgentConfigDeleteResponse(ResponseModel): + result: Literal["success"] + removed_names: list[str] = Field(default_factory=list) + + +register_schema_models(console_ns, AgentConfigFileUploadPayload) +register_response_schema_models( + console_ns, + AgentConfigDeleteResponse, + AgentConfigDownloadResponse, + AgentConfigFileItemResponse, + AgentConfigFileItemsResponse, + AgentConfigFileListResponse, + AgentConfigFilePreviewResponse, + AgentConfigFileUploadResponse, + AgentConfigManifestResponse, + AgentConfigSkillFilePreviewResponse, + AgentConfigSkillFileResponse, + AgentConfigSkillInspectResponse, + AgentConfigSkillItemResponse, + AgentConfigSkillItemsResponse, + AgentConfigSkillListResponse, + AgentConfigSkillMarkdownResponse, + AgentConfigSkillUploadResponse, + AgentConfigVersionResponse, +) + + +@dataclass(frozen=True, slots=True) +class _ResolvedConsoleTarget: + tenant_id: str + agent_id: str + account_id: str + version_id: str + version_kind: AgentConfigVersionKind + + +_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + + +def _service() -> AgentConfigService: + return AgentConfigService() + + +def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: + if node_id: + return AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + node_id=node_id, + ) + return app_model.bound_agent_id + + +def _agent_not_bound() -> tuple[dict[str, object], int]: + return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 + + +def _handle(exc: AgentConfigServiceError) -> tuple[dict[str, object], int]: + return {"code": exc.code, "message": exc.message}, exc.status_code + + +def _json_response(data: Mapping[str, Any]) -> Response: + return Response( + response=json.dumps(data, ensure_ascii=False, separators=(",", ":")), + content_type="application/json; charset=utf-8", + ) + + +def _resolve_console_version( + *, + tenant_id: str, + agent_id: str, + account_id: str, + version_id: str | None, + draft_type: str | None, +) -> tuple[str, AgentConfigVersionKind]: + if version_id: + return version_id, AgentConfigVersionKind.SNAPSHOT + try: + if draft_type == "debug_build": + state = AgentComposerService.load_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=agent_id, + account_id=account_id, + ) + draft = state.get("draft") or {} + draft_id = draft.get("id") + if isinstance(draft_id, str) and draft_id: + return draft_id, AgentConfigVersionKind.BUILD_DRAFT + else: + state = AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=agent_id) + draft = state.get("draft") or {} + draft_id = draft.get("id") + if isinstance(draft_id, str) and draft_id: + # load_agent_composer creates the normal draft on first access. + # Config asset services use their own SQLAlchemy session, so the + # draft must be visible before we hand its id across that boundary. + db.session.commit() + return draft_id, AgentConfigVersionKind.DRAFT + except AgentVersionNotFoundError as exc: + raise AgentConfigServiceError( + "config_version_not_found", + "agent config version was not found", + status_code=404, + ) from exc + raise AgentConfigServiceError( + "config_version_not_found", + "agent config version was not found", + status_code=404, + ) + + +def _resolve_target( + *, + tenant_id: str, + agent_id: str, + account_id: str, + version_id: str | None, + draft_type: str | None, +) -> _ResolvedConsoleTarget: + resolved_version_id, version_kind = _resolve_console_version( + tenant_id=tenant_id, + agent_id=agent_id, + account_id=account_id, + version_id=version_id, + draft_type=draft_type, + ) + return _ResolvedConsoleTarget( + tenant_id=tenant_id, + agent_id=agent_id, + account_id=account_id, + version_id=resolved_version_id, + version_kind=version_kind, + ) + + +def _resolve_agent_route_target( + *, + tenant_id: str, + agent_id: UUID, + current_user: Account, + query: AgentConfigByAgentQuery, +) -> _ResolvedConsoleTarget: + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _resolve_target( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + version_id=query.version_id, + draft_type=query.draft_type, + ) + + +def _resolve_app_route_target( + *, + app_model: App, + current_user: Account, + query: AgentConfigQuery, +) -> _ResolvedConsoleTarget | tuple[dict[str, object], int]: + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + return _resolve_target( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + version_id=query.version_id, + draft_type=query.draft_type, + ) + + +def _with_agent_route_target( + *, + tenant_id: str, + agent_id: UUID, + current_user: Account, + action: Callable[[_ResolvedConsoleTarget], Any], +) -> Any: + query = query_params_from_request(AgentConfigByAgentQuery) + try: + target = _resolve_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + query=query, + ) + return action(target) + except AgentConfigServiceError as exc: + return _handle(exc) + + +def _with_app_route_target( + *, + app_model: App, + current_user: Account, + action: Callable[[_ResolvedConsoleTarget], Any], +) -> Any: + query = query_params_from_request(AgentConfigQuery) + try: + target = _resolve_app_route_target(app_model=app_model, current_user=current_user, query=query) + if isinstance(target, tuple): + return target + return action(target) + except AgentConfigServiceError as exc: + return _handle(exc) + + +def _manifest_response(target: _ResolvedConsoleTarget) -> dict[str, object]: + return _service().manifest( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + user_id=target.account_id, + ) + + +def _skill_list_response(target: _ResolvedConsoleTarget) -> dict[str, object]: + return _service().list_skills( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + user_id=target.account_id, + ) + + +def _file_list_response(target: _ResolvedConsoleTarget) -> dict[str, object]: + return _service().list_files( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + user_id=target.account_id, + ) + + +def _read_single_upload() -> tuple[bytes, str]: + if "file" not in request.files: + raise AgentConfigServiceError("no_file", "no skill file uploaded", status_code=400) + if len(request.files) > 1: + raise AgentConfigServiceError("too_many_files", "only one skill file is allowed", status_code=400) + upload = request.files["file"] + return upload.stream.read(), upload.filename or "" + + +def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]: + return _upload_skill_for_target( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + user_id=target.account_id, + version_id=target.version_id, + version_kind=target.version_kind, + ) + + +def _upload_skill_for_target( + *, + tenant_id: str, + agent_id: str, + user_id: str, + version_id: str, + version_kind: AgentConfigVersionKind, + content: bytes | None = None, + filename: str = "", +) -> tuple[dict[str, object], int]: + if content is None: + content, filename = _read_single_upload() + manifest = _service().upload_skill_for_console( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=version_id, + config_version_kind=version_kind, + content=content, + filename=filename, + ) + return manifest, 201 + + +def _file_upload_response( + target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload +) -> tuple[dict[str, object], int]: + manifest = _service().push_file_for_console( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + user_id=target.account_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + upload_file_id=payload.upload_file_id, + ) + return manifest, 201 + + +def _skill_inspect_response(target: _ResolvedConsoleTarget, name: str) -> Response: + return _json_response( + _service().inspect_skill( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + user_id=target.account_id, + ) + ) + + +def _skill_download_response(target: _ResolvedConsoleTarget, name: str) -> Response: + return _json_response( + { + "url": _service().download_skill_url( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + user_id=target.account_id, + ) + } + ) + + +def _skill_file_preview_response(target: _ResolvedConsoleTarget, name: str, path: str) -> dict[str, object]: + return _service().preview_skill_file( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + path=path, + user_id=target.account_id, + ) + + +def _skill_file_download_response( + target: _ResolvedConsoleTarget, + *, + name: str, + path: str, + raw_endpoint: str, + route_params: dict[str, str], + extra_query_params: dict[str, str] | None = None, +) -> Response: + member_path = _service().resolve_skill_file_member_path( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + path=path, + user_id=target.account_id, + ) + query_args: dict[str, str] = {"path": member_path} + if extra_query_params: + query_args.update(extra_query_params) + if target.version_kind == AgentConfigVersionKind.SNAPSHOT: + query_args["version_id"] = target.version_id + elif target.version_kind == AgentConfigVersionKind.BUILD_DRAFT: + query_args["draft_type"] = "debug_build" + url = url_for(raw_endpoint, _external=False, name=name, **route_params, **query_args) + return _json_response({"url": url}) + + +def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str, path: str) -> Response: + download = _service().pull_skill_file( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + path=path, + user_id=target.account_id, + ) + return send_file( + io.BytesIO(download.payload), + mimetype=download.mime_type, + as_attachment=True, + download_name=download.filename, + ) + + +def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]: + _service().push_for_console( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + user_id=target.account_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + payload=ConfigPushPayload(skills=[ConfigPushSkillItem(name=name)]), + ) + return {"result": "success", "removed_names": [name]} + + +def _file_preview_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]: + return _service().preview_file( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + user_id=target.account_id, + ) + + +def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Response: + return _json_response( + { + "url": _service().download_file_url( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + name=name, + user_id=target.account_id, + ) + } + ) + + +def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]: + _service().push_for_console( + tenant_id=target.tenant_id, + agent_id=target.agent_id, + user_id=target.account_id, + config_version_id=target.version_id, + config_version_kind=target.version_kind, + payload=ConfigPushPayload(files=[ConfigPushFileItem(name=name)]), + ) + return {"result": "success", "removed_names": [name]} + + +@console_ns.route("/agent//config/manifest") +class AgentConfigManifestByAgentApi(Resource): + @console_ns.doc("get_agent_config_manifest_by_agent") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentConfigByAgentQuery)}) + @console_ns.response(200, "Agent config manifest", console_ns.models[AgentConfigManifestResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=_manifest_response, + ) + + +@console_ns.route("/apps//agent/config/manifest") +class AgentConfigManifestApi(Resource): + @console_ns.doc("get_agent_config_manifest") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentConfigQuery)}) + @console_ns.response(200, "Agent config manifest", console_ns.models[AgentConfigManifestResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App): + return _with_app_route_target(app_model=app_model, current_user=current_user, action=_manifest_response) + + +@console_ns.route("/agent//config/skills/upload") +class AgentConfigSkillUploadByAgentApi(Resource): + @console_ns.doc("upload_agent_config_skill_by_agent") + @console_ns.doc( + consumes=["multipart/form-data"], + params={ + "agent_id": "Agent ID", + **query_params_from_model(AgentConfigByAgentQuery), + **_AGENT_CONFIG_SKILL_UPLOAD_PARAMS, + }, + ) + @console_ns.response(201, "Uploaded config skill", console_ns.models[AgentConfigSkillUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=_skill_upload_response, + ) + + +@console_ns.route("/apps//agent/config/skills/upload") +class AgentConfigSkillUploadApi(Resource): + @console_ns.doc("upload_agent_config_skill") + @console_ns.doc( + consumes=["multipart/form-data"], + params={ + "app_id": "Application ID", + **query_params_from_model(AgentConfigQuery), + **_AGENT_CONFIG_SKILL_UPLOAD_PARAMS, + }, + ) + @console_ns.response(201, "Uploaded config skill", console_ns.models[AgentConfigSkillUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def post(self, current_user: Account, app_model: App): + return _with_app_route_target(app_model=app_model, current_user=current_user, action=_skill_upload_response) + + +@console_ns.route("/agent//config/skills") +class AgentConfigSkillsByAgentApi(Resource): + @console_ns.doc("get_agent_config_skills_by_agent") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentConfigByAgentQuery)}) + @console_ns.response(200, "Config skills", console_ns.models[AgentConfigSkillListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=_skill_list_response, + ) + + +@console_ns.route("/apps//agent/config/skills") +class AgentConfigSkillsApi(Resource): + @console_ns.doc("get_agent_config_skills") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentConfigQuery)}) + @console_ns.response(200, "Config skills", console_ns.models[AgentConfigSkillListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App): + return _with_app_route_target(app_model=app_model, current_user=current_user, action=_skill_list_response) + + +@console_ns.route("/agent//config/files") +class AgentConfigFilesByAgentApi(Resource): + @console_ns.doc("get_agent_config_files_by_agent") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentConfigByAgentQuery)}) + @console_ns.response(200, "Config files", console_ns.models[AgentConfigFileListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=_file_list_response, + ) + + @console_ns.doc("upload_agent_config_file_by_agent") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentConfigByAgentQuery)}) + @console_ns.expect(console_ns.models[AgentConfigFileUploadPayload.__name__]) + @console_ns.response(201, "Uploaded config file", console_ns.models[AgentConfigFileUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + payload = AgentConfigFileUploadPayload.model_validate(console_ns.payload or {}) + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _file_upload_response(target, payload), + ) + + +@console_ns.route("/apps//agent/config/files") +class AgentConfigFilesApi(Resource): + @console_ns.doc("get_agent_config_files") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentConfigQuery)}) + @console_ns.response(200, "Config files", console_ns.models[AgentConfigFileListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App): + return _with_app_route_target(app_model=app_model, current_user=current_user, action=_file_list_response) + + @console_ns.doc("upload_agent_config_file") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentConfigQuery)}) + @console_ns.expect(console_ns.models[AgentConfigFileUploadPayload.__name__]) + @console_ns.response(201, "Uploaded config file", console_ns.models[AgentConfigFileUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def post(self, current_user: Account, app_model: App): + payload = AgentConfigFileUploadPayload.model_validate(console_ns.payload or {}) + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _file_upload_response(target, payload), + ) + + +@console_ns.route("/agent//config/skills//inspect") +class AgentConfigSkillInspectByAgentApi(Resource): + @console_ns.doc("inspect_agent_config_skill_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config skill name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Config skill inspect view", console_ns.models[AgentConfigSkillInspectResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _skill_inspect_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/skills//inspect") +class AgentConfigSkillInspectApi(Resource): + @console_ns.doc("inspect_agent_config_skill") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config skill name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Config skill inspect view", console_ns.models[AgentConfigSkillInspectResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _skill_inspect_response(target, name), + ) + + +@console_ns.route("/agent//config/skills//files/preview") +class AgentConfigSkillFilePreviewByAgentApi(Resource): + @console_ns.doc("preview_agent_config_skill_file_by_agent") + @console_ns.doc( + params={ + "agent_id": "Agent ID", + "name": "Config skill name", + **query_params_from_model(AgentConfigSkillFileByAgentQuery), + } + ) + @console_ns.response( + 200, "Config skill file preview", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + query = query_params_from_request(AgentConfigSkillFileByAgentQuery) + try: + target = _resolve_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + query=query, + ) + return _skill_file_preview_response(target, name, query.path) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route("/apps//agent/config/skills//files/preview") +class AgentConfigSkillFilePreviewApi(Resource): + @console_ns.doc("preview_agent_config_skill_file") + @console_ns.doc( + params={ + "app_id": "Application ID", + "name": "Config skill name", + **query_params_from_model(AgentConfigSkillFileQuery), + } + ) + @console_ns.response( + 200, "Config skill file preview", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + query = query_params_from_request(AgentConfigSkillFileQuery) + try: + target = _resolve_app_route_target(app_model=app_model, current_user=current_user, query=query) + if isinstance(target, tuple): + return target + return _skill_file_preview_response(target, name, query.path) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route("/agent//config/skills//download") +class AgentConfigSkillDownloadByAgentApi(Resource): + @console_ns.doc("download_agent_config_skill_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config skill name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Config skill download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _skill_download_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/skills//download") +class AgentConfigSkillDownloadApi(Resource): + @console_ns.doc("download_agent_config_skill") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config skill name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Config skill download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _skill_download_response(target, name), + ) + + +@console_ns.route("/agent//config/skills//files/download") +class AgentConfigSkillFileDownloadByAgentApi(Resource): + @console_ns.doc("download_agent_config_skill_file_by_agent") + @console_ns.doc( + params={ + "agent_id": "Agent ID", + "name": "Config skill name", + **query_params_from_model(AgentConfigSkillFileByAgentQuery), + } + ) + @console_ns.response(200, "Config skill file download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + query = query_params_from_request(AgentConfigSkillFileByAgentQuery) + try: + target = _resolve_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + query=query, + ) + return _skill_file_download_response( + target, + name=name, + path=query.path, + raw_endpoint="console.agent_config_skill_file_download_content_by_agent_api", + route_params={"agent_id": str(agent_id)}, + ) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route("/apps//agent/config/skills//files/download") +class AgentConfigSkillFileDownloadApi(Resource): + @console_ns.doc("download_agent_config_skill_file") + @console_ns.doc( + params={ + "app_id": "Application ID", + "name": "Config skill name", + **query_params_from_model(AgentConfigSkillFileQuery), + } + ) + @console_ns.response(200, "Config skill file download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + query = query_params_from_request(AgentConfigSkillFileQuery) + try: + target = _resolve_app_route_target(app_model=app_model, current_user=current_user, query=query) + if isinstance(target, tuple): + return target + return _skill_file_download_response( + target, + name=name, + path=query.path, + raw_endpoint="console.agent_config_skill_file_download_content_api", + route_params={"app_id": str(app_model.id)}, + extra_query_params={"node_id": query.node_id} if query.node_id else None, + ) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route( + "/agent//config/skills//files/content", + endpoint="agent_config_skill_file_download_content_by_agent_api", +) +class AgentConfigSkillFileDownloadContentByAgentApi(Resource): + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + query = query_params_from_request(AgentConfigSkillFileByAgentQuery) + try: + target = _resolve_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + query=query, + ) + return _skill_file_raw_download_response(target, name, query.path) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route( + "/apps//agent/config/skills//files/content", + endpoint="agent_config_skill_file_download_content_api", +) +class AgentConfigSkillFileDownloadContentApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + query = query_params_from_request(AgentConfigSkillFileQuery) + try: + target = _resolve_app_route_target(app_model=app_model, current_user=current_user, query=query) + if isinstance(target, tuple): + return target + return _skill_file_raw_download_response(target, name, query.path) + except AgentConfigServiceError as exc: + return _handle(exc) + + +@console_ns.route("/agent//config/skills/") +class AgentConfigSkillByAgentApi(Resource): + @console_ns.doc("delete_agent_config_skill_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config skill name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Config skill deleted", console_ns.models[AgentConfigDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _skill_delete_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/skills/") +class AgentConfigSkillApi(Resource): + @console_ns.doc("delete_agent_config_skill") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config skill name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Config skill deleted", console_ns.models[AgentConfigDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _skill_delete_response(target, name), + ) + + +@console_ns.route("/agent//config/files//preview") +class AgentConfigFilePreviewByAgentApi(Resource): + @console_ns.doc("preview_agent_config_file_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config file name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Preview", console_ns.models[AgentConfigFilePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _file_preview_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/files//preview") +class AgentConfigFilePreviewApi(Resource): + @console_ns.doc("preview_agent_config_file") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config file name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Preview", console_ns.models[AgentConfigFilePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _file_preview_response(target, name), + ) + + +@console_ns.route("/agent//config/files//download") +class AgentConfigFileDownloadByAgentApi(Resource): + @console_ns.doc("download_agent_config_file_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config file name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Config file download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _file_download_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/files//download") +class AgentConfigFileDownloadApi(Resource): + @console_ns.doc("download_agent_config_file") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config file name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Config file download URL", console_ns.models[AgentConfigDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def get(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _file_download_response(target, name), + ) + + +@console_ns.route("/agent//config/files/") +class AgentConfigFileByAgentApi(Resource): + @console_ns.doc("delete_agent_config_file_by_agent") + @console_ns.doc( + params={"agent_id": "Agent ID", "name": "Config file name", **query_params_from_model(AgentConfigByAgentQuery)} + ) + @console_ns.response(200, "Config file deleted", console_ns.models[AgentConfigDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, name: str): + return _with_agent_route_target( + tenant_id=tenant_id, + agent_id=agent_id, + current_user=current_user, + action=lambda target: _file_delete_response(target, name), + ) + + +@console_ns.route("/apps//agent/config/files/") +class AgentConfigFileApi(Resource): + @console_ns.doc("delete_agent_config_file") + @console_ns.doc( + params={"app_id": "Application ID", "name": "Config file name", **query_params_from_model(AgentConfigQuery)} + ) + @console_ns.response(200, "Config file deleted", console_ns.models[AgentConfigDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=_WORKFLOW_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App, name: str): + return _with_app_route_target( + app_model=app_model, + current_user=current_user, + action=lambda target: _file_delete_response(target, name), + ) + + +__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)] diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 16c1a2f570b..2862312c1e3 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -1,4 +1,6 @@ +import json import logging +from collections.abc import Generator from typing import Any, Literal from uuid import UUID @@ -34,6 +36,7 @@ from controllers.console.wraps import ( ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.features.rate_limiting.rate_limit import RateLimitGenerator from core.errors.error import ( ModelCurrentlyNotSupportError, ProviderTokenNotInitError, @@ -106,6 +109,32 @@ class ChatMessagePayload(BaseMessagePayload): return uuid_value(value) +_BUILD_CHAT_FINALIZATION_QUERY = """Finalize this Build chat configuration for the agent. + +This step is only for persisting Agent config changes discovered in the current Build chat. Do not install packages, +edit workspace files, run validation or debugging commands, make exploratory checks, or perform other work. + +Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or +validate old config unless the message history already shows that the old config is invalid. + +Persist only the build-draft config resources that need to change, using the Agent config CLI usage provided in the +runtime prompt: + +- config files for reusable artifacts that should be available later, +- config skills for reusable procedures or tools that should be available later, +- config env when environment keys or values need to be recorded, +- config note for concise durable context when useful. + +When updating the config note, record only durable context needed by later runs, such as: + +- what you installed or configured outside the workspace for this agent, +- where those external updates live, including CLI tools, packages, and persistent $HOME paths, +- how the agent should use it in later runs, +- any setup, authentication, or user action still required. + +After config persistence completes, respond FINISHED.""" + + register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload) register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) @@ -231,6 +260,31 @@ class AgentChatMessageApi(Resource): ) +@console_ns.route("/agent//build-chat/finalize") +class AgentBuildChatFinalizeApi(Resource): + @console_ns.doc("finalize_agent_build_chat") + @console_ns.doc(description="Run a build-draft Agent App turn that asks the agent to push config updates") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(400, "Invalid request parameters") + @console_ns.response(404, "Agent, build draft, or conversation not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _create_build_chat_finalization_message( + current_tenant_id=current_tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=str(agent_id), + ) + + @console_ns.route("/apps//chat-messages//stop") class ChatMessageStopApi(Resource): @console_ns.doc("stop_chat_message") @@ -284,7 +338,11 @@ def _resolve_current_user_agent_debug_conversation_id( def _create_chat_message( - *, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None + *, + current_user: Account, + app_model: App, + current_tenant_id: str | None = None, + agent_id: str | None = None, ): raw_payload = console_ns.payload or {} args_model = ChatMessagePayload.model_validate(raw_payload) @@ -314,12 +372,104 @@ def _create_chat_message( if external_trace_id: args["external_trace_id"] = external_trace_id - try: - response = AppGenerateService.generate( - app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming - ) + return _generate_chat_message_response( + current_user=current_user, + app_model=app_model, + args=args, + streaming=streaming, + ) - return helper.compact_generate_response(response) + +def _create_build_chat_finalization_message( + *, current_user: Account, app_model: App, current_tenant_id: str, agent_id: str +): + debug_conversation_id = _resolve_current_user_agent_debug_conversation_id( + current_tenant_id=current_tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=agent_id, + ) + args: dict[str, Any] = { + "query": _BUILD_CHAT_FINALIZATION_QUERY, + "inputs": {}, + "response_mode": "streaming", + "draft_type": "debug_build", + "conversation_id": debug_conversation_id, + "auto_generate_name": False, + } + external_trace_id = get_external_trace_id(request) + if external_trace_id: + args["external_trace_id"] = external_trace_id + + response = _generate_chat_message( + current_user=current_user, + app_model=app_model, + args=args, + streaming=True, + ) + _drain_streaming_generate_response(response) + return {"result": "success"}, 200 + + +def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[str, None, None]) -> None: + """Consume a streamed app-generate response until a terminal message event arrives. + + Finalize keeps the normal Agent App streaming path so the existing queue, + persistence, and runtime-session behavior stay intact. The console API only + changes the HTTP boundary: it drains the SSE stream server-side and returns + success after the generated build-chat message reaches ``message_end``. + """ + close = getattr(response, "close", None) + try: + for chunk in response: + for raw_event in chunk.split("\n\n"): + if not raw_event.strip(): + continue + + event_name: str | None = None + data_lines: list[str] = [] + for line in raw_event.splitlines(): + if line.startswith("event: "): + event_name = line.removeprefix("event: ").strip() + elif line.startswith("data: "): + data_lines.append(line.removeprefix("data: ")) + + if not data_lines: + if event_name == "ping": + continue + continue + + payload = json.loads("\n".join(data_lines)) + if not isinstance(payload, dict): + continue + + payload_event = payload.get("event") + if payload_event == "message_end": + return + if payload_event == "error": + raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed.")) + finally: + if callable(close): + close() + + raise CompletionRequestError("Build chat finalization did not complete.") + + +def _generate_chat_message( + *, + current_user: Account, + app_model: App, + args: dict[str, Any], + streaming: bool, +): + try: + return AppGenerateService.generate( + app_model=app_model, + user=current_user, + args=args, + invoke_from=InvokeFrom.DEBUGGER, + streaming=streaming, + ) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except services.errors.conversation.ConversationCompletedError: @@ -335,6 +485,8 @@ def _create_chat_message( raise ProviderModelCurrentlyNotSupportError() except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) + except CompletionRequestError: + raise except InvokeError as e: raise CompletionRequestError(e.description) except ValueError as e: @@ -344,6 +496,22 @@ def _create_chat_message( raise InternalServerError() +def _generate_chat_message_response( + *, + current_user: Account, + app_model: App, + args: dict[str, Any], + streaming: bool, +): + response = _generate_chat_message( + current_user=current_user, + app_model=app_model, + args=args, + streaming=streaming, + ) + return helper.compact_generate_response(response) + + def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str): AppTaskService.stop_task( task_id=task_id, diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index 20fd651b759..f47861cf274 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -17,8 +17,10 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat from . import mail as _mail from . import runtime_credentials as _runtime_credentials +from .agent import tools as _agent_tools from .app import dsl as _app_dsl from .knowledge import retrieval as _knowledge_retrieval +from .plugin import agent_config as _agent_config from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -26,7 +28,9 @@ from .workspace import workspace as _workspace api.add_namespace(inner_api_ns) __all__ = [ + "_agent_config", "_agent_drive", + "_agent_tools", "_app_dsl", "_knowledge_retrieval", "_mail", diff --git a/api/controllers/inner_api/agent/__init__.py b/api/controllers/inner_api/agent/__init__.py new file mode 100644 index 00000000000..23adade63b7 --- /dev/null +++ b/api/controllers/inner_api/agent/__init__.py @@ -0,0 +1 @@ +"""Agent backend inner API controllers.""" diff --git a/api/controllers/inner_api/agent/tools.py b/api/controllers/inner_api/agent/tools.py new file mode 100644 index 00000000000..8317a64bc28 --- /dev/null +++ b/api/controllers/inner_api/agent/tools.py @@ -0,0 +1,68 @@ +"""Inner API endpoint for Agent core tool invocation.""" + +from flask_restx import Resource +from pydantic import ValidationError + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import agent_inner_api_only +from extensions.ext_database import db +from libs.exception import BaseHTTPException +from services.agent_tool_inner_service import AgentToolInnerService +from services.entities.agent_tool_inner import AgentToolInvokeRequest, AgentToolInvokeResponse +from services.errors.agent_tool_inner import AgentToolInnerServiceError + + +class AgentToolInvokeHttpError(BaseHTTPException): + error_code = "agent_tool_invoke_failed" + description = "Agent tool invocation failed." + code = 500 + + def __init__( + self, + *, + error_code: str | None = None, + description: str | None = None, + status_code: int | None = None, + ) -> None: + if error_code is not None: + self.error_code = error_code + if description is not None: + self.description = description + if status_code is not None: + self.code = status_code + super().__init__(self.description) + + +register_schema_models(inner_api_ns, AgentToolInvokeRequest) +register_response_schema_models(inner_api_ns, AgentToolInvokeResponse) + + +@inner_api_ns.route("/agent/tools/invoke") +class AgentToolInvokeApi(Resource): + """Invoke one Agent tool through the API-owned core tool runtime path.""" + + @agent_inner_api_only + @inner_api_ns.doc("inner_agent_tool_invoke") + @inner_api_ns.expect(inner_api_ns.models[AgentToolInvokeRequest.__name__]) + @inner_api_ns.response(200, "Tool invoked successfully", inner_api_ns.models[AgentToolInvokeResponse.__name__]) + def post(self) -> dict[str, object]: + try: + payload = AgentToolInvokeRequest.model_validate(inner_api_ns.payload or {}) + except ValidationError as exc: + raise AgentToolInvokeHttpError( + error_code="invalid_request", + description=str(exc), + status_code=400, + ) from exc + + try: + response = AgentToolInnerService().invoke(payload, session=db.session()) + except AgentToolInnerServiceError as exc: + raise AgentToolInvokeHttpError( + error_code=exc.error_code, + description=exc.description, + status_code=exc.status_code, + ) from exc + + return response.model_dump(mode="json") diff --git a/api/controllers/inner_api/plugin/agent_config.py b/api/controllers/inner_api/plugin/agent_config.py new file mode 100644 index 00000000000..6f12f6e5d02 --- /dev/null +++ b/api/controllers/inner_api/plugin/agent_config.py @@ -0,0 +1,243 @@ +"""Inner API for Agent Soul-backed config assets. + +These endpoints are called by the dify-agent server with the inner API key. +They resolve the requested Agent config version directly from Agent Soul JSON +and never expose signed download URLs or drive-owned metadata. +""" + +from __future__ import annotations + +import io + +from flask import request, send_file +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.wraps import plugin_inner_api_only +from services.agent_config_service import ( + AgentConfigService, + AgentConfigServiceError, + AgentConfigVersionKind, + ConfigPushPayload, +) + + +class _ConfigTargetQuery(BaseModel): + tenant_id: str + user_id: str | None = None + config_version_id: str + config_version_kind: AgentConfigVersionKind + + +class _ConfigMutationRequest(BaseModel): + tenant_id: str + user_id: str + config_version_id: str + config_version_kind: AgentConfigVersionKind + + +class _ConfigPushRequest(_ConfigMutationRequest): + files: list[dict] = [] + skills: list[dict] = [] + env_text: str | None = None + note: str | None = None + + def to_payload(self) -> ConfigPushPayload: + return ConfigPushPayload.model_validate( + { + "files": self.files, + "skills": self.skills, + "env_text": self.env_text, + "note": self.note, + } + ) + + +class _ConfigEnvUpdateRequest(_ConfigMutationRequest): + env_text: str + + +class _ConfigNoteUpdateRequest(_ConfigMutationRequest): + note: str + + +def _target_query_from_request() -> _ConfigTargetQuery: + return _ConfigTargetQuery.model_validate( + { + "tenant_id": request.args.get("tenant_id"), + "user_id": request.args.get("user_id"), + "config_version_id": request.args.get("config_version_id"), + "config_version_kind": request.args.get("config_version_kind"), + } + ) + + +def _error_response(exc: AgentConfigServiceError) -> tuple[dict[str, str], int]: + return {"code": exc.code, "message": exc.message}, exc.status_code + + +@inner_api_ns.route("/agent-config//manifest") +class AgentConfigManifestApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_manifest") + def get(self, agent_id: str): + try: + query = _target_query_from_request() + return AgentConfigService().manifest( + tenant_id=query.tenant_id, + agent_id=agent_id, + user_id=query.user_id, + config_version_id=query.config_version_id, + config_version_kind=query.config_version_kind, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//skills//pull") +class AgentConfigSkillPullApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_skill_pull") + def get(self, agent_id: str, name: str): + try: + query = _target_query_from_request() + result = AgentConfigService().pull_skill( + tenant_id=query.tenant_id, + agent_id=agent_id, + user_id=query.user_id, + config_version_id=query.config_version_id, + config_version_kind=query.config_version_kind, + name=name, + ) + return send_file( + io.BytesIO(result.payload), + mimetype=result.mime_type, + as_attachment=True, + download_name=result.filename, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//skills//inspect") +class AgentConfigSkillInspectApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_skill_inspect") + def get(self, agent_id: str, name: str): + try: + query = _target_query_from_request() + return AgentConfigService().inspect_skill( + tenant_id=query.tenant_id, + agent_id=agent_id, + user_id=query.user_id, + config_version_id=query.config_version_id, + config_version_kind=query.config_version_kind, + name=name, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//files//pull") +class AgentConfigFilePullApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_file_pull") + def get(self, agent_id: str, name: str): + try: + query = _target_query_from_request() + result = AgentConfigService().pull_file( + tenant_id=query.tenant_id, + agent_id=agent_id, + user_id=query.user_id, + config_version_id=query.config_version_id, + config_version_kind=query.config_version_kind, + name=name, + ) + return send_file( + io.BytesIO(result.payload), + mimetype=result.mime_type, + as_attachment=True, + download_name=result.filename, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//push") +class AgentConfigPushApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_push") + def post(self, agent_id: str): + try: + body = _ConfigPushRequest.model_validate(request.get_json(silent=True) or {}) + return AgentConfigService().push( + tenant_id=body.tenant_id, + agent_id=agent_id, + user_id=body.user_id, + config_version_id=body.config_version_id, + config_version_kind=body.config_version_kind, + payload=body.to_payload(), + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//env") +class AgentConfigEnvApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_env") + def patch(self, agent_id: str): + try: + body = _ConfigEnvUpdateRequest.model_validate(request.get_json(silent=True) or {}) + return AgentConfigService().update_env( + tenant_id=body.tenant_id, + agent_id=agent_id, + user_id=body.user_id, + config_version_id=body.config_version_id, + config_version_kind=body.config_version_kind, + env_text=body.env_text, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) + + +@inner_api_ns.route("/agent-config//note") +class AgentConfigNoteApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_note") + def put(self, agent_id: str): + try: + body = _ConfigNoteUpdateRequest.model_validate(request.get_json(silent=True) or {}) + return AgentConfigService().update_note( + tenant_id=body.tenant_id, + agent_id=agent_id, + user_id=body.user_id, + config_version_id=body.config_version_id, + config_version_kind=body.config_version_kind, + note=body.note, + ) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except AgentConfigServiceError as exc: + return _error_response(exc) diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 999932c98e1..5ddb55aaa3f 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -95,3 +95,15 @@ def plugin_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: return view(*args, **kwargs) return decorated + + +def agent_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Temporary alias for agent-backend inner API callers. + + Agent tool and knowledge calls currently share the same trusted + `dify-agent -> Dify API` transport credentials as the plugin inner bridge. + Keep the wrapper name agent-specific so the controller surface does not grow + more plugin-only semantics while auth settings stay shared. + """ + + return plugin_inner_api_only(view) diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 4f2c546fb7b..6e9a7a79af5 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -1,21 +1,28 @@ -"""Agent App generator: orchestrate one conversation turn for an Agent App. +"""Agent App generator: orchestrate Agent App chat and finalize executions. -Mirrors the agent_chat generator (conversation + message + queue + streamed -response over the EasyUI chat pipeline), but the backing config comes from the -bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the -dify-agent backend rather than an in-process LLM/ReAct loop. +The primary mode mirrors the agent_chat generator (conversation + message + +queue + streamed response over the EasyUI chat pipeline), but the backing +config comes from the bound Agent Soul and the answer is produced by +``AgentAppRunner`` calling the dify-agent backend rather than an in-process +LLM/ReAct loop. + +It also exposes a stateless build-finalize mode that reuses existing runtime +context from the bound debug conversation, triggers the Agent backend side +effect synchronously, and skips Dify-side chat/message persistence. """ from __future__ import annotations import contextvars +import json import logging import threading import uuid -from collections.abc import Generator, Mapping -from typing import Any +from collections.abc import Generator, Mapping, Sequence +from typing import Any, Literal from flask import Flask, current_app +from pydantic import JsonValue from sqlalchemy import and_, or_, select from clients.agent_backend import AgentBackendRunEventAdapter @@ -61,6 +68,13 @@ class AgentAppGeneratorError(ValueError): """Raised when an Agent App turn cannot be set up.""" +def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str: + """Append raw request file references to the backend user prompt.""" + if not prompt_file_mappings: + return query + return f"{query}\n{json.dumps(list(prompt_file_mappings), ensure_ascii=False)}" + + class AgentAppGenerator(MessageBasedAppGenerator): def generate( self, @@ -74,14 +88,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): if not streaming: raise AgentAppGeneratorError("Agent App only supports streaming mode") - query = args.get("query") - if not isinstance(query, str) or not query.strip(): - raise AgentAppGeneratorError("query is required") - query = query.replace("\x00", "") + query = self._require_query(args) inputs = args["inputs"] + prompt_file_mappings = args.get("files") or [] # Resolve the bound roster Agent + its current Agent Soul snapshot. - agent, agent_config_id, agent_soul = self._resolve_agent( + agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent( app_model, invoke_from=invoke_from, draft_type=args.get("draft_type"), @@ -122,6 +134,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): ), query=query, files=[], + prompt_file_mappings=prompt_file_mappings, parent_message_id=( args.get("parent_message_id") if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI} @@ -137,6 +150,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=trace_manager, agent_id=agent.id, agent_config_snapshot_id=agent_config_id, + agent_config_version_kind=agent_config_version_kind, agent_runtime_session_snapshot_id=runtime_session_snapshot_id, ) @@ -176,6 +190,86 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + def generate_stateless( + self, + *, + app_model: App, + user: Account | EndUser, + args: Mapping[str, Any], + invoke_from: InvokeFrom, + ) -> Mapping[str, Any]: + """Run one Agent App turn without persisting Dify conversation messages.""" + query = self._require_query(args) + conversation_id = args.get("conversation_id") + if not isinstance(conversation_id, str) or not conversation_id: + raise AgentAppGeneratorError("conversation_id is required") + + agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type=args.get("draft_type"), + user=user, + ) + runtime_session_snapshot_id = self._runtime_session_snapshot_id( + invoke_from=invoke_from, + snapshot_id=agent_config_id, + ) + + return self._run_stateless( + app_model=app_model, + user=user, + invoke_from=invoke_from, + query=query, + conversation_id=conversation_id, + agent=agent, + agent_config_id=agent_config_id, + agent_config_version_kind=agent_config_version_kind, + agent_soul=agent_soul, + runtime_session_snapshot_id=runtime_session_snapshot_id, + ) + + def _run_stateless( + self, + *, + app_model: App, + user: Account | EndUser, + invoke_from: InvokeFrom, + query: str, + conversation_id: str, + agent: Agent, + agent_config_id: str, + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"], + agent_soul: AgentSoulConfig, + runtime_session_snapshot_id: str | None, + ) -> Mapping[str, Any]: + """Run the Agent backend without creating or updating Dify chat records. + + Build-chat finalization is an action against the Agent backend (for + example, ``dify-agent config push``). It may reuse the active build-chat + runtime snapshot for shell/config context, but the API side must not add + a synthetic user/assistant turn to the debug conversation. + """ + + dify_context = DifyRunContext( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + user_id=user.id, + user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, + invoke_from=invoke_from, + ) + self._build_runner(dify_context).run_stateless( + dify_context=dify_context, + agent_id=agent.id, + agent_config_snapshot_id=agent_config_id, + agent_config_version_kind=agent_config_version_kind, + agent_soul=agent_soul, + conversation_id=conversation_id, + query=query, + idempotency_key=str(uuid.uuid4()), + session_scope_snapshot_id=runtime_session_snapshot_id, + ) + return {"result": "success"} + def resume_after_form_submission( self, *, @@ -192,15 +286,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): persisted to the conversation. Live streaming to a reconnected client is out of scope here — the message is persisted and can be re-fetched. """ - agent, agent_config_id, agent_soul = self._resolve_agent( - app_model, - invoke_from=invoke_from, - draft_type="draft", - user=user, - ) conversation = ConversationService.get_conversation( app_model=app_model, conversation_id=conversation_id, user=user ) + agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type=self._resume_draft_type(app_model=app_model, conversation=conversation, user=user), + user=user, + ) app_config = AgentAppConfigManager.get_app_config( app_model=app_model, @@ -245,6 +339,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=trace_manager, agent_id=agent.id, agent_config_snapshot_id=agent_config_id, + agent_config_version_kind=agent_config_version_kind, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -285,6 +380,30 @@ class AgentAppGenerator(MessageBasedAppGenerator): stream=False, ) + @staticmethod + def _resume_draft_type(*, app_model: App, conversation: Any, user: Account | EndUser) -> str | None: + if conversation.invoke_from != InvokeFrom.DEBUGGER: + return None + active_session = AgentAppRuntimeSessionStore().load_active_session_for_conversation( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + conversation_id=conversation.id, + ) + snapshot_id = active_session.scope.agent_config_snapshot_id if active_session is not None else None + if snapshot_id and isinstance(user, Account): + draft = db.session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == app_model.tenant_id, + AgentConfigDraft.id == snapshot_id, + ) + ) + if draft is not None: + if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD and draft.account_id == user.id: + return AgentConfigDraftType.DEBUG_BUILD.value + if draft.draft_type == AgentConfigDraftType.DRAFT and draft.account_id is None: + return AgentConfigDraftType.DRAFT.value + return AgentConfigDraftType.DRAFT.value + def _generate_worker( self, *, @@ -329,6 +448,10 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) if handled: return + query = _append_prompt_file_mappings( + query=query, + prompt_file_mappings=application_generate_entity.prompt_file_mappings, + ) dify_context = DifyRunContext( tenant_id=app_config.tenant_id, @@ -337,27 +460,18 @@ class AgentAppGenerator(MessageBasedAppGenerator): user_from=user_from, invoke_from=application_generate_entity.invoke_from, ) - credentials_provider, _ = build_dify_model_access(dify_context) _, _, agent_soul = self._resolve_agent_by_id( tenant_id=app_config.tenant_id, agent_id=application_generate_entity.agent_id, snapshot_id=application_generate_entity.agent_config_snapshot_id, ) - runner = AgentAppRunner( - request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider), - agent_backend_client=create_agent_backend_run_client( - base_url=dify_config.AGENT_BACKEND_BASE_URL, - use_fake=dify_config.AGENT_BACKEND_USE_FAKE, - fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, - ), - event_adapter=AgentBackendRunEventAdapter(), - session_store=AgentAppRuntimeSessionStore(), - ) + runner = self._build_runner(dify_context) runner.run( dify_context=dify_context, agent_id=application_generate_entity.agent_id, agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id, + agent_config_version_kind=application_generate_entity.agent_config_version_kind, agent_soul=agent_soul, conversation_id=conversation.id, query=query, @@ -374,6 +488,27 @@ class AgentAppGenerator(MessageBasedAppGenerator): finally: db.session.close() + @staticmethod + def _require_query(args: Mapping[str, Any]) -> str: + query = args.get("query") + if not isinstance(query, str) or not query.strip(): + raise AgentAppGeneratorError("query is required") + return query.replace("\x00", "") + + @staticmethod + def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner: + credentials_provider, _ = build_dify_model_access(dify_context) + return AgentAppRunner( + request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider), + agent_backend_client=create_agent_backend_run_client( + base_url=dify_config.AGENT_BACKEND_BASE_URL, + use_fake=dify_config.AGENT_BACKEND_USE_FAKE, + fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, + ), + event_adapter=AgentBackendRunEventAdapter(), + session_store=AgentAppRuntimeSessionStore(), + ) + def _run_input_guards( self, *, @@ -446,7 +581,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): invoke_from: InvokeFrom, draft_type: Any, user: Account | EndUser, - ) -> tuple[Agent, str, AgentSoulConfig]: + ) -> tuple[Agent, str, Literal["snapshot", "draft", "build_draft"], AgentSoulConfig]: agent = db.session.scalar( select(Agent) .where( @@ -474,13 +609,16 @@ class AgentAppGenerator(MessageBasedAppGenerator): account_id=user.id if isinstance(user, Account) else None, ) agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) - return agent, draft.id, agent_soul + config_version_kind: Literal["snapshot", "draft", "build_draft"] = ( + "build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft" + ) + return agent, draft.id, config_version_kind, agent_soul _, snapshot, agent_soul = self._resolve_agent_by_id( tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id, ) - return agent, snapshot.id, agent_soul + return agent, snapshot.id, "snapshot", agent_soul @staticmethod def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index b482afcdf43..d90dad1f891 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -1,18 +1,23 @@ -"""Agent App runner: drive one conversation turn through the dify-agent backend. +"""Agent App runner: drive Agent backend turns for both chat and finalize flows. Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop), -this runner delegates to the Agent backend: build the run request from the -Agent Soul + conversation, create the run, consume its event stream, and -republish the assistant answer as chat queue events so the existing -EasyUI chat task pipeline persists the message and streams SSE. The conversation -``session_snapshot`` is saved on success for multi-turn continuity (S3). +this runner delegates to the Agent backend and supports two execution modes. + +- Normal chat turns build the run request from the Agent Soul + conversation, + consume backend stream events, republish the assistant answer through the + existing EasyUI chat task pipeline, and save the conversation + ``session_snapshot`` on success for multi-turn continuity (S3). +- Stateless build-finalize turns reuse any prior conversation snapshot only to + construct the backend request, wait synchronously for backend completion, and + intentionally do not persist Dify-side chat records or runtime-session state. """ from __future__ import annotations import json import logging -from typing import Any +from decimal import Decimal +from typing import Any, Literal from dify_agent.layers.ask_human import AskHumanToolArgs from dify_agent.protocol import DeferredToolResultsPayload @@ -28,6 +33,7 @@ from clients.agent_backend import ( AgentBackendStreamInternalEvent, extract_runtime_layer_specs, ) +from configs import dify_config from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, AgentAppRuntimeRequest, @@ -41,13 +47,16 @@ from core.app.apps.agent_app.session_store import ( from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext -from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form +from extensions.ext_database import db from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage from models.agent_config_entities import AgentSoulConfig +from models.enums import CreatorUserRole +from models.model import MessageAgentThought logger = logging.getLogger(__name__) @@ -134,6 +143,283 @@ def publish_message_end( ) +class _AgentProcessRecorder: + """Persist Agent v2 thinking/tool process events through the legacy thought model.""" + + def __init__( + self, + *, + dify_context: DifyRunContext, + message_id: str, + queue_manager: AppQueueManager, + ) -> None: + self._dify_context = dify_context + self._message_id = message_id + self._queue_manager = queue_manager + self._next_position = 1 + self._thinking_by_index: dict[int, str] = {} + self._tool_by_index: dict[int, str] = {} + self._tool_by_call_id: dict[str, str] = {} + self._open_tool_by_name: dict[str, set[str]] = {} + + def handle_stream_event(self, event: AgentBackendStreamInternalEvent) -> None: + data = event.data + if not isinstance(data, dict): + return + + event_kind = data.get("event_kind") + if event_kind == "part_delta": + self._handle_part_delta(data) + elif event_kind == "part_start": + self._handle_part(data) + elif event_kind in {"function_tool_call", "output_tool_call"}: + self._handle_tool_call_event(data) + elif event_kind in {"function_tool_result", "output_tool_result"}: + self._handle_tool_result_event(data) + + def _handle_part_delta(self, data: dict[str, Any]) -> None: + delta = data.get("delta") + if not isinstance(delta, dict): + return + + index = _event_index(data) + delta_kind = delta.get("part_delta_kind") + if delta_kind == "thinking": + content_delta = delta.get("content_delta") + if isinstance(content_delta, str) and content_delta: + self._append_thinking(index, content_delta) + return + + if delta_kind == "tool_call": + self._record_tool_call_delta(index, delta) + + def _handle_part(self, data: dict[str, Any]) -> None: + part = data.get("part") + if not isinstance(part, dict): + return + + index = _event_index(data) + part_kind = part.get("part_kind") + if part_kind == "thinking": + content = part.get("content") + if isinstance(content, str) and content: + self._append_thinking(index, content) + return + + if part_kind in {"tool-call", "builtin-tool-call"}: + self._record_tool_call_part(index, part) + return + + if part_kind in {"tool-return", "builtin-tool-return"}: + self._record_tool_return_part(part) + + def _handle_tool_call_event(self, data: dict[str, Any]) -> None: + part = data.get("part") + if isinstance(part, dict): + self._record_tool_call_part(_event_index(data), part) + + def _handle_tool_result_event(self, data: dict[str, Any]) -> None: + part = data.get("part") or data.get("result") + if isinstance(part, dict): + self._record_tool_return_part(part) + return + + content = data.get("content") + if content is not None: + self._record_tool_observation( + tool_call_id=_string_or_none(data.get("tool_call_id")), + tool_name=_string_or_none(data.get("tool_name")), + observation=content, + ) + + def _append_thinking(self, index: int, content_delta: str) -> None: + thought_id = self._thinking_by_index.get(index) + if thought_id is None: + thought_id = self._create_thought(thought=content_delta) + self._thinking_by_index[index] = thought_id + return + self._update_thought(thought_id, thought_delta=content_delta) + + def _record_tool_call_delta(self, index: int, delta: dict[str, Any]) -> None: + tool_call_id = _string_or_none(delta.get("tool_call_id")) + tool_name = _string_or_none(delta.get("tool_name_delta")) + args_delta = delta.get("args_delta") + thought_id = self._lookup_tool_thought(index=index, tool_call_id=tool_call_id) + if thought_id is None: + thought_id = self._create_thought(tool=tool_name, tool_input=_json_or_text(args_delta)) + self._remember_tool_thought( + index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id + ) + return + + self._update_thought( + thought_id, + tool=tool_name, + tool_input_delta=_json_or_text(args_delta), + ) + + def _record_tool_call_part(self, index: int, part: dict[str, Any]) -> None: + tool_call_id = _string_or_none(part.get("tool_call_id")) + tool_name = _string_or_none(part.get("tool_name")) + thought_id = self._lookup_tool_thought(index=index, tool_call_id=tool_call_id) + if thought_id is None: + thought_id = self._create_thought(tool=tool_name, tool_input=_json_or_text(part.get("args"))) + self._remember_tool_thought( + index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id + ) + return + + self._update_thought( + thought_id, + tool=tool_name, + tool_input=_json_or_text(part.get("args")), + ) + + def _record_tool_return_part(self, part: dict[str, Any]) -> None: + tool_call_id = _string_or_none(part.get("tool_call_id")) + tool_name = _string_or_none(part.get("tool_name")) + content = part.get("content") + if content is None: + content = part + self._record_tool_observation(tool_call_id=tool_call_id, tool_name=tool_name, observation=content) + + def _record_tool_observation(self, *, tool_call_id: str | None, tool_name: str | None, observation: Any) -> None: + thought_id = self._lookup_observation_thought(tool_call_id=tool_call_id, tool_name=tool_name) + if thought_id is None: + thought_id = self._create_thought(tool=tool_name) + else: + self._mark_tool_observed(thought_id) + self._update_thought(thought_id, observation=_json_or_text(observation)) + + def _lookup_tool_thought(self, *, index: int, tool_call_id: str | None) -> str | None: + if tool_call_id and tool_call_id in self._tool_by_call_id: + return self._tool_by_call_id[tool_call_id] + return self._tool_by_index.get(index) + + def _remember_tool_thought( + self, *, index: int, tool_call_id: str | None, tool_name: str | None, thought_id: str + ) -> None: + self._tool_by_index[index] = thought_id + if tool_call_id: + self._tool_by_call_id[tool_call_id] = thought_id + if tool_name: + self._open_tool_by_name.setdefault(tool_name, set()).add(thought_id) + + def _lookup_observation_thought(self, *, tool_call_id: str | None, tool_name: str | None) -> str | None: + if tool_call_id: + return self._tool_by_call_id.get(tool_call_id) + if tool_name: + open_thought_ids = self._open_tool_by_name.get(tool_name, set()) + if len(open_thought_ids) == 1: + return next(iter(open_thought_ids)) + return None + + def _mark_tool_observed(self, thought_id: str) -> None: + for open_thought_ids in self._open_tool_by_name.values(): + open_thought_ids.discard(thought_id) + + def _create_thought( + self, *, thought: str | None = None, tool: str | None = None, tool_input: str | None = None + ) -> str: + row = MessageAgentThought( + message_id=self._message_id, + message_chain_id=None, + thought=thought, + tool=tool, + tool_labels_str=_tool_labels(tool), + tool_meta_str="{}", + tool_input=tool_input, + observation=None, + tool_process_data=None, + message=None, + message_token=0, + message_unit_price=Decimal(0), + message_price_unit=Decimal("0.001"), + message_files="", + answer="", + answer_token=0, + answer_unit_price=Decimal(0), + answer_price_unit=Decimal("0.001"), + tokens=0, + total_price=Decimal(0), + position=self._next_position, + currency="USD", + latency=0, + created_by_role=self._created_by_role(), + created_by=self._dify_context.user_id, + ) + self._next_position += 1 + db.session.add(row) + db.session.commit() + thought_id = str(row.id) + self._queue_manager.publish( + QueueAgentThoughtEvent(agent_thought_id=thought_id), PublishFrom.APPLICATION_MANAGER + ) + return thought_id + + def _update_thought( + self, + thought_id: str, + *, + thought_delta: str | None = None, + tool: str | None = None, + tool_input: str | None = None, + tool_input_delta: str | None = None, + observation: str | None = None, + ) -> None: + row = db.session.get(MessageAgentThought, thought_id) + if row is None: + return + + if thought_delta: + row.thought = f"{row.thought or ''}{thought_delta}" + if tool: + row.tool = tool + row.tool_labels_str = _tool_labels(tool) + if tool_input is not None: + row.tool_input = tool_input + if tool_input_delta: + row.tool_input = f"{row.tool_input or ''}{tool_input_delta}" + if observation is not None: + row.observation = observation + + db.session.commit() + self._queue_manager.publish( + QueueAgentThoughtEvent(agent_thought_id=thought_id), PublishFrom.APPLICATION_MANAGER + ) + + def _created_by_role(self) -> CreatorUserRole: + if self._dify_context.invoke_from.runs_as_account(): + return CreatorUserRole.ACCOUNT + return CreatorUserRole.END_USER + + +def _event_index(data: dict[str, Any]) -> int: + index = data.get("index") + return index if isinstance(index, int) else -1 + + +def _string_or_none(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _json_or_text(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return str(value) + + +def _tool_labels(tool: str | None) -> str: + if not tool: + return "{}" + return json.dumps({tool: {"en_US": tool, "zh_Hans": tool}}, ensure_ascii=False) + + class AgentAppRunner: """Runs one Agent App conversation turn against the Agent backend.""" @@ -156,6 +442,7 @@ class AgentAppRunner: dify_context: DifyRunContext, agent_id: str, agent_config_snapshot_id: str, + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", agent_soul: AgentSoulConfig, conversation_id: str, query: str, @@ -164,42 +451,34 @@ class AgentAppRunner: queue_manager: AppQueueManager, session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, ) -> None: - if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId): - effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id - else: - effective_session_scope_snapshot_id = session_scope_snapshot_id - scope = AgentAppSessionScope( - tenant_id=dify_context.tenant_id, - app_id=dify_context.app_id, - conversation_id=conversation_id, + scope = self._build_session_scope( + dify_context=dify_context, agent_id=agent_id, - agent_config_snapshot_id=effective_session_scope_snapshot_id, + agent_config_snapshot_id=agent_config_snapshot_id, + conversation_id=conversation_id, + session_scope_snapshot_id=session_scope_snapshot_id, ) # ENG-638: if a prior turn paused on ask_human and the form is now answered, # resume by threading the human's reply into this run as deferred_tool_results. stored = self._session_store.load_active_session(scope) - session_snapshot = stored.session_snapshot if stored is not None else None - deferred_tool_results = self._resolve_pending_ask_human( - stored=stored, dify_context=dify_context, message_id=message_id - ) - - runtime = self._request_builder.build( - AgentAppRuntimeBuildContext( - dify_context=dify_context, - agent_id=agent_id, - agent_config_snapshot_id=agent_config_snapshot_id, - agent_soul=agent_soul, - conversation_id=conversation_id, - user_query=query, - idempotency_key=message_id, - session_snapshot=session_snapshot, - deferred_tool_results=deferred_tool_results, - ) + runtime = self._build_runtime( + dify_context=dify_context, + agent_id=agent_id, + agent_config_snapshot_id=agent_config_snapshot_id, + agent_config_version_kind=agent_config_version_kind, + agent_soul=agent_soul, + conversation_id=conversation_id, + query=query, + idempotency_key=message_id, + stored=stored, + message_id=message_id, ) create_response = self._agent_backend_client.create_run(runtime.request) terminal, streamed_answer = self._consume_stream( create_response.run_id, + dify_context=dify_context, + message_id=message_id, queue_manager=queue_manager, model_name=model_name, query=query, @@ -241,6 +520,111 @@ class AgentAppRunner: runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), ) + def run_stateless( + self, + *, + dify_context: DifyRunContext, + agent_id: str, + agent_config_snapshot_id: str, + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", + agent_soul: AgentSoulConfig, + conversation_id: str, + query: str, + idempotency_key: str, + session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, + ) -> None: + """Run the Agent backend without creating Dify chat message records. + + This path is used by build-chat finalization: the API must trigger the + backend side effects in the existing conversation session, but it must + not persist a synthetic user/assistant turn, update API-side runtime + session rows, or set up HITL state that depends on one. + """ + scope = self._build_session_scope( + dify_context=dify_context, + agent_id=agent_id, + agent_config_snapshot_id=agent_config_snapshot_id, + conversation_id=conversation_id, + session_scope_snapshot_id=session_scope_snapshot_id, + ) + runtime = self._build_runtime( + dify_context=dify_context, + agent_id=agent_id, + agent_config_snapshot_id=agent_config_snapshot_id, + agent_config_version_kind=agent_config_version_kind, + agent_soul=agent_soul, + conversation_id=conversation_id, + query=query, + idempotency_key=idempotency_key, + stored=self._session_store.load_active_session(scope), + message_id=None, + ) + + create_response = self._agent_backend_client.create_run(runtime.request) + status = self._agent_backend_client.wait_run( + create_response.run_id, + timeout_seconds=dify_config.APP_MAX_EXECUTION_TIME, + ) + if status.status != "succeeded": + error = getattr(status, "error", None) or f"Agent backend run ended with status {status.status}." + raise AgentBackendError(str(error)) + + def _build_session_scope( + self, + *, + dify_context: DifyRunContext, + agent_id: str, + agent_config_snapshot_id: str, + conversation_id: str, + session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId, + ) -> AgentAppSessionScope: + if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId): + effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id + else: + effective_session_scope_snapshot_id = session_scope_snapshot_id + return AgentAppSessionScope( + tenant_id=dify_context.tenant_id, + app_id=dify_context.app_id, + conversation_id=conversation_id, + agent_id=agent_id, + agent_config_snapshot_id=effective_session_scope_snapshot_id, + ) + + def _build_runtime( + self, + *, + dify_context: DifyRunContext, + agent_id: str, + agent_config_snapshot_id: str, + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"], + agent_soul: AgentSoulConfig, + conversation_id: str, + query: str, + idempotency_key: str, + stored: StoredAgentAppSession | None, + message_id: str | None, + ) -> AgentAppRuntimeRequest: + session_snapshot = stored.session_snapshot if stored is not None else None + deferred_tool_results = ( + self._resolve_pending_ask_human(stored=stored, dify_context=dify_context, message_id=message_id) + if message_id is not None + else None + ) + return self._request_builder.build( + AgentAppRuntimeBuildContext( + dify_context=dify_context, + agent_id=agent_id, + agent_config_snapshot_id=agent_config_snapshot_id, + agent_config_version_kind=agent_config_version_kind, + agent_soul=agent_soul, + conversation_id=conversation_id, + user_query=query, + idempotency_key=idempotency_key, + session_snapshot=session_snapshot, + deferred_tool_results=deferred_tool_results, + ) + ) + def _pause_for_ask_human( self, *, @@ -334,12 +718,19 @@ class AgentAppRunner: self, run_id: str, *, + dify_context: DifyRunContext, + message_id: str, queue_manager: AppQueueManager, model_name: str, query: str | None, ): terminal = None streamed_answer_parts: list[str] = [] + process_recorder = _AgentProcessRecorder( + dify_context=dify_context, + message_id=message_id, + queue_manager=queue_manager, + ) for public_event in self._agent_backend_client.stream_events(run_id): if queue_manager.is_stopped(): self._cancel_run(run_id) @@ -353,6 +744,17 @@ class AgentAppRunner: AgentBackendInternalEventType.STREAM_EVENT, ): if isinstance(internal_event, AgentBackendStreamInternalEvent): + try: + process_recorder.handle_stream_event(internal_event) + except Exception: + db.session.rollback() + logger.warning( + "Failed to persist Agent App process event: run_id=%s message_id=%s event_kind=%s", + run_id, + message_id, + internal_event.event_kind, + exc_info=True, + ) text_delta = self._extract_stream_text_delta(internal_event) if text_delta: streamed_answer_parts.append(text_delta) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 9790f2fbca0..4e850a4887a 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -12,7 +12,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Protocol, cast +from typing import Any, Literal, Protocol, cast from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.execution_context import ( @@ -29,20 +29,22 @@ from clients.agent_backend import ( redact_for_agent_backend_log, ) from configs import dify_config -from core.app.entities.app_invoke_entities import DifyRunContext -from core.workflow.nodes.agent_v2.plugin_tools_builder import ( - WorkflowAgentPluginToolsBuilder, - WorkflowAgentPluginToolsBuildError, +from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom +from core.workflow.nodes.agent_v2.dify_tools_builder import ( + WorkflowAgentDifyToolLayersBuilder, + WorkflowAgentDifyToolsBuilder, + WorkflowAgentDifyToolsBuildError, + WorkflowAgentToolLayers, ) from core.workflow.nodes.agent_v2.runtime_request_builder import ( append_runtime_warnings, build_ask_human_layer_config, - build_drive_aware_soul_mention_resolver, - build_drive_layer_config, + build_config_aware_soul_mention_resolver, + build_config_layer_config, build_knowledge_layer_config, build_shell_layer_config, ) -from models.agent_config_entities import AgentSoulConfig +from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions @@ -68,6 +70,7 @@ class AgentAppRuntimeBuildContext: conversation_id: str user_query: str idempotency_key: str + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" session_snapshot: CompositorSessionSnapshot | None = None # ENG-638: set when resuming a chat turn after a submitted ask_human form. deferred_tool_results: DeferredToolResultsPayload | None = None @@ -88,11 +91,11 @@ class AgentAppRuntimeRequestBuilder: *, credentials_provider: CredentialsProvider, request_builder: AgentBackendRunRequestBuilder | None = None, - plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None, + dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None, ) -> None: self._credentials_provider = credentials_provider self._request_builder = request_builder or AgentBackendRunRequestBuilder() - self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder() + self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder() def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest: agent_soul = context.agent_soul @@ -105,38 +108,33 @@ class AgentAppRuntimeRequestBuilder: metadata = self._build_metadata(context) credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model) try: - tools_layer = self._plugin_tools_builder.build( + tool_layers = self._build_tool_layers( tenant_id=context.dify_context.tenant_id, app_id=context.dify_context.app_id, user_id=context.dify_context.user_id, tools=agent_soul.tools, invoke_from=context.dify_context.invoke_from, ) - except WorkflowAgentPluginToolsBuildError as error: + except WorkflowAgentDifyToolsBuildError as error: raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error - if tools_layer is not None or agent_soul.tools.cli_tools: + if tool_layers.plugin_tools is not None or tool_layers.core_tools is not None or agent_soul.tools.cli_tools: metadata["agent_tools"] = { - "dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0, - "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools] - if tools_layer is not None - else [], + "dify_tool_count": len(tool_layers.exposed_tool_names()), + "dify_tool_names": tool_layers.exposed_tool_names(), "cli_tool_count": len(agent_soul.tools.cli_tools), } - drive_config = None + config_layer_config = None soul_prompt_resolver = build_soul_mention_resolver(agent_soul) if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - drive_config, drive_warnings = build_drive_layer_config( + config_layer_config, config_warnings = build_config_layer_config( agent_soul, - tenant_id=context.dify_context.tenant_id, - agent_id=context.agent_id, - ) - append_runtime_warnings(metadata, drive_warnings) - soul_prompt_resolver = build_drive_aware_soul_mention_resolver( - agent_soul, - tenant_id=context.dify_context.tenant_id, agent_id=context.agent_id, + config_version_id=context.agent_config_snapshot_id, + config_version_kind=context.agent_config_version_kind, ) + append_runtime_warnings(metadata, config_warnings) + soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_agent_app( @@ -158,6 +156,7 @@ class AgentAppRuntimeRequestBuilder: conversation_id=context.conversation_id, agent_id=context.agent_id, agent_config_version_id=context.agent_config_snapshot_id, + agent_config_version_kind=context.agent_config_version_kind, # Agent Files §1.3: real Dify access context + agent run mode. user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value), invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), @@ -168,9 +167,10 @@ class AgentAppRuntimeRequestBuilder: agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() or None, user_prompt=context.user_query, - tools=tools_layer, + tools=tool_layers.plugin_tools, + core_tools=tool_layers.core_tools, knowledge=knowledge_config, - drive_config=drive_config, + config_layer_config=config_layer_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), @@ -183,6 +183,26 @@ class AgentAppRuntimeRequestBuilder: redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata) + def _build_tool_layers( + self, + *, + tenant_id: str, + app_id: str, + user_id: str | None, + tools: AgentSoulToolsConfig, + invoke_from: InvokeFrom, + ) -> WorkflowAgentToolLayers: + # Production Agent App runs intentionally keep existing plugin configs + # on the direct `dify.plugin.tools` route. This builder emits plugin + # tools directly and non-plugin Dify tools through `dify.core.tools`. + return self._dify_tools_builder.build_layers( + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + tools=tools, + invoke_from=invoke_from, + ) + @staticmethod def _build_metadata(context: AgentAppRuntimeBuildContext) -> dict[str, Any]: return { diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index f588c98eefe..690f23c8302 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -1,8 +1,8 @@ from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationInfo, field_validator from constants import UUID_NIL from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig @@ -220,11 +220,24 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity): accepted-entity union. The answer is produced by the dify-agent backend rather than an in-process LLM call; ``model_conf`` is synthesized from the bound Agent Soul model so the chat task pipeline can persist usage. + + ``agent_config_version_kind`` selects which Agent config surface the + backend should read from: immutable snapshot, shared draft, or per-user + build draft. + + ``agent_runtime_session_snapshot_id`` carries the runtime session scope + used to resume or suspend within the same editable config surface. + + ``prompt_file_mappings`` preserves the raw request ``files`` array for the + Agent backend prompt. These references are appended to the backend prompt + text while the stored chat message keeps the user's original query. """ agent_id: str agent_config_snapshot_id: str + agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" agent_runtime_session_snapshot_id: str | None = None + prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list) class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity): diff --git a/api/core/tools/tool_engine.py b/api/core/tools/tool_engine.py index 6a2eb207641..b3cfd55265e 100644 --- a/api/core/tools/tool_engine.py +++ b/api/core/tools/tool_engine.py @@ -111,7 +111,7 @@ class ToolEngine: tool_messages=binary_files, agent_message=message, invoke_from=invoke_from, user_id=user_id ) - plain_text = ToolEngine._convert_tool_response_to_str(message_list) + plain_text = ToolEngine.tool_response_to_str(message_list) meta = invocation_meta_dict["meta"] @@ -234,10 +234,8 @@ class ToolEngine: yield meta @staticmethod - def _convert_tool_response_to_str(tool_response: list[ToolInvokeMessage]) -> str: - """ - Handle tool response - """ + def tool_response_to_str(tool_response: list[ToolInvokeMessage]) -> str: + """Convert tool invoke messages into the plain-text observation shown to the model/user.""" parts: list[str] = [] json_parts: list[str] = [] diff --git a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py new file mode 100644 index 00000000000..acb58a46952 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal, Protocol, cast + +from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolProviderType, DifyCoreToolsLayerConfig +from dify_agent.layers.dify_plugin import ( + DifyPluginCredentialValue, + DifyPluginToolConfig, + DifyPluginToolCredentialType, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolsLayerConfig, +) +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.agent.entities import AgentToolEntity +from core.app.entities.app_invoke_entities import InvokeFrom +from core.tools.__base.tool import Tool +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError +from core.tools.tool_manager import ToolManager +from core.tools.workflow_as_tool.provider import WorkflowToolProviderController +from extensions.ext_database import db +from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig +from models.provider_ids import ToolProviderID +from models.tools import WorkflowToolProvider +from services.tools.mcp_tools_manage_service import MCPToolManageService + + +class WorkflowAgentDifyToolsBuildError(ValueError): + """Raised when Agent Soul tools cannot be prepared for Agent backend.""" + + def __init__(self, error_code: str, message: str) -> None: + self.error_code = error_code + super().__init__(message) + + +class AgentToolRuntimeProvider(Protocol): + def get_agent_tool_runtime( + self, + tenant_id: str, + app_id: str, + agent_tool: AgentToolEntity, + user_id: str | None = None, + invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, + variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, + ) -> Tool: ... + + +class ProviderToolsLister(Protocol): + def __call__( + self, + *, + tenant_id: str, + provider_type: ToolProviderType, + provider_id: str, + ) -> list[str]: ... + + +class MCPProviderIDResolver(Protocol): + def __call__(self, *, tenant_id: str, provider_id: str) -> str: ... + + +@dataclass(frozen=True, slots=True) +class WorkflowAgentToolLayers: + plugin_tools: DifyPluginToolsLayerConfig | None = None + core_tools: DifyCoreToolsLayerConfig | None = None + + def exposed_tool_names(self) -> list[str]: + names: list[str] = [] + if self.plugin_tools is not None: + names.extend(tool.name or tool.tool_name for tool in self.plugin_tools.tools) + if self.core_tools is not None: + names.extend(tool.name or tool.tool_name for tool in self.core_tools.tools) + return names + + +class WorkflowAgentDifyToolLayersBuilder(Protocol): + def build_layers( + self, + *, + tenant_id: str, + app_id: str, + user_id: str | None, + tools: AgentSoulToolsConfig, + invoke_from: InvokeFrom, + ) -> WorkflowAgentToolLayers: ... + + +def _list_provider_tool_names( + *, + tenant_id: str, + provider_type: ToolProviderType, + provider_id: str, +) -> list[str]: + """Tool names a provider currently declares for provider-level Agent entries.""" + match provider_type: + case ToolProviderType.PLUGIN: + plugin_provider = ToolManager.get_plugin_provider(provider_id, tenant_id) + return [tool.entity.identity.name for tool in plugin_provider.get_tools() or []] + case ToolProviderType.BUILT_IN: + builtin_provider = ToolManager.get_builtin_provider(provider_id, tenant_id) + return [tool.entity.identity.name for tool in builtin_provider.get_tools() or []] + case ToolProviderType.API: + api_provider, _ = ToolManager.get_api_provider_controller(tenant_id, provider_id) + return [tool.entity.identity.name for tool in api_provider.get_tools(tenant_id) or []] + case ToolProviderType.WORKFLOW: + db_provider = db.session.scalar( + select(WorkflowToolProvider) + .where( + WorkflowToolProvider.id == provider_id, + WorkflowToolProvider.tenant_id == tenant_id, + ) + .limit(1) + ) + if db_provider is None: + raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") + workflow_provider = WorkflowToolProviderController.from_db(db_provider) + return [tool.entity.identity.name for tool in workflow_provider.get_tools(tenant_id) or []] + case ToolProviderType.MCP: + mcp_provider = ToolManager.get_mcp_provider_controller(tenant_id, provider_id) + return [tool.entity.identity.name for tool in mcp_provider.get_tools() or []] + case _: + raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") + + +def _resolve_mcp_provider_id(*, tenant_id: str, provider_id: str) -> str: + """Normalize MCP provider ids to the runtime-facing server identifier.""" + service = MCPToolManageService(session=cast(Session, db.session)) + try: + return service.get_provider_entity(provider_id, tenant_id, by_server_id=True).provider_id + except ValueError: + try: + return service.get_provider_entity(provider_id, tenant_id, by_server_id=False).provider_id + except ValueError as exc: + raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") from exc + + +class WorkflowAgentDifyToolsBuilder: + """Prepare Agent Soul Dify tools for Agent backend run-layer configs. + + Plugin tools keep their existing direct daemon path. Core-routed tools + (`builtin`/`api`/`workflow`/`mcp`) are emitted as `dify.core.tools`. + """ + + def __init__( + self, + *, + tool_runtime_provider: AgentToolRuntimeProvider | None = None, + provider_tools_lister: ProviderToolsLister | None = None, + mcp_provider_id_resolver: MCPProviderIDResolver | None = None, + ) -> None: + self._tool_runtime_provider = tool_runtime_provider or ToolManager + self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names + self._mcp_provider_id_resolver = mcp_provider_id_resolver or _resolve_mcp_provider_id + + def build_layers( + self, + *, + tenant_id: str, + app_id: str, + user_id: str | None, + tools: AgentSoulToolsConfig, + invoke_from: InvokeFrom, + ) -> WorkflowAgentToolLayers: + """Resolve user-selected Dify tools into direct/core Agent backend DTOs. + + `invoke_from` is the real runtime caller category (DEBUGGER for a + Composer test run, SERVICE_API / WEB_APP for a published run). It must + be threaded through to `ToolManager` so credential quotas, rate limits, + and audit tags match the actual call site. + """ + enabled_tools = [tool for tool in tools.dify_tools if tool.enabled] + if not enabled_tools: + return WorkflowAgentToolLayers() + + prepared_plugin: list[DifyPluginToolConfig] = [] + prepared_core: list[DifyCoreToolConfig] = [] + seen_names: set[str] = set() + + for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools): + normalized_tool_config = self._normalized_tool_config(tenant_id=tenant_id, tool_config=tool_config) + destination = self._tool_layer_destination(normalized_tool_config) + exposed_name = self._exposed_tool_name(normalized_tool_config) + if exposed_name in seen_names: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_name_duplicated", + f"Duplicate Dify Tool name {exposed_name!r}.", + ) + seen_names.add(exposed_name) + + agent_tool = self._to_agent_tool_entity(normalized_tool_config) + tool_runtime = self._fetch_tool_runtime( + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + agent_tool=agent_tool, + invoke_from=invoke_from, + tool_config=normalized_tool_config, + ) + + if destination == "plugin": + prepared_plugin.append( + self._to_plugin_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name) + ) + else: + prepared_core.append( + self._to_core_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name) + ) + + return WorkflowAgentToolLayers( + plugin_tools=DifyPluginToolsLayerConfig(tools=prepared_plugin) if prepared_plugin else None, + core_tools=DifyCoreToolsLayerConfig(tools=prepared_core) if prepared_core else None, + ) + + def _expand_provider_entries( + self, + *, + tenant_id: str, + enabled_tools: list[AgentSoulDifyToolConfig], + ) -> list[AgentSoulDifyToolConfig]: + """Expand provider-level entries (`tool_name` omitted = all tools).""" + explicit_by_provider: dict[tuple[ToolProviderType, str], set[str]] = {} + for tool_config in enabled_tools: + if tool_config.tool_name is not None: + explicit_by_provider.setdefault(self._provider_key(tool_config), set()).add(tool_config.tool_name) + + expanded: list[AgentSoulDifyToolConfig] = [] + for tool_config in enabled_tools: + if tool_config.tool_name is not None: + expanded.append(tool_config) + continue + provider_type = ToolProviderType.value_of(tool_config.provider_type) + provider_id = self._provider_id(tool_config) + try: + tool_names = self._provider_declared_tool_names( + tenant_id=tenant_id, + provider_type=provider_type, + provider_id=provider_id, + ) + except ToolProviderNotFoundError as exc: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_declaration_not_found", + f"Dify Tool provider {provider_id!r} declaration not found: {exc}", + ) from exc + if not tool_names: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_declaration_not_found", + f"Dify Tool provider {provider_id!r} declares no tools.", + ) + already_explicit = explicit_by_provider.get(self._provider_key(tool_config), set()) + for tool_name in tool_names: + if tool_name in already_explicit: + continue + expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}})) + return expanded + + def _provider_declared_tool_names( + self, + *, + tenant_id: str, + provider_type: ToolProviderType, + provider_id: str, + ) -> list[str]: + return self._provider_tools_lister( + tenant_id=tenant_id, + provider_type=provider_type, + provider_id=provider_id, + ) + + def _normalized_tool_config( + self, + *, + tenant_id: str, + tool_config: AgentSoulDifyToolConfig, + ) -> AgentSoulDifyToolConfig: + if tool_config.provider_type != ToolProviderType.MCP.value: + return tool_config + provider_id = self._mcp_provider_id_resolver(tenant_id=tenant_id, provider_id=self._provider_id(tool_config)) + return tool_config.model_copy(update={"provider_id": provider_id, "plugin_id": None, "provider": None}) + + def _fetch_tool_runtime( + self, + *, + tenant_id: str, + app_id: str, + user_id: str | None, + agent_tool: AgentToolEntity, + invoke_from: InvokeFrom, + tool_config: AgentSoulDifyToolConfig, + ) -> Tool: + """Resolve the API-side `Tool` runtime and map fetch errors to stable codes.""" + try: + return self._tool_runtime_provider.get_agent_tool_runtime( + tenant_id=tenant_id, + app_id=app_id, + agent_tool=agent_tool, + user_id=user_id, + invoke_from=invoke_from, + variable_pool=None, + allow_file_parameters=True, + use_default_for_missing_form_parameters=True, + ) + except ToolProviderNotFoundError as exc: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_declaration_not_found", + f"Dify Tool {tool_config.tool_name!r} declaration not found: {exc}", + ) from exc + except ToolProviderCredentialValidationError as exc: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_credential_invalid", + f"Dify Tool {tool_config.tool_name!r} credential validation failed: {exc}", + ) from exc + except ValueError as exc: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_config_invalid", + f"Dify Tool {tool_config.tool_name!r} runtime construction failed: {exc}", + ) from exc + + @staticmethod + def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity: + assert tool_config.tool_name is not None + return AgentToolEntity( + provider_type=ToolProviderType.value_of(tool_config.provider_type), + provider_id=WorkflowAgentDifyToolsBuilder._provider_id(tool_config), + tool_name=tool_config.tool_name, + tool_parameters=dict(tool_config.runtime_parameters), + credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None, + ) + + @staticmethod + def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str: + if tool_config.provider_id: + return tool_config.provider_id + assert tool_config.plugin_id is not None + assert tool_config.provider is not None + return f"{tool_config.plugin_id}/{tool_config.provider}" + + @staticmethod + def _provider_key(tool_config: AgentSoulDifyToolConfig) -> tuple[ToolProviderType, str]: + return ( + ToolProviderType.value_of(tool_config.provider_type), + WorkflowAgentDifyToolsBuilder._provider_id(tool_config), + ) + + @staticmethod + def _tool_layer_destination(tool_config: AgentSoulDifyToolConfig) -> Literal["plugin", "core"]: + provider_type = ToolProviderType.value_of(tool_config.provider_type) + if provider_type is ToolProviderType.PLUGIN: + return "plugin" + if provider_type in { + ToolProviderType.BUILT_IN, + ToolProviderType.API, + ToolProviderType.WORKFLOW, + ToolProviderType.MCP, + }: + return "core" + if provider_type is ToolProviderType.DATASET_RETRIEVAL: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_provider_not_supported", + "dataset-retrieval remains on the knowledge path and is not supported in Agent tool layers.", + ) + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_provider_not_supported", + f"Dify Tool provider type {provider_type.value!r} is not supported in Agent tool layers.", + ) + + @staticmethod + def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str: + assert tool_config.tool_name is not None + return tool_config.tool_name + + def _to_plugin_backend_tool_config( + self, + tool_config: AgentSoulDifyToolConfig, + tool_runtime: Tool, + exposed_name: str, + ) -> DifyPluginToolConfig: + runtime = tool_runtime.runtime + if runtime is None: + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_config_invalid", + f"Dify Tool {tool_config.tool_name!r} has no runtime.", + ) + + provider_id = self._provider_id(tool_config) + plugin_id, provider = self._plugin_provider(tool_config, provider_id) + parameters = self._prepared_parameters(tool_runtime) + runtime_parameters = self._runtime_parameters(tool_runtime, parameters) + description = self._description(tool_config, tool_runtime) + + return DifyPluginToolConfig( + plugin_id=plugin_id, + provider=provider, + tool_name=exposed_name, + credential_type=self._credential_type(tool_config, runtime.credentials), + name=exposed_name, + description=description, + credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name), + runtime_parameters=runtime_parameters, + parameters=parameters, + parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), + ) + + def _to_core_backend_tool_config( + self, + tool_config: AgentSoulDifyToolConfig, + tool_runtime: Tool, + exposed_name: str, + ) -> DifyCoreToolConfig: + parameters = self._prepared_parameters(tool_runtime) + return DifyCoreToolConfig( + provider_type=cast(DifyCoreToolProviderType, tool_config.provider_type), + provider_id=self._provider_id(tool_config), + tool_name=tool_config.tool_name or exposed_name, + credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None, + name=exposed_name, + description=self._description(tool_config, tool_runtime), + runtime_parameters=self._runtime_parameters(tool_runtime, parameters), + parameters=parameters, + parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), + ) + + @staticmethod + def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]: + if tool_config.plugin_id and tool_config.provider: + return tool_config.plugin_id, tool_config.provider + provider_id_entity = ToolProviderID(provider_id) + return provider_id_entity.plugin_id, provider_id_entity.provider_name + + @staticmethod + def _credential_type( + tool_config: AgentSoulDifyToolConfig, + credentials: Mapping[str, Any], + ) -> DifyPluginToolCredentialType: + if not credentials and tool_config.credential_type == "unauthorized": + return "unauthorized" + return tool_config.credential_type + + @staticmethod + def _prepared_parameters(tool_runtime: Tool) -> list[DifyPluginToolParameter]: + return [ + DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json")) + for parameter in tool_runtime.get_merged_runtime_parameters() + ] + + @staticmethod + def _description(tool_config: AgentSoulDifyToolConfig, tool_runtime: Tool) -> str | None: + description = tool_config.description + if description is None and tool_runtime.entity.description is not None: + description = tool_runtime.entity.description.llm + return description + + @staticmethod + def _runtime_parameters( + tool_runtime: Tool, + parameters: list[DifyPluginToolParameter], + ) -> dict[str, Any]: + runtime = tool_runtime.runtime + runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {}) + missing = [ + parameter.name + for parameter in parameters + if parameter.form is not DifyPluginToolParameterForm.LLM + and parameter.required + and parameter.default is None + and parameter.name not in runtime_parameters + ] + if missing: + names = ", ".join(sorted(missing)) + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_runtime_parameter_missing", + f"Dify Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.", + ) + return runtime_parameters + + @staticmethod + def _normalize_credentials( + credentials: Mapping[str, Any], + *, + tool_name: str, + ) -> dict[str, DifyPluginCredentialValue]: + normalized: dict[str, DifyPluginCredentialValue] = {} + for key, value in credentials.items(): + if isinstance(value, str | int | float | bool) or value is None: + normalized[key] = value + continue + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_credential_shape_invalid", + ( + f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value " + f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon." + ), + ) + return normalized diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py deleted file mode 100644 index 80366cc5193..00000000000 --- a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py +++ /dev/null @@ -1,334 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any, Protocol - -from dify_agent.layers.dify_plugin import ( - DifyPluginCredentialValue, - DifyPluginToolConfig, - DifyPluginToolCredentialType, - DifyPluginToolParameter, - DifyPluginToolParameterForm, - DifyPluginToolsLayerConfig, -) - -from core.agent.entities import AgentToolEntity -from core.app.entities.app_invoke_entities import InvokeFrom -from core.tools.__base.tool import Tool -from core.tools.entities.tool_entities import ToolProviderType -from core.tools.errors import ( - ToolProviderCredentialValidationError, - ToolProviderNotFoundError, -) -from core.tools.tool_manager import ToolManager -from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig -from models.provider_ids import ToolProviderID - - -class WorkflowAgentPluginToolsBuildError(ValueError): - """Raised when Agent Soul tools cannot be prepared for Agent backend.""" - - def __init__(self, error_code: str, message: str) -> None: - self.error_code = error_code - super().__init__(message) - - -class AgentToolRuntimeProvider(Protocol): - def get_agent_tool_runtime( - self, - tenant_id: str, - app_id: str, - agent_tool: AgentToolEntity, - user_id: str | None = None, - invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, - variable_pool: Any | None = None, - allow_file_parameters: bool = False, - use_default_for_missing_form_parameters: bool = False, - ) -> Tool: ... - - -class ProviderToolsLister(Protocol): - def __call__(self, *, tenant_id: str, provider_id: str) -> list[str]: ... - - -def _list_provider_tool_names(*, tenant_id: str, provider_id: str) -> list[str]: - """Tool names a provider currently declares (provider-level config entries).""" - provider = ToolManager.get_builtin_provider(provider_id, tenant_id) - return [tool.entity.identity.name for tool in provider.get_tools() or []] - - -class WorkflowAgentPluginToolsBuilder: - """Prepare Agent Soul Dify Plugin Tools for the public Agent backend DTO.""" - - def __init__( - self, - *, - tool_runtime_provider: AgentToolRuntimeProvider | None = None, - provider_tools_lister: ProviderToolsLister | None = None, - ) -> None: - self._tool_runtime_provider = tool_runtime_provider or ToolManager - self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names - - def build( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - tools: AgentSoulToolsConfig, - invoke_from: InvokeFrom, - ) -> DifyPluginToolsLayerConfig | None: - """Resolve user-selected Dify Plugin Tools into the Agent backend DTO. - - ``invoke_from`` is the *real* runtime caller category (DEBUGGER for a - Composer test run, SERVICE_API / WEB_APP for a published run). It must - be threaded through to :class:`ToolManager` so credential quotas, rate - limits, and audit tags match the actual call site. - """ - enabled_tools = [tool for tool in tools.dify_tools if tool.enabled] - if not enabled_tools: - return None - - prepared: list[DifyPluginToolConfig] = [] - seen_names: set[str] = set() - for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools): - agent_tool = self._to_agent_tool_entity(tool_config) - tool_runtime = self._fetch_tool_runtime( - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - agent_tool=agent_tool, - invoke_from=invoke_from, - tool_config=tool_config, - ) - - exposed_name = self._exposed_tool_name(tool_config) - if exposed_name in seen_names: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_name_duplicated", - f"Duplicate Dify Plugin Tool name {exposed_name!r}.", - ) - seen_names.add(exposed_name) - - prepared.append(self._to_backend_tool_config(tool_config, tool_runtime, exposed_name)) - - return DifyPluginToolsLayerConfig(tools=prepared) - - def _expand_provider_entries( - self, - *, - tenant_id: str, - enabled_tools: list[AgentSoulDifyToolConfig], - ) -> list[AgentSoulDifyToolConfig]: - """Expand provider-level entries (``tool_name`` omitted = all tools). - - An explicit per-tool entry of the same provider wins over the expansion - (it may carry its own ``runtime_parameters``); expanded clones share the - provider entry's ``credential_ref`` and start with default parameters. - """ - explicit_by_provider: dict[str, set[str]] = {} - for tool_config in enabled_tools: - if tool_config.tool_name is not None: - explicit_by_provider.setdefault(self._provider_id(tool_config), set()).add(tool_config.tool_name) - - expanded: list[AgentSoulDifyToolConfig] = [] - for tool_config in enabled_tools: - if tool_config.tool_name is not None: - expanded.append(tool_config) - continue - provider_id = self._provider_id(tool_config) - try: - tool_names = self._provider_tools_lister(tenant_id=tenant_id, provider_id=provider_id) - except ToolProviderNotFoundError as exc: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Plugin Tool provider {provider_id!r} declaration not found: {exc}", - ) from exc - if not tool_names: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Plugin Tool provider {provider_id!r} declares no tools.", - ) - already_explicit = explicit_by_provider.get(provider_id, set()) - for tool_name in tool_names: - if tool_name in already_explicit: - continue - expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}})) - return expanded - - def _fetch_tool_runtime( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - agent_tool: AgentToolEntity, - invoke_from: InvokeFrom, - tool_config: AgentSoulDifyToolConfig, - ) -> Tool: - """Resolve the API-side ``Tool`` runtime, mapping fetch errors to - Inspector-friendly error codes so callers can render distinct UX for - "tool definition gone" vs "credential failed". - """ - try: - return self._tool_runtime_provider.get_agent_tool_runtime( - tenant_id=tenant_id, - app_id=app_id, - agent_tool=agent_tool, - user_id=user_id, - invoke_from=invoke_from, - variable_pool=None, - allow_file_parameters=True, - use_default_for_missing_form_parameters=True, - ) - except ToolProviderNotFoundError as exc: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Plugin Tool {tool_config.tool_name!r} declaration not found: {exc}", - ) from exc - except ToolProviderCredentialValidationError as exc: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_credential_invalid", - f"Dify Plugin Tool {tool_config.tool_name!r} credential validation failed: {exc}", - ) from exc - except ValueError as exc: - # ToolManager raises bare ValueError when the agent tool's - # ``runtime`` / runtime parameters are missing. Surface it under a - # narrower error code than a generic "declaration not found" so - # frontend can render an actionable hint. - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_config_invalid", - f"Dify Plugin Tool {tool_config.tool_name!r} runtime construction failed: {exc}", - ) from exc - - @staticmethod - def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity: - # Provider-level entries are expanded into per-tool clones before this point. - assert tool_config.tool_name is not None - return AgentToolEntity( - provider_type=ToolProviderType.value_of(tool_config.provider_type), - provider_id=WorkflowAgentPluginToolsBuilder._provider_id(tool_config), - tool_name=tool_config.tool_name, - tool_parameters=dict(tool_config.runtime_parameters), - credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None, - ) - - @staticmethod - def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str: - if tool_config.provider_id: - return tool_config.provider_id - assert tool_config.plugin_id is not None - assert tool_config.provider is not None - return f"{tool_config.plugin_id}/{tool_config.provider}" - - @staticmethod - def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str: - # Stage 3.1 decision: no user rename yet. Keep the model-visible tool - # name aligned with the plugin declaration identity. Provider-level - # entries are expanded into per-tool clones before this point. - assert tool_config.tool_name is not None - return tool_config.tool_name - - def _to_backend_tool_config( - self, - tool_config: AgentSoulDifyToolConfig, - tool_runtime: Tool, - exposed_name: str, - ) -> DifyPluginToolConfig: - runtime = tool_runtime.runtime - if runtime is None: - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_config_invalid", - f"Dify Plugin Tool {tool_config.tool_name!r} has no runtime.", - ) - - provider_id = self._provider_id(tool_config) - plugin_id, provider = self._plugin_provider(tool_config, provider_id) - parameters = [ - DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json")) - for parameter in tool_runtime.get_merged_runtime_parameters() - ] - runtime_parameters = self._runtime_parameters(tool_runtime, parameters) - description = tool_config.description - if description is None and tool_runtime.entity.description is not None: - description = tool_runtime.entity.description.llm - - return DifyPluginToolConfig( - plugin_id=plugin_id, - provider=provider, - tool_name=exposed_name, - credential_type=self._credential_type(tool_config, runtime.credentials), - name=exposed_name, - description=description, - credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name), - runtime_parameters=runtime_parameters, - parameters=parameters, - parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), - ) - - @staticmethod - def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]: - if tool_config.plugin_id and tool_config.provider: - return tool_config.plugin_id, tool_config.provider - provider_id_entity = ToolProviderID(provider_id) - return provider_id_entity.plugin_id, provider_id_entity.provider_name - - @staticmethod - def _credential_type( - tool_config: AgentSoulDifyToolConfig, - credentials: Mapping[str, Any], - ) -> DifyPluginToolCredentialType: - if not credentials and tool_config.credential_type == "unauthorized": - return "unauthorized" - return tool_config.credential_type - - @staticmethod - def _runtime_parameters( - tool_runtime: Tool, - parameters: list[DifyPluginToolParameter], - ) -> dict[str, Any]: - runtime = tool_runtime.runtime - runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {}) - missing = [ - parameter.name - for parameter in parameters - if parameter.form is not DifyPluginToolParameterForm.LLM - and parameter.required - and parameter.default is None - and parameter.name not in runtime_parameters - ] - if missing: - names = ", ".join(sorted(missing)) - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_runtime_parameter_missing", - f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.", - ) - return runtime_parameters - - @staticmethod - def _normalize_credentials( - credentials: Mapping[str, Any], - *, - tool_name: str, - ) -> dict[str, DifyPluginCredentialValue]: - """Forward only scalar credential values to the Agent backend. - - ``DifyPluginCredentialValue`` is ``str | int | float | bool | None``. - Refusing non-scalar values (lists, dicts, custom objects) is safer than - ``str(value)`` — stringifying a nested OAuth token blob produces a - Python ``repr`` that the plugin daemon cannot use, and we'd rather - surface a clear ``agent_tool_credential_shape_invalid`` than send junk. - """ - normalized: dict[str, DifyPluginCredentialValue] = {} - for key, value in credentials.items(): - if isinstance(value, str | int | float | bool) or value is None: - normalized[key] = value - continue - raise WorkflowAgentPluginToolsBuildError( - "agent_tool_credential_shape_invalid", - ( - f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value " - f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon." - ), - ) - return normalized diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index f4a81bdcc54..5804fd5fdcd 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -8,9 +8,11 @@ from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot from dify_agent.agent_stub.protocol import AgentStubFileMapping from dify_agent.layers.ask_human import DifyAskHumanLayerConfig -from dify_agent.layers.drive import ( - DifyDriveLayerConfig, - DifyDriveSkillConfig, +from dify_agent.layers.config import ( + DifyConfigFileConfig, + DifyConfigLayerConfig, + DifyConfigSkillConfig, + DifyConfigVersionConfig, ) from dify_agent.layers.execution_context import ( DifyExecutionContextInvokeFrom, @@ -55,6 +57,7 @@ from models.agent_config_entities import ( AgentKnowledgeModelConfig, AgentKnowledgeRetrievalConfig, AgentSoulConfig, + AgentSoulToolsConfig, DeclaredArrayItem, DeclaredOutputChildConfig, DeclaredOutputConfig, @@ -76,10 +79,14 @@ from services.agent.prompt_mentions import ( parse_prompt_mentions, workflow_previous_node_output_refs_from_selectors, ) -from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref +from .dify_tools_builder import ( + WorkflowAgentDifyToolLayersBuilder, + WorkflowAgentDifyToolsBuilder, + WorkflowAgentDifyToolsBuildError, + WorkflowAgentToolLayers, +) from .output_failure_orchestrator import retry_idempotency_key -from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError from .runtime_feature_manifest import build_runtime_feature_manifest _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) @@ -97,6 +104,7 @@ _AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTran FileTransferMethod.DATASOURCE_FILE: "datasource_file", FileTransferMethod.REMOTE_URL: "remote_url", } +_CANONICAL_DIFY_FILE_REFERENCE_PATTERN = r"^dify-file-ref:eyJyZWNvcmRfaWQiOi[A-Za-z0-9_-]+={0,2}$" class WorkflowAgentRuntimeRequestBuildError(ValueError): @@ -157,11 +165,11 @@ class WorkflowAgentRuntimeRequestBuilder: *, credentials_provider: CredentialsProvider, request_builder: AgentBackendRunRequestBuilder | None = None, - plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None, + dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None, ) -> None: self._credentials_provider = credentials_provider self._request_builder = request_builder or AgentBackendRunRequestBuilder() - self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder() + self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder() def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest: agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict) @@ -182,42 +190,33 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model) try: - tools_layer = self._plugin_tools_builder.build( + tool_layers = self._build_tool_layers( tenant_id=context.dify_context.tenant_id, app_id=context.dify_context.app_id, user_id=context.dify_context.user_id, tools=agent_soul.tools, - # Thread the *real* runtime invocation source through to - # ToolManager so credential quotas, rate limits, and audit - # trails match the actual call site (DEBUGGER for draft test - # run, SERVICE_API / WEB_APP for published run). invoke_from=context.dify_context.invoke_from, ) - except WorkflowAgentPluginToolsBuildError as error: + except WorkflowAgentDifyToolsBuildError as error: raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error - if tools_layer is not None or agent_soul.tools.cli_tools: + if tool_layers.plugin_tools is not None or tool_layers.core_tools is not None or agent_soul.tools.cli_tools: metadata["agent_tools"] = { - "dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0, - "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools] - if tools_layer is not None - else [], + "dify_tool_count": len(tool_layers.exposed_tool_names()), + "dify_tool_names": tool_layers.exposed_tool_names(), "cli_tool_count": len(agent_soul.tools.cli_tools), } - drive_config: DifyDriveLayerConfig | None = None + config_layer_config: DifyConfigLayerConfig | None = None soul_prompt_resolver = build_soul_mention_resolver(agent_soul) if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - drive_config, drive_warnings = build_drive_layer_config( + config_layer_config, config_warnings = build_config_layer_config( agent_soul, - tenant_id=context.dify_context.tenant_id, - agent_id=context.agent.id, - ) - append_runtime_warnings(metadata, drive_warnings) - soul_prompt_resolver = build_drive_aware_soul_mention_resolver( - agent_soul, - tenant_id=context.dify_context.tenant_id, agent_id=context.agent.id, + config_version_id=context.snapshot.id, + config_version_kind="snapshot", ) + append_runtime_warnings(metadata, config_warnings) + soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() knowledge_config = build_knowledge_layer_config(agent_soul) @@ -251,6 +250,7 @@ class WorkflowAgentRuntimeRequestBuilder: conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID), agent_id=context.agent.id, agent_config_version_id=context.snapshot.id, + agent_config_version_kind="snapshot", agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from), invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), ), @@ -258,9 +258,10 @@ class WorkflowAgentRuntimeRequestBuilder: workflow_node_job_prompt=workflow_job_prompt, user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), - tools=tools_layer, + tools=tool_layers.plugin_tools, + core_tools=tool_layers.core_tools, knowledge=knowledge_config, - drive_config=drive_config, + config_layer_config=config_layer_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), @@ -279,6 +280,26 @@ class WorkflowAgentRuntimeRequestBuilder: metadata=metadata, ) + def _build_tool_layers( + self, + *, + tenant_id: str, + app_id: str, + user_id: str | None, + tools: AgentSoulToolsConfig, + invoke_from: InvokeFrom, + ) -> WorkflowAgentToolLayers: + # Production workflow runs intentionally keep existing plugin configs on + # the direct `dify.plugin.tools` route. This builder emits plugin tools + # directly and non-plugin Dify tools through `dify.core.tools`. + return self._dify_tools_builder.build_layers( + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + tools=tools, + invoke_from=invoke_from, + ) + @staticmethod def _agent_backend_agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]: if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}: @@ -494,7 +515,10 @@ class WorkflowAgentRuntimeRequestBuilder: schema: dict[str, Any] = {"type": "object", "properties": properties} if required: schema["required"] = required - return AgentBackendOutputConfig(json_schema=schema) + return AgentBackendOutputConfig( + json_schema=schema, + description=WorkflowAgentRuntimeRequestBuilder._build_output_description(effective_outputs), + ) @staticmethod def effective_declared_outputs( @@ -551,48 +575,107 @@ class WorkflowAgentRuntimeRequestBuilder: array_schema["items"]["description"] = array_item.description return array_schema case DeclaredOutputType.FILE: - return { - "oneOf": [ - { - "type": "object", - "additionalProperties": False, - "properties": { - "transfer_method": {"const": FileTransferMethod.LOCAL_FILE.value}, - "reference": {"type": "string"}, - }, - "required": ["transfer_method", "reference"], - }, - { - "type": "object", - "additionalProperties": False, - "properties": { - "transfer_method": {"const": FileTransferMethod.TOOL_FILE.value}, - "reference": {"type": "string"}, - }, - "required": ["transfer_method", "reference"], - }, - { - "type": "object", - "additionalProperties": False, - "properties": { - "transfer_method": {"const": FileTransferMethod.DATASOURCE_FILE.value}, - "reference": {"type": "string"}, - }, - "required": ["transfer_method", "reference"], - }, - { - "type": "object", - "additionalProperties": False, - "properties": { - "transfer_method": {"const": FileTransferMethod.REMOTE_URL.value}, - "url": {"type": "string"}, - }, - "required": ["transfer_method", "url"], - }, - ], - } + return WorkflowAgentRuntimeRequestBuilder._agent_stub_output_file_mapping_schema() assert_never(output_type) + @staticmethod + def _agent_stub_output_file_mapping_schema() -> dict[str, Any]: + """JSON Schema for Agent-produced output file mappings. + + ``AgentStubFileMapping.model_json_schema()`` cannot express the model's + ``after`` validator: only ``remote_url`` may carry ``url``; every other + method must carry a canonical ``reference``. The structured-output model + needs that relationship in the schema, otherwise it may emit + ``{"transfer_method": "local_file", "url": "..."}``, which passes the + broad generated schema but fails API-side output type checking. + + For files produced inside an Agent run, the supported persisted shape is + narrower than every downloadable mapping: the sandbox must upload the + local artifact via ``dify-agent file upload ``, which returns a + ``tool_file`` mapping. ``local_file`` and ``datasource_file`` are valid + for existing file references in workflow context, not for newly produced + Agent output files. + """ + return { + "title": "AgentStubFileMapping", + "description": ( + "Agent output file mapping. Use `tool_file` with `reference` for files uploaded by " + "`dify-agent file upload `; use `remote_url` only for files already reachable by URL." + ), + "anyOf": [ + { + "type": "object", + "additionalProperties": False, + "required": ["transfer_method", "reference"], + "properties": { + "transfer_method": { + "type": "string", + "enum": ["tool_file"], + }, + "reference": { + "type": "string", + "minLength": 1, + "pattern": _CANONICAL_DIFY_FILE_REFERENCE_PATTERN, + "description": ( + "Canonical Dify file reference returned by `dify-agent file upload `. " + "Never use a local path, filename, URL, or synthesized dify-file-ref here." + ), + }, + }, + }, + { + "type": "object", + "additionalProperties": False, + "required": ["transfer_method", "url"], + "properties": { + "transfer_method": { + "type": "string", + "enum": ["remote_url"], + }, + "url": { + "type": "string", + "minLength": 1, + "description": "Remote URL for a file that is already publicly reachable.", + }, + }, + }, + ], + } + + @staticmethod + def _build_output_description(declared_outputs: Sequence[DeclaredOutputConfig]) -> str | None: + file_output_lines: list[str] = [] + for output in declared_outputs: + if output.type == DeclaredOutputType.FILE: + file_output_lines.append( + f"- `{output.name}`: create the file in the sandbox, run `dify-agent file upload `, " + f"and set `final_output.{output.name}` to the returned AgentStubFileMapping JSON object. " + "Do not call `final_output` before the upload command succeeds. Do not use the local path, " + "filename, URL, or a synthesized/base64-encoded value as the `reference`." + ) + elif ( + output.type == DeclaredOutputType.ARRAY + and output.array_item is not None + and output.array_item.type == DeclaredOutputType.FILE + ): + file_output_lines.append( + f"- `{output.name}`: for every produced file, run `dify-agent file upload ` and set " + f"`final_output.{output.name}` to an array of the returned AgentStubFileMapping JSON objects. " + "Do not call `final_output` before all upload commands succeed. Do not use local paths, filenames, " + "URLs, or synthesized/base64-encoded values as `reference` values." + ) + if not file_output_lines: + return None + + return "\n".join( + [ + "When filling file outputs, do not return a local filesystem path directly.", + "Upload each sandbox-local file through the Agent Stub CLI first. Copy the JSON printed by " + "`dify-agent file upload ` verbatim into the final output; never invent the `reference` value.", + *file_output_lines, + ] + ) + @staticmethod def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None: if not children: @@ -753,19 +836,12 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s existing.extend(warnings) -def build_drive_aware_soul_mention_resolver( - agent_soul: AgentSoulConfig, - *, - tenant_id: str, - agent_id: str, -): - """Resolve skill/file mentions against the agent drive and everything else via Agent Soul.""" +def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig): + """Resolve config skill/file mentions and delegate the rest to Agent Soul.""" base_resolver = build_soul_mention_resolver(agent_soul) - drive_service = AgentDriveService() - skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id) - skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog} - drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)} + skill_names = {item.name for item in agent_soul.config_skills} + file_names = {item.name for item in agent_soul.config_files} def _resolve(mention: object) -> str | None: if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"): @@ -774,88 +850,97 @@ def build_drive_aware_soul_mention_resolver( ref_id = cast(str, mention.ref_id) label = cast(str | None, getattr(mention, "label", None)) if kind == MentionKind.SKILL: - decoded_key = decode_drive_mention_ref(ref_id) - return skill_names_by_key.get(decoded_key) or label or decoded_key + return ref_id if ref_id in skill_names else label or ref_id if kind == MentionKind.FILE: - decoded_key = decode_drive_mention_ref(ref_id) - if decoded_key in drive_keys: - return decoded_key.rsplit("/", 1)[-1] - return label or decoded_key + return ref_id if ref_id in file_names else label or ref_id return base_resolver(cast(Any, mention)) return _resolve -def build_drive_layer_config( +def build_config_layer_config( agent_soul: AgentSoulConfig, *, - tenant_id: str, - agent_id: str | None, -) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]: - """Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive.""" + agent_id: str | None = None, + config_version_id: str | None = None, + config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", +) -> tuple[DifyConfigLayerConfig | None, list[dict[str, str]]]: + """Derive prompt-mentioned eager-pull names from Agent Soul.""" - mentioned_drive_refs = [ - decode_drive_mention_ref(mention.ref_id) - for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt) - if mention.kind in {MentionKind.SKILL, MentionKind.FILE} - ] - ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref)) - if not agent_id: - if not ordered_mentions: - return None, [] - return None, [ - { - "section": "agent_soul.prompt.system_prompt", - "code": "drive_ref_dangling", - "message": "drive mentions are configured but the run has no bound agent to address a drive by.", - } - ] + ordered_mentions = list( + dict.fromkeys( + mention.ref_id + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt) + if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id + ) + ) + if ( + not agent_soul.config_skills + and not agent_soul.config_files + and not agent_soul.config_note + and not ordered_mentions + ): + return None, [] - drive_service = AgentDriveService() - skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id) - manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id) - manifest_by_key = {item["key"]: item for item in manifest_items} - skill_keys = {skill["skill_md_key"] for skill in skills_catalog} + skill_names = {skill.name for skill in agent_soul.config_skills} + file_names = {file_ref.name for file_ref in agent_soul.config_files} warnings: list[dict[str, str]] = [] - mentioned_skill_keys: list[str] = [] - mentioned_file_keys: list[str] = [] - for drive_key in ordered_mentions: - if drive_key in skill_keys: - mentioned_skill_keys.append(drive_key) + mentioned_skill_names: list[str] = [] + mentioned_file_names: list[str] = [] + for name in ordered_mentions: + if name in skill_names: + mentioned_skill_names.append(name) continue - if drive_key in manifest_by_key: - mentioned_file_keys.append(drive_key) + if name in file_names: + mentioned_file_names.append(name) continue warnings.append( { "section": "agent_soul.prompt.system_prompt", "code": "mention_target_missing", - "message": f"drive mention '{drive_key}' has no matching drive entry.", + "message": f"config mention '{name}' has no matching config asset.", } ) - skills = [ - DifyDriveSkillConfig( - path=skill["path"], - name=skill["name"], - description=skill["description"], - skill_md_key=skill["skill_md_key"], - archive_key=skill["archive_key"], - ) - for skill in skills_catalog - ] - return ( - DifyDriveLayerConfig( - drive_ref=f"agent-{agent_id}", - skills=skills, - mentioned_skill_keys=mentioned_skill_keys, - mentioned_file_keys=mentioned_file_keys, + DifyConfigLayerConfig( + agent_id=agent_id, + config_version=DifyConfigVersionConfig( + id=config_version_id, + kind=config_version_kind, + writable=config_version_kind == "build_draft", + ), + skills=[ + DifyConfigSkillConfig( + name=skill.name, + description=skill.description, + size=skill.size, + mime_type=skill.mime_type, + ) + for skill in agent_soul.config_skills + ], + files=[ + DifyConfigFileConfig( + name=file_ref.name, + size=file_ref.size, + mime_type=file_ref.mime_type, + ) + for file_ref in agent_soul.config_files + ], + env_keys=_agent_soul_config_env_keys(agent_soul), + note=agent_soul.config_note, + mentioned_skill_names=mentioned_skill_names, + mentioned_file_names=mentioned_file_names, ), warnings, ) +def _agent_soul_config_env_keys(agent_soul: AgentSoulConfig) -> list[str]: + keys = [item.key or item.name or item.env_name or item.variable for item in agent_soul.env.variables] + return [key for key in keys if key] + + def _cli_tool_enabled(item: object) -> bool: """A CLI tool is bootstrapped unless explicitly disabled (default is enabled).""" data = _plain_mapping(item) diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index e045ee39afc..db0dbf8dcc0 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -371,6 +371,9 @@ class WorkflowAgentComposerResponse(ResponseModel): backing_app_id: str | None = None hidden_app_backed: bool = False chat_endpoint: str | None = None + debug_conversation_id: str | None = None + debug_conversation_has_messages: bool = False + debug_conversation_message_count: int = 0 workflow_id: str | None = None node_id: str | None = None diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index c1faa5a6975..64990357acd 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -109,6 +109,7 @@ class OutputErrorStrategy(StrEnum): # JSON-schema-friendly name pattern. Stage 4 §3.1 / §10.1. _OUTPUT_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_CONFIG_SKILL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") JsonPrimitive = str | int | float | bool | None RuntimeParameterValue = JsonPrimitive | list[str] | list[int] | list[float] | list[bool] @@ -167,6 +168,63 @@ class AgentSoulFilesConfig(BaseModel): files: list[AgentFileRefConfig] = Field(default_factory=list) +def validate_config_name(name: str) -> str: + normalized = name.strip() + if not normalized: + raise ValueError("config asset name must not be blank") + if normalized in {".", ".."}: + raise ValueError("config asset name must not be '.' or '..'") + if "/" in normalized or "\\" in normalized: + raise ValueError("config asset name must be a single path segment") + if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized): + raise ValueError("config asset name must not contain control characters") + return normalized + + +def validate_config_skill_name(name: str) -> str: + normalized = validate_config_name(name) + if _CONFIG_SKILL_NAME_PATTERN.fullmatch(normalized) is None: + raise ValueError(f"config skill name {normalized!r} must match {_CONFIG_SKILL_NAME_PATTERN.pattern}") + return normalized + + +class AgentConfigFileRefConfig(BaseModel): + """Stable Agent Soul reference to one config file payload.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + file_kind: Literal["upload_file", "tool_file"] + file_id: str = Field(min_length=1, max_length=255) + size: int | None = None + hash: str | None = None + mime_type: str | None = None + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + return validate_config_name(value) + + +class AgentConfigSkillRefConfig(BaseModel): + """Stable Agent Soul reference to one normalized skill archive.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str = "" + file_kind: Literal["tool_file"] = "tool_file" + file_id: str = Field(min_length=1, max_length=255) + size: int | None = None + hash: str | None = None + mime_type: str | None = "application/zip" + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + return validate_config_skill_name(value) + + class AgentPermissionConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -527,12 +585,15 @@ class AgentSoulDifyToolCredentialRef(BaseModel): class AgentSoulDifyToolConfig(BaseModel): - """One Dify Plugin Tool configured on Agent Soul. + """One Dify tool configured on Agent Soul. The API backend prepares this persisted product shape into - ``DifyPluginToolConfig`` before sending a run request to Agent backend. - ``provider_id`` keeps compatibility with existing Agent tool config payloads; - new callers should send ``plugin_id`` + ``provider`` when available. + either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a + run request to Agent backend. ``plugin`` providers keep the direct + ``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` / + ``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id`` + keeps compatibility with existing Agent tool config payloads; new callers + should send ``plugin_id`` + ``provider`` when available. """ # ``extra="ignore"`` (not ``"allow"``) so historical Agent Soul payloads @@ -542,10 +603,10 @@ class AgentSoulDifyToolConfig(BaseModel): model_config = ConfigDict(extra="ignore") enabled: bool = True - # Dify Plugin Tools live behind the ``PLUGIN`` provider type. ``BUILT_IN`` / - # ``WORKFLOW`` / ``API`` providers are not exposed to the Agent backend in - # this layer — keep the default narrow so a missing field surfaces as - # ``agent_tool_declaration_not_found`` against the correct provider table. + # ``plugin`` remains the default for legacy Agent Soul payloads. The runtime + # now also accepts ``builtin`` / ``api`` / ``workflow`` / ``mcp`` here and + # routes them through ``dify.core.tools``; keeping the default narrow still + # makes a missing field resolve against the plugin provider table. provider_type: str = "plugin" provider_id: str | None = Field(default=None, max_length=255) plugin_id: str | None = Field(default=None, max_length=255) @@ -682,6 +743,9 @@ class AgentSoulConfig(BaseModel): knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) + 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) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 207cfcd78ad..3fe4fde4c15 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -465,6 +465,23 @@ Check if activation token is valid | ---- | ----------- | | 204 | Agent service API key deleted | +### [POST] /agent/{agent_id}/build-chat/finalize +Run a build-draft Agent App turn that asks the agent to push config updates + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Invalid request parameters | | +| 404 | Agent, build draft, or conversation not found | | + ### [DELETE] /agent/{agent_id}/build-draft #### Parameters @@ -658,6 +675,237 @@ Stop a running Agent App chat message generation | ---- | ----------- | ------ | | 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| +### [GET] /agent/{agent_id}/config/files +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config files | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)
| + +### [POST] /agent/{agent_id}/config/files +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentConfigFileUploadPayload](#agentconfigfileuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Uploaded config file | **application/json**: [AgentConfigFileUploadResponse](#agentconfigfileuploadresponse)
| + +### [DELETE] /agent/{agent_id}/config/files/{name} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config file deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)
| + +### [GET] /agent/{agent_id}/config/files/{name}/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /agent/{agent_id}/config/files/{name}/preview +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)
| + +### [GET] /agent/{agent_id}/config/manifest +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent config manifest | **application/json**: [AgentConfigManifestResponse](#agentconfigmanifestresponse)
| + +### [GET] /agent/{agent_id}/config/skills +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skills | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)
| + +### [POST] /agent/{agent_id}/config/skills/upload +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Uploaded config skill | **application/json**: [AgentConfigSkillUploadResponse](#agentconfigskilluploadresponse)
| + +### [DELETE] /agent/{agent_id}/config/skills/{name} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)
| + +### [GET] /agent/{agent_id}/config/skills/{name}/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /agent/{agent_id}/config/skills/{name}/files/content +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | +| name | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### [GET] /agent/{agent_id}/config/skills/{name}/files/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| path | query | Normalized zip member path inside the skill package | Yes | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /agent/{agent_id}/config/skills/{name}/files/preview +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| path | query | Normalized zip member path inside the skill package | Yes | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill file preview | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)
| + +### [GET] /agent/{agent_id}/config/skills/{name}/inspect +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)
| + ### [POST] /agent/{agent_id}/copy #### Parameters @@ -1633,6 +1881,250 @@ Run draft workflow for advanced chat application | 400 | Invalid request parameters | | | 403 | Permission denied | | +### [GET] /apps/{app_id}/agent/config/files +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config files | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)
| + +### [POST] /apps/{app_id}/agent/config/files +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentConfigFileUploadPayload](#agentconfigfileuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Uploaded config file | **application/json**: [AgentConfigFileUploadResponse](#agentconfigfileuploadresponse)
| + +### [DELETE] /apps/{app_id}/agent/config/files/{name} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config file deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)
| + +### [GET] /apps/{app_id}/agent/config/files/{name}/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /apps/{app_id}/agent/config/files/{name}/preview +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config file name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)
| + +### [GET] /apps/{app_id}/agent/config/manifest +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent config manifest | **application/json**: [AgentConfigManifestResponse](#agentconfigmanifestresponse)
| + +### [GET] /apps/{app_id}/agent/config/skills +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skills | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)
| + +### [POST] /apps/{app_id}/agent/config/skills/upload +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Uploaded config skill | **application/json**: [AgentConfigSkillUploadResponse](#agentconfigskilluploadresponse)
| + +### [DELETE] /apps/{app_id}/agent/config/skills/{name} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill deleted | **application/json**: [AgentConfigDeleteResponse](#agentconfigdeleteresponse)
| + +### [GET] /apps/{app_id}/agent/config/skills/{name}/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /apps/{app_id}/agent/config/skills/{name}/files/content +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| name | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### [GET] /apps/{app_id}/agent/config/skills/{name}/files/download +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| path | query | Normalized zip member path inside the skill package | Yes | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill file download URL | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)
| + +### [GET] /apps/{app_id}/agent/config/skills/{name}/files/preview +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| path | query | Normalized zip member path inside the skill package | Yes | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Config skill file preview | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)
| + +### [GET] /apps/{app_id}/agent/config/skills/{name}/inspect +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| name | path | Config skill name | Yes | string | +| draft_type | query | Editable draft surface: omit or 'draft' for normal draft, 'debug_build' for build draft | No | string,
**Available values:** "debug_build", "draft" | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| version_id | query | Published snapshot ID for read-only version view | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 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) @@ -12375,7 +12867,9 @@ Default namespace | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | +| debug_conversation_has_messages | boolean | | No | | debug_conversation_id | string | | No | +| debug_conversation_message_count | integer | | No | | deleted_tools | [ [DeletedTool](#deletedtool) ] | | No | | description | string | | No | | enable_api | boolean | | Yes | @@ -12703,6 +13197,19 @@ Risk marker for CLI tool bootstrap commands. | result | string | | Yes | | warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No | +#### AgentConfigDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| removed_names | [ string ] | | No | +| result | string | | Yes | + +#### AgentConfigDownloadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string | | Yes | + #### AgentConfigDraftSummaryResponse | Name | Type | Description | Required | @@ -12725,6 +13232,78 @@ Editable Agent Soul draft workspace type. | ---- | ---- | ----------- | -------- | | AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | | +#### AgentConfigFileItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_id | string | | No | +| hash | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentConfigFileItemsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AgentConfigFileItemResponse](#agentconfigfileitemresponse) ] | | No | + +#### AgentConfigFileListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | Yes | +| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes | +| items | [ [AgentConfigFileItemResponse](#agentconfigfileitemresponse) ] | | No | + +#### AgentConfigFilePreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| name | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + +#### AgentConfigFileRefConfig + +Stable Agent Soul reference to one config file payload. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_id | string | | Yes | +| file_kind | string,
**Available values:** "tool_file", "upload_file" | *Enum:* `"tool_file"`, `"upload_file"` | Yes | +| hash | string | | No | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentConfigFileUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes | + +#### AgentConfigFileUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes | +| file | [AgentConfigFileItemResponse](#agentconfigfileitemresponse) | | Yes | + +#### AgentConfigManifestResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | Yes | +| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes | +| env_keys | [ string ] | | No | +| files | [AgentConfigFileItemsResponse](#agentconfigfileitemsresponse) | | No | +| note | string | | No | +| skills | [AgentConfigSkillItemsResponse](#agentconfigskillitemsresponse) | | No | + #### AgentConfigRevisionOperation Audit operation recorded for Agent Soul version/revision changes. @@ -12747,6 +13326,99 @@ Audit operation recorded for Agent Soul version/revision changes. | summary | string | | No | | version_note | string | | No | +#### AgentConfigSkillFilePreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| path | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + +#### AgentConfigSkillFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| downloadable | boolean | | Yes | +| name | string | | Yes | +| path | string | | Yes | +| previewable | boolean | | Yes | +| type | string,
**Available values:** "directory", "file" | *Enum:* `"directory"`, `"file"` | Yes | + +#### AgentConfigSkillInspectResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| file_tree | [ object ] | | No | +| files | [ [AgentConfigSkillFileResponse](#agentconfigskillfileresponse) ] | | No | +| hash | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | +| skill_md | [AgentConfigSkillMarkdownResponse](#agentconfigskillmarkdownresponse) | | Yes | +| source | string | | Yes | +| warnings | [ string ] | | No | + +#### AgentConfigSkillItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| file_id | string | | No | +| hash | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentConfigSkillItemsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) ] | | No | + +#### AgentConfigSkillListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | Yes | +| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes | +| items | [ [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) ] | | No | + +#### AgentConfigSkillMarkdownResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| path | string | | Yes | +| size | integer | | No | +| text | string | | Yes | +| truncated | boolean | | Yes | + +#### AgentConfigSkillRefConfig + +Stable Agent Soul reference to one normalized skill archive. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| file_id | string | | Yes | +| file_kind | string,
**Default:** tool_file | | No | +| hash | string | | No | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentConfigSkillUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version | [AgentConfigVersionResponse](#agentconfigversionresponse) | | Yes | +| skill | [AgentConfigSkillItemResponse](#agentconfigskillitemresponse) | | Yes | + #### AgentConfigSnapshotDetailResponse | Name | Type | Description | Required | @@ -12792,6 +13464,14 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentConfigVersionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| kind | string,
**Available values:** "build_draft", "draft", "snapshot" | *Enum:* `"build_draft"`, `"draft"`, `"snapshot"` | Yes | +| writable | boolean | | Yes | + #### AgentDailyConversationStatisticResponse | Name | Type | Description | Required | @@ -12817,7 +13497,9 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| debug_conversation_has_messages | boolean | | No | | debug_conversation_id | string | | Yes | +| debug_conversation_message_count | integer | | No | #### AgentDriveDeleteFileByAgentQuery @@ -13588,6 +14270,9 @@ Visibility and lifecycle scope of an Agent record. | ---- | ---- | ----------- | -------- | | app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No | | app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No | +| config_files | [ [AgentConfigFileRefConfig](#agentconfigfilerefconfig) ] | | No | +| config_note | string | | No | +| config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No | | env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No | | files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No | | human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No | @@ -13602,12 +14287,15 @@ Visibility and lifecycle scope of an Agent record. #### AgentSoulDifyToolConfig -One Dify Plugin Tool configured on Agent Soul. +One Dify tool configured on Agent Soul. The API backend prepares this persisted product shape into -``DifyPluginToolConfig`` before sending a run request to Agent backend. -``provider_id`` keeps compatibility with existing Agent tool config payloads; -new callers should send ``plugin_id`` + ``provider`` when available. +either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a +run request to Agent backend. ``plugin`` providers keep the direct +``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` / +``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id`` +keeps compatibility with existing Agent tool config payloads; new callers +should send ``plugin_id`` + ``provider`` when available. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -20790,6 +21478,9 @@ How a workflow node is bound to an Agent. | backing_app_id | string | | No | | binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No | | chat_endpoint | string | | No | +| debug_conversation_has_messages | boolean | | No | +| debug_conversation_id | string | | No | +| debug_conversation_message_count | integer | | No | | effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No | | hidden_app_backed | boolean | | No | | impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index ec81ab3daf2..99817eb6441 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -96,10 +96,14 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non ComposerConfigValidator.validate_draft_save_payload(payload) +def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]: + return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json") + + class AgentComposerService: @classmethod def load_workflow_composer( - cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None + cls, *, tenant_id: str, app_id: str, node_id: str, account_id: str | None = None, snapshot_id: str | None = None ) -> dict[str, Any]: workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) @@ -115,7 +119,7 @@ class AgentComposerService: agent=agent, snapshot_id=snapshot_id, ) - return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id) @classmethod def _workflow_composer_version( @@ -214,7 +218,7 @@ class AgentComposerService: agent_id=agent.id if agent else None, version_id=version_id, ) - state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id) state["validation"] = cls.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -246,7 +250,7 @@ class AgentComposerService: agent_id=agent.id if agent else None, version_id=binding.current_snapshot_id, ) - return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version, account_id=account_id) if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT: raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.") @@ -300,7 +304,9 @@ class AgentComposerService: agent_id=inline_agent.id, version_id=inline_agent.active_config_snapshot_id, ) - return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version) + return cls._serialize_workflow_state( + binding=binding, agent=inline_agent, version=version, account_id=account_id + ) @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: @@ -419,7 +425,11 @@ class AgentComposerService: account_id_for_audit=account_id, ) agent.updated_by = account_id - agent.active_config_is_published = False + agent.active_config_is_published = cls._agent_soul_matches_active_config( + tenant_id=tenant_id, + agent=agent, + agent_soul=payload.agent_soul, + ) db.session.commit() state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id) @@ -430,6 +440,54 @@ class AgentComposerService: ) return state + @classmethod + def _agent_soul_matches_active_config( + cls, + *, + tenant_id: str, + agent: Agent, + agent_soul: AgentSoulConfig, + ) -> bool: + if not agent.active_config_snapshot_id: + return False + + active_version = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + if not active_version: + return False + if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision( + tenant_id=tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ): + return False + + return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict) + + @classmethod + def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool: + revisions = db.session.scalars( + select(AgentConfigRevision.operation).where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.current_snapshot_id == snapshot_id, + ) + ).all() + + return any( + operation + in { + AgentConfigRevisionOperation.PUBLISH_DRAFT, + AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, + AgentConfigRevisionOperation.RESTORE_VERSION, + } + for operation in revisions + ) + @classmethod def publish_agent_app_draft( cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None @@ -557,16 +615,21 @@ class AgentComposerService: ) if build_draft is None: raise AgentVersionNotFoundError() + applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict) normal_draft = cls._save_agent_draft( tenant_id=tenant_id, agent=agent, draft_type=AgentConfigDraftType.DRAFT, account_id=None, - agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict), + agent_soul=applied_agent_soul, account_id_for_audit=account_id, base_snapshot_id=build_draft.base_snapshot_id, ) - agent.active_config_is_published = False + agent.active_config_is_published = cls._agent_soul_matches_active_config( + tenant_id=tenant_id, + agent=agent, + agent_soul=applied_agent_soul, + ) agent.updated_by = account_id db.session.delete(build_draft) db.session.commit() @@ -1788,6 +1851,7 @@ class AgentComposerService: binding: WorkflowAgentNodeBinding, agent: Agent | None, version: AgentConfigSnapshot | None, + account_id: str | None = None, ) -> dict[str, Any]: locked = bool(agent and agent.scope == AgentScope.ROSTER) save_options = [ComposerSaveStrategy.NODE_JOB_ONLY.value] @@ -1801,6 +1865,19 @@ class AgentComposerService: ) else: save_options.append(ComposerSaveStrategy.SAVE_TO_ROSTER.value) + debug_conversation_id = cls._workflow_inline_debug_conversation_id( + tenant_id=binding.tenant_id, + binding=binding, + agent=agent, + account_id=account_id, + ) + debug_conversation_message_count = ( + AgentRosterService(db.session).count_agent_app_debug_conversation_messages( + conversation_id=debug_conversation_id + ) + if debug_conversation_id + else 0 + ) return { "variant": ComposerVariant.WORKFLOW.value, "agent": cls._serialize_agent(agent) if agent else None, @@ -1835,10 +1912,37 @@ class AgentComposerService: "backing_app_id": agent.backing_app_id if agent else None, "hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), "chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None, + "debug_conversation_id": debug_conversation_id, + "debug_conversation_has_messages": debug_conversation_message_count > 0, + "debug_conversation_message_count": debug_conversation_message_count, "workflow_id": binding.workflow_id, "node_id": binding.node_id, } + @staticmethod + def _workflow_inline_debug_conversation_id( + *, + tenant_id: str, + binding: WorkflowAgentNodeBinding, + agent: Agent | None, + account_id: str | None, + ) -> str | None: + if ( + not account_id + or not agent + or binding.binding_type != WorkflowAgentBindingType.INLINE_AGENT + or agent.scope != AgentScope.WORKFLOW_ONLY + ): + return None + + from services.agent.roster_service import AgentRosterService + + return AgentRosterService(db.session).get_or_create_agent_app_debug_conversation_id( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + ) + @classmethod def _serialize_agent(cls, agent: Agent) -> dict[str, Any]: return { diff --git a/api/services/agent/config_skill_normalize_service.py b/api/services/agent/config_skill_normalize_service.py new file mode 100644 index 00000000000..8c06dc2bcc8 --- /dev/null +++ b/api/services/agent/config_skill_normalize_service.py @@ -0,0 +1,67 @@ +"""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``. +""" + +from __future__ import annotations + +from core.tools.tool_file_manager import ToolFileManager +from models.agent_config_entities import AgentConfigSkillRefConfig, validate_config_skill_name +from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService + + +class ConfigSkillNormalizeService: + """Validate, normalize, and persist one config skill archive.""" + + def __init__( + self, + *, + package_service: SkillPackageService | None = None, + tool_file_manager: ToolFileManager | None = None, + ) -> None: + self._package = package_service or SkillPackageService() + self._tool_files = tool_file_manager or ToolFileManager() + + def normalize( + self, + *, + content: bytes, + filename: str, + requested_name: str | None, + tenant_id: str, + user_id: str, + ) -> tuple[AgentConfigSkillRefConfig, NormalizedSkillPackage]: + package = self._package.validate_and_normalize(content=content, filename=filename) + normalized_name = validate_config_skill_name(requested_name or package.manifest.name) + if package.manifest.name != normalized_name: + raise SkillPackageError( + "skill_name_mismatch", + "skill package name must match the requested config skill name", + status_code=400, + ) + + 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=f"{normalized_name}.zip", + ) + return ( + AgentConfigSkillRefConfig( + name=normalized_name, + description=package.manifest.description, + file_id=tool_file.id, + size=tool_file.size, + hash=package.manifest.hash, + mime_type=tool_file.mimetype, + ), + package, + ) + + +__all__ = ["ConfigSkillNormalizeService"] diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 520c36043bd..15d257f474a 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -33,6 +33,8 @@ from enum import StrEnum from models.agent_config_entities import ( AgentHumanContactConfig, AgentSoulConfig, + DeclaredOutputConfig, + DeclaredOutputType, WorkflowNodeJobConfig, WorkflowPreviousNodeOutputRef, ) @@ -50,6 +52,8 @@ class MentionKind(StrEnum): MENTION_PATTERN = re.compile( + r"(?:\[§(?P[^:§]+?):(?P[^:§]*?):(?Poutput)§\])" + r"|" r"(?:\[§(?Pskill|file|tool|cli_tool|knowledge|human|node_output|output):" r"(?P[^:§]+?)(?::(?P[^§]*?))?§\])" r"|(?:§(?Poutput):(?P[^:§]+?)(?::(?P[^§]*?))?§)" @@ -111,6 +115,8 @@ MentionResolver = Callable[[PromptMention], str | None] def _mention_groups(match: re.Match[str]) -> tuple[str, str, str | None]: + if match.group("reversed_output_kind"): + return MentionKind.OUTPUT.value, match.group("reversed_output_id"), match.group("reversed_output_label") kind = match.group("bracket_kind") or match.group("legacy_kind") ref_id = match.group("bracket_id") or match.group("legacy_id") label = match.group("bracket_label") or match.group("legacy_label") @@ -162,7 +168,7 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str: resolved = resolver(mention) if resolved is None or not resolved.strip(): return fallback - return resolved[:MAX_MENTION_LABEL_LENGTH] + return resolved return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt)) @@ -298,7 +304,7 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR case MentionKind.OUTPUT: for output in node_job.declared_outputs: if output.name == mention.ref_id: - return f"{output.name} ({output.type.value})" + return _format_output_mention(output) case MentionKind.HUMAN: return _resolve_human_contact(node_job.human_contacts, mention.ref_id) case _: @@ -308,6 +314,28 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR return _resolve +def _format_output_mention(output: DeclaredOutputConfig) -> str: + if output.type == DeclaredOutputType.FILE: + return ( + f"{output.name} (file output; create the file locally, run " + f"`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON " + f"as final_output.{output.name}; do not call final_output before upload succeeds, and do not use " + "the local path, filename, URL, or a synthesized dify-file-ref as the reference)" + ) + if ( + output.type == DeclaredOutputType.ARRAY + and output.array_item + and output.array_item.type == DeclaredOutputType.FILE + ): + return ( + f"{output.name} (array[file] output; upload each produced file with " + f"`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON objects " + f"as final_output.{output.name}; do not call final_output before all uploads succeed, and do not use " + "local paths, filenames, URLs, or synthesized dify-file-ref values as references)" + ) + return f"{output.name} ({output.type.value})" + + def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None: for contact in contacts: if ref_id in (contact.id, contact.contact_id, contact.human_id): diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index d18a1781892..a34fca67105 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -24,7 +24,7 @@ from models.agent import ( ) from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus, ConversationFromSource, ConversationStatus -from models.model import App, AppMode, AppModelConfig, Conversation, IconType +from models.model import App, AppMode, AppModelConfig, Conversation, IconType, Message from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -571,6 +571,35 @@ class AgentRosterService: self._session.commit() return conversation_id + def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None: + """Return the current editor's existing debug conversation without creating or repairing rows.""" + + return self._session.scalar( + select(Conversation.id) + .join(AgentDebugConversation, AgentDebugConversation.conversation_id == Conversation.id) + .where( + AgentDebugConversation.tenant_id == tenant_id, + AgentDebugConversation.agent_id == agent_id, + AgentDebugConversation.account_id == account_id, + AgentDebugConversation.app_id == Conversation.app_id, + Conversation.from_source == ConversationFromSource.CONSOLE, + Conversation.from_account_id == account_id, + Conversation.is_deleted.is_(False), + ) + ) + + def count_agent_app_debug_conversation_messages(self, *, conversation_id: str) -> int: + """Return the number of visible messages in an Agent App debug conversation.""" + + return ( + self._session.scalar( + select(func.count(Message.id)).where( + Message.conversation_id == conversation_id, + ) + ) + or 0 + ) + def refresh_agent_app_debug_conversation_id( self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True ) -> str: @@ -992,9 +1021,10 @@ class AgentRosterService: def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: """Return each Agent's stored normal-draft publish state. - The flag is maintained by write paths: normal shared draft writes mark it - dirty, while publish/version creation paths mark it clean. User-scoped - debug drafts intentionally do not affect this state. + The flag is maintained by write paths against the normal shared draft: + saves compare the draft content with the active snapshot, while publish + and version creation paths mark the new active snapshot clean. + User-scoped debug drafts intentionally do not affect this state. """ agents = [agent for agent in agents if agent.id] if not agents: diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index fb20c5c4dac..2c69d9135df 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -12,7 +12,6 @@ from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationE from models.agent import ( Agent, AgentConfigSnapshot, - AgentDriveFile, AgentScope, AgentStatus, WorkflowAgentBindingType, @@ -187,38 +186,22 @@ class WorkflowAgentPublishService: agent_soul: AgentSoulConfig, ) -> None: 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]] = {} + del session + configured_skill_names = {item.name for item in agent_soul.config_skills} + configured_file_names = {item.name for item in agent_soul.config_files} + missing_refs: list[str] = [] 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 - code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling" - wanted_keys[drive_key] = (code, mention.label or drive_key) - if not wanted_keys or not binding.agent_id: - return - - existing_keys = set( - session.scalars( - select(AgentDriveFile.key).where( - AgentDriveFile.tenant_id == binding.tenant_id, - AgentDriveFile.agent_id == binding.agent_id, - AgentDriveFile.key.in_(sorted(wanted_keys)), - ) - ).all() - ) - messages: list[str] = [] - for key, (code, display) in wanted_keys.items(): - if key in existing_keys: - continue - kind = "skill" if code == "skill_ref_dangling" else "file" - messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.") - if messages: + ref_name = mention.ref_id + if mention.kind == MentionKind.SKILL and ref_name not in configured_skill_names: + missing_refs.append(f"skill_ref_dangling: skill '{mention.label or ref_name}' is not configured.") + if mention.kind == MentionKind.FILE and ref_name not in configured_file_names: + missing_refs.append(f"file_ref_dangling: file '{mention.label or ref_name}' is not configured.") + if missing_refs: raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}" + f"Workflow Agent node {binding.node_id} has invalid Agent Soul config refs: {'; '.join(missing_refs)}" ) @classmethod diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py new file mode 100644 index 00000000000..af05acb9ef7 --- /dev/null +++ b/api/services/agent_config_service.py @@ -0,0 +1,1367 @@ +"""Agent Soul-backed config assets for skills, files, env, and note text. + +This service is the single API-side control plane for Agent config assets. It +resolves the requested config version (published snapshot, shared draft, or +per-user build draft), reads only from ``AgentSoulConfig.config_skills``, +``config_files``, ``env.variables``, and ``config_note``, and mutates only the +target Soul JSON. Source file refs are tenant-scoped rather than user-scoped: +config writes can be owned by a build-draft Account while the uploaded +``ToolFile`` came from an end-user execution context. It intentionally does not +manage object-storage lifecycle: removing or replacing a config asset drops the +Soul reference only. +""" + +from __future__ import annotations + +import io +import urllib.parse +import zipfile +from dataclasses import dataclass +from enum import StrEnum +from operator import itemgetter +from typing import Literal + +from dotenv import dotenv_values +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.exc import DataError, SQLAlchemyError +from sqlalchemy.orm import Session + +from core.app.file_access.controller import DatabaseFileAccessController +from core.db.session_factory import session_factory +from core.tools.tool_file_manager import ToolFileManager +from extensions.ext_storage import storage +from factories import file_factory +from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigSnapshot +from models.agent_config_entities import ( + AgentConfigFileRefConfig, + AgentConfigSkillRefConfig, + AgentEnvVariableConfig, + AgentSoulConfig, + validate_config_name, + validate_config_skill_name, +) +from models.model import UploadFile +from models.tools import ToolFile +from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService +from services.agent.skill_package_service import SkillPackageError +from services.agent_drive_service import DriveFileRef + + +class AgentConfigVersionKind(StrEnum): + SNAPSHOT = "snapshot" + DRAFT = "draft" + BUILD_DRAFT = "build_draft" + + +class AgentConfigMutationSurface(StrEnum): + AGENT_STUB = "agent_stub" + CONSOLE = "console" + + +class AgentConfigServiceError(Exception): + """Config operation failure mapped to HTTP status at controller boundaries.""" + + 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 ConfigPushFileItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + file_ref: DriveFileRef | None = None + + +class ConfigPushSkillItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + file_ref: DriveFileRef | None = None + + +class ConfigPushPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + files: list[ConfigPushFileItem] = Field(default_factory=list) + skills: list[ConfigPushSkillItem] = Field(default_factory=list) + env_text: str | None = None + note: str | None = None + + +@dataclass(slots=True) +class AgentConfigTarget: + agent_id: str + version_id: str + kind: AgentConfigVersionKind + writable: bool + version: AgentConfigSnapshot | AgentConfigDraft + agent_soul: AgentSoulConfig + + +@dataclass(frozen=True, slots=True) +class ConfigDownload: + filename: str + mime_type: str + payload: bytes + + +class AgentConfigService: + """Read and update Agent Soul-backed config assets for one version target.""" + + PREVIEW_MAX_BYTES = 64 * 1024 + + def __init__( + self, + *, + tool_file_manager: ToolFileManager | None = None, + skill_normalize_service: ConfigSkillNormalizeService | None = None, + ) -> None: + self._tool_files = tool_file_manager or ToolFileManager() + self._skill_normalizer = skill_normalize_service or ConfigSkillNormalizeService() + + def resolve_target( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None = None, + ) -> AgentConfigTarget: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + return AgentConfigTarget( + agent_id=target.agent_id, + version_id=target.version_id, + kind=target.kind, + writable=target.writable, + version=target.version, + agent_soul=target.agent_soul.model_copy(deep=True), + ) + + def manifest( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + return self._manifest_for_target(target) + + def list_skills( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + return { + "agent_id": target.agent_id, + "config_version": self._config_version_payload(target), + "items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills], + } + + def list_files( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + return { + "agent_id": target.agent_id, + "config_version": self._config_version_payload(target), + "items": [self._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files], + } + + def pull_skill( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> ConfigDownload: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=skill.file_id) + return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload) + + def download_skill_url( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> str: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + url = self._resolve_download_url(tenant_id=tenant_id, file_kind=skill.file_kind, file_id=skill.file_id) + if url is None: + raise AgentConfigServiceError("config_skill_not_found", "config skill payload is missing", status_code=404) + return url + + def inspect_skill( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=skill.file_id) + try: + archive_items, skill_md = self._inspect_skill_archive(archive_bytes) + except (OSError, ValueError, zipfile.BadZipFile) as exc: + raise AgentConfigServiceError( + "skill_archive_invalid", + "stored config skill archive is invalid", + status_code=500, + ) from exc + return { + **self._serialize_skill_item(skill), + "source": "config_skill_zip", + "files": archive_items, + "skill_md": skill_md, + "warnings": [], + } + + def preview_skill_file( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + path: str, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + member_path = self._normalize_archive_member_path(path) + payload = self._load_skill_archive_member( + tenant_id=tenant_id, + file_id=skill.file_id, + path=member_path, + ) + return self._preview_bytes(path=member_path, size=len(payload), payload=payload) + + def pull_skill_file( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + path: str, + user_id: str | None = None, + ) -> ConfigDownload: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + member_path = self._normalize_archive_member_path(path) + payload = self._load_skill_archive_member( + tenant_id=tenant_id, + file_id=skill.file_id, + path=member_path, + ) + return ConfigDownload( + filename=member_path.rsplit("/", 1)[-1], + mime_type="application/octet-stream", + payload=payload, + ) + + def resolve_skill_file_member_path( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + path: str, + user_id: str | None = None, + ) -> str: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + skill = self._require_skill(target.agent_soul, name=name) + member_path = self._normalize_archive_member_path(path) + self._load_skill_archive_member( + tenant_id=tenant_id, + file_id=skill.file_id, + path=member_path, + ) + return member_path + + def pull_file( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> ConfigDownload: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + file_ref = self._require_file(target.agent_soul, name=name) + payload, filename, mime_type = self._load_file_ref_bytes( + tenant_id=tenant_id, + file_kind=file_ref.file_kind, + file_id=file_ref.file_id, + ) + return ConfigDownload( + filename=filename or file_ref.name, mime_type=mime_type or "application/octet-stream", payload=payload + ) + + def download_file_url( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> str: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + file_ref = self._require_file(target.agent_soul, name=name) + url = self._resolve_download_url(tenant_id=tenant_id, file_kind=file_ref.file_kind, file_id=file_ref.file_id) + if url is None: + raise AgentConfigServiceError("config_file_not_found", "config file payload is missing", status_code=404) + return url + + def upload_skill( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + content: bytes, + filename: str, + ) -> dict[str, object]: + return self._upload_skill( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + content=content, + filename=filename, + surface=AgentConfigMutationSurface.AGENT_STUB, + ) + + def upload_skill_for_console( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + content: bytes, + filename: str, + ) -> dict[str, object]: + return self._upload_skill( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + content=content, + filename=filename, + surface=AgentConfigMutationSurface.CONSOLE, + ) + + def _upload_skill( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + content: bytes, + filename: str, + surface: AgentConfigMutationSurface, + ) -> dict[str, object]: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=surface) + skill_ref, _package = self._skill_normalizer.normalize( + content=content, + filename=filename, + requested_name=None, + tenant_id=tenant_id, + user_id=user_id, + ) + agent_soul = target.agent_soul.model_copy(deep=True) + existing = {item.name: item for item in agent_soul.config_skills} + order = [item.name for item in agent_soul.config_skills] + if skill_ref.name not in order: + order.append(skill_ref.name) + existing[skill_ref.name] = skill_ref + agent_soul.config_skills = [existing[name] for name in order if name in existing] + target.version.config_snapshot = agent_soul + session.commit() + target.agent_soul = agent_soul + return { + "skill": self._serialize_skill_item(skill_ref), + "config_version": self._config_version_payload(target), + } + + def push( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + payload: ConfigPushPayload, + ) -> dict[str, object]: + return self._push( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + payload=payload, + surface=AgentConfigMutationSurface.AGENT_STUB, + ) + + def push_for_console( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + payload: ConfigPushPayload, + ) -> dict[str, object]: + return self._push( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + payload=payload, + surface=AgentConfigMutationSurface.CONSOLE, + ) + + def push_file_for_console( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + upload_file_id: str, + ) -> dict[str, object]: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=AgentConfigMutationSurface.CONSOLE) + upload_file = self._require_console_upload_file_source(session, tenant_id=tenant_id, file_id=upload_file_id) + agent_soul = target.agent_soul.model_copy(deep=True) + agent_soul.config_files = self._upsert_upload_file( + current=agent_soul.config_files, + upload_file=upload_file, + ) + target.version.config_snapshot = agent_soul + session.commit() + target.agent_soul = agent_soul + file_ref = self._require_file(agent_soul, name=upload_file.name) + return { + "file": self._serialize_file_item(file_ref), + "config_version": self._config_version_payload(target), + } + + def _push( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + payload: ConfigPushPayload, + surface: AgentConfigMutationSurface, + ) -> dict[str, object]: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=surface) + agent_soul = target.agent_soul.model_copy(deep=True) + if payload.files: + agent_soul.config_files = self._apply_file_updates( + session, + tenant_id=tenant_id, + current=agent_soul.config_files, + updates=payload.files, + ) + if payload.skills: + agent_soul.config_skills = self._apply_skill_updates( + session, + tenant_id=tenant_id, + user_id=user_id, + current=agent_soul.config_skills, + updates=payload.skills, + ) + if payload.env_text is not None: + agent_soul.env.variables = self._apply_env_text(agent_soul.env.variables, payload.env_text) + if payload.note is not None: + agent_soul.config_note = payload.note + target.version.config_snapshot = agent_soul + session.commit() + target.agent_soul = agent_soul + return self._manifest_for_target(target) + + def preview_file( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + name: str, + user_id: str | None = None, + ) -> dict[str, object]: + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + file_ref = self._require_file(target.agent_soul, name=name) + payload, filename, _mime_type = self._load_file_ref_bytes( + tenant_id=tenant_id, + file_kind=file_ref.file_kind, + file_id=file_ref.file_id, + ) + return self._preview_bytes( + path=filename or file_ref.name, size=file_ref.size, payload=payload, field_name="name" + ) + + def update_env( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + env_text: str, + ) -> dict[str, object]: + return self._update_env( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + env_text=env_text, + surface=AgentConfigMutationSurface.AGENT_STUB, + ) + + def _update_env( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + env_text: str, + surface: AgentConfigMutationSurface, + ) -> dict[str, object]: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=surface) + agent_soul = target.agent_soul.model_copy(deep=True) + agent_soul.env.variables = self._apply_env_text(agent_soul.env.variables, env_text) + target.version.config_snapshot = agent_soul + session.commit() + return {"env_keys": self._env_keys(agent_soul)} + + def update_note( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + note: str, + ) -> dict[str, object]: + return self._update_note( + tenant_id=tenant_id, + agent_id=agent_id, + user_id=user_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + note=note, + surface=AgentConfigMutationSurface.AGENT_STUB, + ) + + def _update_note( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + note: str, + surface: AgentConfigMutationSurface, + ) -> dict[str, object]: + with session_factory.create_session() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=surface) + agent_soul = target.agent_soul.model_copy(deep=True) + agent_soul.config_note = note + target.version.config_snapshot = agent_soul + session.commit() + return {"note": agent_soul.config_note} + + def _resolve_target_in_session( + self, + session: Session, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None, + ) -> AgentConfigTarget: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + version: AgentConfigSnapshot | AgentConfigDraft | None + try: + match config_version_kind: + case AgentConfigVersionKind.SNAPSHOT: + version = session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == config_version_id, + ) + ) + writable = False + case AgentConfigVersionKind.DRAFT: + version = session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.id == config_version_id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), + ) + ) + writable = False + case AgentConfigVersionKind.BUILD_DRAFT: + if not user_id: + raise AgentConfigServiceError( + "missing_user_id", + "user_id is required for build draft config access", + status_code=400, + ) + version = session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.id == config_version_id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD, + AgentConfigDraft.account_id == user_id, + ) + ) + writable = True + case _: + raise AgentConfigServiceError("invalid_config_version_kind", "unsupported config version kind") + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentConfigServiceError( + "config_version_not_found", + "agent config version was not found", + status_code=404, + ) from exc + if version is None: + raise AgentConfigServiceError( + "config_version_not_found", + "agent config version was not found", + status_code=404, + ) + return AgentConfigTarget( + agent_id=agent_id, + version_id=version.id, + kind=config_version_kind, + writable=writable, + version=version, + agent_soul=AgentSoulConfig.model_validate(version.config_snapshot_dict), + ) + + @staticmethod + def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None: + try: + found = session.scalar(select(Agent.id).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentConfigServiceError( + "agent_not_found", "agent does not belong to this tenant", status_code=404 + ) from exc + if found is None: + raise AgentConfigServiceError("agent_not_found", "agent does not belong to this tenant", status_code=404) + + @staticmethod + def _require_writable(target: AgentConfigTarget, *, surface: AgentConfigMutationSurface) -> None: + if target.writable: + return + if surface == AgentConfigMutationSurface.CONSOLE and target.kind == AgentConfigVersionKind.DRAFT: + return + if surface == AgentConfigMutationSurface.CONSOLE: + raise AgentConfigServiceError( + "config_not_writable", + "config mutations are only allowed for editable drafts", + status_code=403, + ) + raise AgentConfigServiceError( + "config_not_writable", + "config push is only allowed for build drafts", + status_code=403, + ) + + def _apply_file_updates( + self, + session: Session, + *, + tenant_id: str, + current: list[AgentConfigFileRefConfig], + updates: list[ConfigPushFileItem], + ) -> list[AgentConfigFileRefConfig]: + by_name = {item.name: item for item in current} + order = [item.name for item in current] + for update in updates: + name = validate_config_name(update.name) + if update.file_ref is None: + by_name.pop(name, None) + continue + size, hash_value, mime_type = self._validate_source_ref( + session, + tenant_id=tenant_id, + file_ref=update.file_ref, + ) + if name not in order: + order.append(name) + by_name[name] = AgentConfigFileRefConfig( + name=name, + file_kind=update.file_ref.kind, + file_id=update.file_ref.id, + size=size, + hash=hash_value, + mime_type=mime_type, + ) + return [by_name[name] for name in order if name in by_name] + + @staticmethod + def _upsert_upload_file( + *, + current: list[AgentConfigFileRefConfig], + upload_file: UploadFile, + ) -> list[AgentConfigFileRefConfig]: + name = validate_config_name(upload_file.name) + by_name = {item.name: item for item in current} + order = [item.name for item in current] + if name not in order: + order.append(name) + by_name[name] = AgentConfigFileRefConfig( + name=name, + file_kind="upload_file", + file_id=upload_file.id, + size=upload_file.size, + hash=upload_file.hash, + mime_type=upload_file.mime_type, + ) + return [by_name[item_name] for item_name in order if item_name in by_name] + + def _apply_skill_updates( + self, + session: Session, + *, + tenant_id: str, + user_id: str, + current: list[AgentConfigSkillRefConfig], + updates: list[ConfigPushSkillItem], + ) -> list[AgentConfigSkillRefConfig]: + by_name = {item.name: item for item in current} + order = [item.name for item in current] + for update in updates: + name = validate_config_skill_name(update.name) + if update.file_ref is None: + by_name.pop(name, None) + continue + if update.file_ref.kind != "tool_file": + raise AgentConfigServiceError( + "invalid_skill_file_ref", + "config skills must be uploaded as tool files", + status_code=400, + ) + tool_file = self._require_tool_file_source( + session, + tenant_id=tenant_id, + file_id=update.file_ref.id, + ) + archive_bytes = storage.load_once(tool_file.file_key) + try: + skill_ref, _package = self._skill_normalizer.normalize( + content=archive_bytes, + filename=tool_file.name, + requested_name=name, + tenant_id=tenant_id, + user_id=user_id, + ) + except SkillPackageError as exc: + raise AgentConfigServiceError(exc.code, exc.message, status_code=exc.status_code) from exc + if name not in order: + order.append(name) + by_name[name] = skill_ref + return [by_name[name] for name in order if name in by_name] + + def _validate_source_ref( + self, + session: Session, + *, + tenant_id: str, + file_ref: DriveFileRef, + ) -> tuple[int | None, str | None, str | None]: + if file_ref.kind == "tool_file": + tool_file = self._require_tool_file_source( + session, + tenant_id=tenant_id, + file_id=file_ref.id, + ) + return tool_file.size, None, tool_file.mimetype + upload_file = self._require_upload_file_source(session, tenant_id=tenant_id, file_id=file_ref.id) + return upload_file.size, upload_file.hash, upload_file.mime_type + + @staticmethod + def _require_tool_file_source( + session: Session, + *, + tenant_id: str, + file_id: str, + ) -> ToolFile: + try: + tool_file = session.scalar( + select(ToolFile).where( + ToolFile.id == file_id, + ToolFile.tenant_id == tenant_id, + ) + ) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentConfigServiceError( + "source_not_found", "source tool file ref is invalid", status_code=404 + ) from exc + if tool_file is None: + raise AgentConfigServiceError( + "source_not_found", + "source ToolFile not found for this tenant", + status_code=404, + ) + return tool_file + + @staticmethod + def _require_console_upload_file_source(session: Session, *, tenant_id: str, file_id: str) -> UploadFile: + try: + upload_file = session.scalar( + select(UploadFile).where( + UploadFile.id == file_id, + UploadFile.tenant_id == tenant_id, + ) + ) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentConfigServiceError( + "upload_file_not_found", + "upload file not found in this workspace", + status_code=404, + ) from exc + if upload_file is None: + raise AgentConfigServiceError( + "upload_file_not_found", + "upload file not found in this workspace", + status_code=404, + ) + return upload_file + + @staticmethod + def _require_upload_file_source(session: Session, *, tenant_id: str, file_id: str) -> UploadFile: + try: + upload_file = session.scalar( + select(UploadFile).where( + UploadFile.id == file_id, + UploadFile.tenant_id == tenant_id, + ) + ) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentConfigServiceError( + "source_not_found", "source upload file ref is invalid", status_code=404 + ) from exc + if upload_file is None: + raise AgentConfigServiceError( + "source_not_found", + "source UploadFile not found for this tenant", + status_code=404, + ) + return upload_file + + @staticmethod + def _env_keys(agent_soul: AgentSoulConfig) -> list[str]: + keys = [item.key or item.name or item.env_name or item.variable for item in agent_soul.env.variables] + return [key for key in keys if key] + + @staticmethod + def _apply_env_text(current: list[AgentEnvVariableConfig], env_text: str) -> list[AgentEnvVariableConfig]: + updates = AgentConfigService._parse_env_text(env_text) + order: list[str] = [] + by_key: dict[str, AgentEnvVariableConfig] = {} + for item in current: + key = AgentConfigService._env_variable_key(item) + if key and key not in by_key: + by_key[key] = item + order.append(key) + for key, value in updates: + if value is None: + by_key.pop(key, None) + continue + if key not in order: + order.append(key) + by_key[key] = AgentEnvVariableConfig(key=key, name=key, value=value) + return [by_key[key] for key in order if key in by_key] + + @staticmethod + def _env_variable_key(item: AgentEnvVariableConfig) -> str | None: + for candidate in (item.key, item.name, item.env_name, item.variable): + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return None + + @staticmethod + def _parse_env_text(env_text: str) -> list[tuple[str, str | None]]: + updates: list[tuple[str, str | None]] = [] + for raw_line in env_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line.removeprefix("export ").strip() + if "=" not in line: + raise AgentConfigServiceError("invalid_env_file", "env line must contain '='", status_code=400) + key, raw_value = line.split("=", 1) + key = key.strip() + AgentConfigService._validate_env_key(key) + if raw_value == "": + updates.append((key, None)) + continue + parsed = dotenv_values(stream=io.StringIO(f"{key}={raw_value}\n")) + value = parsed.get(key) + if value is None: + raise AgentConfigServiceError("invalid_env_file", f"env value for {key} is invalid", status_code=400) + updates.append((key, value)) + return updates + + @staticmethod + def _validate_env_key(key: str) -> None: + normalized = key.strip() + if not normalized: + raise AgentConfigServiceError("invalid_env_file", "env key must not be blank", status_code=400) + if not (normalized[0].isalpha() or normalized[0] == "_"): + raise AgentConfigServiceError("invalid_env_file", f"invalid env key {normalized!r}", status_code=400) + if any(not (ch.isalnum() or ch == "_") for ch in normalized[1:]): + raise AgentConfigServiceError("invalid_env_file", f"invalid env key {normalized!r}", status_code=400) + + @staticmethod + def _manifest_for_target(target: AgentConfigTarget) -> dict[str, object]: + return { + "agent_id": target.agent_id, + "config_version": AgentConfigService._config_version_payload(target), + "skills": { + "items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills] + }, + "files": { + "items": [ + AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files + ] + }, + "env_keys": AgentConfigService._env_keys(target.agent_soul), + "note": target.agent_soul.config_note, + } + + @staticmethod + def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]: + return { + "id": target.version_id, + "kind": target.kind.value, + "writable": AgentConfigService._is_console_writable(target), + } + + @staticmethod + def _is_console_writable(target: AgentConfigTarget) -> bool: + return target.writable or target.kind == AgentConfigVersionKind.DRAFT + + @staticmethod + def _serialize_skill_item(skill: AgentConfigSkillRefConfig) -> dict[str, object]: + return { + "id": skill.name, + "name": skill.name, + "file_id": skill.file_id, + "description": skill.description, + "size": skill.size, + "hash": skill.hash, + "mime_type": skill.mime_type, + } + + @staticmethod + def _serialize_file_item(file_ref: AgentConfigFileRefConfig) -> dict[str, object]: + return { + "id": file_ref.name, + "name": file_ref.name, + "file_id": file_ref.file_id, + "size": file_ref.size, + "hash": file_ref.hash, + "mime_type": file_ref.mime_type, + } + + @classmethod + def _preview_bytes( + cls, + *, + path: str, + size: int | None, + payload: bytes, + field_name: Literal["name", "path"] = "path", + ) -> dict[str, object]: + truncated = len(payload) > cls.PREVIEW_MAX_BYTES + sample = payload[: cls.PREVIEW_MAX_BYTES] + if b"\x00" in sample: + return {field_name: path, "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 {field_name: path, "size": size, "truncated": truncated, "binary": True, "text": None} + else: + return {field_name: path, "size": size, "truncated": truncated, "binary": True, "text": None} + return {field_name: path, "size": size, "truncated": truncated, "binary": False, "text": text} + + @classmethod + def _normalize_archive_member_path(cls, path: str) -> str: + normalized = path.replace("\\", "/").strip("/") + if not normalized: + raise AgentConfigServiceError( + "config_skill_file_invalid", "config skill file path is invalid", status_code=400 + ) + if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized): + raise AgentConfigServiceError( + "config_skill_file_invalid", "config skill file path is invalid", status_code=400 + ) + segments = normalized.split("/") + if any(not segment or segment in {".", ".."} for segment in segments): + raise AgentConfigServiceError( + "config_skill_file_invalid", "config skill file path is invalid", status_code=400 + ) + return "/".join(segments) + + @classmethod + def _inspect_skill_archive(cls, archive_bytes: bytes) -> tuple[list[dict[str, object]], dict[str, object]]: + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + members: list[dict[str, object]] = [] + directories: set[str] = set() + skill_md_preview: dict[str, object] | None = None + for info in archive.infolist(): + normalized_path = cls._normalize_archive_member_path(info.filename) + segments = normalized_path.split("/") + directories.update("/".join(segments[:index]) for index in range(1, len(segments))) + if info.is_dir(): + directories.add(normalized_path) + continue + members.append( + { + "path": normalized_path, + "name": segments[-1], + "type": "file", + "previewable": True, + "downloadable": True, + } + ) + if normalized_path == "SKILL.md": + payload = archive.read(info) + skill_md_preview = cls._preview_bytes(path="SKILL.md", size=info.file_size, payload=payload) + if skill_md_preview is None or skill_md_preview["binary"]: + raise ValueError("skill archive is missing a text SKILL.md") + + directory_items: list[dict[str, object]] = [ + { + "path": path, + "name": path.rsplit("/", 1)[-1], + "type": "directory", + "previewable": False, + "downloadable": False, + } + for path in sorted(directories) + ] + files = sorted([*directory_items, *members], key=itemgetter("path", "type")) + return files, skill_md_preview + + def _load_skill_archive_member(self, *, tenant_id: str, file_id: str, path: str) -> bytes: + archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id) + normalized_path = self._normalize_archive_member_path(path) + try: + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + for info in archive.infolist(): + candidate_path = self._normalize_archive_member_path(info.filename) + if candidate_path != normalized_path: + continue + if info.is_dir(): + raise AgentConfigServiceError( + "config_skill_file_not_found", + "config skill file not found", + status_code=404, + ) + return archive.read(info) + except zipfile.BadZipFile as exc: + raise AgentConfigServiceError( + "skill_archive_invalid", + "stored config skill archive is invalid", + status_code=500, + ) from exc + raise AgentConfigServiceError("config_skill_file_not_found", "config skill file not found", status_code=404) + + @staticmethod + def _require_skill(agent_soul: AgentSoulConfig, *, name: str) -> AgentConfigSkillRefConfig: + normalized = validate_config_skill_name(name) + for item in agent_soul.config_skills: + if item.name == normalized: + return item + raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) + + @staticmethod + def _require_file(agent_soul: AgentSoulConfig, *, name: str) -> AgentConfigFileRefConfig: + normalized = validate_config_name(name) + for item in agent_soul.config_files: + if item.name == normalized: + return item + raise AgentConfigServiceError("config_file_not_found", "config file not found", status_code=404) + + def _load_tool_file_bytes(self, *, tenant_id: str, file_id: str) -> tuple[bytes, str | None]: + with session_factory.create_session() as session: + tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id)) + if tool_file is None: + raise AgentConfigServiceError("config_skill_not_found", "config skill payload is missing", status_code=404) + return storage.load_once(tool_file.file_key), tool_file.mimetype + + def _load_file_ref_bytes( + self, + *, + tenant_id: str, + file_kind: Literal["upload_file", "tool_file"], + file_id: str, + ) -> tuple[bytes, str | None, str | None]: + with session_factory.create_session() as session: + if file_kind == "tool_file": + tool_file = session.scalar( + select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id) + ) + if tool_file is None: + raise AgentConfigServiceError( + "config_file_not_found", "config file payload is missing", status_code=404 + ) + return storage.load_once(tool_file.file_key), tool_file.name, tool_file.mimetype + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) + ) + if upload_file is None: + raise AgentConfigServiceError("config_file_not_found", "config file payload is missing", status_code=404) + return storage.load_once(upload_file.key), upload_file.name, upload_file.mime_type + + @staticmethod + def _resolve_download_url( + *, tenant_id: str, file_kind: Literal["upload_file", "tool_file"], file_id: str + ) -> str | None: + controller = DatabaseFileAccessController() + from core.app.workflow.file_runtime import DifyWorkflowFileRuntime + + runtime = DifyWorkflowFileRuntime(file_access_controller=controller) + try: + if file_kind == "upload_file": + return runtime.resolve_upload_file_url( + upload_file_id=file_id, + for_external=True, + as_attachment=True, + ) + file = file_factory.build_from_mapping( + mapping={"transfer_method": "tool_file", "tool_file_id": file_id}, + tenant_id=tenant_id, + access_controller=controller, + ) + url = runtime.resolve_file_url(file=file, for_external=True) + if not url: + return None + 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))) + except ValueError: + return None + + +__all__ = [ + "AgentConfigMutationSurface", + "AgentConfigService", + "AgentConfigServiceError", + "AgentConfigTarget", + "AgentConfigVersionKind", + "ConfigDownload", + "ConfigPushFileItem", + "ConfigPushPayload", + "ConfigPushSkillItem", +] diff --git a/api/services/agent_tool_inner_service.py b/api/services/agent_tool_inner_service.py new file mode 100644 index 00000000000..aa3fbe1c701 --- /dev/null +++ b/api/services/agent_tool_inner_service.py @@ -0,0 +1,150 @@ +"""Service layer for Agent-backend core tool invocations. + +The `dify.core.tools` layer executes inside the API service boundary so Dify +Agent can expose API-owned tool providers (`plugin`, `builtin`, `api`, +`workflow`, and `mcp`) without learning their storage, credential, or runtime +internals. Production workflow and agent-app request builders still keep +existing plugin tool configs on the direct `dify.plugin.tools` route by +default; this service is the lower-level API execution path used when a caller +explicitly submits a `dify.core.tools` declaration. The service validates +tenant/app ownership, reuses `ToolManager.get_agent_tool_runtime(...)` to build +the runtime with `ToolInvokeFrom.AGENT`, invokes through +`ToolEngine.generic_invoke(...)`, and formats observations through the shared +public `ToolEngine.tool_response_to_str` helper so Agent and workflow paths +stay consistent. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from core.agent.entities import AgentToolEntity +from core.app.entities.app_invoke_entities import InvokeFrom +from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.errors import ( + ToolInvokeError, + ToolNotFoundError, + ToolNotSupportedError, + ToolParameterValidationError, + ToolProviderCredentialValidationError, + ToolProviderNotFoundError, +) +from core.tools.tool_engine import ToolEngine +from core.tools.tool_manager import ToolManager +from core.tools.utils.message_transformer import ToolFileMessageTransformer +from models.model import App +from services.entities.agent_tool_inner import AgentToolInvokeRequest, AgentToolInvokeResponse +from services.errors.agent_tool_inner import AgentToolInnerServiceError + + +class AgentToolInnerService: + """Invoke one API-owned Agent tool declaration, including explicit plugin-via-core calls.""" + + def invoke(self, request: AgentToolInvokeRequest, *, session: Session) -> AgentToolInvokeResponse: + app = session.get(App, request.caller.app_id) + if app is None: + raise AgentToolInnerServiceError( + error_code="app_not_found", + description="App not found.", + status_code=404, + ) + if app.tenant_id != request.caller.tenant_id: + raise AgentToolInnerServiceError( + error_code="app_tenant_mismatch", + description="App does not belong to the caller tenant.", + status_code=403, + ) + + agent_tool = AgentToolEntity( + provider_type=ToolProviderType.value_of(request.tool.provider_type), + provider_id=request.tool.provider_id, + tool_name=request.tool.tool_name, + tool_parameters=dict(request.tool.runtime_parameters), + credential_id=request.tool.credential_id, + ) + try: + tool_runtime = ToolManager.get_agent_tool_runtime( + tenant_id=request.caller.tenant_id, + app_id=request.caller.app_id, + agent_tool=agent_tool, + user_id=request.caller.user_id, + invoke_from=InvokeFrom.value_of(request.caller.invoke_from), + variable_pool=None, + allow_file_parameters=True, + use_default_for_missing_form_parameters=True, + ) + messages = ToolEngine.generic_invoke( + tool=tool_runtime, + tool_parameters=dict(request.tool.tool_parameters), + user_id=request.caller.user_id, + workflow_tool_callback=DifyWorkflowCallbackHandler(), + workflow_call_depth=0, + conversation_id=request.caller.conversation_id, + app_id=request.caller.app_id, + ) + transformed_messages = list( + ToolFileMessageTransformer.transform_tool_invoke_messages( + messages=messages, + user_id=request.caller.user_id, + tenant_id=request.caller.tenant_id, + conversation_id=request.caller.conversation_id, + ) + ) + except ToolProviderNotFoundError as exc: + raise AgentToolInnerServiceError( + error_code="agent_tool_declaration_not_found", + description=str(exc), + status_code=404, + ) from exc + except ToolProviderCredentialValidationError as exc: + raise AgentToolInnerServiceError( + error_code="agent_tool_credential_invalid", + description=str(exc), + status_code=422, + ) from exc + except ToolParameterValidationError as exc: + raise AgentToolInnerServiceError( + error_code="tool_parameters_invalid", + description=str(exc), + status_code=422, + ) from exc + except (ToolInvokeError, ToolNotFoundError, ToolNotSupportedError) as exc: + raise AgentToolInnerServiceError( + error_code="agent_tool_invoke_failed", + description=str(exc), + status_code=422, + ) from exc + except ValueError as exc: + raise _map_value_error(exc) from exc + except Exception as exc: + raise AgentToolInnerServiceError( + error_code="agent_tool_invoke_unexpected_error", + description=str(exc), + status_code=500, + ) from exc + + return AgentToolInvokeResponse( + messages=[message.model_dump(mode="json") for message in transformed_messages], + observation=ToolEngine.tool_response_to_str(transformed_messages), + metadata={ + "provider_type": request.tool.provider_type, + "provider_id": request.tool.provider_id, + "tool_name": request.tool.tool_name, + }, + ) + + +def _map_value_error(error: ValueError) -> AgentToolInnerServiceError: + description = str(error) + if description == "app not found": + return AgentToolInnerServiceError( + error_code="app_not_found", + description="App not found.", + status_code=404, + ) + return AgentToolInnerServiceError( + error_code="agent_tool_invoke_failed", + description=description, + status_code=422, + ) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 22fc2da322b..79a46ab52c9 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -38,6 +38,35 @@ if TYPE_CHECKING: class AppGenerateService: + @classmethod + @trace_span(AppGenerateHandler) + def generate_stateless_agent_app( + cls, + *, + app_model: App, + user: Account | EndUser, + args: Mapping[str, Any], + invoke_from: InvokeFrom, + ): + """Run build-chat finalization as a blocking, non-SSE Agent App action. + + This is the service entry point for the Agent build-chat finalize flow. + It applies the same tracing, quota, and rate-limit guardrails as normal + app generation, but invokes the Agent App generator in stateless mode: + the call waits synchronously for Agent backend completion, triggers only + the backend side effect, and does not create Dify chat/message records. + """ + return cls._run_with_guardrails( + app_model=app_model, + streaming=False, + action=lambda _rate_limit, _request_id: AgentAppGenerator().generate_stateless( + app_model=app_model, + user=user, + args=args, + invoke_from=invoke_from, + ), + ) + @staticmethod def _build_streaming_task_on_subscribe(start_task: Callable[[], None]) -> Callable[[], None]: """ @@ -105,6 +134,29 @@ class AppGenerateService: :param streaming: streaming :return: """ + return cls._run_with_guardrails( + app_model=app_model, + streaming=streaming, + action=lambda rate_limit, request_id: cls._dispatch_generate( + app_model=app_model, + user=user, + args=args, + invoke_from=invoke_from, + streaming=streaming, + root_node_id=root_node_id, + rate_limit=rate_limit, + request_id=request_id, + ), + ) + + @classmethod + def _run_with_guardrails( + cls, + *, + app_model: App, + streaming: bool, + action: Callable[[RateLimit, str], Any], + ): quota_charge = unlimited() if dify_config.BILLING_ENABLED: try: @@ -119,166 +171,182 @@ class AppGenerateService: try: request_id = rate_limit.enter(request_id) quota_charge.commit() - effective_mode = ( - AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode - ) - match effective_mode: - case AppMode.COMPLETION: - return rate_limit.generate( - CompletionAppGenerator.convert_to_event_stream( - CompletionAppGenerator().generate( - app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming - ), - ), - request_id=request_id, - ) - case AppMode.AGENT_CHAT: - return rate_limit.generate( - AgentChatAppGenerator.convert_to_event_stream( - AgentChatAppGenerator().generate( - app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming - ), - ), - request_id, - ) - case AppMode.AGENT: - return rate_limit.generate( - AgentAppGenerator.convert_to_event_stream( - AgentAppGenerator().generate( - app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming - ), - ), - request_id, - ) - case AppMode.CHAT: - return rate_limit.generate( - ChatAppGenerator.convert_to_event_stream( - ChatAppGenerator().generate( - app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming - ), - ), - request_id=request_id, - ) - case AppMode.ADVANCED_CHAT: - workflow_id = args.get("workflow_id") - workflow = cls._get_workflow(app_model, invoke_from, workflow_id) - - if streaming: - # Streaming mode: subscribe to SSE and enqueue the execution on first subscriber - with rate_limit_context(rate_limit, request_id): - payload = AppExecutionParams.new( - app_model=app_model, - workflow=workflow, - user=user, - args=args, - invoke_from=invoke_from, - streaming=True, - call_depth=0, - workflow_run_id=str(uuid.uuid4()), - ) - payload_json = payload.model_dump_json() - - def on_subscribe(): - workflow_based_app_execution_task.delay(payload_json) - - on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) - generator = AdvancedChatAppGenerator() - return rate_limit.generate( - generator.convert_to_event_stream( - generator.retrieve_events( - AppMode.ADVANCED_CHAT, - payload.workflow_run_id, - on_subscribe=on_subscribe, - ), - ), - request_id=request_id, - ) - else: - # Blocking mode: run synchronously and return JSON instead of SSE - # Keep behaviour consistent with WORKFLOW blocking branch. - pause_config = PauseStateLayerConfig( - session_factory=session_factory.get_session_maker(), - state_owner_user_id=workflow.created_by, - ) - advanced_generator = AdvancedChatAppGenerator() - return rate_limit.generate( - advanced_generator.convert_to_event_stream( - advanced_generator.generate( - app_model=app_model, - workflow=workflow, - user=user, - args=args, - invoke_from=invoke_from, - workflow_run_id=str(uuid.uuid4()), - streaming=False, - pause_state_config=pause_config, - ) - ), - request_id=request_id, - ) - case AppMode.WORKFLOW: - workflow_id = args.get("workflow_id") - workflow = cls._get_workflow(app_model, invoke_from, workflow_id) - if streaming: - with rate_limit_context(rate_limit, request_id): - payload = AppExecutionParams.new( - app_model=app_model, - workflow=workflow, - user=user, - args=args, - invoke_from=invoke_from, - streaming=True, - call_depth=0, - root_node_id=root_node_id, - workflow_run_id=str(uuid.uuid4()), - ) - payload_json = payload.model_dump_json() - - def on_subscribe(): - workflow_based_app_execution_task.delay(payload_json) - - on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) - return rate_limit.generate( - WorkflowAppGenerator.convert_to_event_stream( - MessageBasedAppGenerator.retrieve_events( - AppMode.WORKFLOW, - payload.workflow_run_id, - on_subscribe=on_subscribe, - ), - ), - request_id, - ) - - pause_config = PauseStateLayerConfig( - session_factory=session_factory.get_session_maker(), - state_owner_user_id=workflow.created_by, - ) - return rate_limit.generate( - WorkflowAppGenerator.convert_to_event_stream( - WorkflowAppGenerator().generate( - app_model=app_model, - workflow=workflow, - user=user, - args=args, - invoke_from=invoke_from, - streaming=False, - root_node_id=root_node_id, - call_depth=0, - pause_state_config=pause_config, - ), - ), - request_id, - ) - case _: - raise ValueError(f"Invalid app mode {app_model.mode}") + return action(rate_limit, request_id) except Exception: quota_charge.refund() - rate_limit.exit(request_id) + if streaming: + rate_limit.exit(request_id) raise finally: if not streaming: rate_limit.exit(request_id) + @classmethod + def _dispatch_generate( + cls, + *, + app_model: App, + user: Account | EndUser, + args: Mapping[str, Any], + invoke_from: InvokeFrom, + streaming: bool, + root_node_id: str | None, + rate_limit: RateLimit, + request_id: str, + ): + effective_mode = ( + AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode + ) + match effective_mode: + case AppMode.COMPLETION: + return rate_limit.generate( + CompletionAppGenerator.convert_to_event_stream( + CompletionAppGenerator().generate( + app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming + ), + ), + request_id=request_id, + ) + case AppMode.AGENT_CHAT: + return rate_limit.generate( + AgentChatAppGenerator.convert_to_event_stream( + AgentChatAppGenerator().generate( + app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming + ), + ), + request_id, + ) + case AppMode.AGENT: + return rate_limit.generate( + AgentAppGenerator.convert_to_event_stream( + AgentAppGenerator().generate( + app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming + ), + ), + request_id, + ) + case AppMode.CHAT: + return rate_limit.generate( + ChatAppGenerator.convert_to_event_stream( + ChatAppGenerator().generate( + app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming + ), + ), + request_id=request_id, + ) + case AppMode.ADVANCED_CHAT: + workflow_id = args.get("workflow_id") + workflow = cls._get_workflow(app_model, invoke_from, workflow_id) + + if streaming: + # Streaming mode: subscribe to SSE and enqueue the execution on first subscriber + with rate_limit_context(rate_limit, request_id): + payload = AppExecutionParams.new( + app_model=app_model, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + streaming=True, + call_depth=0, + workflow_run_id=str(uuid.uuid4()), + ) + payload_json = payload.model_dump_json() + + def on_subscribe(): + workflow_based_app_execution_task.delay(payload_json) + + on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) + generator = AdvancedChatAppGenerator() + return rate_limit.generate( + generator.convert_to_event_stream( + generator.retrieve_events( + AppMode.ADVANCED_CHAT, + payload.workflow_run_id, + on_subscribe=on_subscribe, + ), + ), + request_id=request_id, + ) + + # Blocking mode: run synchronously and return JSON instead of SSE + # Keep behaviour consistent with WORKFLOW blocking branch. + pause_config = PauseStateLayerConfig( + session_factory=session_factory.get_session_maker(), + state_owner_user_id=workflow.created_by, + ) + advanced_generator = AdvancedChatAppGenerator() + return rate_limit.generate( + advanced_generator.convert_to_event_stream( + advanced_generator.generate( + app_model=app_model, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + workflow_run_id=str(uuid.uuid4()), + streaming=False, + pause_state_config=pause_config, + ) + ), + request_id=request_id, + ) + case AppMode.WORKFLOW: + workflow_id = args.get("workflow_id") + workflow = cls._get_workflow(app_model, invoke_from, workflow_id) + if streaming: + with rate_limit_context(rate_limit, request_id): + payload = AppExecutionParams.new( + app_model=app_model, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + streaming=True, + call_depth=0, + root_node_id=root_node_id, + workflow_run_id=str(uuid.uuid4()), + ) + payload_json = payload.model_dump_json() + + def on_subscribe(): + workflow_based_app_execution_task.delay(payload_json) + + on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) + return rate_limit.generate( + WorkflowAppGenerator.convert_to_event_stream( + MessageBasedAppGenerator.retrieve_events( + AppMode.WORKFLOW, + payload.workflow_run_id, + on_subscribe=on_subscribe, + ), + ), + request_id, + ) + + pause_config = PauseStateLayerConfig( + session_factory=session_factory.get_session_maker(), + state_owner_user_id=workflow.created_by, + ) + return rate_limit.generate( + WorkflowAppGenerator.convert_to_event_stream( + WorkflowAppGenerator().generate( + app_model=app_model, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + streaming=False, + root_node_id=root_node_id, + call_depth=0, + pause_state_config=pause_config, + ), + ), + request_id, + ) + case _: + raise ValueError(f"Invalid app mode {app_model.mode}") + @staticmethod def _get_max_active_requests(app: App) -> int: """ diff --git a/api/services/entities/agent_tool_inner.py b/api/services/entities/agent_tool_inner.py new file mode 100644 index 00000000000..85c56d68555 --- /dev/null +++ b/api/services/entities/agent_tool_inner.py @@ -0,0 +1,59 @@ +"""Request/response DTOs for the Agent tool inner invoke API.""" + +from __future__ import annotations + +from typing import ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +type AgentToolProviderType = Literal["plugin", "builtin", "api", "workflow", "mcp"] + + +class AgentToolInvokeCaller(BaseModel): + tenant_id: str + user_id: str + user_from: str + app_id: str + invoke_from: str + conversation_id: str | None = None + workflow_id: str | None = None + workflow_run_id: str | None = None + node_id: str | None = None + node_execution_id: str | None = None + agent_id: str | None = None + agent_config_version_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentToolInvokeTarget(BaseModel): + """One tool invocation payload. + + `runtime_parameters` are API-prepared hidden/form/runtime values forwarded + into `ToolManager` runtime construction. `tool_parameters` are the live + LLM/user invocation arguments supplied at call time. + """ + + provider_type: AgentToolProviderType + provider_id: str + tool_name: str + credential_id: str | None = None + tool_parameters: dict[str, JsonValue] = Field(default_factory=dict) + runtime_parameters: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentToolInvokeRequest(BaseModel): + caller: AgentToolInvokeCaller + tool: AgentToolInvokeTarget + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentToolInvokeResponse(BaseModel): + messages: list[dict[str, JsonValue]] = Field(default_factory=list) + observation: str + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/api/services/errors/agent_tool_inner.py b/api/services/errors/agent_tool_inner.py new file mode 100644 index 00000000000..c1c9d1fbef8 --- /dev/null +++ b/api/services/errors/agent_tool_inner.py @@ -0,0 +1,13 @@ +"""Domain errors for the Agent tool inner invoke boundary.""" + + +class AgentToolInnerServiceError(ValueError): + error_code: str + description: str + status_code: int + + def __init__(self, *, error_code: str, description: str, status_code: int) -> None: + self.error_code = error_code + self.description = description + self.status_code = status_code + super().__init__(description) diff --git a/api/tasks/app_generate/resume_agent_app_task.py b/api/tasks/app_generate/resume_agent_app_task.py index 118a476bc8d..88dfbaa04b3 100644 --- a/api/tasks/app_generate/resume_agent_app_task.py +++ b/api/tasks/app_generate/resume_agent_app_task.py @@ -51,7 +51,7 @@ def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None: app_model=app_model, user=user, conversation_id=conversation_id, - invoke_from=InvokeFrom.WEB_APP, + invoke_from=_resolve_invoke_from(conversation), ) except Exception: logger.exception("Agent App resume failed for conversation %s form %s", conversation_id, form_id) @@ -68,3 +68,9 @@ def _resolve_conversation_user(*, app_model: App, conversation: Conversation) -> if conversation.from_end_user_id: return db.session.get(EndUser, conversation.from_end_user_id) return None + + +def _resolve_invoke_from(conversation: Conversation) -> InvokeFrom: + if conversation.invoke_from is None: + return InvokeFrom.WEB_APP + return InvokeFrom.value_of(conversation.invoke_from.value) 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 274ab737328..f3aac73aac7 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 @@ -7,6 +7,11 @@ from agenton.layers import ExitIntent from agenton.layers.base import LifecycleState from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID +from dify_agent.layers.dify_core_tools import ( + DIFY_CORE_TOOLS_LAYER_TYPE_ID, + DifyCoreToolConfig, + DifyCoreToolsLayerConfig, +) from dify_agent.layers.dify_plugin import ( DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, @@ -29,6 +34,7 @@ from pydantic import ValidationError from clients.agent_backend import ( AGENT_SOUL_PROMPT_LAYER_ID, + DIFY_CORE_TOOLS_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, @@ -160,6 +166,30 @@ def test_request_builder_adds_dify_plugin_tools_layer_when_configured(): assert tools_config.tools[0].tool_name == "current_time" +def test_request_builder_adds_dify_core_tools_layer_when_configured(): + run_input = _run_input() + run_input.core_tools = DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + runtime_parameters={"language": "en"}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ) + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert layers[DIFY_CORE_TOOLS_LAYER_ID].type == DIFY_CORE_TOOLS_LAYER_TYPE_ID + assert layers[DIFY_CORE_TOOLS_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + + def test_request_builder_adds_knowledge_layer_when_configured(): run_input = _run_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 847f60c2570..e4f347ca61e 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 @@ -44,7 +44,12 @@ from controllers.console.agent.roster import ( ) from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller -from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi +from controllers.console.app.completion import ( + AgentBuildChatFinalizeApi, + AgentChatMessageApi, + AgentChatMessageStopApi, +) +from controllers.console.app.error import CompletionRequestError from controllers.console.app.message import ( AgentChatMessageListApi, AgentMessageApi, @@ -296,6 +301,11 @@ def test_agent_app_list_and_create_use_agent_route( "load_or_create_agent_app_debug_conversation_ids_by_agent_id", lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "count_agent_app_debug_conversation_messages", + lambda _self, **kwargs: 0, + ) monkeypatch.setattr( roster_controller.AgentRosterService, "get_or_create_agent_app_debug_conversation_id", @@ -407,6 +417,11 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( "get_or_create_agent_app_debug_conversation_id", lambda _self, **kwargs: "debug-conversation-detail", ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "count_agent_app_debug_conversation_messages", + lambda _self, **kwargs: 2, + ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -431,6 +446,8 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert detail["id"] == agent_id assert detail["app_id"] == "app-1" assert detail["debug_conversation_id"] == "debug-conversation-detail" + assert detail["debug_conversation_has_messages"] is True + assert detail["debug_conversation_message_count"] == 2 assert detail["role"] == "Resolved role" assert detail["active_config_is_published"] is False assert "bound_agent_id" not in detail @@ -445,6 +462,8 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert updated["id"] == agent_id assert updated["app_id"] == "app-1" assert updated["debug_conversation_id"] == "debug-conversation-detail" + assert updated["debug_conversation_has_messages"] is True + assert updated["debug_conversation_message_count"] == 2 assert updated["role"] == "Resolved role" assert updated["active_config_is_published"] is False assert "bound_agent_id" not in updated @@ -529,7 +548,11 @@ def test_agent_debug_conversation_refresh_uses_current_user( agent_id, ) - assert response == {"debug_conversation_id": "new-debug-conversation-id"} + assert response == { + "debug_conversation_id": "new-debug-conversation-id", + "debug_conversation_has_messages": False, + "debug_conversation_message_count": 0, + } assert captured == { "tenant_id": "tenant-1", "agent_id": agent_id, @@ -1141,9 +1164,10 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( with app.test_request_context("?snapshot_id=preview-version"): workflow_state = unwrap(WorkflowAgentComposerApi.get)( - WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1" + WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1" ) assert workflow_state["node_id"] == "node-1" + assert captured_load["account_id"] == account_id assert captured_load["snapshot_id"] == "preview-version" with app.test_request_context(json=payload): saved_state = unwrap(WorkflowAgentComposerApi.put)( @@ -1341,6 +1365,132 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"} +def test_agent_build_chat_finalize_route_resolves_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") + captured: dict[str, object] = {} + + def resolve_agent_app_model(**kwargs: object) -> object: + captured["resolve"] = kwargs + return app_model + + def create_finalization_message(**kwargs: object) -> dict[str, object]: + captured["finalize"] = kwargs + return {"result": "generated"} + + monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model) + monkeypatch.setattr(completion_controller, "_create_build_chat_finalization_message", create_finalization_message) + + with app.test_request_context(): + assert unwrap(AgentBuildChatFinalizeApi.post)( + AgentBuildChatFinalizeApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id + ) == {"result": "generated"} + + assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id} + finalize_call = cast(dict[str, object], captured["finalize"]) + assert finalize_call["app_model"] is app_model + assert finalize_call["current_tenant_id"] == "tenant-1" + assert finalize_call["agent_id"] == agent_id + assert cast(SimpleNamespace, finalize_call["current_user"]).id == account_id + + +def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") + captured: dict[str, object] = {} + + def resolve_debug_conversation(**kwargs: object) -> str: + captured["resolve_debug_conversation"] = kwargs + return "debug-conversation-1" + + def generate(**kwargs: object) -> object: + captured["generate"] = kwargs + return iter( + [ + "event: ping\n\n", + 'data: {"event":"message","answer":"working"}\n\n', + 'data: {"event":"message_end"}\n\n', + ] + ) + + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + resolve_debug_conversation, + ) + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + + with app.test_request_context(headers={"X-Trace-Id": "trace-1"}): + result = completion_controller._create_build_chat_finalization_message( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id=account_id), + app_model=app_model, + agent_id="agent-1", + ) + + assert result == ({"result": "success"}, 200) + assert captured["resolve_debug_conversation"] == { + "current_tenant_id": "tenant-1", + "current_user": SimpleNamespace(id=account_id), + "app_model": app_model, + "agent_id": "agent-1", + } + generate_call = cast(dict[str, object], captured["generate"]) + assert generate_call["app_model"] is app_model + assert generate_call["streaming"] is True + args = cast(dict[str, object], generate_call["args"]) + assert args["draft_type"] == "debug_build" + assert args["response_mode"] == "streaming" + assert args["conversation_id"] == "debug-conversation-1" + assert args["inputs"] == {} + assert args["auto_generate_name"] is False + assert args["external_trace_id"] == "trace-1" + + +def test_drain_streaming_generate_response_returns_on_message_end() -> None: + class ClosableResponse: + def __init__(self) -> None: + self._chunks = iter( + [ + "event: ping\n\n", + 'data: {"event":"message","answer":"working"}\n\n', + 'data: {"event":"message_end","message_id":"msg-1"}\n\n', + ] + ) + self.closed = False + + def __iter__(self): + return self + + def __next__(self) -> str: + return next(self._chunks) + + def close(self) -> None: + self.closed = True + + response = ClosableResponse() + + assert completion_controller._drain_streaming_generate_response(response) is None + assert response.closed is True + + +def test_drain_streaming_generate_response_maps_error_event() -> None: + response = iter(['data: {"event":"error","message":"backend failed"}\n\n']) + + with pytest.raises(CompletionRequestError, match="backend failed"): + completion_controller._drain_streaming_generate_response(response) + + +def test_drain_streaming_generate_response_raises_when_stream_ends_early() -> None: + response = iter(['data: {"event":"message","answer":"working"}\n\n']) + + with pytest.raises(CompletionRequestError, match="did not complete"): + completion_controller._drain_streaming_generate_response(response) + + def test_agent_chat_helper_forces_agent_streaming_and_external_trace( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_config_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_config_inspector.py new file mode 100644 index 00000000000..0b9ca517e13 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_config_inspector.py @@ -0,0 +1,435 @@ +"""Unit tests for the console agent config inspector routes. + +These tests unwrap the Flask decorators and focus on version resolution, +workflow-node agent binding, and service delegation for the new config surface. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +from flask import Flask + +from controllers.console.app import agent_config_inspector as inspector +from controllers.console.app.agent_config_inspector import ( + AgentConfigFileDownloadApi, + AgentConfigFilePreviewApi, + AgentConfigFilesApi, + AgentConfigFilesByAgentApi, + AgentConfigManifestApi, + AgentConfigManifestByAgentApi, + AgentConfigSkillFileDownloadApi, + AgentConfigSkillFileDownloadByAgentApi, + AgentConfigSkillFilePreviewByAgentApi, + AgentConfigSkillInspectByAgentApi, + AgentConfigSkillsApi, + AgentConfigSkillUploadByAgentApi, + console_ns, +) +from services.agent_config_service import AgentConfigServiceError + +_MOD = "controllers.console.app.agent_config_inspector" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1") +_USER = SimpleNamespace(id="acct-1") + + +def test_manifest_by_agent_resolves_build_draft_version(): + raw = _raw(AgentConfigManifestByAgentApi.get) + with app.test_request_context("/?draft_type=debug_build"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.manifest.return_value = { + "agent_id": "agent-1", + "config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True}, + "skills": {"items": []}, + "files": {"items": []}, + "env_keys": [], + "note": "", + } + body = raw(AgentConfigManifestByAgentApi(), "tenant-1", _USER, "agent-1") + + assert body["config_version"]["kind"] == "build_draft" + assert config_service.return_value.manifest.call_args.kwargs["config_version_id"] == "build-draft-1" + assert config_service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "build_draft" + + +def test_manifest_resolves_workflow_node_agent_and_normal_draft(): + raw = _raw(AgentConfigManifestApi.get) + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.manifest.return_value = { + "agent_id": "wf-agent-9", + "config_version": {"id": "draft-1", "kind": "draft", "writable": True}, + "skills": {"items": []}, + "files": {"items": []}, + "env_keys": [], + "note": "", + } + body = raw(AgentConfigManifestApi(), _USER, _APP) + + assert body["agent_id"] == "wf-agent-9" + assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "node-1" + assert config_service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "draft" + + +def test_normal_draft_resolution_commits_created_draft_before_service_session() -> None: + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.db") as db, + ): + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + + version_id, version_kind = inspector._resolve_console_version( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="acct-1", + version_id=None, + draft_type="draft", + ) + + assert version_id == "draft-1" + assert version_kind.value == "draft" + db.session.commit.assert_called_once() + + +def test_skill_inspect_by_agent_returns_strict_json_response(): + raw = _raw(AgentConfigSkillInspectByAgentApi.get) + with app.test_request_context("/"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.inspect_skill.return_value = { + "id": "pdf-toolkit", + "name": "pdf-toolkit", + "description": "Work with PDFs.", + "size": 42, + "mime_type": "application/zip", + "hash": "sha256:abc", + "source": "config_skill_zip", + "files": [ + { + "path": "SKILL.md", + "name": "SKILL.md", + "type": "file", + "previewable": True, + "downloadable": True, + } + ], + "skill_md": { + "path": "SKILL.md", + "size": 24, + "truncated": False, + "binary": False, + "text": "# PDF Toolkit\nUse it.\n", + }, + "warnings": [], + } + response = raw(AgentConfigSkillInspectByAgentApi(), "tenant-1", _USER, "agent-1", "pdf-toolkit") + + assert response.status_code == 200 + assert response.get_json()["name"] == "pdf-toolkit" + assert b"PDF Toolkit" in response.get_data() + + +def test_file_preview_api_passes_through_and_maps_errors(): + raw = _raw(AgentConfigFilePreviewApi.get) + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.preview_file.return_value = { + "name": "sample.txt", + "size": 5, + "truncated": False, + "binary": False, + "text": "hello", + } + body = raw(AgentConfigFilePreviewApi(), _USER, _APP, "sample.txt") + assert body["text"] == "hello" + + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.preview_file.side_effect = AgentConfigServiceError( + "config_file_not_found", "missing", status_code=404 + ) + body, status = raw(AgentConfigFilePreviewApi(), _USER, _APP, "missing.txt") + assert status == 404 + assert body["code"] == "config_file_not_found" + + +def test_skill_upload_by_agent_delegates_after_version_resolution(): + raw = _raw(AgentConfigSkillUploadByAgentApi.post) + with app.test_request_context("/?draft_type=debug_build"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch( + f"{_MOD}._upload_skill_for_target", + return_value=( + { + "skill": {"id": "alpha", "name": "alpha", "file_id": "tool-file-1", "description": "Alpha"}, + "config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True}, + }, + 201, + ), + ) as upload_skill, + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + body, status = raw(AgentConfigSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["skill"]["name"] == "alpha" + assert upload_skill.call_args.kwargs["version_id"] == "build-draft-1" + assert upload_skill.call_args.kwargs["version_kind"].value == "build_draft" + + +def test_file_upload_by_agent_delegates_to_service_owned_upload_lookup(): + raw = _raw(AgentConfigFilesByAgentApi.post) + with app.test_request_context("/?draft_type=debug_build"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch.object( + type(console_ns), + "payload", + new_callable=PropertyMock, + return_value={"upload_file_id": "upload-1"}, + ), + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.push_file_for_console.return_value = { + "file": {"id": "guide.txt", "name": "guide.txt", "file_id": "upload-1"}, + "config_version": {"id": "build-draft-1", "kind": "build_draft", "writable": True}, + } + body, status = raw(AgentConfigFilesByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["file"]["name"] == "guide.txt" + assert config_service.return_value.push_file_for_console.call_args.kwargs["upload_file_id"] == "upload-1" + assert config_service.return_value.push_file_for_console.call_args.kwargs["config_version_id"] == "build-draft-1" + assert ( + config_service.return_value.push_file_for_console.call_args.kwargs["config_version_kind"].value == "build_draft" + ) + + +def test_skill_list_api_uses_config_list_shape() -> None: + raw = _raw(AgentConfigSkillsApi.get) + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.list_skills.return_value = { + "agent_id": "wf-agent-9", + "config_version": {"id": "draft-1", "kind": "draft", "writable": True}, + "items": [{"id": "alpha", "name": "alpha", "file_id": "tool-file-1", "description": "Alpha"}], + } + body = raw(AgentConfigSkillsApi(), _USER, _APP) + + assert body["items"][0]["name"] == "alpha" + assert body["items"][0]["file_id"] == "tool-file-1" + assert config_service.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9" + + +def test_file_list_api_uses_config_list_shape() -> None: + raw = _raw(AgentConfigFilesApi.get) + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.list_files.return_value = { + "agent_id": "wf-agent-9", + "config_version": {"id": "draft-1", "kind": "draft", "writable": True}, + "items": [ + { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-file-1", + "hash": "sha256:file-1", + "mime_type": "text/plain", + "size": 7, + } + ], + } + body = raw(AgentConfigFilesApi(), _USER, _APP) + + assert body == { + "agent_id": "wf-agent-9", + "config_version": {"id": "draft-1", "kind": "draft", "writable": True}, + "items": [ + { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-file-1", + "hash": "sha256:file-1", + "mime_type": "text/plain", + "size": 7, + } + ], + } + assert config_service.return_value.list_files.call_args.kwargs["agent_id"] == "wf-agent-9" + + +def test_skill_file_preview_by_agent_reads_path_query() -> None: + raw = _raw(AgentConfigSkillFilePreviewByAgentApi.get) + with app.test_request_context("/?draft_type=debug_build&path=references/guide.md"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.preview_skill_file.return_value = { + "path": "references/guide.md", + "size": 11, + "truncated": False, + "binary": False, + "text": "hello world", + } + body = raw(AgentConfigSkillFilePreviewByAgentApi(), "tenant-1", _USER, "agent-1", "alpha") + + assert body["path"] == "references/guide.md" + assert config_service.return_value.preview_skill_file.call_args.kwargs["path"] == "references/guide.md" + + +def test_skill_file_download_by_agent_returns_proxy_url() -> None: + raw = _raw(AgentConfigSkillFileDownloadByAgentApi.get) + with app.test_request_context("/?draft_type=debug_build&path=references/guide.md"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + patch( + f"{_MOD}.url_for", + return_value="/console/api/agent/agent-1/config/skills/alpha/files/content?path=references%2Fguide.md&draft_type=debug_build", + ), + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.resolve_skill_file_member_path.return_value = "references/guide.md" + response = raw(AgentConfigSkillFileDownloadByAgentApi(), "tenant-1", _USER, "agent-1", "alpha") + + assert response.status_code == 200 + assert response.get_json()["url"].endswith( + "/agent/agent-1/config/skills/alpha/files/content?path=references%2Fguide.md&draft_type=debug_build" + ) + + +def test_skill_file_download_by_agent_validates_member_path() -> None: + raw = _raw(AgentConfigSkillFileDownloadByAgentApi.get) + with app.test_request_context("/?draft_type=debug_build&path=references/missing.md"): + with ( + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.resolve_skill_file_member_path.side_effect = AgentConfigServiceError( + "config_skill_file_not_found", "missing", status_code=404 + ) + body, status = raw(AgentConfigSkillFileDownloadByAgentApi(), "tenant-1", _USER, "agent-1", "alpha") + + assert status == 404 + assert body["code"] == "config_skill_file_not_found" + + +def test_skill_file_download_api_forwards_workflow_node_and_draft_type() -> None: + raw = _raw(AgentConfigSkillFileDownloadApi.get) + with app.test_request_context("/?node_id=node-1&draft_type=debug_build&path=references/guide.md"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + patch( + f"{_MOD}.url_for", + return_value=( + "/console/api/apps/app-1/agent/config/skills/alpha/files/content" + "?node_id=node-1&draft_type=debug_build&path=references%2Fguide.md" + ), + ) as url_for_mock, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_app_build_draft.return_value = {"draft": {"id": "build-draft-1"}} + config_service.return_value.resolve_skill_file_member_path.return_value = "references/guide.md" + response = raw(AgentConfigSkillFileDownloadApi(), _USER, _APP, "alpha") + + assert response.status_code == 200 + assert response.get_json()["url"].endswith( + "/apps/app-1/agent/config/skills/alpha/files/content" + "?node_id=node-1&draft_type=debug_build&path=references%2Fguide.md" + ) + assert url_for_mock.call_args.kwargs == { + "_external": False, + "app_id": "app-1", + "draft_type": "debug_build", + "name": "alpha", + "node_id": "node-1", + "path": "references/guide.md", + } + + +def test_skill_file_download_api_propagates_member_lookup_404s() -> None: + raw = _raw(AgentConfigSkillFileDownloadApi.get) + with app.test_request_context("/?node_id=node-1&path=references/missing.md"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.resolve_skill_file_member_path.side_effect = AgentConfigServiceError( + "config_skill_file_not_found", "missing", status_code=404 + ) + body, status = raw(AgentConfigSkillFileDownloadApi(), _USER, _APP, "alpha") + + assert status == 404 + assert body["code"] == "config_skill_file_not_found" + + +def test_file_download_api_returns_signed_url_json() -> None: + raw = _raw(AgentConfigFileDownloadApi.get) + with app.test_request_context("/?node_id=node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentConfigService") as config_service, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}} + config_service.return_value.download_file_url.return_value = "https://example.com/guide.txt" + response = raw(AgentConfigFileDownloadApi(), _USER, _APP, "guide.txt") + + assert response.status_code == 200 + assert response.get_json() == {"url": "https://example.com/guide.txt"} diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py new file mode 100644 index 00000000000..eef6fdf31c0 --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py @@ -0,0 +1,303 @@ +"""Unit tests for the Agent config inner-API controller.""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import patch + +from flask import Flask + +from controllers.inner_api.plugin.agent_config import ( + AgentConfigEnvApi, + AgentConfigFilePullApi, + AgentConfigManifestApi, + AgentConfigNoteApi, + AgentConfigPushApi, + AgentConfigSkillInspectApi, + AgentConfigSkillPullApi, +) +from services.agent_config_service import AgentConfigServiceError + +MODULE = "controllers.inner_api.plugin.agent_config" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +def test_manifest_happy_path_calls_service() -> None: + raw = _raw(AgentConfigManifestApi.get) + + with app.test_request_context( + "/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft" + ): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.manifest.return_value = { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": { + "items": [ + { + "id": "alpha", + "name": "alpha", + "file_id": "tool-file-1", + "description": "Alpha skill", + "size": 123, + "hash": "sha256:abc", + "mime_type": "application/zip", + } + ] + }, + "files": { + "items": [ + { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-file-1", + "size": 7, + "hash": "sha256:def", + "mime_type": "text/plain", + } + ] + }, + "env_keys": ["KEY"], + "note": "hello", + } + body = raw(AgentConfigManifestApi(), "agent-1") + + assert body == { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": { + "items": [ + { + "id": "alpha", + "name": "alpha", + "file_id": "tool-file-1", + "description": "Alpha skill", + "size": 123, + "hash": "sha256:abc", + "mime_type": "application/zip", + } + ] + }, + "files": { + "items": [ + { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-file-1", + "size": 7, + "hash": "sha256:def", + "mime_type": "text/plain", + } + ] + }, + "env_keys": ["KEY"], + "note": "hello", + } + assert service.return_value.manifest.call_args.kwargs == { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": service.return_value.manifest.call_args.kwargs["config_version_kind"], + } + assert service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "build_draft" + + +def test_skill_pull_returns_send_file_response() -> None: + raw = _raw(AgentConfigSkillPullApi.get) + + with app.test_request_context( + "/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft" + ): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.pull_skill.return_value = SimpleNamespace( + payload=b"zip-bytes", + mime_type="application/zip", + filename="alpha.zip", + ) + response = raw(AgentConfigSkillPullApi(), "agent-1", "alpha") + + response.direct_passthrough = False + assert response.status_code == 200 + assert response.mimetype == "application/zip" + assert response.get_data() == b"zip-bytes" + assert "filename=alpha.zip" in response.headers["Content-Disposition"] + assert service.return_value.pull_skill.call_args.kwargs["user_id"] == "user-1" + + +def test_skill_inspect_happy_path_returns_service_payload() -> None: + raw = _raw(AgentConfigSkillInspectApi.get) + + with app.test_request_context( + "/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft" + ): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.inspect_skill.return_value = {"name": "alpha", "files": ["SKILL.md"]} + body = raw(AgentConfigSkillInspectApi(), "agent-1", "alpha") + + assert body == {"name": "alpha", "files": ["SKILL.md"]} + assert service.return_value.inspect_skill.call_args.kwargs["user_id"] == "user-1" + + +def test_file_pull_returns_send_file_response() -> None: + raw = _raw(AgentConfigFilePullApi.get) + + with app.test_request_context( + "/?tenant_id=tenant-1&user_id=user-1&config_version_id=cfg-1&config_version_kind=build_draft" + ): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.pull_file.return_value = SimpleNamespace( + payload=b"file-bytes", + mime_type="text/plain", + filename="guide.txt", + ) + response = raw(AgentConfigFilePullApi(), "agent-1", "guide.txt") + + response.direct_passthrough = False + assert response.status_code == 200 + assert response.mimetype == "text/plain" + assert response.get_data() == b"file-bytes" + assert "filename=guide.txt" in response.headers["Content-Disposition"] + assert service.return_value.pull_file.call_args.kwargs["user_id"] == "user-1" + + +def test_push_happy_path_validates_body_and_preserves_execution_user() -> None: + raw = _raw(AgentConfigPushApi.post) + payload = { + "tenant_id": "tenant-1", + "user_id": "account-user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "files": [{"name": "guide.txt", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}], + "skills": [], + "env_text": "KEY=value\n", + "note": "hello", + } + + with app.test_request_context("/", method="POST", json=payload): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.push.return_value = { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": {"items": []}, + "files": {"items": []}, + "env_keys": ["KEY"], + "note": "hello", + } + body = raw(AgentConfigPushApi(), "agent-1") + + assert body == { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": {"items": []}, + "files": {"items": []}, + "env_keys": ["KEY"], + "note": "hello", + } + assert service.return_value.push.call_args.kwargs["user_id"] == "account-user-1" + assert service.return_value.push.call_args.kwargs["config_version_kind"].value == "build_draft" + + +def test_env_happy_path_calls_service() -> None: + raw = _raw(AgentConfigEnvApi.patch) + payload = { + "tenant_id": "tenant-1", + "user_id": "account-user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "env_text": "KEY=value\n", + } + + with app.test_request_context("/", method="PATCH", json=payload): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.update_env.return_value = {"env_keys": ["KEY"]} + body = raw(AgentConfigEnvApi(), "agent-1") + + assert body == {"env_keys": ["KEY"]} + assert service.return_value.update_env.call_args.kwargs["user_id"] == "account-user-1" + assert service.return_value.update_env.call_args.kwargs["env_text"] == "KEY=value\n" + + +def test_note_happy_path_calls_service() -> None: + raw = _raw(AgentConfigNoteApi.put) + payload = { + "tenant_id": "tenant-1", + "user_id": "account-user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "note": "hello", + } + + with app.test_request_context("/", method="PUT", json=payload): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.update_note.return_value = {"note": "hello"} + body = raw(AgentConfigNoteApi(), "agent-1") + + assert body == {"note": "hello"} + assert service.return_value.update_note.call_args.kwargs["user_id"] == "account-user-1" + assert service.return_value.update_note.call_args.kwargs["note"] == "hello" + + +def test_manifest_invalid_query_returns_400() -> None: + raw = _raw(AgentConfigManifestApi.get) + + with app.test_request_context("/?tenant_id=tenant-1"): + body, status = raw(AgentConfigManifestApi(), "agent-1") + + assert status == 400 + assert body["code"] == "invalid_request" + + +def test_push_invalid_body_returns_400() -> None: + raw = _raw(AgentConfigPushApi.post) + + with app.test_request_context("/", method="POST", json={"tenant_id": "tenant-1"}): + body, status = raw(AgentConfigPushApi(), "agent-1") + + assert status == 400 + assert body["code"] == "invalid_request" + + +def test_manifest_maps_service_errors() -> None: + raw = _raw(AgentConfigManifestApi.get) + + with app.test_request_context("/?tenant_id=tenant-1&config_version_id=cfg-1&config_version_kind=build_draft"): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.manifest.side_effect = AgentConfigServiceError( + "config_version_not_found", + "missing", + status_code=404, + ) + body, status = raw(AgentConfigManifestApi(), "agent-1") + + assert status == 404 + assert body == {"code": "config_version_not_found", "message": "missing"} + + +def test_push_maps_service_errors() -> None: + raw = _raw(AgentConfigPushApi.post) + payload = { + "tenant_id": "tenant-1", + "user_id": "account-user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "files": [], + "skills": [], + } + + with app.test_request_context("/", method="POST", json=payload): + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.push.side_effect = AgentConfigServiceError( + "config_not_writable", + "denied", + status_code=403, + ) + body, status = raw(AgentConfigPushApi(), "agent-1") + + assert status == 403 + assert body == {"code": "config_not_writable", "message": "denied"} diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py new file mode 100644 index 00000000000..ea53555807e --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py @@ -0,0 +1,124 @@ +"""Unit tests for the Agent tool inner API controller.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import patch + +from flask import Flask + +from controllers.inner_api import bp as inner_api_bp +from services.entities.agent_tool_inner import AgentToolInvokeResponse +from services.errors.agent_tool_inner import AgentToolInnerServiceError + + +def _headers(api_key: str | None = "inner-key") -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if api_key is not None: + headers["X-Inner-Api-Key"] = api_key + return headers + + +def _payload() -> dict[str, object]: + return { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "user_from": "account", + "app_id": "app-1", + "invoke_from": "service-api", + "conversation_id": "conversation-1", + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "node_id": "node-1", + "node_execution_id": "node-exec-1", + "agent_id": "agent-1", + "agent_config_version_id": "snapshot-1", + }, + "tool": { + "provider_type": "plugin", + "provider_id": "langgenius/search/search", + "tool_name": "search", + "credential_id": "credential-1", + "tool_parameters": {"query": "dify"}, + "runtime_parameters": {"region": "us"}, + }, + } + + +@contextmanager +def _agent_inner_auth() -> Iterator[None]: + with ( + patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), + patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + ): + yield + + +def test_post_returns_service_response() -> None: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + + with ( + _agent_inner_auth(), + patch("controllers.inner_api.agent.tools.AgentToolInnerService.invoke") as mock_invoke, + ): + mock_invoke.return_value = AgentToolInvokeResponse( + messages=[{"type": "text", "message": {"text": "ok"}}], + observation="ok", + metadata={"provider_type": "plugin", "tool_name": "search"}, + ) + + response = app.test_client().post( + "/inner/api/agent/tools/invoke", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["observation"] == "ok" + assert body["metadata"]["provider_type"] == "plugin" + + +def test_post_returns_400_for_invalid_body() -> None: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + + with _agent_inner_auth(): + response = app.test_client().post( + "/inner/api/agent/tools/invoke", + json={"caller": {}}, + headers=_headers(), + ) + + assert response.status_code == 400 + body = response.get_json() + assert body["code"] == "invalid_request" + + +def test_post_preserves_service_error_status_code_and_description() -> None: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + + with ( + _agent_inner_auth(), + patch("controllers.inner_api.agent.tools.AgentToolInnerService.invoke") as mock_invoke, + ): + mock_invoke.side_effect = AgentToolInnerServiceError( + error_code="app_tenant_mismatch", + description="App does not belong to the caller tenant.", + status_code=403, + ) + response = app.test_client().post( + "/inner/api/agent/tools/invoke", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 403 + body = response.get_json() + assert body["code"] == "app_tenant_mismatch" + assert body["message"] == "App does not belong to the caller tenant." diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index 7418ca2660a..6f8c6af2258 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -10,13 +10,20 @@ manager is replaced with a no-op so the thread body can run inline. from __future__ import annotations import contextlib +import json +from types import SimpleNamespace import pytest from pytest_mock import MockerFixture -from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError +from core.app.apps.agent_app.app_generator import ( + AgentAppGenerator, + AgentAppGeneratorError, +) from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom +from models import Account +from models.agent import AgentConfigDraftType MODULE = "core.app.apps.agent_app.app_generator" @@ -66,7 +73,7 @@ class TestGenerateGuards: class TestGenerateSuccess: - def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self): + def test_runtime_session_snapshot_id_preserves_snapshot_for_debugger_and_web_app(self): assert ( AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1") == "snap-1" @@ -81,7 +88,7 @@ class TestGenerateSuccess: user = DummyAccount("user") generator._resolve_agent = mocker.MagicMock( - return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock()) + return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock()) ) generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1}) generator._init_generate_records = mocker.MagicMock( @@ -95,16 +102,26 @@ class TestGenerateSuccess: ) mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini")) mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) - mocker.patch(f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user")) + generate_entity = mocker.patch( + f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") + ) mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) thread_obj = mocker.MagicMock() mocker.patch(f"{MODULE}.threading.Thread", return_value=thread_obj) mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"}) + file_mappings = [ + { + "type": "image", + "transfer_method": "local_file", + "url": "", + "upload_file_id": "upload-file-1", + } + ] result = generator.generate( app_model=app_model, user=user, - args={"query": "hello", "inputs": {"name": "world"}}, + args={"query": "hello", "inputs": {"name": "world"}, "files": file_mappings}, invoke_from=InvokeFrom.WEB_APP, streaming=True, ) @@ -117,11 +134,12 @@ class TestGenerateSuccess: draft_type=None, user=user, ) + assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") generator._resolve_agent = mocker.MagicMock( - return_value=(mocker.MagicMock(id="a"), mocker.MagicMock(id="s"), mocker.MagicMock()) + return_value=(mocker.MagicMock(id="a"), "snap1", "snapshot", mocker.MagicMock()) ) generator._prepare_user_inputs = mocker.MagicMock(return_value={}) generator._init_generate_records = mocker.MagicMock( @@ -156,7 +174,7 @@ class TestGenerateSuccess: user = DummyAccount("user") generator._resolve_agent = mocker.MagicMock( - return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock()) + return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock()) ) generator._prepare_user_inputs = mocker.MagicMock(return_value={}) generator._init_generate_records = mocker.MagicMock( @@ -187,6 +205,81 @@ class TestGenerateSuccess: assert generate_entity.call_args.kwargs["extras"] == {"auto_generate_conversation_name": True} + def test_generate_stateless_skips_chat_records(self, generator: AgentAppGenerator, mocker: MockerFixture): + app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") + user = DummyAccount("user") + + generator._resolve_agent = mocker.MagicMock( + return_value=(mocker.MagicMock(id="agent1"), "build-draft-1", "build_draft", mocker.MagicMock()) + ) + generator._init_generate_records = mocker.MagicMock() + run_stateless = mocker.patch.object(generator, "_run_stateless", return_value={"result": "success"}) + converter = mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert") + + result = generator.generate_stateless( + app_model=app_model, + user=user, + args={ + "query": "finalize", + "inputs": {}, + "conversation_id": "debug-conversation-1", + "draft_type": "debug_build", + }, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert result == {"result": "success"} + generator._init_generate_records.assert_not_called() + converter.assert_not_called() + run_call = run_stateless.call_args.kwargs + assert run_call["conversation_id"] == "debug-conversation-1" + assert run_call["runtime_session_snapshot_id"] == "build-draft-1" + + def test_generate_stateless_requires_conversation_id(self, generator: AgentAppGenerator, mocker: MockerFixture): + with pytest.raises(AgentAppGeneratorError, match="conversation_id is required"): + generator.generate_stateless( + app_model=mocker.MagicMock(), + user=DummyAccount("user"), + args={"query": "finalize", "inputs": {}, "draft_type": "debug_build"}, + invoke_from=InvokeFrom.DEBUGGER, + ) + + def test_stateless_run_uses_agent_app_runner(self, generator: AgentAppGenerator, mocker: MockerFixture): + app_model = mocker.MagicMock(id="app1", tenant_id="tenant", app_model_config=mocker.MagicMock()) + user = DummyAccount("user") + agent = mocker.MagicMock(id="agent1") + dify_context = SimpleNamespace(tenant_id="tenant", app_id="app1") + mocker.patch(f"{MODULE}.DifyRunContext", return_value=dify_context) + runner = mocker.MagicMock() + build_runner = mocker.patch.object(generator, "_build_runner", return_value=runner) + + result = generator._run_stateless( + app_model=app_model, + user=user, + invoke_from=InvokeFrom.DEBUGGER, + query="finalize", + conversation_id="debug-conversation-1", + agent=agent, + agent_config_id="build-draft-1", + agent_config_version_kind="build_draft", + agent_soul=mocker.MagicMock(), + runtime_session_snapshot_id="build-draft-1", + ) + + assert result == {"result": "success"} + build_runner.assert_called_once_with(dify_context) + runner.run_stateless.assert_called_once_with( + dify_context=dify_context, + agent_id="agent1", + agent_config_snapshot_id="build-draft-1", + agent_config_version_kind="build_draft", + agent_soul=mocker.ANY, + conversation_id="debug-conversation-1", + query="finalize", + idempotency_key=mocker.ANY, + session_scope_snapshot_id="build-draft-1", + ) + class TestGenerateWorker: @pytest.fixture(autouse=True) @@ -197,10 +290,18 @@ class TestGenerateWorker: mocker.patch("libs.flask_utils.preserve_flask_contexts", ctx_manager) - def _wire(self, generator: AgentAppGenerator, mocker: MockerFixture, *, run_side_effect=None, handled=False): + def _wire( + self, + generator: AgentAppGenerator, + mocker: MockerFixture, + *, + run_side_effect=None, + handled=False, + guard_query="query", + ): generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv")) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg")) - generator._run_input_guards = mocker.MagicMock(return_value=(handled, "query")) + generator._run_input_guards = mocker.MagicMock(return_value=(handled, guard_query)) generator._resolve_agent_by_id = mocker.MagicMock( return_value=(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) ) @@ -227,6 +328,7 @@ class TestGenerateWorker: is_resume=False, query="query", runtime_session_snapshot_id="s", + prompt_file_mappings=(), ): generator._generate_worker( flask_app=mocker.MagicMock(), @@ -237,6 +339,7 @@ class TestGenerateWorker: agent_runtime_session_snapshot_id=runtime_session_snapshot_id, model_conf=mocker.MagicMock(model="m"), query=query, + prompt_file_mappings=prompt_file_mappings, ), queue_manager=queue_manager, conversation_id="conv", @@ -261,6 +364,30 @@ class TestGenerateWorker: assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s" assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None + def test_worker_appends_prompt_files_to_backend_query(self, generator, mocker: MockerFixture): + runner = self._wire(generator, mocker, guard_query="你看得见这张图片吗") + queue_manager = mocker.MagicMock() + file_mappings = [ + { + "type": "image", + "transfer_method": "local_file", + "url": "", + "upload_file_id": "upload-file-1", + } + ] + + self._call( + generator, + mocker, + queue_manager, + query="你看得见这张图片吗", + prompt_file_mappings=file_mappings, + ) + + assert runner.run.call_args.kwargs["query"] == ( + f"你看得见这张图片吗\n{json.dumps(file_mappings, ensure_ascii=False)}" + ) + def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture): runner = self._wire(generator, mocker, handled=True) queue_manager = mocker.MagicMock() @@ -300,18 +427,22 @@ class TestResumeAfterFormSubmission: def _wire(self, generator, mocker: MockerFixture): generator._resolve_agent = mocker.MagicMock( - return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock()) + return_value=(mocker.MagicMock(id="agent1"), "snap1", "draft", mocker.MagicMock()) ) generator._init_generate_records = mocker.MagicMock( return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg")) ) generator._handle_response = mocker.MagicMock(return_value=None) - mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=mocker.MagicMock(id="conv")) + mocker.patch( + f"{MODULE}.ConversationService.get_conversation", + return_value=mocker.MagicMock(id="conv", invoke_from=InvokeFrom.WEB_APP), + ) mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[])) mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore") return mocker.patch( f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") ) @@ -345,3 +476,26 @@ class TestResumeAfterFormSubmission: # No prior user message -> a non-blank placeholder, still never blank. assert entity.call_args.kwargs["query"] == "(resumed)" + + def test_resume_uses_build_draft_for_debugger_conversation(self, generator, mocker: MockerFixture): + self._wire(generator, mocker) + conversation = mocker.MagicMock(id="conv", invoke_from=InvokeFrom.DEBUGGER) + mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=conversation) + session_store = mocker.patch(f"{MODULE}.AgentAppRuntimeSessionStore") + session_store.return_value.load_active_session_for_conversation.return_value = mocker.MagicMock( + scope=mocker.MagicMock(agent_config_snapshot_id="draft-build-1") + ) + draft_row = mocker.MagicMock(draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="user") + db_mock = mocker.patch(f"{MODULE}.db") + db_mock.session.scalar.side_effect = [draft_row, mocker.MagicMock(query="original question")] + account_user = mocker.MagicMock(spec=Account) + account_user.id = "user" + + generator.resume_after_form_submission( + app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), + user=account_user, + conversation_id="conv", + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert generator._resolve_agent.call_args.kwargs["draft_type"] == "debug_build" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index f5f6c12365f..a1f6512625b 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -23,22 +23,35 @@ from dify_agent.protocol import ( RunSucceededEventData, RuntimeLayerSpec, ) -from pydantic_ai.messages import PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta +from pydantic_ai.messages import ( + FunctionToolCallEvent, + FunctionToolResultEvent, + PartDeltaEvent, + PartStartEvent, + TextPart, + TextPartDelta, + ThinkingPartDelta, + ToolCallPart, + ToolReturnPart, +) from clients.agent_backend import ( AgentBackendError, AgentBackendRunEventAdapter, + AgentBackendStreamInternalEvent, FakeAgentBackendRunClient, FakeAgentBackendScenario, ) +from core.app.apps.agent_app import app_runner as app_runner_module from core.app.apps.agent_app.app_runner import AgentAppRunner from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from models.agent_config_entities import AgentSoulConfig +from models.model import MessageAgentThought class _FakeCredentialsProvider: @@ -54,8 +67,9 @@ def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: class _NoToolsBuilder: - def build(self, **kwargs): + def build_layers(self, **kwargs): del kwargs + return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: []) class _FakeQueueManager: @@ -86,6 +100,24 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): return super().cancel_run(run_id, request=request) +class _BlockingRecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.wait_calls: list[tuple[str, float | None]] = [] + self.stream_called = False + + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del run_id, after + self.stream_called = True + return iter(()) + + @override + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None): + self.wait_calls.append((run_id, timeout_seconds)) + return super().wait_run(run_id, timeout_seconds=timeout_seconds) + + class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: @@ -138,6 +170,65 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): ) +class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="I need to inspect the file.")), + ) + yield PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=FunctionToolCallEvent(part=ToolCallPart(tool_name="bash", args={"cmd": "ls"}, tool_call_id="tool-1")), + ) + yield PydanticAIStreamRunEvent( + id="4-0", + run_id=run_id, + created_at=created_at, + data=FunctionToolResultEvent(part=ToolReturnPart(tool_name="bash", content="ok", tool_call_id="tool-1")), + ) + yield PydanticAIStreamRunEvent( + id="5-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="final answer")), + ) + yield RunSucceededEvent( + id="6-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "final answer"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + +class _FakeDbSession: + def __init__(self) -> None: + self.rows: dict[str, MessageAgentThought] = {} + self.rollback_count = 0 + + def add(self, row: MessageAgentThought) -> None: + self.rows[str(row.id)] = row + + def commit(self) -> None: + pass + + def get(self, _model: type[MessageAgentThought], row_id: str) -> MessageAgentThought | None: + return self.rows.get(row_id) + + def rollback(self) -> None: + self.rollback_count += 1 + + class _FakeSessionStore: def __init__( self, @@ -212,7 +303,7 @@ def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> Agen return AgentAppRunner( request_builder=AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ), agent_backend_client=client, event_adapter=AgentBackendRunEventAdapter(), @@ -234,6 +325,18 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: ) +def _run_stateless(runner: AgentAppRunner) -> None: + runner.run_stateless( + dify_context=_dify_ctx(), + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + agent_soul=_soul(), + conversation_id="conv-1", + query="finalize", + idempotency_key="run-req-1", + ) + + def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent: return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent)) @@ -311,6 +414,116 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal assert end_events[0].llm_result.message.content == "hello agent" +def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + client = _ProcessStreamingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"] + thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)] + assert len(thought_events) >= 3 + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert rows[0].thought == "I need to inspect the file." + assert rows[0].tool is None + assert rows[1].tool == "bash" + assert rows[1].tool_input == '{"cmd": "ls"}' + assert rows[1].observation == "ok" + + +def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + qm = _FakeQueueManager() + recorder = app_runner_module._AgentProcessRecorder( + dify_context=_dify_ctx(), + message_id="msg-1", + queue_manager=qm, # type: ignore[arg-type] + ) + + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_call", + "part": { + "part_kind": "tool-call", + "tool_name": "shell_run", + "args": {"script": "npx skills find browser"}, + "tool_call_id": "shell-call-1", + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_result", + "content": "Knowledge base search results: browser skill", + }, + ) + ) + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 2 + assert rows[0].tool == "shell_run" + assert rows[0].tool_input == '{"script": "npx skills find browser"}' + assert rows[0].observation is None + assert rows[1].tool is None + assert rows[1].tool_input is None + assert rows[1].observation == "Knowledge base search results: browser skill" + + +def test_tool_result_without_call_id_matches_unique_open_tool_name(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + qm = _FakeQueueManager() + recorder = app_runner_module._AgentProcessRecorder( + dify_context=_dify_ctx(), + message_id="msg-1", + queue_manager=qm, # type: ignore[arg-type] + ) + + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_call", + "part": { + "part_kind": "tool-call", + "tool_name": "knowledge_base_search", + "args": {"query": "browser"}, + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_result", + "part": { + "part_kind": "tool-return", + "tool_name": "knowledge_base_search", + "content": "Knowledge base search results: browser skill", + }, + }, + ) + ) + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].tool == "knowledge_base_search" + assert rows[0].tool_input == '{"query": "browser"}' + assert rows[0].observation == "Knowledge base search results: browser skill" + + def test_prior_session_snapshot_is_threaded_into_request(): prior = CompositorSessionSnapshot(layers=[]) client = FakeAgentBackendRunClient() @@ -348,6 +561,31 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots(): assert store.saved[0][0].agent_config_snapshot_id is None +def test_stateless_run_uses_bounded_wait_and_does_not_save_session_state(): + prior = CompositorSessionSnapshot(layers=[]) + client = _BlockingRecordingFakeAgentBackendRunClient() + store = _FakeSessionStore(loaded=prior) + + _run_stateless(_runner(client, store)) + + assert client.request is not None + assert client.request.session_snapshot is prior + assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)] + assert client.stream_called is False + assert store.saved == [] + + +def test_stateless_run_raises_backend_error_on_failed_bounded_wait(): + client = _BlockingRecordingFakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) + store = _FakeSessionStore() + + with pytest.raises(AgentBackendError): + _run_stateless(_runner(client, store)) + + assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)] + assert store.saved == [] + + def test_failed_run_raises_agent_backend_error(): client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) store = _FakeSessionStore() diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 15f1bacb8f6..806741bb8fe 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -85,7 +85,7 @@ class TestResolveAgent: _patch_session(monkeypatch, [bound_agent, inner_agent, snapshot]) app_model = SimpleNamespace(id="app-1", tenant_id="t1") - agent, snap, soul = AgentAppGenerator()._resolve_agent( + agent, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent( app_model, invoke_from=InvokeFrom.WEB_APP, draft_type=None, @@ -93,7 +93,8 @@ class TestResolveAgent: ) # type: ignore[arg-type] assert agent is bound_agent - assert snap == snapshot.id + assert config_id == snapshot.id + assert config_version_kind == "snapshot" assert soul.model is not None def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch): 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 61bf3443cc8..5267d1833e3 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 @@ -7,9 +7,14 @@ from types import SimpleNamespace from typing import Any import pytest +from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig +from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from clients.agent_backend import ( + DIFY_CONFIG_LAYER_ID, + DIFY_CORE_TOOLS_LAYER_ID, + DIFY_PLUGIN_TOOLS_LAYER_ID, AgentBackendAgentAppRunInput, AgentBackendModelConfig, AgentBackendRunRequestBuilder, @@ -81,54 +86,64 @@ class _FakeCredentialsProvider: class _NoToolsBuilder: - def build(self, **kwargs): + def build_layers(self, **kwargs): del kwargs + return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: []) -def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills", - lambda self, *, tenant_id, agent_id: [], - ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest", - lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [], - ) +class _PluginLayerBuilder: + def build_layers(self, **kwargs): + return SimpleNamespace( + plugin_tools=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/time", + provider="time", + tool_name="current_time", + credential_type="unauthorized", + name="current_time", + description="Get current time.", + credentials={}, + runtime_parameters={}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + core_tools=None, + exposed_tool_names=lambda: ["current_time"], + ) -def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills", - lambda self, *, tenant_id, agent_id: [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "size": 123, - "mime_type": "text/markdown", - "hash": "hash-1", - "created_at": 1, - } - ], - ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest", - lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [ - {"key": "tender-analyzer/SKILL.md", "is_skill": True}, - {"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False}, - {"key": "files/sample.pdf", "is_skill": False}, - ], - ) +class _CoreLayerBuilder: + def build_layers(self, **kwargs): + del kwargs + return SimpleNamespace( + plugin_tools=None, + core_tools=DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + runtime_parameters={}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + exposed_tool_names=lambda: ["transcribe"], + ) -@pytest.fixture(autouse=True) -def _mock_default_agent_app_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - _mock_empty_drive_catalog(monkeypatch) - - -def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext: +def _ctx( + soul: AgentSoulConfig, + *, + query: str = "hello", + agent_config_version_kind: str = "snapshot", +) -> AgentAppRuntimeBuildContext: dify_context = SimpleNamespace( tenant_id="tenant-1", app_id="app-1", @@ -144,6 +159,7 @@ def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuild conversation_id="conv-1", user_query=query, idempotency_key="msg-1", + agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type] ) @@ -164,7 +180,7 @@ class TestAgentAppRuntimeRequestBuilder: def test_build_maps_soul_to_run_request(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(_soul_with_model())) @@ -175,8 +191,6 @@ class TestAgentAppRuntimeRequestBuilder: "agent_soul_prompt", "agent_app_user_prompt", "execution_context", - "shell", - "drive", "history", "llm", ] @@ -195,6 +209,68 @@ class TestAgentAppRuntimeRequestBuilder: assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_draft(self): + soul = _soul_with_model() + soul.tools.dify_tools = [ + { + "provider_type": "plugin", + "provider_id": "langgenius/time/time", + "tool_name": "current_time", + } + ] + tools_builder = _PluginLayerBuilder() + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=tools_builder, # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul, agent_config_version_kind="draft")) + + names = [layer.name for layer in result.request.composition.layers] + assert DIFY_PLUGIN_TOOLS_LAYER_ID in names + assert DIFY_CORE_TOOLS_LAYER_ID not in names + + def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_snapshot(self): + soul = _soul_with_model() + soul.tools.dify_tools = [ + { + "provider_type": "plugin", + "provider_id": "langgenius/time/time", + "tool_name": "current_time", + } + ] + tools_builder = _PluginLayerBuilder() + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=tools_builder, # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul, agent_config_version_kind="snapshot")) + + names = [layer.name for layer in result.request.composition.layers] + assert DIFY_PLUGIN_TOOLS_LAYER_ID in names + assert DIFY_CORE_TOOLS_LAYER_ID not in names + + def test_build_includes_core_tools_layer_returned_by_injected_builder(self): + soul = _soul_with_model() + soul.tools.dify_tools = [ + { + "provider_type": "builtin", + "provider_id": "audio", + "tool_name": "transcribe", + } + ] + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_CoreLayerBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + names = [layer.name for layer in result.request.composition.layers] + assert DIFY_CORE_TOOLS_LAYER_ID in names + assert DIFY_PLUGIN_TOOLS_LAYER_ID not in names + def test_build_normalizes_marketplace_model_plugin_id(self): soul = _soul_with_model() soul.model.plugin_id = ( @@ -202,7 +278,7 @@ class TestAgentAppRuntimeRequestBuilder: ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(soul)) @@ -238,7 +314,7 @@ class TestAgentAppRuntimeRequestBuilder: ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(soul)) @@ -257,7 +333,7 @@ class TestAgentAppRuntimeRequestBuilder: def test_build_raises_when_model_missing(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) with pytest.raises(AgentAppRuntimeRequestBuildError) as exc: builder.build(_ctx(AgentSoulConfig())) @@ -279,7 +355,7 @@ class TestAgentAppRuntimeRequestBuilder: ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(soul)) @@ -298,7 +374,7 @@ class TestAgentAppRuntimeRequestBuilder: } -# ── ENG-623: drive declaration on the Agent App surface ────────────────────── +# ── Agent config layer declaration on the Agent App surface ────────────────── def _soul_with_model_and_skill() -> AgentSoulConfig: @@ -309,143 +385,138 @@ def _soul_with_model_and_skill() -> AgentSoulConfig: "model_provider": "langgenius/openai/openai", "model": "gpt-4o-mini", }, - "prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"}, - "files": { - "skills": [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "name": "Tender Analyzer", - "description": "Parses RFPs.", - } - ] - }, + "prompt": {"system_prompt": "Use [§skill:tender-analyzer:Tender Analyzer§]"}, + "config_skills": [{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}], + "config_files": [{"name": "sample.pdf", "file_kind": "upload_file", "file_id": "upload-file-1"}], + "config_note": "Read the proposal first.", } ) -class TestAgentAppDriveLayer: - def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch): +class TestAgentAppConfigLayer: + def test_config_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) - _mock_drive_catalog(monkeypatch) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(_soul_with_model_and_skill())) - drive = next(layer for layer in result.request.composition.layers if layer.name == "drive") - assert drive.type == "dify.drive" - assert drive.deps == {"shell": DIFY_SHELL_LAYER_ID} - assert drive.config.drive_ref == "agent-agent-1" - assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"] - assert drive.config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"] - # shell enters first; drive uses that shell to materialize mentioned targets. + config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID) + assert config.type == "dify.config" + assert config.deps == {"shell": DIFY_SHELL_LAYER_ID} + assert config.config.agent_id == "agent-1" + assert config.config.config_version is not None + assert config.config.config_version.id == "snap-1" + assert config.config.config_version.kind == "snapshot" + assert config.config.config_version.writable is False + assert [skill.name for skill in config.config.skills] == ["tender-analyzer"] + assert [file_ref.name for file_ref in config.config.files] == ["sample.pdf"] + assert config.config.note == "Read the proposal first." + assert config.config.mentioned_skill_names == ["tender-analyzer"] + assert config.config.mentioned_file_names == [] + # shell enters first; config uses that shell to materialize mentioned targets. names = [layer.name for layer in result.request.composition.layers] assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1 - assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1 + assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1 - def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(self, monkeypatch: pytest.MonkeyPatch): + def test_no_config_layer_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(_soul_with_model())) layers = {layer.name: layer for layer in result.request.composition.layers} - assert layers["drive"].config.drive_ref == "agent-agent-1" - assert layers["drive"].config.skills == [] + assert DIFY_CONFIG_LAYER_ID not in layers assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"} - assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref == "agent-agent-1" - assert layers["drive"].deps == {"shell": DIFY_SHELL_LAYER_ID} + assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None - def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch): + def test_config_layer_for_build_draft_marks_config_writable(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model_and_skill(), agent_config_version_kind="build_draft")) + + config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID) + assert config.config.model_dump(mode="json") == { + "agent_id": "agent-1", + "config_version": {"id": "snap-1", "kind": "build_draft", "writable": True}, + "skills": [ + { + "name": "tender-analyzer", + "description": "Parses RFPs.", + "size": None, + "mime_type": "application/zip", + } + ], + "files": [{"name": "sample.pdf", "size": None, "mime_type": None}], + "env_keys": [], + "note": "Read the proposal first.", + "mentioned_skill_names": ["tender-analyzer"], + "mentioned_file_names": [], + } + + def test_no_config_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(_soul_with_model_and_skill())) - assert all(layer.name != "drive" for layer in result.request.composition.layers) + assert all(layer.name != DIFY_CONFIG_LAYER_ID for layer in result.request.composition.layers) - def test_agent_app_runtime_expands_skill_and_file_mentions_in_agent_soul_prompt( + @pytest.mark.parametrize( + ("system_prompt", "expected_prefix"), + [ + ( + "Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§].", + "Use tender-analyzer and sample.pdf.", + ), + ( + "Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§]", + "Use tender-analyzer and sample.pdf", + ), + ], + ) + def test_agent_app_runtime_expands_config_mentions_in_agent_soul_prompt( self, monkeypatch: pytest.MonkeyPatch, + system_prompt: str, + expected_prefix: str, ): monkeypatch.setattr( "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) - _mock_drive_catalog(monkeypatch) - soul = _soul_with_model() - soul.prompt.system_prompt = ( - "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]." - ) + soul = _soul_with_model_and_skill() + soul.prompt.system_prompt = system_prompt builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(soul)) prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf." + assert prompt_layer.config.prefix == expected_prefix assert "[§" not in prompt_layer.config.prefix - def test_agent_app_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key( - self, - monkeypatch: pytest.MonkeyPatch, - ): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) - _mock_drive_catalog(monkeypatch) - soul = _soul_with_model() - soul.prompt.system_prompt = ( - "Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], " - "and [§file:files%2Fmissing.txt§]." - ) - builder = AgentAppRuntimeRequestBuilder( - credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] - ) - - result = builder.build(_ctx(soul)) - - prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/missing.txt." - assert "[§" not in prompt_layer.config.prefix - - def test_agent_app_runtime_expands_drive_mentions_in_agent_soul_prompt(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) - _mock_drive_catalog(monkeypatch) - soul = _soul_with_model() - soul.prompt.system_prompt = ( - "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]" - ) - builder = AgentAppRuntimeRequestBuilder( - credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] - ) - - result = builder.build(_ctx(soul)) - - prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf" - assert "[§" not in prompt_layer.config.prefix - - def test_agent_app_runtime_missing_drive_mentions_fall_back_without_marker_leak( + def test_agent_app_runtime_missing_config_mentions_fall_back_without_marker_leak( self, monkeypatch: pytest.MonkeyPatch, ): @@ -454,16 +525,15 @@ class TestAgentAppDriveLayer: ) soul = _soul_with_model() soul.prompt.system_prompt = ( - "Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], " - "and [§file:files%2Fno-label.txt§]." + "Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]." ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), - plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) result = builder.build(_ctx(soul)) prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt." + assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt." assert "[§" not in prompt_layer.config.prefix diff --git a/api/tests/unit_tests/core/tools/test_tool_engine.py b/api/tests/unit_tests/core/tools/test_tool_engine.py index 8118f40e8f7..ec1da418228 100644 --- a/api/tests/unit_tests/core/tools/test_tool_engine.py +++ b/api/tests/unit_tests/core/tools/test_tool_engine.py @@ -88,7 +88,7 @@ def test_convert_tool_response_to_str_and_extract_binary_messages(): tool.create_json_message({"a": 1}), tool.create_json_message({"a": 1}, suppress_output=True), ] - text = ToolEngine._convert_tool_response_to_str(messages) + text = ToolEngine.tool_response_to_str(messages) assert "hello" in text assert "result link: https://example.com." in text assert '"a": 1' in text @@ -271,7 +271,7 @@ def test_convert_tool_response_excludes_variable_messages(): """Regression test for issue #34723. WorkflowTool._invoke yields VARIABLE, TEXT, and suppressed-JSON messages. - _convert_tool_response_to_str must skip VARIABLE messages so that the + tool_response_to_str must skip VARIABLE messages so that the returned string contains only the TEXT representation and not a duplicated, garbled Pydantic repr of the same data. """ @@ -283,7 +283,7 @@ def test_convert_tool_response_excludes_variable_messages(): tool.create_json_message(outputs, suppress_output=True), ] - result = ToolEngine._convert_tool_response_to_str(messages) + result = ToolEngine.tool_response_to_str(messages) assert result == '{"reports": "hello"}' assert "variable_name" not in result diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py similarity index 71% rename from api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py rename to api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py index 1ff87614b08..e9af5e7707a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py @@ -16,11 +16,12 @@ from core.tools.entities.tool_entities import ( ToolIdentity, ToolInvokeMessage, ToolParameter, + ToolProviderType, ) from core.tools.tool_manager import ToolManager -from core.workflow.nodes.agent_v2.plugin_tools_builder import ( - WorkflowAgentPluginToolsBuilder, - WorkflowAgentPluginToolsBuildError, +from core.workflow.nodes.agent_v2.dify_tools_builder import ( + WorkflowAgentDifyToolsBuilder, + WorkflowAgentDifyToolsBuildError, ) from models.agent_config_entities import AgentSoulToolsConfig @@ -29,8 +30,9 @@ class FakeRuntimeProvider: def __init__(self, tool: Tool | Exception) -> None: # Either a Tool to hand back, or an exception to raise on lookup. The # latter lets tests exercise the error-mapping branches in - # ``WorkflowAgentPluginToolsBuilder._fetch_tool_runtime``. + # ``WorkflowAgentDifyToolsBuilder._fetch_tool_runtime``. self.tool = tool + self.call_count = 0 self.last_agent_tool: AgentToolEntity | None = None self.last_invoke_from: InvokeFrom | None = None self.last_allow_file_parameters: bool | None = None @@ -47,6 +49,7 @@ class FakeRuntimeProvider: allow_file_parameters: bool = False, use_default_for_missing_form_parameters: bool = False, ) -> Tool: + self.call_count += 1 self.last_agent_tool = agent_tool self.last_invoke_from = invoke_from self.last_allow_file_parameters = allow_file_parameters @@ -182,25 +185,26 @@ def _tts_tool() -> FakeTool: def _build( - builder: WorkflowAgentPluginToolsBuilder, + builder: WorkflowAgentDifyToolsBuilder, tools: AgentSoulToolsConfig, *, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, ): - """Shorthand for ``builder.build(...)`` with the standard tenant/app/user - triple, so each test only highlights what's actually unique to it.""" - return builder.build( + """Shorthand for tests that expect ``build_layers(...)`` to return only plugin tools.""" + layers = builder.build_layers( tenant_id="tenant-1", app_id="app-1", user_id="user-1", tools=tools, invoke_from=invoke_from, ) + assert layers.core_tools is None + return layers.plugin_tools def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -235,9 +239,9 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" -def test_builds_dify_plugin_tool_with_file_llm_parameter(): +def test_builds_core_tool_with_file_llm_parameter(): runtime_provider = FakeRuntimeProvider(_file_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -251,24 +255,192 @@ def test_builds_dify_plugin_tool_with_file_llm_parameter(): } ) - result = _build(builder, tools) + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) - assert result is not None - prepared = result.tools[0] + assert result.core_tools is not None + prepared = result.core_tools.tools[0] assert prepared.tool_name == "asr" assert prepared.runtime_parameters == {} assert prepared.parameters[0].name == "audio_file" assert prepared.parameters[0].type == "file" - # The public Agent backend DTO carries non-scalar tool inputs in - # ``parameters``; legacy JSON schema generation omits file fields. assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []} assert runtime_provider.last_allow_file_parameters is True assert runtime_provider.last_use_default_for_missing_form_parameters is True -def test_builds_dify_plugin_tool_with_missing_required_select_default(): +def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None: + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "langgenius/search/search", + "provider_type": "plugin", + "tool_name": "search", + "credential_type": "api-key", + "credential_id": "credential-1", + "runtime_parameters": {"region": "us"}, + }, + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "transcribe", + "credential_type": "unauthorized", + }, + ] + } + ) + + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert result.plugin_tools is not None + assert [tool.tool_name for tool in result.plugin_tools.tools] == ["search"] + assert result.core_tools is not None + assert [tool.provider_type for tool in result.core_tools.tools] == ["builtin"] + assert [tool.tool_name for tool in result.core_tools.tools] == ["transcribe"] + + +@pytest.mark.parametrize( + ("provider_type", "provider_id"), + [ + ("api", "search-api"), + ("workflow", "workflow-provider-1"), + ], +) +def test_build_layers_routes_api_and_workflow_via_core(provider_type: str, provider_id: str) -> None: + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": provider_id, + "provider_type": provider_type, + "tool_name": "search", + "credential_type": "unauthorized", + } + ] + } + ) + + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert result.plugin_tools is None + assert result.core_tools is not None + assert [tool.provider_type for tool in result.core_tools.tools] == [provider_type] + assert [tool.provider_id for tool in result.core_tools.tools] == [provider_id] + assert [tool.tool_name for tool in result.core_tools.tools] == ["search"] + + +def test_build_layers_normalizes_mcp_provider_ids_to_server_identifier() -> None: + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + mcp_provider_id_resolver=lambda *, tenant_id, provider_id: "mcp-server-1", + ) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "db-row-id", + "provider_type": "mcp", + "tool_name": "search", + "credential_type": "unauthorized", + } + ] + } + ) + + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert result.core_tools is not None + assert result.core_tools.tools[0].provider_id == "mcp-server-1" + + +def test_build_layers_rejects_app_provider_type() -> None: + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "app-provider", + "provider_type": "app", + "tool_name": "search", + "credential_type": "unauthorized", + } + ] + } + ) + + with pytest.raises(WorkflowAgentDifyToolsBuildError, match="not supported"): + builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + assert runtime_provider.last_agent_tool is None + + +def test_build_layers_rejects_dataset_retrieval_provider_type() -> None: + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "dataset-provider", + "provider_type": "dataset-retrieval", + "tool_name": "search", + "credential_type": "unauthorized", + } + ] + } + ) + + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: + builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert exc_info.value.error_code == "agent_tool_provider_not_supported" + assert runtime_provider.last_agent_tool is None + + +def test_builds_core_tool_with_missing_required_select_default(): runtime_provider = FakeRuntimeProvider(_tts_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -282,17 +454,24 @@ def test_builds_dify_plugin_tool_with_missing_required_select_default(): } ) - result = _build(builder, tools) + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) - assert result is not None - prepared = result.tools[0] + assert result.core_tools is not None + prepared = result.core_tools.tools[0] assert prepared.tool_name == "tts" assert prepared.runtime_parameters == {"model": "provider-a#model-a"} assert runtime_provider.last_use_default_for_missing_form_parameters is True def test_rejects_duplicate_exposed_tool_names(): - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool())) + runtime_provider = FakeRuntimeProvider(_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -314,14 +493,15 @@ def test_rejects_duplicate_exposed_tool_names(): } ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, tools) assert exc_info.value.error_code == "agent_tool_name_duplicated" + assert runtime_provider.call_count == 1 def test_rejects_missing_required_runtime_parameter(): - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={}))) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={}))) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -335,7 +515,7 @@ def test_rejects_missing_required_runtime_parameter(): } ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, tools) assert exc_info.value.error_code == "agent_tool_runtime_parameter_missing" @@ -355,7 +535,7 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider(): representative invoke_from values.""" for invoke_from in (InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -382,7 +562,7 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider(): def test_disabled_tools_are_skipped(): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -408,7 +588,7 @@ def test_plugin_id_plus_provider_fallback_when_provider_id_missing(): """Frontend may send ``plugin_id`` + ``provider`` instead of the concatenated ``provider_id``; the builder must accept both shapes.""" runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -444,7 +624,7 @@ def test_unauthorized_tool_without_credentials(): return tool runtime_provider = FakeRuntimeProvider(_no_credentials_tool()) - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -488,10 +668,10 @@ def _standard_tools_payload() -> AgentSoulToolsConfig: def test_tool_provider_not_found_maps_to_declaration_not_found(): from core.tools.errors import ToolProviderNotFoundError - builder = WorkflowAgentPluginToolsBuilder( + builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone")) ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_declaration_not_found" @@ -499,10 +679,10 @@ def test_tool_provider_not_found_maps_to_declaration_not_found(): def test_credential_validation_error_maps_to_credential_invalid(): from core.tools.errors import ToolProviderCredentialValidationError - builder = WorkflowAgentPluginToolsBuilder( + builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired")) ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_credential_invalid" @@ -512,8 +692,8 @@ def test_generic_value_error_maps_to_config_invalid(): ``agent_tool_config_invalid`` — distinct from ``agent_tool_declaration_not_found`` so callers can render a different hint.""" - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing"))) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing"))) + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_config_invalid" @@ -536,8 +716,8 @@ def test_rejects_non_scalar_credential_value(): tool.runtime.credentials = {"oauth": {"access_token": "secret", "expires_in": 3600}} return tool - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool())) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool())) + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_credential_shape_invalid" @@ -578,13 +758,13 @@ def test_legacy_provider_name_and_tool_parameters_normalized(): def test_provider_level_entry_expands_to_all_tools(): runtime_provider = FakeRuntimeProvider(_tool()) - listed: list[tuple[str, str]] = [] + listed: list[tuple[str, str, ToolProviderType]] = [] - def lister(*, tenant_id: str, provider_id: str) -> list[str]: - listed.append((tenant_id, provider_id)) + def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) -> list[str]: + listed.append((tenant_id, provider_id, provider_type)) return ["search", "image_search"] - builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister) tools = AgentSoulToolsConfig.model_validate( {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} ) @@ -593,13 +773,13 @@ def test_provider_level_entry_expands_to_all_tools(): assert result is not None assert [tool.tool_name for tool in result.tools] == ["search", "image_search"] - assert listed == [("tenant-1", "langgenius/search/search")] + assert listed == [("tenant-1", "langgenius/search/search", ToolProviderType.PLUGIN)] def test_explicit_tool_entry_wins_over_provider_expansion(): - builder = WorkflowAgentPluginToolsBuilder( + builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool()), - provider_tools_lister=lambda *, tenant_id, provider_id: ["search", "image_search"], + provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: ["search", "image_search"], ) tools = AgentSoulToolsConfig.model_validate( { @@ -623,15 +803,15 @@ def test_explicit_tool_entry_wins_over_provider_expansion(): def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(): - builder = WorkflowAgentPluginToolsBuilder( + builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool()), - provider_tools_lister=lambda *, tenant_id, provider_id: [], + provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: [], ) tools = AgentSoulToolsConfig.model_validate( {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, tools) assert exc_info.value.error_code == "agent_tool_declaration_not_found" @@ -639,17 +819,17 @@ def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(): def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found(): from core.tools.errors import ToolProviderNotFoundError - def lister(*, tenant_id: str, provider_id: str) -> list[str]: + def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) -> list[str]: raise ToolProviderNotFoundError("provider gone") - builder = WorkflowAgentPluginToolsBuilder( + builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister ) tools = AgentSoulToolsConfig.model_validate( {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} ) - with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, tools) assert exc_info.value.error_code == "agent_tool_declaration_not_found" @@ -659,7 +839,7 @@ def test_list_provider_tool_names_reads_builtin_provider(monkeypatch): to the plain name list the expansion step consumes.""" from types import SimpleNamespace - from core.workflow.nodes.agent_v2 import plugin_tools_builder as module + from core.workflow.nodes.agent_v2 import dify_tools_builder as module provider = SimpleNamespace( get_tools=lambda: [ @@ -676,7 +856,11 @@ def test_list_provider_tool_names_reads_builtin_provider(monkeypatch): monkeypatch.setattr(module.ToolManager, "get_builtin_provider", staticmethod(fake_get_builtin_provider)) - names = module._list_provider_tool_names(tenant_id="tenant-1", provider_id="langgenius/duckduckgo/duckduckgo") + names = module._list_provider_tool_names( + tenant_id="tenant-1", + provider_type=module.ToolProviderType.BUILT_IN, + provider_id="langgenius/duckduckgo/duckduckgo", + ) assert names == ["ddg_search", "ddg_news"] assert captured == {"provider_id": "langgenius/duckduckgo/duckduckgo", "tenant_id": "tenant-1"} 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 289319d74cb..3ed5a688c8b 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 @@ -1,17 +1,24 @@ import json from dataclasses import replace +from types import SimpleNamespace from typing import cast import pytest from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID -from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID +from clients.agent_backend import ( + DIFY_CONFIG_LAYER_ID, + DIFY_CORE_TOOLS_LAYER_ID, + DIFY_EXECUTION_CONTEXT_LAYER_ID, + DIFY_PLUGIN_TOOLS_LAYER_ID, +) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.workflow.file_reference import build_file_reference -from core.workflow.nodes.agent_v2.plugin_tools_builder import WorkflowAgentPluginToolsBuilder +from core.workflow.nodes.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder from core.workflow.nodes.agent_v2.runtime_request_builder import ( WorkflowAgentRuntimeBuildContext, WorkflowAgentRuntimeRequestBuilder, @@ -24,6 +31,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, + AgentSoulToolsConfig, DeclaredArrayItem, DeclaredOutputChildConfig, DeclaredOutputConfig, @@ -79,35 +87,65 @@ def test_agent_soul_round_trip_preserves_existing_app_feature_fields(): assert dumped["app_features"]["more_like_this"] == {"enabled": False} -class FakePluginToolsBuilder: +class CapturingPluginLayerBuilder: def __init__(self) -> None: # Capture the runtime invocation source so tests can assert it was # threaded through from ``DifyRunContext.invoke_from`` rather than # hard-coded to a placeholder like ``VALIDATION``. self.last_invoke_from: InvokeFrom | None = None - def build(self, *, tenant_id, app_id, user_id, tools, invoke_from): + def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from): assert tenant_id == "tenant-1" assert app_id == "app-1" assert user_id == "user-1" self.last_invoke_from = invoke_from if not tools.dify_tools: - return None - return DifyPluginToolsLayerConfig( - tools=[ - DifyPluginToolConfig( - plugin_id="langgenius/time", - provider="time", - tool_name="current_time", - credential_type="unauthorized", - name="current_time", - description="Get current time.", - credentials={}, - runtime_parameters={}, - parameters=[], - parameters_json_schema={"type": "object", "properties": {}, "required": []}, - ) - ] + return SimpleNamespace(plugin_tools=None, core_tools=None, exposed_tool_names=lambda: []) + return SimpleNamespace( + plugin_tools=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/time", + provider="time", + tool_name="current_time", + credential_type="unauthorized", + name="current_time", + description="Get current time.", + credentials={}, + runtime_parameters={}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + core_tools=None, + exposed_tool_names=lambda: ["current_time"], + ) + + +class FakeCoreLayerBuilder: + def build_layers(self, *, tenant_id, app_id, user_id, tools, invoke_from): + assert tenant_id == "tenant-1" + assert app_id == "app-1" + assert user_id == "user-1" + del tools, invoke_from + return SimpleNamespace( + plugin_tools=None, + core_tools=DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + runtime_parameters={}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + exposed_tool_names=lambda: ["transcribe"], ) @@ -218,6 +256,89 @@ def test_builds_create_run_request_from_agent_soul_and_node_job(): assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]" +def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_debugger(): + tools_builder = CapturingPluginLayerBuilder() + context = _context() + context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_type": "plugin", + "provider_id": "langgenius/time/time", + "tool_name": "current_time", + "credential_type": "unauthorized", + } + ] + } + ) + + result = WorkflowAgentRuntimeRequestBuilder( + credentials_provider=FakeCredentialsProvider(), + dify_tools_builder=tools_builder, # type: ignore[arg-type] + ).build(context) + + layers = _request_layers(result) + assert DIFY_PLUGIN_TOOLS_LAYER_ID in layers + assert DIFY_CORE_TOOLS_LAYER_ID not in layers + assert tools_builder.last_invoke_from == InvokeFrom.DEBUGGER + + +def test_build_forwards_service_api_invoke_from_to_injected_plugin_layer_builder(): + tools_builder = CapturingPluginLayerBuilder() + context = _context() + context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_type": "plugin", + "provider_id": "langgenius/time/time", + "tool_name": "current_time", + "credential_type": "unauthorized", + } + ] + } + ) + context = replace( + context, + dify_context=context.dify_context.model_copy(update={"invoke_from": InvokeFrom.SERVICE_API}), + ) + + result = WorkflowAgentRuntimeRequestBuilder( + credentials_provider=FakeCredentialsProvider(), + dify_tools_builder=tools_builder, # type: ignore[arg-type] + ).build(context) + + layers = _request_layers(result) + assert DIFY_PLUGIN_TOOLS_LAYER_ID in layers + assert DIFY_CORE_TOOLS_LAYER_ID not in layers + assert tools_builder.last_invoke_from == InvokeFrom.SERVICE_API + + +def test_build_includes_core_tools_layer_returned_by_injected_builder(): + context = _context() + context.snapshot.config_snapshot.tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_type": "builtin", + "provider_id": "audio", + "tool_name": "transcribe", + "credential_type": "unauthorized", + } + ] + } + ) + + result = WorkflowAgentRuntimeRequestBuilder( + credentials_provider=FakeCredentialsProvider(), + dify_tools_builder=FakeCoreLayerBuilder(), # type: ignore[arg-type] + ).build(context) + + layers = _request_layers(result) + assert DIFY_CORE_TOOLS_LAYER_ID in layers + assert DIFY_PLUGIN_TOOLS_LAYER_ID not in layers + + def test_normalizes_langgenius_model_provider_for_agent_backend_transport(): context = _context() context.snapshot.config_snapshot = AgentSoulConfig( @@ -287,13 +408,25 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "service-api" assert dumped["idempotency_key"] == "node-exec-1" output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"] + output_description = dumped["composition"]["layers"][-1]["config"]["description"] report_schema = output_schema["properties"]["report"] - assert len(report_schema["oneOf"]) == 4 - assert all(branch["additionalProperties"] is False for branch in report_schema["oneOf"]) - assert report_schema["oneOf"][0]["required"] == ["transfer_method", "reference"] - assert report_schema["oneOf"][1]["required"] == ["transfer_method", "reference"] - assert report_schema["oneOf"][2]["required"] == ["transfer_method", "reference"] - assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"] + report_schema_branches = { + branch["properties"]["transfer_method"]["enum"][0]: branch for branch in report_schema["anyOf"] + } + assert set(report_schema_branches) == {"tool_file", "remote_url"} + tool_file_branch = report_schema_branches["tool_file"] + assert tool_file_branch["additionalProperties"] is False + assert tool_file_branch["required"] == ["transfer_method", "reference"] + assert set(tool_file_branch["properties"]) == {"transfer_method", "reference"} + assert tool_file_branch["properties"]["reference"]["pattern"].startswith("^dify-file-ref:") + remote_url_branch = report_schema_branches["remote_url"] + assert remote_url_branch["additionalProperties"] is False + assert remote_url_branch["required"] == ["transfer_method", "url"] + assert set(remote_url_branch["properties"]) == {"transfer_method", "url"} + assert "dify-agent file upload " in output_description + assert "final_output.report" in output_description + assert "never invent the `reference` value" in output_description + assert "Do not call `final_output` before the upload command succeeds" in output_description assert output_schema["properties"]["confidence"]["type"] == "number" assert output_schema["required"] == ["report"] assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2} @@ -507,10 +640,10 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): ) context = replace(context, snapshot=snapshot) - plugin_tools_builder = FakePluginToolsBuilder() + dify_tools_builder = CapturingPluginLayerBuilder() result = WorkflowAgentRuntimeRequestBuilder( credentials_provider=FakeCredentialsProvider(), - plugin_tools_builder=cast(WorkflowAgentPluginToolsBuilder, plugin_tools_builder), + dify_tools_builder=cast(WorkflowAgentDifyToolsBuilder, dify_tools_builder), ).build(context) dumped = result.request.model_dump(mode="json") @@ -527,7 +660,7 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): # into the plugin tools builder so ToolManager attributes credential # quotas / rate limits / audit tags to the real call site instead of a # hard-coded ``VALIDATION`` placeholder. - assert plugin_tools_builder.last_invoke_from == context.dify_context.invoke_from + assert dify_tools_builder.last_invoke_from == context.dify_context.invoke_from def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): @@ -840,8 +973,12 @@ def test_empty_declared_outputs_injects_prd_defaults_text_files_json(): assert properties["text"]["type"] == "string" assert properties["files"]["type"] == "array" # `files` defaults to array → items is a file ref object. - assert len(properties["files"]["items"]["oneOf"]) == 4 - assert all(branch["additionalProperties"] is False for branch in properties["files"]["items"]["oneOf"]) + file_item_branches = properties["files"]["items"]["anyOf"] + assert [branch["properties"]["transfer_method"]["enum"] for branch in file_item_branches] == [ + ["tool_file"], + ["remote_url"], + ] + assert all(branch["additionalProperties"] is False for branch in file_item_branches) assert properties["json"]["type"] == "object" # Defaults are all required=False so no `required:` key on the schema. assert "required" not in output_layer["json_schema"] @@ -1123,196 +1260,141 @@ def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_cont assert "...[truncated]" not in _workflow_user_prompt(result) -# ── ENG-623: dify.drive declaration layer ───────────────────────────────────── +# ── Agent config declaration layer ──────────────────────────────────────────── -def _soul_with_drive_skill() -> AgentSoulConfig: +def _soul_with_config_assets() -> AgentSoulConfig: return AgentSoulConfig( prompt={ "system_prompt": ( - "You are careful. Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] " - "and [§file:files%2Fsample.pdf:sample.pdf§]." + "You are careful. Use [§skill:tender-analyzer:Tender Analyzer§] and [§file:sample.pdf:sample.pdf§]." ) }, model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + config_skills=[{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}], + config_files=[{"name": "sample.pdf", "file_kind": "upload_file", "file_id": "upload-file-1"}], + config_note="Read the proposal first.", ) -def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills", - lambda self, *, tenant_id, agent_id: [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "size": 123, - "mime_type": "text/markdown", - "hash": "hash-1", - "created_at": 1, - } - ], +def test_build_config_layer_config_includes_soul_context_and_mentions(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + + config, warnings = build_config_layer_config( + _soul_with_config_assets(), + agent_id="agent-1", + config_version_id="snapshot-1", ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest", - lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [ - {"key": "tender-analyzer/SKILL.md", "is_skill": True}, - {"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False}, - {"key": "files/sample.pdf", "is_skill": False}, - ], - ) - - -def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills", - lambda self, *, tenant_id, agent_id: [], - ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest", - lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [], - ) - - -def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config - - _mock_drive_catalog(monkeypatch) - config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id="agent-1") assert config is not None - assert config.drive_ref == "agent-agent-1" - assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"] - assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip" - assert config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"] - assert config.mentioned_file_keys == ["files/sample.pdf"] + assert config.agent_id == "agent-1" + assert config.config_version is not None + assert config.config_version.id == "snapshot-1" + assert config.config_version.kind == "snapshot" + assert config.config_version.writable is False + assert [skill.name for skill in config.skills] == ["tender-analyzer"] + assert [file_ref.name for file_ref in config.files] == ["sample.pdf"] + assert config.note == "Read the proposal first." + assert config.mentioned_skill_names == ["tender-analyzer"] + assert config.mentioned_file_names == ["sample.pdf"] assert warnings == [] -def test_build_drive_layer_config_emits_drive_ref_when_catalog_is_empty(monkeypatch: pytest.MonkeyPatch): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config +def test_build_config_layer_config_returns_none_for_empty_agent_soul(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config - _mock_empty_drive_catalog(monkeypatch) soul = AgentSoulConfig( model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test") ) - config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1") + config, warnings = build_config_layer_config(soul) - assert config is not None - assert config.drive_ref == "agent-agent-1" - assert config.skills == [] - assert config.mentioned_skill_keys == [] - assert config.mentioned_file_keys == [] + assert config is None assert warnings == [] -def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatch: pytest.MonkeyPatch): +def test_workflow_run_request_has_no_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) - _mock_empty_drive_catalog(monkeypatch) result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context()) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} - assert layers["drive"]["config"] == { - "drive_ref": "agent-agent-1", - "skills": [], - "mentioned_skill_keys": [], - "mentioned_file_keys": [], - } + assert DIFY_CONFIG_LAYER_ID not in layers assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} - assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] == "agent-agent-1" - assert layers["drive"]["deps"] == {"shell": DIFY_SHELL_LAYER_ID} + assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None -def test_build_drive_layer_config_requires_agent_identity(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config - - config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id=None) - - assert config is None - assert [w["code"] for w in warnings] == ["drive_ref_dangling"] - - -def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch): - """Contract test: locks the dify.drive composition shape against cross-package drift.""" +def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch): + """Contract test: locks the dify.config composition shape against cross-package drift.""" monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) - _mock_drive_catalog(monkeypatch) context = _context() - context.snapshot.config_snapshot = _soul_with_drive_skill() + context.snapshot.config_snapshot = _soul_with_config_assets() result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) dumped = result.request.model_dump(mode="json") layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] - assert "drive" in layer_names - # shell enters first; drive uses that shell to materialize mentioned targets. + assert DIFY_CONFIG_LAYER_ID in layer_names + # shell enters first; config uses that shell to materialize mentioned targets. assert layer_names.index(DIFY_SHELL_LAYER_ID) == layer_names.index("execution_context") + 1 - assert layer_names.index("drive") == layer_names.index(DIFY_SHELL_LAYER_ID) + 1 - drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive") - assert drive["type"] == "dify.drive" - assert drive["deps"] == {"shell": DIFY_SHELL_LAYER_ID} - assert drive["config"]["drive_ref"] == "agent-agent-1" - assert drive["config"]["skills"] == [ - { - "path": "tender-analyzer", - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", - } - ] - assert drive["config"]["mentioned_skill_keys"] == ["tender-analyzer/SKILL.md"] - assert drive["config"]["mentioned_file_keys"] == ["files/sample.pdf"] + assert layer_names.index(DIFY_CONFIG_LAYER_ID) == layer_names.index(DIFY_SHELL_LAYER_ID) + 1 + config = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == DIFY_CONFIG_LAYER_ID) + assert config["type"] == "dify.config" + assert config["deps"] == {"shell": DIFY_SHELL_LAYER_ID} + assert config["config"] == { + "agent_id": "agent-1", + "config_version": {"id": "snapshot-1", "kind": "snapshot", "writable": False}, + "skills": [ + { + "name": "tender-analyzer", + "description": "Parses RFPs.", + "size": None, + "mime_type": "application/zip", + } + ], + "files": [{"name": "sample.pdf", "size": None, "mime_type": None}], + "env_keys": [], + "note": "Read the proposal first.", + "mentioned_skill_names": ["tender-analyzer"], + "mentioned_file_names": ["sample.pdf"], + } warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] assert warnings == [] - # the drive layer is non-sensitive and must survive into persistable specs + # the config layer is non-sensitive and must survive into persistable specs from dify_agent.protocol import extract_runtime_layer_specs specs = extract_runtime_layer_specs(result.request.composition) - assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs) + assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs) -def test_workflow_runtime_expands_drive_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch): +def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) - _mock_drive_catalog(monkeypatch) context = _context() - context.snapshot.config_snapshot = _soul_with_drive_skill() + context.snapshot.config_snapshot = _soul_with_config_assets() result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert soul_prompt.config.prefix == "You are careful. Use Tender Analyzer and sample.pdf." + assert soul_prompt.config.prefix == "You are careful. Use tender-analyzer and sample.pdf." assert "[§" not in soul_prompt.config.prefix -def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(monkeypatch: pytest.MonkeyPatch): +def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills", - lambda self, *, tenant_id, agent_id: [], - ) - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest", - lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [], - ) context = _context() context.snapshot.config_snapshot = AgentSoulConfig( prompt={ "system_prompt": ( - "Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], " - "and [§file:files%2Fno-label.txt§]." + "Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]." ) }, model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), @@ -1321,34 +1403,36 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") - assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt." + assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt." assert "[§" not in soul_prompt.config.prefix -def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch): +def test_workflow_run_request_has_no_config_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False ) context = _context() - context.snapshot.config_snapshot = _soul_with_drive_skill() + context.snapshot.config_snapshot = _soul_with_config_assets() result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) dumped = result.request.model_dump(mode="json") - assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"]) + assert all(layer["name"] != DIFY_CONFIG_LAYER_ID for layer in dumped["composition"]["layers"]) assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == [] -def test_build_drive_layer_config_missing_mentions_warn_but_keep_skill_catalog(monkeypatch: pytest.MonkeyPatch): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config +def test_build_config_layer_config_missing_mentions_warn_without_catalog(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config - _mock_drive_catalog(monkeypatch) soul = AgentSoulConfig( model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), - prompt={"system_prompt": "Use [§skill:ghost%2FSKILL.md:Ghost§]"}, + config_skills=[{"name": "tender-analyzer", "description": "Parses RFPs.", "file_id": "tool-file-1"}], + prompt={"system_prompt": "Use [§skill:ghost-skill:Ghost§]"}, ) - config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1") + config, warnings = build_config_layer_config(soul) assert config is not None + assert config.mentioned_skill_names == [] + assert config.mentioned_file_names == [] assert [w["code"] for w in warnings] == ["mention_target_missing"] 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 60bc36c773f..2ff68108c81 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -236,6 +236,62 @@ def test_load_workflow_composer_uses_inline_preview_snapshot(monkeypatch: pytest assert result == {"agent": "inline-agent-1", "version": "inline-preview-version"} +def test_workflow_inline_debug_conversation_seed(monkeypatch: pytest.MonkeyPatch): + captured: dict[str, object] = {} + + class FakeRosterService: + def __init__(self, session): + captured["session"] = session + + def get_or_create_agent_app_debug_conversation_id(self, **kwargs): + captured.update(kwargs) + return "debug-conversation-1" + + monkeypatch.setattr(roster_service, "AgentRosterService", FakeRosterService) + + binding = SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT) + agent = SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY) + + debug_conversation_id = AgentComposerService._workflow_inline_debug_conversation_id( + tenant_id="tenant-1", + binding=binding, + agent=agent, + account_id="account-1", + ) + + assert debug_conversation_id == "debug-conversation-1" + assert captured["tenant_id"] == "tenant-1" + assert captured["agent_id"] == "inline-agent-1" + assert captured["account_id"] == "account-1" + + +def test_workflow_inline_debug_conversation_seed_skips_non_inline(monkeypatch: pytest.MonkeyPatch): + class UnexpectedRosterService: + def __init__(self, session): + raise AssertionError("roster service should not be used") + + monkeypatch.setattr(roster_service, "AgentRosterService", UnexpectedRosterService) + + assert ( + AgentComposerService._workflow_inline_debug_conversation_id( + tenant_id="tenant-1", + binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.ROSTER_AGENT), + agent=SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER), + account_id="account-1", + ) + is None + ) + assert ( + AgentComposerService._workflow_inline_debug_conversation_id( + tenant_id="tenant-1", + binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT), + agent=SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY), + account_id=None, + ) + is None + ) + + def test_load_workflow_composer_rejects_preview_without_binding(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) @@ -267,6 +323,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, current_snapshot_id="version-1", ) calls = [] + serialize_calls = [] monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) @@ -282,7 +339,12 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, "_get_version_if_present", lambda **kwargs: SimpleNamespace(id="version-1"), ) - monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", lambda **kwargs: {"state": "ok"}) + + def serialize_workflow_state(**kwargs): + serialize_calls.append(kwargs) + return {"state": "ok"} + + monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state) def save_helper(**kwargs): calls.append(kwargs) @@ -304,6 +366,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"state": "ok"} assert calls + assert serialize_calls[0]["account_id"] == "account-1" assert fake_session.commits == 1 @@ -437,10 +500,12 @@ def test_save_agent_app_composer_rejects_version_save_strategy(): def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch): agent = SimpleNamespace( id="agent-1", + source=AgentSource.AGENT_APP, active_config_snapshot_id="version-1", active_config_is_published=True, updated_by=None, ) + active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json")) fake_session = FakeSession(scalar=[agent]) saved = {} @@ -451,6 +516,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey "_save_agent_draft", lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"), ) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version) monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( { @@ -473,6 +539,43 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey assert fake_session.commits == 1 +def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch): + agent_soul = _agent_soul_with_model() + agent = SimpleNamespace( + id="agent-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="version-1", + active_config_is_published=False, + updated_by=None, + ) + active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json")) + fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]]) + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) + monkeypatch.setattr( + AgentComposerService, + "_save_agent_draft", + lambda **_kwargs: SimpleNamespace(id="draft-1"), + ) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version) + monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True}) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "agent_soul": agent_soul.model_dump(mode="json"), + } + ) + + AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload + ) + + assert agent.active_config_is_published is True + assert fake_session.commits == 1 + + def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch): agent = Agent( id="agent-1", @@ -565,7 +668,11 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict assert fake_session.commits == 1 - fake_session = FakeSession(scalar=[agent, build_draft, normal_draft]) + active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict) + fake_session = FakeSession( + scalar=[agent, build_draft, normal_draft, active_version], + scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]], + ) monkeypatch.setattr(composer_service.db, "session", fake_session) applied = AgentComposerService.apply_agent_app_build_draft( @@ -576,6 +683,62 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey assert applied["result"] == "success" assert applied["draft"]["id"] == normal_draft.id + assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict + assert agent.active_config_is_published is True + assert fake_session.deleted == [build_draft] + assert fake_session.commits == 1 + + +def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + active_config_is_published=True, + ) + active_agent_soul = _agent_soul_with_model() + build_agent_soul = AgentSoulConfig.model_validate( + { + **active_agent_soul.model_dump(mode="json"), + "prompt": { + "system_prompt": "Build draft prompt", + }, + } + ) + build_draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id="account-1", + draft_owner_key="account-1", + base_snapshot_id="version-1", + config_snapshot=build_agent_soul, + ) + normal_draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=active_agent_soul, + ) + active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json")) + fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + + AgentComposerService.apply_agent_app_build_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict assert agent.active_config_is_published is False assert fake_session.deleted == [build_draft] @@ -690,6 +853,53 @@ def test_serialize_workflow_state_passes_user_declared_outputs_through_effective assert effective[0]["required"] is True +def test_serialize_workflow_state_includes_inline_debug_conversation_message_state( + monkeypatch: pytest.MonkeyPatch, +): + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + workflow_id="workflow-1", + node_id="node-1", + node_job_config='{"workflow_prompt":"work"}', + ) + agent = Agent( + id="agent-1", + name="Inline Agent", + description="", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + backing_app_id="backing-app-1", + ) + version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') + monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) + monkeypatch.setattr( + AgentComposerService, + "_workflow_inline_debug_conversation_id", + lambda **kwargs: "debug-conversation-1", + ) + monkeypatch.setattr( + roster_service.AgentRosterService, + "count_agent_app_debug_conversation_messages", + lambda self, *, conversation_id: 2, + ) + + state = AgentComposerService._serialize_workflow_state( + binding=binding, + agent=agent, + version=version, + account_id="account-1", + ) + + assert state["debug_conversation_id"] == "debug-conversation-1" + assert state["debug_conversation_has_messages"] is True + assert state["debug_conversation_message_count"] == 2 + + def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -1215,16 +1425,18 @@ def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(mo version=1, config_snapshot='{"prompt":{"system_prompt":"inline"}}', ) + serialize_calls = [] monkeypatch.setattr(composer_service.db, "session", FakeSession()) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding) monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent) monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version) - monkeypatch.setattr( - AgentComposerService, - "_serialize_workflow_state", - lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value}, - ) + + def serialize_workflow_state(**kwargs): + serialize_calls.append(kwargs) + return {"binding_type": kwargs["binding"].binding_type.value} + + monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state) state = AgentComposerService.copy_workflow_composer_from_roster( tenant_id="tenant-1", @@ -1236,6 +1448,7 @@ def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(mo ) assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value} + assert serialize_calls[0]["account_id"] == "account-1" @pytest.mark.parametrize( @@ -1611,6 +1824,52 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa ) +def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch): + agent_soul = AgentSoulConfig() + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="snapshot-1", + ) + snapshot = SimpleNamespace(config_snapshot_dict=agent_soul) + fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot) + + assert ( + AgentComposerService._agent_soul_matches_active_config( + tenant_id="tenant-1", + agent=agent, + agent_soul=agent_soul, + ) + is False + ) + + +def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch): + agent_soul = AgentSoulConfig() + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="snapshot-1", + ) + snapshot = SimpleNamespace(config_snapshot_dict=agent_soul) + fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot) + + assert ( + AgentComposerService._agent_soul_matches_active_config( + tenant_id="tenant-1", + agent=agent, + agent_soul=agent_soul, + ) + is True + ) + + def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession( scalar=[ @@ -2220,6 +2479,16 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert recreate_session.commits == 1 +def test_agent_app_debug_conversation_message_count(): + session = FakeSession(scalar=[3]) + + count = AgentRosterService(session).count_agent_app_debug_conversation_messages( + conversation_id="debug-conversation-1", + ) + + assert count == 3 + + def test_agent_app_debug_conversation_requires_app_binding(): agent = 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 588093b89ca..b65f8b6f410 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -241,7 +241,21 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig): expanded = expand_prompt_mentions(prompt, resolver) - assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.") + assert expanded == ( + "Read START/tenders and produce qna_report (file output; create the file locally, run " + "`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON " + "as final_output.qna_report; do not call final_output before upload succeeds, and do not use " + "the local path, filename, URL, or a synthesized dify-file-ref as the reference); " + "if unsure contact EMAIL · David Hayes." + ) + + +def test_node_job_resolver_accepts_legacy_reversed_output_token(node_job: WorkflowNodeJobConfig): + resolver = build_node_job_mention_resolver(node_job) + + expanded = expand_prompt_mentions("[§qna_report:qna_report:output§]", resolver) + + assert "final_output.qna_report" in expanded def test_node_job_resolver_matches_ref_by_node_id_and_output_fields(): diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py new file mode 100644 index 00000000000..e122bcb066b --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -0,0 +1,661 @@ +"""Focused tests for the Agent Soul-backed config service.""" + +from __future__ import annotations + +import io +import zipfile +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from models.agent_config_entities import ( + AgentConfigFileRefConfig, + AgentConfigSkillRefConfig, + AgentEnvVariableConfig, + AgentSoulConfig, +) +from services.agent.skill_package_service import SkillPackageError +from services.agent_config_service import ( + AgentConfigService, + AgentConfigServiceError, + AgentConfigTarget, + AgentConfigVersionKind, + ConfigPushPayload, + ConfigPushSkillItem, +) + +MODULE = "services.agent_config_service" +TENANT = "tenant-1" +AGENT = "agent-1" +USER = "user-1" + + +def _session_cm(session: MagicMock) -> MagicMock: + context_manager = MagicMock() + context_manager.__enter__.return_value = session + context_manager.__exit__.return_value = None + return context_manager + + +def _soul(**updates) -> AgentSoulConfig: + payload = AgentSoulConfig().model_dump(mode="json") + payload.update(updates) + return AgentSoulConfig.model_validate(payload) + + +def _version(*, version_id: str = "version-1", snapshot: AgentSoulConfig | None = None) -> SimpleNamespace: + agent_soul = snapshot or _soul() + return SimpleNamespace( + id=version_id, + config_snapshot_dict=agent_soul.model_dump(mode="json"), + config_snapshot=agent_soul, + ) + + +def _target( + *, + kind: AgentConfigVersionKind, + writable: bool, + version_id: str = "version-1", + soul: AgentSoulConfig | None = None, +) -> AgentConfigTarget: + agent_soul = soul or _soul() + return AgentConfigTarget( + agent_id=AGENT, + version_id=version_id, + kind=kind, + writable=writable, + version=_version(version_id=version_id, snapshot=agent_soul), + agent_soul=agent_soul, + ) + + +def _zip_bytes(members: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, payload in members.items(): + archive.writestr(name, payload) + return buffer.getvalue() + + +@pytest.mark.parametrize( + ("kind", "user_id", "version_row", "expected_writable"), + [ + (AgentConfigVersionKind.SNAPSHOT, None, _version(version_id="snapshot-1"), False), + (AgentConfigVersionKind.DRAFT, USER, _version(version_id="draft-1"), False), + (AgentConfigVersionKind.BUILD_DRAFT, USER, _version(version_id="build-draft-1"), True), + ], +) +def test_resolve_target_supports_snapshot_draft_and_build_draft( + kind: AgentConfigVersionKind, + user_id: str | None, + version_row: SimpleNamespace, + expected_writable: bool, +) -> None: + session = MagicMock() + session.scalar.side_effect = [AGENT, version_row] + service = AgentConfigService() + + with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)): + target = service.resolve_target( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id=version_row.id, + config_version_kind=kind, + user_id=user_id, + ) + + assert target.agent_id == AGENT + assert target.version_id == version_row.id + assert target.kind == kind + assert target.writable is expected_writable + + +def test_resolve_target_requires_user_for_build_draft() -> None: + session = MagicMock() + session.scalar.side_effect = [AGENT] + service = AgentConfigService() + + with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)): + with pytest.raises(AgentConfigServiceError, match="user_id is required") as exc_info: + service.resolve_target( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + ) + + assert exc_info.value.code == "missing_user_id" + + +@pytest.mark.parametrize( + ("first_scalar", "expected_code"), + [ + (None, "agent_not_found"), + (AGENT, "config_version_not_found"), + ], +) +def test_resolve_target_maps_missing_agent_and_version(first_scalar: str | None, expected_code: str) -> None: + session = MagicMock() + if first_scalar is None: + session.scalar.return_value = None + else: + session.scalar.side_effect = [first_scalar, None] + service = AgentConfigService() + + with patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)): + with pytest.raises(AgentConfigServiceError) as exc_info: + service.resolve_target( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="missing", + config_version_kind=AgentConfigVersionKind.SNAPSHOT, + user_id=USER, + ) + + assert exc_info.value.code == expected_code + + +def test_push_rejects_non_build_draft_writes() -> None: + session = MagicMock() + service = AgentConfigService() + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object( + service, + "_resolve_target_in_session", + return_value=_target(kind=AgentConfigVersionKind.DRAFT, writable=False), + ), + ): + with pytest.raises(AgentConfigServiceError, match="build drafts") as exc_info: + service.push( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="draft-1", + config_version_kind=AgentConfigVersionKind.DRAFT, + payload=ConfigPushPayload(note="ignored"), + ) + + assert exc_info.value.code == "config_not_writable" + session.commit.assert_not_called() + + +def test_push_for_console_allows_shared_draft_mutations() -> None: + session = MagicMock() + service = AgentConfigService() + target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=_soul(config_note="before")) + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object(service, "_resolve_target_in_session", return_value=target), + ): + manifest = service.push_for_console( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="draft-1", + config_version_kind=AgentConfigVersionKind.DRAFT, + payload=ConfigPushPayload(note="after"), + ) + + assert manifest["note"] == "after" + assert target.version.config_snapshot.config_note == "after" + session.commit.assert_called_once() + + +def test_push_accepts_tenant_scoped_tool_file_sources_from_different_upload_owner() -> None: + session = MagicMock() + service = AgentConfigService() + target = _target(kind=AgentConfigVersionKind.BUILD_DRAFT, writable=True) + file_source = SimpleNamespace( + id="tool-file-file", + tenant_id=TENANT, + user_id="end-user-1", + size=7, + mimetype="text/plain", + file_key="file-key", + name="guide.txt", + ) + skill_source = SimpleNamespace( + id="tool-file-skill", + tenant_id=TENANT, + user_id="end-user-1", + size=123, + mimetype="application/zip", + file_key="skill-key", + name="alpha.zip", + ) + skill_ref = AgentConfigSkillRefConfig( + name="alpha", + description="Alpha skill", + file_id="normalized-skill-file", + size=321, + mime_type="application/zip", + ) + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object(service, "_resolve_target_in_session", return_value=target), + patch.object(service, "_require_tool_file_source", side_effect=[file_source, skill_source]) as require_source, + patch(f"{MODULE}.storage.load_once", return_value=b"skill-archive"), + patch.object(service._skill_normalizer, "normalize", return_value=(skill_ref, object())), + ): + manifest = service.push( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + payload=ConfigPushPayload.model_validate( + { + "files": [{"name": "guide.txt", "file_ref": {"kind": "tool_file", "id": "tool-file-file"}}], + "skills": [{"name": "alpha", "file_ref": {"kind": "tool_file", "id": "tool-file-skill"}}], + } + ), + ) + + assert [call.args for call in require_source.call_args_list] == [(session,), (session,)] + assert [call.kwargs for call in require_source.call_args_list] == [ + {"tenant_id": TENANT, "file_id": "tool-file-file"}, + {"tenant_id": TENANT, "file_id": "tool-file-skill"}, + ] + files = manifest["files"] + skills = manifest["skills"] + assert isinstance(files, dict) + assert isinstance(skills, dict) + assert files["items"][0]["file_id"] == "tool-file-file" + assert skills["items"][0]["file_id"] == "normalized-skill-file" + session.commit.assert_called_once() + + +def test_push_file_for_console_rejects_snapshot_writes() -> None: + session = MagicMock() + service = AgentConfigService() + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object( + service, + "_resolve_target_in_session", + return_value=_target(kind=AgentConfigVersionKind.SNAPSHOT, writable=False), + ), + ): + with pytest.raises(AgentConfigServiceError, match="editable drafts") as exc_info: + service.push_file_for_console( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="snapshot-1", + config_version_kind=AgentConfigVersionKind.SNAPSHOT, + upload_file_id="upload-1", + ) + + assert exc_info.value.code == "config_not_writable" + + +def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() -> None: + session = MagicMock() + service = AgentConfigService() + target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False) + upload_file = SimpleNamespace( + id="upload-1", + name="guide.txt", + size=7, + hash="sha256:abc", + mime_type="text/plain", + ) + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object(service, "_resolve_target_in_session", return_value=target), + patch.object(service, "_require_console_upload_file_source", return_value=upload_file), + ): + response = service.push_file_for_console( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="draft-1", + config_version_kind=AgentConfigVersionKind.DRAFT, + upload_file_id="upload-1", + ) + + assert response == { + "file": { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-1", + "size": 7, + "hash": "sha256:abc", + "mime_type": "text/plain", + }, + "config_version": { + "id": "version-1", + "kind": "draft", + "writable": True, + }, + } + session.commit.assert_called_once() + + +def test_apply_skill_updates_rejects_non_tool_file_refs() -> None: + service = AgentConfigService() + + with pytest.raises(AgentConfigServiceError, match="tool files") as exc_info: + service._apply_skill_updates( + MagicMock(), + tenant_id=TENANT, + user_id=USER, + current=[], + updates=[ + ConfigPushSkillItem.model_validate( + {"name": "alpha", "file_ref": {"kind": "upload_file", "id": "upload-1"}} + ) + ], + ) + + assert exc_info.value.code == "invalid_skill_file_ref" + + +@pytest.mark.parametrize( + ("error_code", "message"), + [ + ("skill_name_mismatch", "skill name does not match requested config key"), + ("invalid_archive", "stored tool file is not a valid skill archive"), + ], +) +def test_apply_skill_updates_maps_normalizer_failures(error_code: str, message: str) -> None: + service = AgentConfigService() + tool_file = SimpleNamespace(name="alpha.zip", file_key="tool-files/alpha.zip") + + with ( + patch.object(service, "_require_tool_file_source", return_value=tool_file), + patch(f"{MODULE}.storage.load_once", return_value=b"bad-archive"), + patch.object( + service._skill_normalizer, + "normalize", + side_effect=SkillPackageError(error_code, message, status_code=400), + ), + ): + with pytest.raises(AgentConfigServiceError, match=message) as exc_info: + service._apply_skill_updates( + MagicMock(), + tenant_id=TENANT, + user_id=USER, + current=[], + updates=[ + ConfigPushSkillItem.model_validate( + {"name": "alpha", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}} + ) + ], + ) + + assert exc_info.value.code == error_code + + +def test_apply_env_text_supports_delete_comments_export_and_keeps_unmentioned_values() -> None: + current = [ + AgentEnvVariableConfig(key="KEEP", name="KEEP", value="old"), + AgentEnvVariableConfig(key="REMOVE", name="REMOVE", value="gone"), + AgentEnvVariableConfig(key="UNTOUCHED", name="UNTOUCHED", value="still-here"), + ] + + updated = AgentConfigService._apply_env_text( + current, + "# comment\nexport KEEP=new-value\nREMOVE=\nNEW='two words'\n", + ) + + values = {item.key: item.value for item in updated} + assert values == { + "KEEP": "new-value", + "UNTOUCHED": "still-here", + "NEW": "two words", + } + + +@pytest.mark.parametrize( + "archive_bytes", + [ + b"not-a-zip-archive", + _zip_bytes({"README.md": b"missing skill md"}), + ], +) +def test_inspect_skill_maps_invalid_archives_to_service_errors(archive_bytes: bytes) -> None: + service = AgentConfigService() + target = _target( + kind=AgentConfigVersionKind.BUILD_DRAFT, + writable=True, + soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]), + ) + + with ( + patch.object(service, "resolve_target", return_value=target), + patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")), + ): + with pytest.raises(AgentConfigServiceError, match="stored config skill archive is invalid") as exc_info: + service.inspect_skill( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + user_id=USER, + ) + + assert exc_info.value.code == "skill_archive_invalid" + assert exc_info.value.status_code == 500 + + +def test_manifest_uses_items_shape_without_download_urls() -> None: + target = _target( + kind=AgentConfigVersionKind.DRAFT, + writable=False, + soul=_soul( + config_skills=[AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")], + config_files=[AgentConfigFileRefConfig(name="guide.txt", file_kind="upload_file", file_id="upload-file-1")], + config_note="Use the guide.", + ), + ) + + manifest = AgentConfigService._manifest_for_target(target) + + assert manifest == { + "agent_id": AGENT, + "config_version": { + "id": "version-1", + "kind": "draft", + "writable": True, + }, + "skills": { + "items": [ + { + "id": "alpha", + "name": "alpha", + "file_id": "tool-file-1", + "description": "Alpha skill", + "size": None, + "hash": None, + "mime_type": "application/zip", + } + ] + }, + "files": { + "items": [ + { + "id": "guide.txt", + "name": "guide.txt", + "file_id": "upload-file-1", + "size": None, + "hash": None, + "mime_type": None, + } + ] + }, + "env_keys": [], + "note": "Use the guide.", + } + + +def test_preview_skill_file_returns_text_preview() -> None: + service = AgentConfigService() + target = _target( + kind=AgentConfigVersionKind.BUILD_DRAFT, + writable=True, + soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]), + ) + archive_bytes = _zip_bytes( + { + "SKILL.md": b"# Alpha\n", + "references/guide.md": b"hello world", + } + ) + + with ( + patch.object(service, "resolve_target", return_value=target), + patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")), + ): + preview = service.preview_skill_file( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + path="references/guide.md", + user_id=USER, + ) + + assert preview == { + "path": "references/guide.md", + "size": 11, + "truncated": False, + "binary": False, + "text": "hello world", + } + + +def test_preview_skill_file_marks_binary_and_truncated_payloads() -> None: + service = AgentConfigService() + target = _target( + kind=AgentConfigVersionKind.BUILD_DRAFT, + writable=True, + soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]), + ) + archive_bytes = _zip_bytes( + { + "SKILL.md": b"# Alpha\n", + "bin/data.bin": b"\x00" + (b"x" * (AgentConfigService.PREVIEW_MAX_BYTES + 10)), + } + ) + + with ( + patch.object(service, "resolve_target", return_value=target), + patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")), + ): + preview = service.preview_skill_file( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + path="bin/data.bin", + user_id=USER, + ) + + assert preview == { + "path": "bin/data.bin", + "size": AgentConfigService.PREVIEW_MAX_BYTES + 11, + "truncated": True, + "binary": True, + "text": None, + } + + +def test_resolve_skill_file_member_path_requires_existing_member() -> None: + service = AgentConfigService() + target = _target( + kind=AgentConfigVersionKind.BUILD_DRAFT, + writable=True, + soul=_soul(config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")]), + ) + archive_bytes = _zip_bytes( + { + "SKILL.md": b"# Alpha\n", + "references/guide.md": b"hello world", + } + ) + + with ( + patch.object(service, "resolve_target", return_value=target), + patch.object(service, "_load_tool_file_bytes", return_value=(archive_bytes, "application/zip")), + ): + assert ( + service.resolve_skill_file_member_path( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + path="references/guide.md", + user_id=USER, + ) + == "references/guide.md" + ) + + with pytest.raises(AgentConfigServiceError, match="config skill file not found") as exc_info: + service.resolve_skill_file_member_path( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + path="references/missing.md", + user_id=USER, + ) + + assert exc_info.value.code == "config_skill_file_not_found" + assert exc_info.value.status_code == 404 + + +def test_download_url_helpers_use_shared_url_resolution() -> None: + service = AgentConfigService() + target = _target( + kind=AgentConfigVersionKind.BUILD_DRAFT, + writable=True, + soul=_soul( + config_skills=[AgentConfigSkillRefConfig(name="alpha", file_id="tool-file-1")], + config_files=[AgentConfigFileRefConfig(name="guide.txt", file_kind="upload_file", file_id="upload-file-1")], + ), + ) + + with ( + patch.object(service, "resolve_target", return_value=target), + patch.object( + service, + "_resolve_download_url", + side_effect=["https://example.com/alpha.zip", "https://example.com/guide.txt"], + ), + ): + assert ( + service.download_skill_url( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="alpha", + user_id=USER, + ) + == "https://example.com/alpha.zip" + ) + assert ( + service.download_file_url( + tenant_id=TENANT, + agent_id=AGENT, + config_version_id="build-draft-1", + config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, + name="guide.txt", + user_id=USER, + ) + == "https://example.com/guide.txt" + ) diff --git a/api/tests/unit_tests/services/test_agent_tool_inner_service.py b/api/tests/unit_tests/services/test_agent_tool_inner_service.py new file mode 100644 index 00000000000..61049d29e9e --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_tool_inner_service.py @@ -0,0 +1,166 @@ +"""Unit tests for the Agent tool inner invoke service.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType +from core.tools.errors import ( + ToolInvokeError, + ToolParameterValidationError, + ToolProviderCredentialValidationError, + ToolProviderNotFoundError, +) +from services.agent_tool_inner_service import AgentToolInnerService +from services.entities.agent_tool_inner import AgentToolInvokeRequest +from services.errors.agent_tool_inner import AgentToolInnerServiceError + + +def _request() -> AgentToolInvokeRequest: + return AgentToolInvokeRequest.model_validate( + { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "user_from": "account", + "app_id": "app-1", + "invoke_from": "service-api", + "conversation_id": "conversation-1", + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "node_id": "node-1", + "node_execution_id": "node-exec-1", + "agent_id": "agent-1", + "agent_config_version_id": "snapshot-1", + }, + "tool": { + "provider_type": "plugin", + "provider_id": "langgenius/search/search", + "tool_name": "search", + "credential_id": "credential-1", + "tool_parameters": {"query": "dify"}, + "runtime_parameters": {"region": "us"}, + }, + } + ) + + +def _messages() -> Generator[ToolInvokeMessage, None, None]: + yield ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.TEXT, + message=ToolInvokeMessage.TextMessage(text="ok"), + ) + + +def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: + fake_tool = MagicMock() + fake_app = MagicMock(id="app-1", tenant_id="tenant-1") + session = MagicMock() + session.get.return_value = fake_app + + with ( + patch( + "services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", + return_value=fake_tool, + ) as mock_get_runtime, + patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", return_value=_messages()) as mock_invoke, + patch( + "services.agent_tool_inner_service.ToolFileMessageTransformer.transform_tool_invoke_messages", + side_effect=lambda messages, **_kwargs: messages, + ), + ): + response = AgentToolInnerService().invoke(_request(), session=session) + + assert response.observation == "ok" + assert response.metadata == { + "provider_type": "plugin", + "provider_id": "langgenius/search/search", + "tool_name": "search", + } + agent_tool = mock_get_runtime.call_args.kwargs["agent_tool"] + assert agent_tool.provider_type is ToolProviderType.PLUGIN + assert agent_tool.tool_parameters == {"region": "us"} + mock_invoke.assert_called_once() + + +def test_invoke_raises_app_not_found_when_session_has_no_app() -> None: + session = MagicMock() + session.get.return_value = None + + with pytest.raises(AgentToolInnerServiceError) as exc_info: + AgentToolInnerService().invoke(_request(), session=session) + + assert exc_info.value.error_code == "app_not_found" + assert exc_info.value.status_code == 404 + assert exc_info.value.description == "App not found." + + +def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant() -> None: + fake_app = MagicMock(id="app-1", tenant_id="tenant-2") + session = MagicMock() + session.get.return_value = fake_app + + with pytest.raises(AgentToolInnerServiceError) as exc_info: + AgentToolInnerService().invoke(_request(), session=session) + + assert exc_info.value.error_code == "app_tenant_mismatch" + assert exc_info.value.status_code == 403 + assert exc_info.value.description == "App does not belong to the caller tenant." + + +def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code() -> None: + fake_tool = MagicMock() + fake_app = MagicMock(id="app-1", tenant_id="tenant-1") + session = MagicMock() + session.get.return_value = fake_app + + with ( + patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), + patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", side_effect=ValueError("app not found")), + ): + with pytest.raises(AgentToolInnerServiceError) as exc_info: + AgentToolInnerService().invoke(_request(), session=session) + + assert exc_info.value.error_code == "app_not_found" + assert exc_info.value.status_code == 404 + assert exc_info.value.description == "App not found." + + +def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> None: + fake_tool = MagicMock() + fake_app = MagicMock(id="app-1", tenant_id="tenant-1") + session = MagicMock() + session.get.return_value = fake_app + + with ( + patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), + patch( + "services.agent_tool_inner_service.ToolEngine.generic_invoke", + side_effect=ToolInvokeError("workflow crashed"), + ), + ): + with pytest.raises(AgentToolInnerServiceError) as exc_info: + AgentToolInnerService().invoke(_request(), session=session) + + assert exc_info.value.error_code == "agent_tool_invoke_failed" + + +@pytest.mark.parametrize( + ("error", "expected_code"), + [ + (ToolProviderNotFoundError("provider gone"), "agent_tool_declaration_not_found"), + (ToolProviderCredentialValidationError("credential invalid"), "agent_tool_credential_invalid"), + (ToolParameterValidationError("query is required"), "tool_parameters_invalid"), + ], +) +def test_invoke_maps_runtime_lookup_errors_to_service_error_codes(error: Exception, expected_code: str) -> None: + fake_app = MagicMock(id="app-1", tenant_id="tenant-1") + session = MagicMock() + session.get.return_value = fake_app + + with patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", side_effect=error): + with pytest.raises(AgentToolInnerServiceError) as exc_info: + AgentToolInnerService().invoke(_request(), session=session) + + assert exc_info.value.error_code == expected_code diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 216c5d9db65..f34afb243eb 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -306,6 +306,22 @@ class TestGenerate: assert result == {"result": "chat"} gen_spy.assert_called_once() + def test_stateless_agent_mode(self, mocker: MockerFixture): + gen_spy = mocker.patch( + "services.app_generate_service.AgentAppGenerator.generate_stateless", + return_value={"result": "stateless-agent"}, + ) + + result = AppGenerateService.generate_stateless_agent_app( + app_model=_make_app(AppMode.AGENT), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + ) + + assert result == {"result": "stateless-agent"} + gen_spy.assert_called_once() + # -- ADVANCED_CHAT blocking --------------------------------------------- def test_advanced_chat_blocking(self, mocker: MockerFixture): workflow = _make_workflow() @@ -550,7 +566,136 @@ class TestGenerateBilling: streaming=False, ) # exit is called in finally block for non-streaming - assert len(exit_calls) >= 1 + assert exit_calls == ["dummy-request-id"] + + def test_stateless_agent_app_uses_billing_and_rate_limit_guardrails( + self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + quota_charge = MagicMock() + reserve_mock = mocker.patch( + "services.app_generate_service.QuotaService.reserve", + return_value=quota_charge, + ) + exit_calls: list[str] = [] + + class _TrackingRateLimit(_DummyRateLimit): + def exit(self, request_id: str) -> None: + exit_calls.append(request_id) + + mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) + gen_spy = mocker.patch( + "services.app_generate_service.AgentAppGenerator.generate_stateless", + return_value={"ok": True}, + ) + + result = AppGenerateService.generate_stateless_agent_app( + app_model=_make_app(AppMode.AGENT), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + ) + + assert result == {"ok": True} + reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") + quota_charge.commit.assert_called_once() + assert exit_calls == ["dummy-request-id"] + gen_spy.assert_called_once() + + def test_stateless_agent_app_failure_refunds_quota_and_exits_once( + self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + quota_charge = MagicMock() + mocker.patch( + "services.app_generate_service.QuotaService.reserve", + return_value=quota_charge, + ) + exit_calls: list[str] = [] + + class _TrackingRateLimit(_DummyRateLimit): + def exit(self, request_id: str) -> None: + exit_calls.append(request_id) + + mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) + mocker.patch( + "services.app_generate_service.AgentAppGenerator.generate_stateless", + side_effect=RuntimeError("boom"), + ) + + with pytest.raises(RuntimeError, match="boom"): + AppGenerateService.generate_stateless_agent_app( + app_model=_make_app(AppMode.AGENT), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + ) + + quota_charge.commit.assert_called_once() + quota_charge.refund.assert_called_once() + assert exit_calls == ["dummy-request-id"] + + def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + quota_charge = MagicMock() + mocker.patch( + "services.app_generate_service.QuotaService.reserve", + return_value=quota_charge, + ) + exit_calls: list[str] = [] + + class _TrackingRateLimit(_DummyRateLimit): + def exit(self, request_id: str) -> None: + exit_calls.append(request_id) + + mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) + mocker.patch( + "services.app_generate_service.CompletionAppGenerator.generate", + side_effect=RuntimeError("boom"), + ) + + with pytest.raises(RuntimeError, match="boom"): + AppGenerateService.generate( + app_model=_make_app(AppMode.COMPLETION), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + ) + + quota_charge.refund.assert_called_once() + assert exit_calls == ["dummy-request-id"] + + def test_streaming_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + quota_charge = MagicMock() + mocker.patch( + "services.app_generate_service.QuotaService.reserve", + return_value=quota_charge, + ) + exit_calls: list[str] = [] + + class _TrackingRateLimit(_DummyRateLimit): + def exit(self, request_id: str) -> None: + exit_calls.append(request_id) + + mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) + mocker.patch( + "services.app_generate_service.CompletionAppGenerator.generate", + side_effect=RuntimeError("boom"), + ) + + with pytest.raises(RuntimeError, match="boom"): + AppGenerateService.generate( + app_model=_make_app(AppMode.COMPLETION), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=True, + ) + + quota_charge.refund.assert_called_once() + assert exit_calls == ["dummy-request-id"] # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py index 237b43200de..189f6c1512e 100644 --- a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock from pytest_mock import MockerFixture +from core.app.entities.app_invoke_entities import InvokeFrom from models.account import Account from models.human_input import HumanInputForm from models.model import App, Conversation, EndUser @@ -45,7 +46,7 @@ def _wire_db( def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) account = MagicMock() app = MagicMock(tenant_id="tenant-1") _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) @@ -59,10 +60,11 @@ def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixtu assert kwargs["conversation_id"] == "conv-1" assert kwargs["user"] is account assert kwargs["app_model"] is app + assert kwargs["invoke_from"] == InvokeFrom.WEB_APP def test_resume_end_user_path(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1") + conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP) end_user = MagicMock() _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) gen = mocker.patch(f"{MODULE}.AgentAppGenerator") @@ -72,6 +74,18 @@ def test_resume_end_user_path(mocker: MockerFixture): assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user +def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER) + account = MagicMock() + app = MagicMock(tenant_id="tenant-1") + _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER + + def test_resume_returns_when_form_missing(mocker: MockerFixture): _wire_db(mocker, form=None) gen = mocker.patch(f"{MODULE}.AgentAppGenerator") @@ -109,7 +123,7 @@ def test_resume_returns_when_conversation_missing(mocker: MockerFixture): def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id=None) + conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) gen = mocker.patch(f"{MODULE}.AgentAppGenerator") @@ -119,7 +133,7 @@ def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None) + conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) gen = mocker.patch(f"{MODULE}.AgentAppGenerator") @@ -129,7 +143,7 @@ def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixtu def test_resume_swallows_generator_exception(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) gen = mocker.patch(f"{MODULE}.AgentAppGenerator") gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") diff --git a/api/uv.lock b/api/uv.lock index dfdbc7343c9..b53c89bf5a8 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1302,11 +1302,11 @@ requires-dist = [ { name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" }, { name = "protobuf", marker = "extra == 'grpc'", specifier = ">=6.33.5,<7.0.0" }, { name = "pydantic", specifier = ">=2.12.5,<2.13" }, - { name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" }, + { name = "pydantic-ai-slim", specifier = ">=1.102.0,<2.0.0" }, { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, - { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" }, + { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.3.0" }, { name = "typer", specifier = ">=0.16.1,<0.17" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, @@ -2650,15 +2650,15 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.0.59" +version = "0.0.67" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/c8/b61a028b8d8ee286ffab3f9b9f1c9229087184e7d543cea4e349e11375b0/genai_prices-0.0.59.tar.gz", hash = "sha256:3e1c7dcd9b38163589c8cf4a9bcfd286c52ea57a3becdc062a2cbaa8295b08c4", size = 67406, upload-time = "2026-05-07T12:08:40.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/9e/f96ad08d62f7bd33a5b24e65d4eb220569714b9a2a8813ada2e1fa47b4dd/genai_prices-0.0.67.tar.gz", hash = "sha256:54e07eb6541fda377187a471c5dba21a81b439c57f8dc44d89db3103c29ca343", size = 80015, upload-time = "2026-06-24T20:16:23.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/f9/4693c127f9fab0a8d39c47c198e378ecafcb043463e6dd73df205eacbc13/genai_prices-0.0.59-py3-none-any.whl", hash = "sha256:88fd8818e6807374e5a5c03f293b574ade5f18a3060622080cdd94a03cf43115", size = 70509, upload-time = "2026-05-07T12:08:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/dc/05/d1ca6b960a3305f86d1c5f4274f2ddf8c94611ec7edc436a01cd38a01742/genai_prices-0.0.67-py3-none-any.whl", hash = "sha256:08977f1e83b4132abcfc60dabf21ff13c2d25958afb9199e59c4407bf5c9ed3f", size = 82495, upload-time = "2026-06-24T20:16:22.4Z" }, ] [[package]] @@ -3278,6 +3278,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, +] + [[package]] name = "httplib2" version = "0.31.0" @@ -3337,6 +3350,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, +] + [[package]] name = "huggingface-hub" version = "1.6.0" @@ -5153,7 +5181,7 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "1.94.0" +version = "1.107.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, @@ -5164,9 +5192,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/0b/ce4992e0e29ba81ba48d5bba955c53b72e2cda3636f9b6417386ae7e45f7/pydantic_ai_slim-1.94.0.tar.gz", hash = "sha256:7d7b1d6aec4d0fd31533a4ef5848863e8513ec75e82910296247a08b737aa828", size = 640338, upload-time = "2026-05-12T07:03:55.486Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/26/ced63dfaabbc77f3beb86d59689cdea748e7ccffb6b419dbaf4780f211e8/pydantic_ai_slim-1.107.0.tar.gz", hash = "sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4", size = 779902, upload-time = "2026-06-10T14:53:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/dd/b104641c2af7044a788b1071159679aa755e5c9281f110fdc54b4729117b/pydantic_ai_slim-1.94.0-py3-none-any.whl", hash = "sha256:f47cf89c61ef45a48dd575a8b32707edfec2b33ef7af80aa069bde1ce3fb6795", size = 805546, upload-time = "2026-05-12T07:03:46.535Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/71044e17f931b08cc3930bc0fe5a1e1fd37fa474ae826be004729ef1cb4a/pydantic_ai_slim-1.107.0-py3-none-any.whl", hash = "sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6", size = 964046, upload-time = "2026-06-10T14:53:03.333Z" }, ] [[package]] @@ -5213,7 +5241,7 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "1.94.0" +version = "1.107.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -5221,9 +5249,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/80/c41aa5ccf7104eba172ff45e617967b0075b3fa34ab76cd3795d7d62334a/pydantic_graph-1.94.0.tar.gz", hash = "sha256:8f991c05d412c9d12d6560c1e131de48bfde12ebd27a0b196440620210f2d52c", size = 59252, upload-time = "2026-05-12T07:03:58.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/c3/6e8c2d13b8701041f1b3eac5deb41f25d4dbfa479a190d5c6becc23f2a49/pydantic_graph-1.107.0.tar.gz", hash = "sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972", size = 62564, upload-time = "2026-06-10T14:53:13.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/7e/edcc6177d89174024bca1fb85044bec4855b96f4b724a310638283dfc8c5/pydantic_graph-1.94.0-py3-none-any.whl", hash = "sha256:c6e285abbc8a55d1b65162c238006913edd1ef05e63a29401a580e51f798503e", size = 73063, upload-time = "2026-05-12T07:03:50.651Z" }, + { url = "https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size = 80106, upload-time = "2026-06-10T14:53:06.543Z" }, ] [[package]] @@ -6552,6 +6580,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.16.1" diff --git a/dify-agent/docker/local-sandbox/Dockerfile b/dify-agent/docker/local-sandbox/Dockerfile index 2dd3dbb677a..955ed68a599 100644 --- a/dify-agent/docker/local-sandbox/Dockerfile +++ b/dify-agent/docker/local-sandbox/Dockerfile @@ -9,45 +9,79 @@ FROM python:3.12-slim-bookworm AS base +ARG NODE_VERSION=22.22.1 +ARG PNPM_VERSION=11.9.0 +ARG UV_VERSION=0.8.9 +ARG DIFY_AGENT_TOOL_SPEC=.[grpc] +ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager + ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ - DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive + DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive \ + UV_TOOL_DIR=/opt/dify-agent-tools/envs \ + UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin +ENV PATH="${UV_TOOL_BIN_DIR}:${PATH}" RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + file \ + git \ + jq \ + less \ + openssh-client \ + procps \ + ripgrep \ tmux \ + unzip \ + xz-utils \ + zip \ + && node_arch="$(dpkg --print-architecture)" \ + && case "${node_arch}" in \ + amd64) node_arch="x64" ;; \ + arm64) node_arch="arm64" ;; \ + *) echo "Unsupported Node.js architecture: ${node_arch}" >&2; exit 1 ;; \ + esac \ + && node_dist="node-v${NODE_VERSION}-linux-${node_arch}" \ + && curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \ + && curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${node_dist}.tar.xz" \ + && grep " ${node_dist}.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ + && tar -xJf "${node_dist}.tar.xz" -C /usr/local --strip-components=1 \ + && rm -f SHASUMS256.txt "${node_dist}.tar.xz" \ + && npm install --global "pnpm@${PNPM_VERSION}" \ + && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* -ENV UV_VERSION=0.8.9 RUN python -m pip install --no-cache-dir "uv==${UV_VERSION}" WORKDIR /opt/dify-agent -FROM base AS packages +FROM base AS tools -ENV SHELL_SESSION_MANAGER_VERSION=2.2.1 +ARG DIFY_AGENT_TOOL_SPEC +ARG SHELL_SESSION_MANAGER_TOOL_SPEC COPY pyproject.toml uv.lock README.md ./ COPY src ./src -RUN uv sync --frozen --no-dev --no-editable --extra grpc \ - && uv pip install --python .venv/bin/python "shell-session-manager==${SHELL_SESSION_MANAGER_VERSION}" +RUN uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes \ + > /tmp/dify-agent-constraints.txt \ + && uv tool install --force --python /usr/local/bin/python --no-python-downloads \ + --constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}" \ + && uv tool install --force --python /usr/local/bin/python --no-python-downloads \ + --constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}" \ + && rm -f /tmp/dify-agent-constraints.txt FROM base AS production -ENV VIRTUAL_ENV=/opt/dify-agent/.venv -ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" +COPY --from=tools ${UV_TOOL_DIR} ${UV_TOOL_DIR} +COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR} -COPY --from=packages ${VIRTUAL_ENV} ${VIRTUAL_ENV} - -RUN ln -s ${VIRTUAL_ENV}/bin/dify-agent /usr/local/bin/dify-agent \ - && ln -s ${VIRTUAL_ENV}/bin/shellctl /usr/local/bin/shellctl \ - && useradd --create-home --shell /bin/sh dify \ +RUN useradd --create-home --shell /bin/sh dify \ && mkdir -p /mnt/drive \ && chown -R dify:dify /home/dify /mnt/drive 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 91842f05dbc..a915031fe1d 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 @@ -228,8 +228,14 @@ and pre-creates that directory with write access for the same user. The provided `docker/local-sandbox/Dockerfile` installs: - `tmux`, required by `shellctl` to manage shell jobs; -- `shell-session-manager==2.2.1`, which provides the `shellctl` CLI/server; +- common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`, + `unzip`, `zip`, `file`, `procps`, and `less`; +- `shell-session-manager==2.3.0` as a standalone uv tool, which provides the + `shellctl` CLI/server; - `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell - workspace; -- the `dify-agent` Agent Stub client CLI, including its gRPC transport extra; + workspace and Python CLI tools can be installed with isolated tool + environments; +- `node==22.22.1` and `pnpm==11.9.0`, so JavaScript and TypeScript tooling can + run inside the shell workspace without per-job installation; +- the `dify-agent[grpc]` Agent Stub client CLI as a standalone uv tool; - a non-root default user named `dify`. diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 2b0ce1dff56..1c8842dccc5 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -27,7 +27,7 @@ server = [ "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", "pydantic-settings>=2.12.0,<3.0.0", "redis>=7.4.0,<8.0.0", - "shell-session-manager==2.2.1", + "shell-session-manager==2.3.0", "uvicorn[standard]==0.46.0", ] diff --git a/dify-agent/src/dify_agent/adapters/shell/__init__.py b/dify-agent/src/dify_agent/adapters/shell/__init__.py index e87bb39feb7..bb655da3b9a 100644 --- a/dify-agent/src/dify_agent/adapters/shell/__init__.py +++ b/dify-agent/src/dify_agent/adapters/shell/__init__.py @@ -1,41 +1,30 @@ -"""Provider-agnostic shell provisioning/execution adapter for the Dify agent. - -The boundary protocols live in ``protocols``; the default shellctl backend in -``shellctl``; and env-var-driven provider selection in ``config``/``factory``. -``create_shell_provisioner`` is the recommended entry point for callers. -""" +"""Provider-agnostic shell adapter exports for the Dify agent.""" from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings -from dify_agent.adapters.shell.factory import create_shell_provisioner +from dify_agent.adapters.shell.factory import create_shell_provider from dify_agent.adapters.shell.protocols import ( - ShellEnvironmentDescriptor, - ShellExecutionResult, - ShellExecutorProtocol, + CompleteShellCommandResult, + ShellCommandProtocol, + ShellCommandResult, + ShellCommandStatus, ShellFileTransferProtocol, - ShellHandle, - ShellProvisionProtocol, -) -from dify_agent.adapters.shell.shellctl import ( - ShellctlEnvironmentDescriptor, - ShellctlProvisioner, - ShellFileTransferError, - ShellProvisionError, - create_default_shellctl_client_factory, + ShellPromptObservation, + ShellProviderError, + ShellProviderProtocol, + ShellResourceProtocol, ) __all__ = [ + "CompleteShellCommandResult", "DEFAULT_SHELL_PROVIDER", "ShellAdapterSettings", - "ShellEnvironmentDescriptor", - "ShellExecutionResult", - "ShellExecutorProtocol", - "ShellFileTransferError", + "ShellCommandProtocol", + "ShellCommandResult", + "ShellCommandStatus", "ShellFileTransferProtocol", - "ShellHandle", - "ShellProvisionError", - "ShellProvisionProtocol", - "ShellctlEnvironmentDescriptor", - "ShellctlProvisioner", - "create_default_shellctl_client_factory", - "create_shell_provisioner", + "ShellPromptObservation", + "ShellProviderError", + "ShellProviderProtocol", + "ShellResourceProtocol", + "create_shell_provider", ] diff --git a/dify-agent/src/dify_agent/adapters/shell/config.py b/dify-agent/src/dify_agent/adapters/shell/config.py index d2bd96ae348..53325270861 100644 --- a/dify-agent/src/dify_agent/adapters/shell/config.py +++ b/dify-agent/src/dify_agent/adapters/shell/config.py @@ -6,7 +6,7 @@ DEFAULT_SHELL_PROVIDER = "shellctl" class ShellAdapterSettings(BaseSettings): - """Env-backed settings used to construct a shell provisioner. + """Env-backed settings used to construct a shell provider. ``shellctl_auth_token`` defaults to ``None``; the factory forwards an empty string to the shellctl client so it does not fall back to ambient process diff --git a/dify-agent/src/dify_agent/adapters/shell/factory.py b/dify-agent/src/dify_agent/adapters/shell/factory.py index 4de892affc8..104cdf8148b 100644 --- a/dify-agent/src/dify_agent/adapters/shell/factory.py +++ b/dify-agent/src/dify_agent/adapters/shell/factory.py @@ -1,21 +1,10 @@ from dify_agent.adapters.shell.config import ShellAdapterSettings -from dify_agent.adapters.shell.protocols import ShellProvisionProtocol -from dify_agent.adapters.shell.shellctl import ( - ShellctlEnvironmentDescriptor, - ShellctlProvisioner, - create_default_shellctl_client_factory, -) +from dify_agent.adapters.shell.protocols import ShellProviderProtocol +from dify_agent.adapters.shell.shellctl import ShellctlProvider -def create_shell_provisioner( - settings: ShellAdapterSettings | None = None, -) -> ShellProvisionProtocol[ShellctlEnvironmentDescriptor]: - """Return the shell provisioner selected by ``DIFY_AGENT_SHELL_PROVIDER``. - - Raises: - ValueError: if the provider name is unknown, or if the ``shellctl`` - provider is selected without a non-empty ``DIFY_AGENT_SHELLCTL_ENTRYPOINT``. - """ +def create_shell_provider(settings: ShellAdapterSettings | None = None) -> ShellProviderProtocol: + """Return the shell provider selected by ``DIFY_AGENT_SHELL_PROVIDER``.""" resolved = settings or ShellAdapterSettings() provider = resolved.shell_provider.strip().lower() match provider: @@ -23,14 +12,12 @@ def create_shell_provisioner( entrypoint = (resolved.shellctl_entrypoint or "").strip() if not entrypoint: raise ValueError("DIFY_AGENT_SHELLCTL_ENTRYPOINT is required for the 'shellctl' shell provider.") - return ShellctlProvisioner( - client_factory=create_default_shellctl_client_factory( - entrypoint=entrypoint, - token=resolved.shellctl_auth_token or "", - ), + return ShellctlProvider( + entrypoint=entrypoint, + token=resolved.shellctl_auth_token or "", ) case _: raise ValueError(f"Unknown shell provider: {resolved.shell_provider!r}.") -__all__ = ["create_shell_provisioner"] +__all__ = ["create_shell_provider"] diff --git a/dify-agent/src/dify_agent/adapters/shell/protocols.py b/dify-agent/src/dify_agent/adapters/shell/protocols.py index 147441ccb7a..9487a202f45 100644 --- a/dify-agent/src/dify_agent/adapters/shell/protocols.py +++ b/dify-agent/src/dify_agent/adapters/shell/protocols.py @@ -1,128 +1,125 @@ -from typing import Protocol, TypeVar +from __future__ import annotations -from pydantic import BaseModel, ConfigDict, model_validator -from typing_extensions import Self +from dataclasses import dataclass +from typing import Literal, Protocol -class ShellEnvironmentDescriptor(BaseModel): - """Minimal, serializable seed used to re-derive a provisioned environment. - - Holds only the provider-agnostic identity needed to reattach to an existing - shell environment across a snapshot/resume cycle — never live resources - (clients, handles, executors). Callers persist this in their snapshot and - pass it back to ``ShellProvisionProtocol.reattach`` to reconstruct an - equivalent ``ShellHandle`` without allocating a new environment. - - Each provider defines its own concrete subclass carrying the fields it needs - to reattach (e.g. workspace path + session id for shellctl). Validation runs - at instantiation time via Pydantic so a malicious or corrupt snapshot cannot - escape the workspace root or inject shell syntax into lifecycle commands, - even if a future caller uses the provider directly. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - @model_validator(mode="after") - def _run_validate(self) -> Self: - self.validate() - return self - - def validate(self) -> None: - """validate the correctness of the object. - - Advanced validations that requires remote procedure calls, - for example access control, quota checks, should be implemented in - provision and reattach. - - """ - raise NotImplementedError +@dataclass(frozen=True, slots=True) +class ShellCommandResult: + job_id: str + status: str + done: bool + exit_code: int | None + output: str + offset: int + truncated: bool + output_path: str | None = None -DescriptorT = TypeVar("DescriptorT", bound=ShellEnvironmentDescriptor) +@dataclass(frozen=True, slots=True) +class ShellCommandStatus: + job_id: str + status: str + done: bool + exit_code: int | None + offset: int -class ShellExecutionResult(Protocol): - """Completed shell command result. - - ``stdout``/``stderr``/``exit_code`` are reserved fields. Backends that - cannot distinguish a stream return an empty string for it, and return - ``None`` from ``exit_code()`` when no exit status is available. - """ - - def stdout(self) -> str: ... - - def stderr(self) -> str: ... - - def exit_code(self) -> int | None: ... - - def truncated(self) -> bool: ... +@dataclass(frozen=True, slots=True) +class CompleteShellCommandResult: + job_id: str + status: str + done: bool + exit_code: int | None + output: str + output_complete: bool + incomplete_reason: Literal["output_limit", "timeout"] | None + offset: int + output_path: str | None = None -class ShellExecutorProtocol(Protocol): - """Runs commands inside an already-provisioned shell environment. +@dataclass(frozen=True, slots=True) +class ShellPromptObservation: + text: str + output_path: str | None + offset: int - ``execute`` drains the command to completion before returning — there is no - separate ``wait`` step. This suits the current server-side callers (sandbox - file helpers, workspace bootstrap) that always run a script to completion. - If a future use case needs to start a command, interact with its stdin, or - interrupt it before completion, split this protocol into ``execute`` → - ``ShellExecutionHandle`` plus ``wait`` / ``input`` / ``interrupt`` optional - capabilities, mirroring the shape that was prototyped here before - simplification. - """ +class ShellProviderError(RuntimeError): + code: str | None - async def execute( + def __init__(self, message: str, *, code: str | None = None) -> None: + super().__init__(message) + self.code = code + + +class ShellCommandProtocol(Protocol): + async def run( self, - command: str, + script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, - ) -> ShellExecutionResult: ... + timeout: float, + ) -> ShellCommandResult: ... + + async def wait( + self, + job_id: str, + *, + offset: int, + timeout: float, + ) -> ShellCommandResult: ... + + async def read_output( + self, + job_id: str, + *, + offset: int, + ) -> ShellCommandResult: ... + + async def input( + self, + job_id: str, + text: str, + *, + offset: int, + timeout: float, + ) -> ShellCommandResult: ... + + async def interrupt( + self, + job_id: str, + *, + grace_seconds: float, + ) -> ShellCommandStatus: ... + + async def tail(self, job_id: str) -> ShellCommandResult: ... + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> None: ... class ShellFileTransferProtocol(Protocol): - """Moves file bytes between the caller and a provisioned shell environment. + async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: ... - ``remote_path`` is interpreted by the backend relative to the provisioned - environment (for the shellctl backend, the session workspace). Higher-level - Dify/skill transfers are layered on top of this primitive, not implemented - here. Implementations raise on transfer failure (missing path, decode error, - or a non-zero transfer command). - """ - - async def upload(self, *, content: bytes, remote_path: str) -> None: ... - - async def download(self, *, remote_path: str) -> bytes: ... + async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: ... -# pyrefly: ignore [variance-mismatch] -# intended to be invariant -class ShellHandle(Protocol[DescriptorT]): - """Live reference to one provisioned shell environment. +class ShellResourceProtocol(Protocol): + @property + def commands(self) -> ShellCommandProtocol: ... - The handle itself is not serialized. ``descriptor()`` returns the minimal - seed needed to reconstruct an equivalent handle after a snapshot/resume. - """ + @property + def files(self) -> ShellFileTransferProtocol: ... - def descriptor(self) -> DescriptorT: ... - - async def get_executor(self) -> ShellExecutorProtocol: ... - - async def get_file_transfer(self) -> ShellFileTransferProtocol: ... + async def close(self) -> None: ... -class ShellProvisionProtocol(Protocol[DescriptorT]): - """Creates, reattaches to, and destroys shell environments. - - ``provision`` allocates a fresh environment; ``reattach`` rebuilds a live - handle for an environment that already exists (from a persisted descriptor) - without allocating a new one, so resumed runs can keep executing and - eventually clean up. ``destroy`` tears an environment down. - """ - - async def provision(self) -> ShellHandle[DescriptorT]: ... - - async def reattach(self, descriptor: DescriptorT) -> ShellHandle[DescriptorT]: ... - - async def destroy(self, handle: ShellHandle[DescriptorT]) -> None: ... +class ShellProviderProtocol(Protocol): + async def create(self) -> ShellResourceProtocol: ... diff --git a/dify-agent/src/dify_agent/adapters/shell/shellctl.py b/dify-agent/src/dify_agent/adapters/shell/shellctl.py index c1f4e786284..31f2e13675f 100644 --- a/dify-agent/src/dify_agent/adapters/shell/shellctl.py +++ b/dify-agent/src/dify_agent/adapters/shell/shellctl.py @@ -4,87 +4,60 @@ import base64 import binascii import logging import re -import secrets +import time +from collections.abc import Awaitable from collections.abc import Callable from dataclasses import dataclass -from typing import Protocol +from typing import Protocol, TypeVar -from dify_agent.adapters.shell.protocols import ShellEnvironmentDescriptor, ShellHandle +from dify_agent.adapters.shell.protocols import ( + ShellCommandProtocol, + ShellCommandResult, + ShellCommandStatus, + ShellFileTransferProtocol, + ShellProviderError, + ShellProviderProtocol, + ShellResourceProtocol, +) logger = logging.getLogger(__name__) -_WORKSPACE_ROOT = "~/workspace" +ResultT = TypeVar("ResultT") + _DEFAULT_TIMEOUT_SECONDS = 30.0 -_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{7,16}") -# Drains at most this many shellctl output windows per wait so a stuck or -# pathologically chatty job cannot loop forever inside one wait() call. -_MAX_OUTPUT_WINDOWS = 64 +_READ_OUTPUT_TIMEOUT_SECONDS = 0.0 _DEFAULT_TERMINATE_GRACE_SECONDS = 10.0 _FILE_TRANSFER_TIMEOUT_SECONDS = 60.0 -# Sentinels frame base64 download payloads so prompt/tmux noise around the -# shellctl merged output stream can be stripped before decoding. +_SHELLCTL_OUTPUT_LIMIT_BYTES = 16 * 1024 _TRANSFER_BEGIN = "<<>>" _TRANSFER_END = "<<>>" _DOWNLOAD_MISSING_EXIT_CODE = 66 -class ShellProvisionError(RuntimeError): - """Raised when a shell environment cannot be provisioned.""" - - class ShellFileTransferError(RuntimeError): - """Raised when a file cannot be uploaded to or downloaded from the workspace.""" - - -class ShellctlEnvironmentDescriptor(ShellEnvironmentDescriptor): - """Shellctl-specific descriptor carrying the workspace path and session id.""" - - workspace_cwd: str - session_id: str - - def validate(self) -> None: - if not _SESSION_ID_PATTERN.fullmatch(self.session_id): - raise ValueError(f"Invalid session_id in reattach descriptor: {self.session_id!r}.") - expected_workspace = f"{_WORKSPACE_ROOT}/{self.session_id}" - if self.workspace_cwd != expected_workspace: - raise ValueError( - f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}, " - f"got {self.workspace_cwd!r}." - ) + """Raised when a file cannot be uploaded or downloaded through shellctl.""" class ShellctlJobResult(Protocol): - """Structural shape of one shellctl job result the adapter relies on. - - Mirrors the fields the adapter reads from ``shell_session_manager`` job - results without importing the concrete type, so the merged output stream, - paging offset, completion flag, and exit status stay duck-typed. - """ - job_id: str + status: object done: bool output: str offset: int truncated: bool exit_code: int | None + output_path: str | None class ShellctlJobStatus(Protocol): - """Structural shape of one shellctl status-only result (no output stream). - - Returned by ``terminate``; carries completion and exit status plus the - latest paging offset so the adapter can drain any remaining output. - """ - job_id: str + status: object done: bool offset: int exit_code: int | None class ShellctlClientProtocol(Protocol): - """Boundary the shellctl adapter needs from a shell-session-manager client.""" - async def run( self, script: str, @@ -111,13 +84,21 @@ class ShellctlClientProtocol(Protocol): timeout: float = _DEFAULT_TIMEOUT_SECONDS, ) -> ShellctlJobResult: ... + async def tail(self, job_id: str) -> ShellctlJobResult: ... + async def terminate( self, job_id: str, grace_seconds: float = _DEFAULT_TERMINATE_GRACE_SECONDS, ) -> ShellctlJobStatus: ... - async def delete(self, job_id: str, *, force: bool = False) -> object: ... + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> object: ... async def close(self) -> None: ... @@ -125,127 +106,86 @@ class ShellctlClientProtocol(Protocol): type ShellctlClientFactory = Callable[[], ShellctlClientProtocol] -class ShellctlExecutionResult: - """Completed shellctl command result. - - shellctl merges stderr into a single output stream, so ``stderr()`` is - always empty and the merged stream is reported as ``stdout()``. - """ - - _stdout: str - _stderr: str - _exit_code: int | None - _truncated: bool - - def __init__( - self, - *, - stdout: str, - stderr: str = "", - exit_code: int | None, - truncated: bool = False, - ) -> None: - self._stdout = stdout - self._stderr = stderr - self._exit_code = exit_code - self._truncated = truncated - - def stdout(self) -> str: - return self._stdout - - def stderr(self) -> str: - return self._stderr - - def exit_code(self) -> int | None: - return self._exit_code - - def truncated(self) -> bool: - """Whether the returned ``stdout`` may be incomplete. - - ``True`` means shellctl still reported more output past what was - captured when draining stopped (its per-window ``truncated`` flag on the - final window, e.g. the output-window cap was hit). Callers that need the - command's *entire* output must treat a truncated result as a failure or - re-read, rather than trusting ``stdout()`` as complete. - """ - return self._truncated - - @dataclass(slots=True) -class ShellctlExecutor: - """Runs commands in one provisioned shellctl workspace. - - Conforms structurally to ``ShellExecutorProtocol``. ``execute`` drains the - command to completion (accumulating shellctl's paged output windows) and - best-effort deletes the finished job before returning. The executor is - single-environment and is not safe to share across workspaces. - """ - +class ShellctlCommands(ShellCommandProtocol): client: ShellctlClientProtocol - workspace_cwd: str - timeout: float = _DEFAULT_TIMEOUT_SECONDS - async def execute( + async def run( self, - command: str, + script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, - ) -> ShellctlExecutionResult: - result = await self.client.run( - command, - cwd=cwd if cwd is not None else self.workspace_cwd, - env=env, - timeout=self.timeout, - ) - output_parts = [result.output] - done = result.done - truncated = result.truncated - offset = result.offset - exit_code = result.exit_code - job_id = result.job_id - windows = 1 - while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS: - result = await self.client.wait(job_id, offset=offset, timeout=self.timeout) - output_parts.append(result.output) - done = result.done - truncated = result.truncated - offset = result.offset - exit_code = result.exit_code - windows += 1 - if done: - await _delete_job_best_effort(self.client, job_id) - return ShellctlExecutionResult( - stdout="".join(output_parts), - exit_code=exit_code, - truncated=truncated, + timeout: float, + ) -> ShellCommandResult: + return _from_job_result(await _run_client_call(self.client.run(script, cwd=cwd, env=env, timeout=timeout))) + + async def wait( + self, + job_id: str, + *, + offset: int, + timeout: float, + ) -> ShellCommandResult: + return _from_job_result(await _run_client_call(self.client.wait(job_id, offset=offset, timeout=timeout))) + + async def read_output( + self, + job_id: str, + *, + offset: int, + ) -> ShellCommandResult: + return _from_job_result( + await _run_client_call(self.client.wait(job_id, offset=offset, timeout=_READ_OUTPUT_TIMEOUT_SECONDS)) ) + async def input( + self, + job_id: str, + text: str, + *, + offset: int, + timeout: float, + ) -> ShellCommandResult: + return _from_job_result(await _run_client_call(self.client.input(job_id, text, offset=offset, timeout=timeout))) + + async def interrupt( + self, + job_id: str, + *, + grace_seconds: float, + ) -> ShellCommandStatus: + return _from_job_status(await _run_client_call(self.client.terminate(job_id, grace_seconds=grace_seconds))) + + async def tail(self, job_id: str) -> ShellCommandResult: + return _from_job_result(await _run_client_call(self.client.tail(job_id))) + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> None: + try: + _ = await self.client.delete(job_id, force=force, grace_seconds=grace_seconds) + except RuntimeError as exc: + if getattr(exc, "code", None) == "job_not_found": + return + raise _map_error(exc) from exc + @dataclass(slots=True) -class ShellctlFileTransfer: - """Moves file bytes in and out of one provisioned shellctl workspace. - - Conforms structurally to ``ShellFileTransferProtocol``. Transfers run as - workspace-scoped shellctl jobs over the merged text channel: uploads embed - base64 in the command and pipe it through ``base64 -d``; downloads emit the - file's base64 framed by sentinels so prompt/tmux noise can be stripped - before decoding. Because the encoded payload is embedded in the upload - command, very large files can exceed the shell argument limit; this - primitive targets ordinary control-plane file sizes, not bulk binary - transfer. - """ - +class ShellctlFileTransfer(ShellFileTransferProtocol): client: ShellctlClientProtocol - workspace_cwd: str timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS - async def upload(self, *, content: bytes, remote_path: str) -> None: + async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: encoded = base64.b64encode(content).decode("ascii") completed = await _run_to_completion( self.client, _upload_script(remote_path=remote_path, encoded=encoded), - cwd=self.workspace_cwd, + cwd=cwd, timeout=self.timeout, ) if completed.exit_code != 0: @@ -254,133 +194,126 @@ class ShellctlFileTransfer: f"output={_output_tail(completed.output)!r}" ) - async def download(self, *, remote_path: str) -> bytes: + async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: completed = await _run_to_completion( self.client, _download_script(remote_path=remote_path), - cwd=self.workspace_cwd, + cwd=cwd, timeout=self.timeout, ) if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE: - raise ShellFileTransferError(f"File not found in workspace: {remote_path!r}.") + raise ShellFileTransferError(f"Remote path not found: {remote_path!r}.") if completed.exit_code != 0: raise ShellFileTransferError( f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, " f"output={_output_tail(completed.output)!r}" ) - framed = _extract_framed_payload(completed.output) + encoded = _extract_transfer_payload(completed.output) try: - return base64.b64decode("".join(framed.split()), validate=True) - except (binascii.Error, ValueError) as exc: - raise ShellFileTransferError(f"Failed to decode downloaded file {remote_path!r}: {exc}") from exc + return base64.b64decode(encoded.encode("ascii"), validate=True) + except (ValueError, binascii.Error) as exc: + raise ShellFileTransferError(f"Downloaded payload for {remote_path!r} was not valid base64.") from exc @dataclass(slots=True) -class ShellctlHandle: - """Live reference to one provisioned shellctl workspace. - - Conforms structurally to ``ShellHandle``. Owns the shellctl ``client`` and - the allocated ``workspace_cwd`` until the provisioner destroys it. - ``get_executor`` returns a fresh executor bound to this workspace each call. - """ - +class ShellctlResource(ShellResourceProtocol): client: ShellctlClientProtocol - workspace_cwd: str - session_id: str + commands: ShellCommandProtocol + files: ShellFileTransferProtocol - def descriptor(self) -> ShellctlEnvironmentDescriptor: - return ShellctlEnvironmentDescriptor(workspace_cwd=self.workspace_cwd, session_id=self.session_id) - - async def get_executor(self) -> ShellctlExecutor: - return ShellctlExecutor(client=self.client, workspace_cwd=self.workspace_cwd) - - async def get_file_transfer(self) -> ShellctlFileTransfer: - return ShellctlFileTransfer(client=self.client, workspace_cwd=self.workspace_cwd) + async def close(self) -> None: + try: + await self.client.close() + except RuntimeError as exc: + raise _map_error(exc) from exc @dataclass(slots=True) -class ShellctlProvisioner: - """Provisions isolated shellctl workspaces, one client per environment. +class ShellctlProvider(ShellProviderProtocol): + entrypoint: str + token: str + output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES + client_factory: ShellctlClientFactory | None = None - Conforms structurally to ``ShellProvisionProtocol``. - """ - - client_factory: ShellctlClientFactory - timeout: float = _DEFAULT_TIMEOUT_SECONDS - - async def provision(self) -> ShellctlHandle: - client = self.client_factory() - session_id = _generate_session_id() - workspace_cwd = f"{_WORKSPACE_ROOT}/{session_id}" - try: - completed = await _run_to_completion(client, _mkdir_script(session_id), cwd=None, timeout=self.timeout) - except BaseException: - await client.close() - raise - if completed.exit_code != 0: - await client.close() - raise ShellProvisionError( - f"Failed to create shell workspace {workspace_cwd}: mkdir exited with code {completed.exit_code}." - ) - return ShellctlHandle(client=client, workspace_cwd=workspace_cwd, session_id=session_id) - - async def reattach(self, descriptor: ShellctlEnvironmentDescriptor) -> ShellctlHandle: - """Rebuild a live handle for an existing workspace without re-allocating it. - - Opens a fresh shellctl client and points it at the workspace recorded in - ``descriptor``. No ``mkdir`` is issued: the workspace is assumed to still - exist from the original ``provision``. Used on snapshot resume so a run - can keep executing in and eventually clean up its prior workspace. - """ - client = self.client_factory() - return ShellctlHandle( + async def create(self) -> ShellctlResource: + client = ( + self.client_factory() + if self.client_factory is not None + else create_default_shellctl_client_factory( + entrypoint=self.entrypoint, + token=self.token, + output_limit=self.output_limit, + )() + ) + return ShellctlResource( client=client, - workspace_cwd=descriptor.workspace_cwd, - session_id=descriptor.session_id, + commands=ShellctlCommands(client=client), + files=ShellctlFileTransfer(client=client), ) - async def destroy(self, handle: ShellHandle[ShellctlEnvironmentDescriptor]) -> None: - if not isinstance(handle, ShellctlHandle): - raise TypeError("ShellctlProvisioner can only destroy handles it provisioned.") - try: - completed = await _run_to_completion( - handle.client, _cleanup_script(handle.session_id), cwd=None, timeout=self.timeout - ) - if completed.exit_code != 0: - logger.warning( - "Shell workspace cleanup for session %s exited with code %s.", - handle.session_id, - completed.exit_code, - ) - except (RuntimeError, ValueError) as exc: - logger.warning("Failed to remove shell workspace for session %s: %s", handle.session_id, exc) - finally: - await handle.client.close() - - -def create_default_shellctl_client_factory(*, entrypoint: str, token: str) -> ShellctlClientFactory: - """Return a factory that builds a real shell-session-manager shellctl client. - - The concrete client is imported lazily so importing this module does not - require the private ``shell-session-manager`` package. An explicit empty - ``token`` is forwarded as-is to avoid the client falling back to ambient - process credentials. - """ +def create_default_shellctl_client_factory( + *, + entrypoint: str, + token: str, + output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES, +) -> ShellctlClientFactory: def factory() -> ShellctlClientProtocol: from shell_session_manager.shellctl.client import ShellctlClient - return ShellctlClient(entrypoint, token=token) + return ShellctlClient(entrypoint, token=token, output_limit=output_limit) return factory -@dataclass(slots=True) -class _CompletedJob: - """Drained result of one internal shellctl job: merged output plus exit code.""" - - output: str +@dataclass(frozen=True, slots=True) +class _CompletedShellctlJob: + job_id: str exit_code: int | None + output: str + + +async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT: + try: + return await awaitable + except RuntimeError as exc: + raise _map_error(exc) from exc + + +def _map_error(exc: RuntimeError) -> ShellProviderError: + return ShellProviderError(str(exc), code=getattr(exc, "code", None)) + + +def _from_job_result(result: ShellctlJobResult) -> ShellCommandResult: + return ShellCommandResult( + job_id=result.job_id, + status=_status_name(result.status), + done=result.done, + exit_code=result.exit_code, + output=result.output, + offset=result.offset, + truncated=result.truncated, + output_path=result.output_path or None, + ) + + +def _from_job_status(result: ShellctlJobStatus) -> ShellCommandStatus: + return ShellCommandStatus( + job_id=result.job_id, + status=_status_name(result.status), + done=result.done, + exit_code=result.exit_code, + offset=result.offset, + ) + + +def _status_name(status: object) -> str: + value = getattr(status, "value", None) + if isinstance(value, str): + return value + if isinstance(status, str): + return status + return str(status) async def _run_to_completion( @@ -389,98 +322,78 @@ async def _run_to_completion( *, cwd: str | None, timeout: float, -) -> _CompletedJob: - """Run one internal lifecycle script to completion, returning output and exit code.""" - result = await client.run(script, cwd=cwd, env=None, timeout=timeout) - output_parts = [result.output] - done = result.done - truncated = result.truncated - offset = result.offset - exit_code = result.exit_code - job_id = result.job_id - windows = 1 - while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS: - result = await client.wait(job_id, offset=offset, timeout=timeout) - output_parts.append(result.output) - done = result.done - truncated = result.truncated - offset = result.offset - exit_code = result.exit_code - windows += 1 - if done: - await _delete_job_best_effort(client, job_id) - return _CompletedJob(output="".join(output_parts), exit_code=exit_code) - - -async def _delete_job_best_effort(client: ShellctlClientProtocol, job_id: str) -> None: - """Force-delete one shellctl job, never failing the caller on cleanup errors.""" +) -> _CompletedShellctlJob: + deadline = time.monotonic() + timeout + job_id: str | None = None try: - _ = await client.delete(job_id, force=True) - except Exception as exc: # noqa: BLE001 - best-effort teardown must not surface cleanup errors - logger.warning("Failed to delete shellctl job %s: %s", job_id, exc) - - -def _generate_session_id() -> str: - """Return a shell-safe random session id used as the workspace directory name.""" - return secrets.token_hex(8) - - -def _mkdir_script(session_id: str) -> str: - return f'mkdir -p "$HOME/workspace/{session_id}"' - - -def _cleanup_script(session_id: str) -> str: - return f'rm -rf -- "$HOME/workspace/{session_id}"' + result = await _run_client_call(client.run(script, cwd=cwd, env=None, timeout=_remaining_timeout(deadline))) + parts = [result.output] + job_id = result.job_id + while not result.done or result.truncated: + result = await _run_client_call( + client.wait(job_id, offset=result.offset, timeout=_remaining_timeout(deadline)) + ) + parts.append(result.output) + return _CompletedShellctlJob(job_id=job_id, exit_code=result.exit_code, output="".join(parts)) + finally: + if job_id is not None: + try: + await client.delete(job_id, force=True) + except RuntimeError as exc: + logger.warning("Failed to delete shellctl job %s: %s", job_id, exc) def _upload_script(*, remote_path: str, encoded: str) -> str: - """Return a script that recreates a file from embedded base64 in the workspace.""" - quoted = _shquote(remote_path) - return f"mkdir -p \"$(dirname -- {quoted})\" && printf %s '{encoded}' | base64 -d > {quoted}" - - -def _download_script(*, remote_path: str) -> str: - """Return a script that emits a file's base64 between transfer sentinels.""" - quoted = _shquote(remote_path) return ( - f"if [ ! -f {quoted} ]; then exit {_DOWNLOAD_MISSING_EXIT_CODE}; fi; " - f"printf %s {_shquote(_TRANSFER_BEGIN)}; " - f'base64 < {quoted} | tr -d "\\n"; ' - f"printf %s {_shquote(_TRANSFER_END)}" + "set -eu\n" + f'mkdir -p "$(dirname -- {_shquote(remote_path)})"\n' + f"printf %s {_shquote(encoded)} | base64 -d > {_shquote(remote_path)}" ) -def _extract_framed_payload(output: str) -> str: - """Return the base64 text framed by the transfer sentinels in shellctl output.""" - begin = output.find(_TRANSFER_BEGIN) - end = output.find(_TRANSFER_END, begin + len(_TRANSFER_BEGIN)) if begin != -1 else -1 - if begin == -1 or end == -1: - raise ShellFileTransferError("download command returned no framed payload") - return output[begin + len(_TRANSFER_BEGIN) : end] +def _download_script(*, remote_path: str) -> str: + return "\n".join( + [ + "set -eu", + f"path={_shquote(remote_path)}", + 'if [ ! -f "$path" ]; then exit 66; fi', + f"printf %s {_shquote(_TRANSFER_BEGIN)}", + 'base64 < "$path" | tr -d "\\n"', + f"printf %s {_shquote(_TRANSFER_END)}", + ] + ) + + +def _extract_transfer_payload(output: str) -> str: + pattern = re.escape(_TRANSFER_BEGIN) + r"(.*?)" + re.escape(_TRANSFER_END) + match = re.search(pattern, output, re.DOTALL) + if match is None: + raise ShellFileTransferError("Transfer payload markers were missing from shell output.") + return "".join(match.group(1).split()) + + +def _output_tail(output: str, *, limit: int = 256) -> str: + return output[-limit:] + + +def _remaining_timeout(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0.0: + raise ShellProviderError("Shellctl command timed out before completion.", code="timeout") + return remaining def _shquote(value: str) -> str: - """Single-quote a value for POSIX shells, escaping embedded single quotes.""" return "'" + value.replace("'", "'\\''") + "'" -def _output_tail(output: str, *, limit: int = 500) -> str: - """Return the trailing slice of command output for compact error messages.""" - return output[-limit:] - - __all__ = [ "ShellFileTransferError", - "ShellProvisionError", "ShellctlClientFactory", "ShellctlClientProtocol", - "ShellctlEnvironmentDescriptor", - "ShellctlExecutionResult", - "ShellctlExecutor", + "ShellctlCommands", "ShellctlFileTransfer", - "ShellctlHandle", - "ShellctlJobResult", - "ShellctlJobStatus", - "ShellctlProvisioner", + "ShellctlProvider", + "ShellctlResource", "create_default_shellctl_client_factory", ] diff --git a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py index cf2ec969ae2..b59395496d1 100644 --- a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py +++ b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py @@ -92,12 +92,12 @@ def resolve_drive_destination(base_path: Path, drive_key: str) -> Path: return destination -def extract_skill_archive(archive_path: Path) -> None: - """Safely extract one downloaded skill archive into its containing directory.""" +def extract_archive_to_directory(archive_path: Path, *, target_dir: Path) -> None: + """Safely extract one downloaded archive into one resolved target directory.""" - target_dir = archive_path.parent.resolve() + resolved_target_dir = target_dir.resolve() try: - with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name: + 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(): @@ -118,7 +118,7 @@ def extract_skill_archive(archive_path: Path) -> None: if staged_path.is_dir(): continue relative_path = staged_path.relative_to(staging_dir) - destination = (target_dir / relative_path).resolve() + destination = (resolved_target_dir / relative_path).resolve() destination.parent.mkdir(parents=True, exist_ok=True) _ = staged_path.replace(destination) except DriveMaterializationValidationError: @@ -127,6 +127,12 @@ def extract_skill_archive(archive_path: Path) -> None: 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) @@ -163,6 +169,7 @@ __all__ = [ "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/cli/_config.py b/dify-agent/src/dify_agent/agent_stub/cli/_config.py new file mode 100644 index 00000000000..0aabc9b1e25 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/_config.py @@ -0,0 +1,320 @@ +"""CLI helpers for sandbox-visible Agent Stub config commands.""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory + +from pydantic import BaseModel, ConfigDict + +from dify_agent.agent_stub._drive_materialization import ( + DriveMaterializationTransferError, + DriveMaterializationValidationError, + extract_archive_to_directory, +) +from dify_agent.agent_stub.cli._drive import _build_skill_archive +from dify_agent.agent_stub.cli._env import read_agent_stub_environment +from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment +from dify_agent.agent_stub.client._agent_stub import ( + request_agent_stub_config_manifest_sync, + request_agent_stub_config_push_sync, + request_agent_stub_config_file_pull_sync, + request_agent_stub_config_skill_pull_sync, +) +from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubConfigFileRef, + AgentStubConfigManifestResponse, + AgentStubConfigPushFileItem, + AgentStubConfigPushRequest, + AgentStubConfigPushSkillItem, +) + +_DEFAULT_CONFIG_BASE = Path("./.dify_conf") +_SKILL_MD_FILENAME = "SKILL.md" +_SAFE_ENV_VALUE = re.compile(r"^[A-Za-z0-9_./:@+-]+$") + + +class ConfigSkillPullResult(BaseModel): + class Item(BaseModel): + name: str + archive_path: str + directory_path: str + skill_md: str + + model_config = ConfigDict(extra="forbid") + + items: list[Item] + + model_config = ConfigDict(extra="forbid") + + +class ConfigFilePullResult(BaseModel): + class Item(BaseModel): + name: str + path: str + + model_config = ConfigDict(extra="forbid") + + items: list[Item] + + model_config = ConfigDict(extra="forbid") + + +@dataclass(frozen=True, slots=True) +class _PreparedPushItem: + name: str + path: Path + + +def manifest_from_environment() -> AgentStubConfigManifestResponse: + environment = read_agent_stub_environment() + return request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe) + + +def pull_config_skills_from_environment( + names: list[str] | None = None, + local_dir: str | None = None, +) -> ConfigSkillPullResult: + environment = read_agent_stub_environment() + manifest = request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe) + selected_names = names or [item.name for item in manifest.skills.items] + target_dir = Path(local_dir or (_DEFAULT_CONFIG_BASE / "skills")).expanduser().resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + items: list[ConfigSkillPullResult.Item] = [] + for name in selected_names: + archive_bytes = request_agent_stub_config_skill_pull_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + name=name, + ) + archive_path = target_dir / f"{name}.zip" + skill_dir = target_dir / name + skill_dir.mkdir(parents=True, exist_ok=True) + archive_path.write_bytes(archive_bytes) + try: + extract_archive_to_directory(archive_path, target_dir=skill_dir) + except DriveMaterializationValidationError as exc: + raise AgentStubValidationError(str(exc)) from exc + except DriveMaterializationTransferError as exc: + raise AgentStubTransferError(str(exc)) from exc + skill_md_path = skill_dir / _SKILL_MD_FILENAME + if not skill_md_path.is_file(): + raise AgentStubValidationError(f"pulled config skill is missing {_SKILL_MD_FILENAME}: {name}") + items.append( + ConfigSkillPullResult.Item( + name=name, + archive_path=str(archive_path), + directory_path=str(skill_dir), + skill_md=skill_md_path.read_text(encoding="utf-8"), + ) + ) + return ConfigSkillPullResult(items=items) + + +def pull_config_files_from_environment( + names: list[str] | None = None, + local_dir: str | None = None, +) -> ConfigFilePullResult: + environment = read_agent_stub_environment() + manifest = request_agent_stub_config_manifest_sync(url=environment.url, auth_jwe=environment.auth_jwe) + selected_names = names or [item.name for item in manifest.files.items] + target_dir = Path(local_dir or (_DEFAULT_CONFIG_BASE / "files")).expanduser().resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + items: list[ConfigFilePullResult.Item] = [] + for name in selected_names: + payload = request_agent_stub_config_file_pull_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + name=name, + ) + target_path = target_dir / name + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(payload) + items.append(ConfigFilePullResult.Item(name=name, path=str(target_path))) + return ConfigFilePullResult(items=items) + + +def pull_config_env_from_environment(local_path: str | None = None) -> Path: + manifest = manifest_from_environment() + target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / ".env")).expanduser().resolve() + target_path.parent.mkdir(parents=True, exist_ok=True) + lines: list[str] = [] + for key in manifest.env_keys: + if key not in os.environ: + raise AgentStubValidationError(f"config env key is not present in the current environment: {key}") + lines.append(f"{key}={_format_env_value(os.environ[key])}") + target_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") + return target_path + + +def pull_config_note_from_environment(local_path: str | None = None) -> Path: + manifest = manifest_from_environment() + target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / "note.md")).expanduser().resolve() + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(manifest.note, encoding="utf-8") + return target_path + + +def push_config_note_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse: + note = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / "note.md") + return _push_config_from_environment(note=note) + + +def push_config_env_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse: + env_text = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / ".env") + return _push_config_from_environment(env_text=env_text) + + +def push_config_files_from_environment(paths: list[str], name: str | None) -> AgentStubConfigManifestResponse: + _require_non_empty_inputs(paths, kind="file path") + override_name = None if name is None else _require_config_entry_name(name, kind="file") + if override_name is not None and len(paths) != 1: + raise AgentStubValidationError("--name requires exactly one PATH") + items = [ + _PreparedPushItem( + name=override_name if index == 0 and override_name is not None else _infer_name_from_path(source_path), + path=source_path, + ) + for index, source_path in enumerate(_resolve_input_paths(paths)) + ] + return _push_config_from_environment(files=[_build_file_push_item(item=item) for item in items]) + + +def delete_config_files_from_environment(names: list[str]) -> AgentStubConfigManifestResponse: + _require_non_empty_inputs(names, kind="file name") + return _push_config_from_environment( + files=[ + AgentStubConfigPushFileItem(name=_require_config_entry_name(name, kind="file"), file_ref=None) + for name in names + ] + ) + + +def push_config_skills_from_environment(paths: list[str]) -> AgentStubConfigManifestResponse: + _require_non_empty_inputs(paths, kind="skill directory") + items = [ + _PreparedPushItem(name=_infer_name_from_path(source_path), path=source_path) + for source_path in _resolve_input_paths(paths) + ] + return _push_config_from_environment(skills=[_build_skill_push_item(item=item) for item in items]) + + +def delete_config_skills_from_environment(names: list[str]) -> AgentStubConfigManifestResponse: + _require_non_empty_inputs(names, kind="skill name") + return _push_config_from_environment( + skills=[ + AgentStubConfigPushSkillItem(name=_require_config_entry_name(name, kind="skill"), file_ref=None) + for name in names + ] + ) + + +def _push_config_from_environment( + *, + files: list[AgentStubConfigPushFileItem] | None = None, + skills: list[AgentStubConfigPushSkillItem] | None = None, + env_text: str | None = None, + note: str | None = None, +) -> AgentStubConfigManifestResponse: + environment = read_agent_stub_environment() + return request_agent_stub_config_push_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + request=AgentStubConfigPushRequest( + files=files or [], + skills=skills or [], + env_text=env_text, + note=note, + ), + ) + + +def _read_text_input(path_value: str | None, default_path: Path) -> str: + if path_value == "-": + return os.fdopen(os.dup(0), encoding="utf-8").read() + source_path = Path(path_value or default_path).expanduser().resolve() + if not source_path.is_file(): + raise AgentStubValidationError(f"local file not found: {source_path}") + return source_path.read_text(encoding="utf-8") + + +def _resolve_input_paths(paths: list[str]) -> list[Path]: + return [Path(path).expanduser().resolve() for path in paths] + + +def _infer_name_from_path(path: Path) -> str: + return path.name + + +def _build_file_push_item( + *, + item: _PreparedPushItem, +) -> AgentStubConfigPushFileItem: + if not item.path.is_file(): + raise AgentStubValidationError(f"config file path must be a regular file: {item.path}") + uploaded = upload_tool_file_resource_from_environment(path=str(item.path)) + return AgentStubConfigPushFileItem( + name=item.name, + file_ref=AgentStubConfigFileRef(kind="tool_file", id=uploaded.tool_file_id), + ) + + +def _build_skill_push_item( + *, + item: _PreparedPushItem, +) -> AgentStubConfigPushSkillItem: + if not item.path.is_dir(): + raise AgentStubValidationError(f"config skill path must be a directory: {item.path}") + skill_md_path = item.path / _SKILL_MD_FILENAME + if not skill_md_path.is_file(): + raise AgentStubValidationError(f"config skill directory must contain {_SKILL_MD_FILENAME}: {item.path}") + with TemporaryDirectory() as temp_dir: + archive_path = Path(temp_dir) / f"{item.name}.zip" + _build_skill_archive(item.path, archive_path) + uploaded = upload_tool_file_resource_from_environment(path=str(archive_path)) + return AgentStubConfigPushSkillItem( + name=item.name, + file_ref=AgentStubConfigFileRef(kind="tool_file", id=uploaded.tool_file_id), + ) + + +def _require_config_entry_name(name: str, *, kind: str) -> str: + normalized = name.strip() + if not normalized: + raise AgentStubValidationError(f"config {kind} name must not be empty") + return normalized + + +def _require_non_empty_inputs(values: list[str], *, kind: str) -> None: + if not values: + raise AgentStubValidationError(f"at least one {kind} is required") + + +def _format_env_value(value: str) -> str: + if _SAFE_ENV_VALUE.fullmatch(value): + return value + return json.dumps(value) + + +__all__ = [ + "ConfigFilePullResult", + "ConfigSkillPullResult", + "manifest_from_environment", + "pull_config_env_from_environment", + "pull_config_files_from_environment", + "pull_config_note_from_environment", + "pull_config_skills_from_environment", + "push_config_env_from_environment", + "push_config_files_from_environment", + "push_config_note_from_environment", + "push_config_skills_from_environment", + "delete_config_files_from_environment", + "delete_config_skills_from_environment", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py index 3d34b96d62e..1c1e328e436 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -13,10 +13,24 @@ from __future__ import annotations import sys from typing import cast +import click import typer from typer.main import get_command from dify_agent.agent_stub.cli._agent_stub import connect_from_environment +from dify_agent.agent_stub.cli._config import ( + delete_config_files_from_environment, + delete_config_skills_from_environment, + manifest_from_environment, + pull_config_env_from_environment, + pull_config_files_from_environment, + pull_config_note_from_environment, + pull_config_skills_from_environment, + push_config_env_from_environment, + push_config_files_from_environment, + push_config_note_from_environment, + push_config_skills_from_environment, +) from dify_agent.agent_stub.cli._drive import ( DrivePushKind, format_drive_manifest, @@ -33,17 +47,45 @@ from dify_agent.agent_stub.cli._files import download_file_from_environment, upl from dify_agent.agent_stub.client._errors import AgentStubClientError from dify_agent.agent_stub.protocol.agent_stub import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE +_CONFIG_MANIFEST_STDOUT_EXCLUDE = { + "skills": {"items": {"__all__": {"hash"}}}, + "files": {"items": {"__all__": {"hash"}}}, +} + app = typer.Typer( add_completion=False, help="Forward shell-visible dify-agent commands to the Dify Agent Stub server.", no_args_is_help=True, + rich_markup_mode=None, ) -file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.") -drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.") +file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.", rich_markup_mode=None) +config_app = typer.Typer( + help="Inspect or update Agent Soul-backed config assets through the Agent Stub.", + rich_markup_mode=None, +) +config_skills_app = typer.Typer(help="Pull or update config skills through the Agent Stub.", rich_markup_mode=None) +config_files_app = typer.Typer(help="Pull or update config files through the Agent Stub.", rich_markup_mode=None) +config_skill_pull_alias_app = typer.Typer(help="Pull config skills through the Agent Stub.", rich_markup_mode=None) +config_file_pull_alias_app = typer.Typer(help="Pull config files through the Agent Stub.", rich_markup_mode=None) +config_env_app = typer.Typer( + help="Pull or update config env variables visible to the current run.", rich_markup_mode=None +) +config_note_app = typer.Typer(help="Pull or update the current config note.", rich_markup_mode=None) +drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.", rich_markup_mode=None) app.add_typer(file_app, name="file") +app.add_typer(config_app, name="config") +config_app.add_typer(config_skills_app, name="skills") +# Keep hidden singular aliases on separate pull-only apps. Reusing the plural +# apps here would also expose push/delete through `config skill|file ...`, +# which is intentionally not part of the compatibility surface. +config_app.add_typer(config_skill_pull_alias_app, name="skill", hidden=True) +config_app.add_typer(config_files_app, name="files") +config_app.add_typer(config_file_pull_alias_app, name="file", hidden=True) +config_app.add_typer(config_env_app, name="env") +config_app.add_typer(config_note_app, name="note") app.add_typer(drive_app, name="drive") -_KNOWN_ROOT_COMMANDS = frozenset({"connect", "drive", "file"}) +_KNOWN_ROOT_COMMANDS = frozenset({"config", "connect", "drive", "file"}) @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) @@ -77,6 +119,121 @@ def download( ) +@config_app.command("manifest") +def config_manifest() -> None: + """Show the current visible Agent config manifest as JSON.""" + _run_config_manifest() + + +@config_skills_app.command("pull") +def config_skills_pull( + names: list[str] = typer.Argument(None, metavar="NAME"), + local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config skills."), + json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."), +) -> None: + """Pull one or all visible config skills into ./.dify_conf/skills by default.""" + _run_config_skill_pull(names=names or None, local_dir=local_dir, json_output=json_output) + + +@config_skill_pull_alias_app.command("pull") +def config_skill_pull_alias( + names: list[str] = typer.Argument(None, metavar="NAME"), + local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config skills."), + json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."), +) -> None: + """Pull one or all visible config skills into ./.dify_conf/skills by default.""" + _run_config_skill_pull(names=names or None, local_dir=local_dir, json_output=json_output) + + +@config_skills_app.command("push") +def config_skills_push( + paths: list[str] = typer.Argument(..., metavar="PATH"), +) -> None: + """Upload one or more local skill directories into the current config manifest.""" + _run_config_skills_push(paths=paths) + + +@config_skills_app.command("delete") +def config_skills_delete( + names: list[str] = typer.Argument(..., metavar="NAME"), +) -> None: + """Delete one or more config skills by name without touching local directories.""" + _run_config_skills_delete(names=names) + + +@config_files_app.command("pull") +def config_files_pull( + names: list[str] = typer.Argument(None, metavar="NAME"), + local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config files."), + json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."), +) -> None: + """Pull one or all visible config files into ./.dify_conf/files by default.""" + _run_config_file_pull(names=names or None, local_dir=local_dir, json_output=json_output) + + +@config_file_pull_alias_app.command("pull") +def config_file_pull_alias( + names: list[str] = typer.Argument(None, metavar="NAME"), + local_dir: str | None = typer.Option(None, "--to", help="Local directory for pulled config files."), + json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."), +) -> None: + """Pull one or all visible config files into ./.dify_conf/files by default.""" + _run_config_file_pull(names=names or None, local_dir=local_dir, json_output=json_output) + + +@config_files_app.command("push") +def config_files_push( + paths: list[str] = typer.Argument(..., metavar="PATH"), + name: str | None = typer.Option( + None, + "--name", + help="Override the target config file name when uploading exactly one local file.", + ), +) -> None: + """Upload one or more local files into the current config manifest.""" + _run_config_files_push(paths=paths, name=name) + + +@config_files_app.command("delete") +def config_files_delete( + names: list[str] = typer.Argument(..., metavar="NAME"), +) -> None: + """Delete one or more config files by name without touching local files.""" + _run_config_files_delete(names=names) + + +@config_env_app.command("pull") +def config_env_pull( + local_path: str | None = typer.Option(None, "--to", help="Local dotenv file path."), +) -> None: + """Export visible config env values into ./.dify_conf/.env by default.""" + _run_config_env_pull(local_path=local_path) + + +@config_env_app.command("push") +def config_env_push( + local_path: str | None = typer.Argument(None, metavar="PATH|-"), +) -> None: + """Replace visible config env values from one local dotenv file or stdin.""" + _run_config_env_push(local_path=local_path) + + +@config_note_app.command("pull") +def config_note_pull( + local_path: str | None = typer.Option(None, "--to", help="Local markdown file path."), +) -> None: + """Export the current config note into ./.dify_conf/note.md by default.""" + _run_config_note_pull(local_path=local_path) + + +@config_note_app.command("push") +def config_note_push( + local_path: str | None = typer.Argument(None, metavar="PATH|-"), +) -> None: + """Replace the current config note from one local text file or stdin.""" + _run_config_note_push(local_path=local_path) + + @drive_app.command("list") def drive_list( path_prefix: str = typer.Argument("", metavar="REMOTE_PREFIX"), @@ -174,6 +331,25 @@ def _show_root_help() -> None: typer.echo(command.get_help(context)) +def render_agent_stub_cli_help(args: tuple[str, ...]) -> str: + """Render Click help for one known ``dify-agent`` subcommand without executing a shell.""" + command: click.Command = get_command(app) + parent_context: click.Context | None = None + command_path = ["dify-agent"] + for name in args: + if not isinstance(command, click.Group): + raise ValueError(f"dify-agent {' '.join(args)} is not a command group") + next_command = command.commands.get(name) + if next_command is None: + raise ValueError(f"unknown dify-agent command path: {' '.join(args)}") + current_context = click.Context(command, info_name=command_path[-1], parent=parent_context) + parent_context = current_context + command = next_command + command_path.append(name) + context = click.Context(command, info_name=command_path[-1], parent=parent_context) + return command.get_help(context).strip() + + def _run_connect(*, argv: list[str], json_output: bool) -> None: try: response = connect_from_environment(argv=argv) @@ -225,6 +401,149 @@ def _run_file_download( typer.echo(str(response.path)) +def _run_config_manifest() -> None: + try: + response = manifest_from_environment() + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json(exclude=_CONFIG_MANIFEST_STDOUT_EXCLUDE)) + + +def _run_config_skill_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None: + try: + response = pull_config_skills_from_environment(names=names, local_dir=local_dir) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + if json_output: + typer.echo(response.model_dump_json()) + return + for index, item in enumerate(response.items): + if index: + typer.echo("") + typer.echo(item.directory_path) + typer.echo(item.skill_md, nl=False) + + +def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, json_output: bool) -> None: + try: + response = pull_config_files_from_environment(names=names, local_dir=local_dir) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + if json_output: + typer.echo(response.model_dump_json()) + return + for item in response.items: + typer.echo(item.path) + + +def _run_config_env_pull(*, local_path: str | None) -> None: + try: + path = pull_config_env_from_environment(local_path=local_path) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(str(path)) + + +def _run_config_note_pull(*, local_path: str | None) -> None: + try: + path = pull_config_note_from_environment(local_path=local_path) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(str(path)) + + +def _run_config_note_push(*, local_path: str | None) -> None: + try: + response = push_config_note_from_environment(local_path=local_path) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + +def _run_config_env_push(*, local_path: str | None) -> None: + try: + response = push_config_env_from_environment(local_path=local_path) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + +def _run_config_files_push(*, paths: list[str], name: str | None) -> None: + try: + response = push_config_files_from_environment(paths=paths, name=name) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + +def _run_config_files_delete(*, names: list[str]) -> None: + try: + response = delete_config_files_from_environment(names=names) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + +def _run_config_skills_push(*, paths: list[str]) -> None: + try: + response = push_config_skills_from_environment(paths=paths) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + +def _run_config_skills_delete(*, names: list[str]) -> None: + try: + response = delete_config_skills_from_environment(names=names) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + def _run_drive_list(*, path_prefix: str, json_output: bool) -> None: try: response = list_drive_manifest_from_environment(prefix=path_prefix) diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py index 34b54cabaca..b76e74cd354 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py @@ -8,6 +8,13 @@ from pydantic import JsonValue from dify_agent.agent_stub.client._agent_stub_http import ( connect_agent_stub_http_sync, download_file_bytes_from_signed_url_sync, + request_agent_stub_config_env_update_http_sync, + request_agent_stub_config_file_pull_http_sync, + request_agent_stub_config_manifest_http_sync, + request_agent_stub_config_note_update_http_sync, + request_agent_stub_config_push_http_sync, + request_agent_stub_config_skill_inspect_http_sync, + request_agent_stub_config_skill_pull_http_sync, request_agent_stub_drive_commit_http_sync, request_agent_stub_drive_manifest_http_sync, request_agent_stub_file_download_http_sync, @@ -16,9 +23,10 @@ from dify_agent.agent_stub.client._agent_stub_http import ( ) from dify_agent.agent_stub.client._errors import AgentStubValidationError from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubConfigPushRequest, AgentStubDriveCommitRequest, - AgentStubFileMapping, parse_agent_stub_endpoint, + AgentStubFileMapping, ) @@ -166,7 +174,157 @@ def request_agent_stub_drive_commit_sync( ) +def request_agent_stub_config_manifest_sync( + *, + url: str, + auth_jwe: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Fetch the current config manifest through the HTTP Agent Stub transport. + + Config operations are HTTP-only in this stage. ``grpc://`` endpoints raise + ``AgentStubValidationError`` instead of attempting transport fallback. + """ + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_manifest_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_skill_pull_sync( + *, + url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Download one config skill archive through the HTTP Agent Stub transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_skill_pull_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + name=name, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_skill_inspect_sync( + *, + url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Fetch one config skill inspect view through the HTTP Agent Stub transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_skill_inspect_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + name=name, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_file_pull_sync( + *, + url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Download one config file payload through the HTTP Agent Stub transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_file_pull_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + name=name, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_push_sync( + *, + url: str, + auth_jwe: str, + request: AgentStubConfigPushRequest, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Push config file/skill/env/note mutations through the HTTP transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_push_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + request=request, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_env_update_sync( + *, + url: str, + auth_jwe: str, + env_text: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Replace or delete config env entries through the HTTP transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_env_update_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + env_text=env_text, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_config_note_update_sync( + *, + url: str, + auth_jwe: str, + note: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Update the config note text through the HTTP Agent Stub transport.""" + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") + return request_agent_stub_config_note_update_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + note=note, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + def _parse_endpoint(url: str): + """Normalize one Agent Stub base URL and map parse failures to client errors.""" try: return parse_agent_stub_endpoint(url) except ValueError as exc: @@ -176,6 +334,13 @@ def _parse_endpoint(url: str): __all__ = [ "connect_agent_stub_sync", "download_file_bytes_from_signed_url_sync", + "request_agent_stub_config_env_update_sync", + "request_agent_stub_config_file_pull_sync", + "request_agent_stub_config_manifest_sync", + "request_agent_stub_config_note_update_sync", + "request_agent_stub_config_push_sync", + "request_agent_stub_config_skill_inspect_sync", + "request_agent_stub_config_skill_pull_sync", "request_agent_stub_drive_commit_sync", "request_agent_stub_drive_manifest_sync", "request_agent_stub_file_download_sync", diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py index 9c80e2d060d..49c7c83ae94 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py @@ -8,6 +8,7 @@ must stay safe to import in default installations, so these helpers live under from __future__ import annotations +import json from collections.abc import Callable from typing import BinaryIO from typing import cast @@ -24,6 +25,11 @@ from dify_agent.agent_stub.client._errors import ( from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubConfigEnvUpdateRequest, + AgentStubConfigManifestResponse, + AgentStubConfigNoteUpdateRequest, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, AgentStubDriveCommitRequest, AgentStubDriveCommitResponse, AgentStubDriveManifestResponse, @@ -32,6 +38,13 @@ from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubFileMapping, AgentStubFileUploadRequest, AgentStubFileUploadResponse, + agent_stub_config_env_url, + agent_stub_config_file_pull_url, + agent_stub_config_manifest_url, + agent_stub_config_note_url, + agent_stub_config_push_url, + agent_stub_config_skill_inspect_url, + agent_stub_config_skill_pull_url, agent_stub_connections_url, agent_stub_drive_commit_url, agent_stub_drive_manifest_url, @@ -172,13 +185,213 @@ def request_agent_stub_drive_commit_http_sync( auth_jwe=auth_jwe, endpoint_name="drive commit request", endpoint_url_factory=agent_stub_drive_commit_url, - request_body=request.model_dump_json(exclude_none=True), + request_body=_dump_drive_commit_request(request), timeout=timeout, sync_http_client=sync_http_client, ) return _parse_success_response(response=response, response_model=AgentStubDriveCommitResponse, label="drive commit") +def request_agent_stub_config_manifest_http_sync( + *, + base_url: str, + auth_jwe: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> AgentStubConfigManifestResponse: + """Fetch the current config manifest from the HTTP Agent Stub endpoint.""" + response = _get_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config manifest request", + endpoint_url_factory=agent_stub_config_manifest_url, + params={}, + timeout=timeout, + sync_http_client=sync_http_client, + ) + return _parse_success_response( + response=response, + response_model=AgentStubConfigManifestResponse, + label="config manifest", + ) + + +def request_agent_stub_config_skill_pull_http_sync( + *, + base_url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> bytes: + """Download one config skill archive from the HTTP Agent Stub endpoint.""" + response = _get_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config skill pull request", + endpoint_url_factory=lambda resolved_base_url: agent_stub_config_skill_pull_url(resolved_base_url, name), + params={}, + timeout=timeout, + sync_http_client=sync_http_client, + ) + if response.is_error: + payload = _parse_json_payload( + response, invalid_json_message="Agent Stub returned invalid JSON for config skill pull" + ) + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubHTTPError(response.status_code, detail) + return response.content + + +def request_agent_stub_config_skill_inspect_http_sync( + *, + base_url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> dict[str, object]: + """Fetch the JSON inspect view for one config skill.""" + response = _get_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config skill inspect request", + endpoint_url_factory=lambda resolved_base_url: agent_stub_config_skill_inspect_url(resolved_base_url, name), + params={}, + timeout=timeout, + sync_http_client=sync_http_client, + ) + payload = _parse_json_payload( + response, invalid_json_message="Agent Stub returned invalid JSON for config skill inspect" + ) + if response.is_error: + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubHTTPError(response.status_code, detail) + if not isinstance(payload, dict): + raise AgentStubValidationError("invalid Agent Stub config skill inspect response") + return cast(dict[str, object], payload) + + +def request_agent_stub_config_file_pull_http_sync( + *, + base_url: str, + auth_jwe: str, + name: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> bytes: + """Download one config file payload from the HTTP Agent Stub endpoint.""" + response = _get_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config file pull request", + endpoint_url_factory=lambda resolved_base_url: agent_stub_config_file_pull_url(resolved_base_url, name), + params={}, + timeout=timeout, + sync_http_client=sync_http_client, + ) + if response.is_error: + payload = _parse_json_payload( + response, invalid_json_message="Agent Stub returned invalid JSON for config file pull" + ) + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubHTTPError(response.status_code, detail) + return response.content + + +def request_agent_stub_config_push_http_sync( + *, + base_url: str, + auth_jwe: str, + request: AgentStubConfigPushRequest, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> AgentStubConfigPushResponse: + """Push config file/skill/env/note mutations to the HTTP Agent Stub endpoint.""" + response = _post_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config push request", + endpoint_url_factory=agent_stub_config_push_url, + request_body=request.model_dump_json(exclude_none=True, exclude_defaults=True), + timeout=timeout, + sync_http_client=sync_http_client, + ) + return _parse_success_response(response=response, response_model=AgentStubConfigPushResponse, label="config push") + + +def _dump_drive_commit_request(request: AgentStubDriveCommitRequest) -> str: + payload = cast(dict[str, object], request.model_dump(mode="json", exclude_none=True)) + items = payload.get("items") + if isinstance(items, list): + for item in items: + if isinstance(item, dict) and item.get("is_skill") is False: + item.pop("is_skill") + return json.dumps(payload, separators=(",", ":")) + + +def request_agent_stub_config_env_update_http_sync( + *, + base_url: str, + auth_jwe: str, + env_text: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> dict[str, object]: + """Replace or delete config env entries through the HTTP Agent Stub endpoint.""" + request_model = AgentStubConfigEnvUpdateRequest(env_text=env_text) + response = _send_agent_stub_json( + method="PATCH", + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config env update request", + endpoint_url_factory=agent_stub_config_env_url, + request_body=request_model.model_dump_json(), + timeout=timeout, + sync_http_client=sync_http_client, + ) + payload = _parse_json_payload( + response, invalid_json_message="Agent Stub returned invalid JSON for config env update" + ) + if response.is_error: + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubHTTPError(response.status_code, detail) + if not isinstance(payload, dict): + raise AgentStubValidationError("invalid Agent Stub config env update response") + return cast(dict[str, object], payload) + + +def request_agent_stub_config_note_update_http_sync( + *, + base_url: str, + auth_jwe: str, + note: str, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> dict[str, object]: + """Update the config note text through the HTTP Agent Stub endpoint.""" + request_model = AgentStubConfigNoteUpdateRequest(note=note) + response = _send_agent_stub_json( + method="PUT", + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="config note update request", + endpoint_url_factory=agent_stub_config_note_url, + request_body=request_model.model_dump_json(), + timeout=timeout, + sync_http_client=sync_http_client, + ) + payload = _parse_json_payload( + response, invalid_json_message="Agent Stub returned invalid JSON for config note update" + ) + if response.is_error: + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubHTTPError(response.status_code, detail) + if not isinstance(payload, dict): + raise AgentStubValidationError("invalid Agent Stub config note update response") + return cast(dict[str, object], payload) + + def upload_file_to_signed_url_sync( *, upload_url: str, @@ -258,6 +471,29 @@ def _post_agent_stub_json( request_body: str, timeout: float | httpx.Timeout, sync_http_client: httpx.Client | None, +) -> httpx.Response: + return _send_agent_stub_json( + method="POST", + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name=endpoint_name, + endpoint_url_factory=endpoint_url_factory, + request_body=request_body, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def _send_agent_stub_json( + *, + method: str, + base_url: str, + auth_jwe: str, + endpoint_name: str, + endpoint_url_factory: Callable[[str], str], + request_body: str, + timeout: float | httpx.Timeout, + sync_http_client: httpx.Client | None, ) -> httpx.Response: try: endpoint_url = endpoint_url_factory(base_url) @@ -266,7 +502,8 @@ def _post_agent_stub_json( owns_client = sync_http_client is None client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True) try: - return client.post( + return client.request( + method, endpoint_url, content=request_body, headers={ @@ -344,6 +581,13 @@ def _parse_json_payload(response: httpx.Response, *, invalid_json_message: str) __all__ = [ "connect_agent_stub_http_sync", "download_file_bytes_from_signed_url_sync", + "request_agent_stub_config_env_update_http_sync", + "request_agent_stub_config_file_pull_http_sync", + "request_agent_stub_config_manifest_http_sync", + "request_agent_stub_config_note_update_http_sync", + "request_agent_stub_config_push_http_sync", + "request_agent_stub_config_skill_inspect_http_sync", + "request_agent_stub_config_skill_pull_http_sync", "request_agent_stub_drive_commit_http_sync", "request_agent_stub_drive_manifest_http_sync", "request_agent_stub_file_download_http_sync", 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 b607b3daa09..d10643de944 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -8,6 +8,17 @@ from .agent_stub import ( DEFAULT_AGENT_STUB_DRIVE_BASE, AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubConfigEnvUpdateRequest, + AgentStubConfigFileItem, + AgentStubConfigFileRef, + AgentStubConfigManifestResponse, + AgentStubConfigNoteUpdateRequest, + AgentStubConfigPushFileItem, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, + AgentStubConfigPushSkillItem, + AgentStubConfigSkillItem, + AgentStubConfigVersionInfo, AgentStubDriveCommitItem, AgentStubDriveCommitRequest, AgentStubDriveCommitResponse, @@ -21,6 +32,13 @@ from .agent_stub import ( AgentStubFileUploadRequest, AgentStubFileUploadResponse, AgentStubURLScheme, + agent_stub_config_env_url, + agent_stub_config_file_pull_url, + agent_stub_config_manifest_url, + agent_stub_config_note_url, + agent_stub_config_push_url, + agent_stub_config_skill_inspect_url, + agent_stub_config_skill_pull_url, agent_stub_connections_url, agent_stub_drive_base_for_ref, agent_stub_drive_commit_url, @@ -40,6 +58,17 @@ __all__ = [ "DEFAULT_AGENT_STUB_DRIVE_BASE", "AgentStubConnectRequest", "AgentStubConnectResponse", + "AgentStubConfigEnvUpdateRequest", + "AgentStubConfigFileItem", + "AgentStubConfigFileRef", + "AgentStubConfigManifestResponse", + "AgentStubConfigNoteUpdateRequest", + "AgentStubConfigPushFileItem", + "AgentStubConfigPushRequest", + "AgentStubConfigPushResponse", + "AgentStubConfigPushSkillItem", + "AgentStubConfigSkillItem", + "AgentStubConfigVersionInfo", "AgentStubDriveCommitItem", "AgentStubDriveCommitRequest", "AgentStubDriveCommitResponse", @@ -53,6 +82,13 @@ __all__ = [ "AgentStubFileUploadRequest", "AgentStubFileUploadResponse", "AgentStubURLScheme", + "agent_stub_config_env_url", + "agent_stub_config_file_pull_url", + "agent_stub_config_manifest_url", + "agent_stub_config_note_url", + "agent_stub_config_push_url", + "agent_stub_config_skill_inspect_url", + "agent_stub_config_skill_pull_url", "agent_stub_connections_url", "agent_stub_drive_base_for_ref", "agent_stub_drive_commit_url", 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 c6d0333cd5c..f13e5d9fb9f 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 @@ -142,6 +142,35 @@ def agent_stub_drive_commit_url(base_url: str) -> str: 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" + + +def agent_stub_config_skill_pull_url(base_url: str, name: str) -> str: + return f"{_require_http_base_url(base_url)}/config/skills/{name}/pull" + + +def agent_stub_config_skill_inspect_url(base_url: str, name: str) -> str: + return f"{_require_http_base_url(base_url)}/config/skills/{name}/inspect" + + +def agent_stub_config_file_pull_url(base_url: str, name: str) -> str: + return f"{_require_http_base_url(base_url)}/config/files/{name}/pull" + + +def agent_stub_config_push_url(base_url: str) -> str: + return f"{_require_http_base_url(base_url)}/config/push" + + +def agent_stub_config_env_url(base_url: str) -> str: + return f"{_require_http_base_url(base_url)}/config/env" + + +def agent_stub_config_note_url(base_url: str) -> str: + return f"{_require_http_base_url(base_url)}/config/note" + + def is_canonical_dify_file_reference(reference: str) -> bool: """Return whether one string matches Dify's opaque file reference format.""" prefix = "dify-file-ref:" @@ -189,8 +218,6 @@ class AgentStubFileUploadResponse(BaseModel): upload_url: str - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - class AgentStubFileMapping(BaseModel): """Public file mapping used by download-request control-plane calls.""" @@ -234,8 +261,6 @@ class AgentStubFileDownloadResponse(BaseModel): size: int download_url: str - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - class AgentStubDriveFileRef(BaseModel): """Trusted file reference used by Agent Stub drive commit requests.""" @@ -294,14 +319,94 @@ class AgentStubDriveManifestResponse(BaseModel): items: list[AgentStubDriveItem] - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - 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"] + writable: bool + + +class AgentStubConfigSkillItem(BaseModel): + name: str + description: str + size: int | None = None + hash: str | None = None + mime_type: str | None = None + + +class AgentStubConfigSkillItemsResponse(BaseModel): + items: list[AgentStubConfigSkillItem] = Field(default_factory=list) + + +class AgentStubConfigFileItem(BaseModel): + name: str + size: int | None = None + hash: str | None = None + mime_type: str | None = None + + +class AgentStubConfigFileItemsResponse(BaseModel): + items: list[AgentStubConfigFileItem] = Field(default_factory=list) + + +class AgentStubConfigManifestResponse(BaseModel): + agent_id: str + config_version: AgentStubConfigVersionInfo + skills: AgentStubConfigSkillItemsResponse = Field(default_factory=AgentStubConfigSkillItemsResponse) + files: AgentStubConfigFileItemsResponse = Field(default_factory=AgentStubConfigFileItemsResponse) + env_keys: list[str] = Field(default_factory=list) + note: str = "" + + +class AgentStubConfigFileRef(BaseModel): + kind: Literal["upload_file", "tool_file"] + id: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigPushFileItem(BaseModel): + name: str + file_ref: AgentStubConfigFileRef | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigPushSkillItem(BaseModel): + name: str + file_ref: AgentStubConfigFileRef | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigPushRequest(BaseModel): + files: list[AgentStubConfigPushFileItem] = Field(default_factory=list) + skills: list[AgentStubConfigPushSkillItem] = Field(default_factory=list) + env_text: str | None = None + note: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigPushResponse(AgentStubConfigManifestResponse): + """Updated config manifest returned after one config push.""" + + +class AgentStubConfigEnvUpdateRequest(BaseModel): + env_text: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigNoteUpdateRequest(BaseModel): + note: str + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -325,6 +430,19 @@ __all__ = [ "AgentStubConnectRequest", "AgentStubConnectResponse", "AgentStubEndpoint", + "AgentStubConfigEnvUpdateRequest", + "AgentStubConfigFileItem", + "AgentStubConfigFileItemsResponse", + "AgentStubConfigFileRef", + "AgentStubConfigManifestResponse", + "AgentStubConfigNoteUpdateRequest", + "AgentStubConfigPushFileItem", + "AgentStubConfigPushRequest", + "AgentStubConfigPushResponse", + "AgentStubConfigPushSkillItem", + "AgentStubConfigSkillItem", + "AgentStubConfigSkillItemsResponse", + "AgentStubConfigVersionInfo", "AgentStubDriveCommitItem", "AgentStubDriveCommitRequest", "AgentStubDriveCommitResponse", @@ -337,6 +455,13 @@ __all__ = [ "AgentStubFileUploadRequest", "AgentStubFileUploadResponse", "AgentStubURLScheme", + "agent_stub_config_env_url", + "agent_stub_config_file_pull_url", + "agent_stub_config_manifest_url", + "agent_stub_config_note_url", + "agent_stub_config_push_url", + "agent_stub_config_skill_inspect_url", + "agent_stub_config_skill_pull_url", "agent_stub_connections_url", "agent_stub_drive_base_for_ref", "agent_stub_drive_commit_url", diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py new file mode 100644 index 00000000000..46f4a13bb77 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py @@ -0,0 +1,262 @@ +"""Server-side Dify API client for Agent Stub config endpoints. + +Config requests are scoped entirely by the signed execution context carried in +the Agent Stub token. Tenant, agent, user, and config-version identifiers come +only from that trusted context; sandbox request bodies contribute only mutable +content such as asset names, env text, and note text. +""" + +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 ( + AgentStubConfigManifestResponse, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, +) +from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +class AgentStubConfigRequestHandler(Protocol): + async def manifest(self, *, principal: AgentStubPrincipal) -> AgentStubConfigManifestResponse: ... + + async def pull_skill(self, *, principal: AgentStubPrincipal, name: str) -> bytes: ... + + async def inspect_skill(self, *, principal: AgentStubPrincipal, name: str) -> dict[str, object]: ... + + async def pull_file(self, *, principal: AgentStubPrincipal, name: str) -> bytes: ... + + async def push( + self, + *, + principal: AgentStubPrincipal, + request: AgentStubConfigPushRequest, + ) -> AgentStubConfigPushResponse: ... + + async def update_env(self, *, principal: AgentStubPrincipal, env_text: str) -> dict[str, object]: ... + + async def update_note(self, *, principal: AgentStubPrincipal, note: str) -> dict[str, object]: ... + + +class AgentStubConfigRequestError(RuntimeError): + 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 DifyApiAgentStubConfigRequestHandler: + """Call Dify API inner config endpoints on behalf of authenticated sandboxes. + + The sandbox never chooses tenant, agent, user, or config-version scope + directly. Those routing fields are derived from the signed execution + context, while request payloads only carry mutable config content. + """ + + inner_api_url: str + inner_api_key: str + timeout: httpx.Timeout | float = 30.0 + + async def manifest(self, *, principal: AgentStubPrincipal) -> AgentStubConfigManifestResponse: + execution_context = self._require_config_context(principal.execution_context) + payload = await self._get_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/manifest", + self._config_query_params(execution_context), + ) + try: + return AgentStubConfigManifestResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubConfigRequestError(502, "Dify API config manifest response is invalid") from exc + + async def pull_skill(self, *, principal: AgentStubPrincipal, name: str) -> bytes: + execution_context = self._require_config_context(principal.execution_context) + return await self._get_inner_api_bytes( + f"/inner/api/agent-config/{execution_context.agent_id}/skills/{name}/pull", + self._config_query_params(execution_context), + ) + + async def inspect_skill(self, *, principal: AgentStubPrincipal, name: str) -> dict[str, object]: + execution_context = self._require_config_context(principal.execution_context) + payload = await self._get_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/skills/{name}/inspect", + self._config_query_params(execution_context), + ) + if not isinstance(payload, dict): + raise AgentStubConfigRequestError(502, "Dify API config skill inspect response is invalid") + return payload + + async def pull_file(self, *, principal: AgentStubPrincipal, name: str) -> bytes: + execution_context = self._require_config_context(principal.execution_context) + return await self._get_inner_api_bytes( + f"/inner/api/agent-config/{execution_context.agent_id}/files/{name}/pull", + self._config_query_params(execution_context), + ) + + async def push( + self, + *, + principal: AgentStubPrincipal, + request: AgentStubConfigPushRequest, + ) -> AgentStubConfigPushResponse: + execution_context = self._require_user_context(self._require_config_context(principal.execution_context)) + payload = await self._post_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/push", + { + "tenant_id": execution_context.tenant_id, + "user_id": execution_context.user_id, + "config_version_id": execution_context.agent_config_version_id, + "config_version_kind": execution_context.agent_config_version_kind, + **request.model_dump(mode="json", exclude_none=True), + }, + ) + try: + return AgentStubConfigPushResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubConfigRequestError(502, "Dify API config push response is invalid") from exc + + async def update_env(self, *, principal: AgentStubPrincipal, env_text: str) -> dict[str, object]: + execution_context = self._require_user_context(self._require_config_context(principal.execution_context)) + payload = await self._patch_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/env", + { + "tenant_id": execution_context.tenant_id, + "user_id": execution_context.user_id, + "config_version_id": execution_context.agent_config_version_id, + "config_version_kind": execution_context.agent_config_version_kind, + "env_text": env_text, + }, + ) + if not isinstance(payload, dict): + raise AgentStubConfigRequestError(502, "Dify API config env response is invalid") + return payload + + async def update_note(self, *, principal: AgentStubPrincipal, note: str) -> dict[str, object]: + execution_context = self._require_user_context(self._require_config_context(principal.execution_context)) + payload = await self._put_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/note", + { + "tenant_id": execution_context.tenant_id, + "user_id": execution_context.user_id, + "config_version_id": execution_context.agent_config_version_id, + "config_version_kind": execution_context.agent_config_version_kind, + "note": note, + }, + ) + if not isinstance(payload, dict): + raise AgentStubConfigRequestError(502, "Dify API config note response is invalid") + return payload + + @staticmethod + def _require_config_context(execution_context: DifyExecutionContextLayerConfig) -> DifyExecutionContextLayerConfig: + if execution_context.agent_id is None: + raise AgentStubConfigRequestError(400, "execution context agent_id is required for config operations") + if execution_context.agent_config_version_id is None: + raise AgentStubConfigRequestError(400, "execution context agent_config_version_id is required") + if execution_context.agent_config_version_kind is None: + raise AgentStubConfigRequestError(400, "execution context agent_config_version_kind is required") + return execution_context + + @staticmethod + def _require_user_context(execution_context: DifyExecutionContextLayerConfig) -> DifyExecutionContextLayerConfig: + if execution_context.user_id is None: + raise AgentStubConfigRequestError(400, "execution context user_id is required for config write operations") + return execution_context + + @staticmethod + def _config_query_params(execution_context: DifyExecutionContextLayerConfig) -> dict[str, str]: + params = { + "tenant_id": execution_context.tenant_id, + "config_version_id": execution_context.agent_config_version_id or "", + "config_version_kind": execution_context.agent_config_version_kind or "", + } + if execution_context.user_id is not None: + params["user_id"] = execution_context.user_id + return params + + async def _get_inner_api_json(self, path: str, params: Mapping[str, str]) -> object: + response = await self._request("GET", path, params=dict(params)) + return self._normalize_json_payload( + response, invalid_json_detail="Dify API config request returned invalid JSON" + ) + + async def _get_inner_api_bytes(self, path: str, params: Mapping[str, str]) -> bytes: + response = await self._request("GET", path, params=dict(params)) + if response.is_error: + detail = self._normalize_json_payload( + response, + invalid_json_detail="Dify API config request returned invalid JSON", + ) + raise AgentStubConfigRequestError( + response.status_code, detail.get("detail", detail) if isinstance(detail, dict) else detail + ) + return response.content + + async def _post_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object: + response = await self._request("POST", path, json=dict(payload)) + return self._normalize_json_payload( + response, invalid_json_detail="Dify API config request returned invalid JSON" + ) + + async def _patch_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object: + response = await self._request("PATCH", path, json=dict(payload)) + return self._normalize_json_payload( + response, invalid_json_detail="Dify API config request returned invalid JSON" + ) + + async def _put_inner_api_json(self, path: str, payload: Mapping[str, Any]) -> object: + response = await self._request("PUT", path, json=dict(payload)) + return self._normalize_json_payload( + response, invalid_json_detail="Dify API config request returned invalid JSON" + ) + + async def _request( + self, + method: str, + path: str, + *, + params: Mapping[str, str] | None = None, + json: Mapping[str, Any] | None = None, + ) -> httpx.Response: + url = f"{self.inner_api_url.rstrip('/')}{path}" + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client: + try: + return await client.request( + method, + url, + params=dict(params or {}), + json=dict(json or {}) if json is not None else None, + headers={"X-Inner-Api-Key": self.inner_api_key}, + ) + except httpx.TimeoutException as exc: + raise AgentStubConfigRequestError(504, "Dify API config request timed out") from exc + except httpx.RequestError as exc: + raise AgentStubConfigRequestError(502, f"Dify API config request failed: {exc}") from exc + + @staticmethod + def _normalize_json_payload(response: httpx.Response, *, invalid_json_detail: str) -> object: + try: + payload = response.json() + except ValueError as exc: + raise AgentStubConfigRequestError(502, invalid_json_detail) from exc + if response.is_error: + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + raise AgentStubConfigRequestError(response.status_code, detail) + return payload + + +__all__ = [ + "AgentStubConfigRequestError", + "AgentStubConfigRequestHandler", + "DifyApiAgentStubConfigRequestHandler", +] 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 8467dea79f4..ba16abf18c7 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/app.py +++ b/dify-agent/src/dify_agent/agent_stub/server/app.py @@ -23,6 +23,7 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI: create_agent_stub_router( 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(), ) ) 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 ef747bd6d0e..5b887af8288 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,4 +1,10 @@ -"""Shared Agent Stub control-plane service used by HTTP and gRPC transports.""" +"""Shared Agent Stub control-plane service used by HTTP and gRPC transports. + +This layer owns the authenticated control-plane delegation for file, config, +and drive operations. Transport adapters validate transport DTOs first, then +call into this service so auth, handler lookup, and error mapping stay shared +across HTTP and gRPC. +""" from __future__ import annotations @@ -8,6 +14,9 @@ from uuid import uuid4 from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectResponse, + AgentStubConfigManifestResponse, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, AgentStubDriveCommitRequest, AgentStubDriveCommitResponse, AgentStubDriveManifestResponse, @@ -16,6 +25,7 @@ from dify_agent.agent_stub.protocol.agent_stub import ( 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, AgentStubTokenCodec, AgentStubTokenError @@ -47,11 +57,12 @@ class AgentStubControlPlaneService: HTTP and gRPC adapters validate or decode transport payloads before calling this service, so this layer focuses only on shared auth, connection-id - generation, plus file and drive request delegation. + generation, plus file, config, and drive request delegation. """ 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())) @@ -107,6 +118,96 @@ class AgentStubControlPlaneService: except AgentStubDriveRequestError as exc: raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + async def get_config_manifest( + self, + *, + authorization: str | None, + ) -> AgentStubConfigManifestResponse: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.manifest(principal=principal) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def pull_config_skill( + self, + *, + name: str, + authorization: str | None, + ) -> bytes: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.pull_skill(principal=principal, name=name) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def inspect_config_skill( + self, + *, + name: str, + authorization: str | None, + ) -> dict[str, object]: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.inspect_skill(principal=principal, name=name) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def pull_config_file( + self, + *, + name: str, + authorization: str | None, + ) -> bytes: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.pull_file(principal=principal, name=name) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def push_config( + self, + *, + request: AgentStubConfigPushRequest, + authorization: str | None, + ) -> AgentStubConfigPushResponse: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.push(principal=principal, request=request) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def update_config_env( + self, + *, + env_text: str, + authorization: str | None, + ) -> dict[str, object]: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.update_env(principal=principal, env_text=env_text) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def update_config_note( + self, + *, + note: str, + authorization: str | None, + ) -> dict[str, object]: + principal = self._authenticate(authorization) + handler = self._require_config_request_handler() + try: + return await handler.update_note(principal=principal, note=note) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + async def commit_drive( self, *, @@ -135,6 +236,11 @@ class AgentStubControlPlaneService: raise AgentStubConfigurationError(503, "Agent Stub file API is not configured") return self.file_request_handler + def _require_config_request_handler(self) -> AgentStubConfigRequestHandler: + if self.config_request_handler is None: + 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") 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 fa506ab0188..5c77202093d 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/router.py +++ b/dify-agent/src/dify_agent/agent_stub/server/router.py @@ -12,6 +12,7 @@ from __future__ import annotations from fastapi import APIRouter +from dify_agent.agent_stub.server.agent_stub_config import AgentStubConfigRequestHandler from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router @@ -23,9 +24,15 @@ 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) + return create_agent_stub_http_router( + token_codec, + file_request_handler, + drive_request_handler, + config_request_handler, + ) __all__ = ["create_agent_stub_router"] 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 72077c88545..411fcc4c6af 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,17 +2,22 @@ The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It keeps FastAPI-specific request parsing and HTTPException translation here while -sharing auth, DTO validation, connection-id generation, and file/drive +sharing auth, DTO validation, connection-id generation, and file/config/drive delegation with the gRPC transport. """ from __future__ import annotations -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Header, HTTPException, Response from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubConfigEnvUpdateRequest, + AgentStubConfigManifestResponse, + AgentStubConfigNoteUpdateRequest, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, AgentStubDriveCommitRequest, AgentStubDriveCommitResponse, AgentStubDriveManifestResponse, @@ -21,6 +26,7 @@ from dify_agent.agent_stub.protocol.agent_stub import ( 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 @@ -31,10 +37,13 @@ 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.""" router = APIRouter(prefix="/agent-stub", tags=["agent-stub"]) - service = AgentStubControlPlaneService(token_codec, file_request_handler, drive_request_handler) + service = AgentStubControlPlaneService( + token_codec, file_request_handler, config_request_handler, drive_request_handler + ) @router.post("/connections", response_model=AgentStubConnectResponse) async def create_connection( @@ -67,6 +76,77 @@ def create_agent_stub_http_router( except AgentStubControlPlaneError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + @router.get("/config/manifest", response_model=AgentStubConfigManifestResponse) + async def get_config_manifest( + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubConfigManifestResponse: + try: + return await service.get_config_manifest(authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.get("/config/skills/{name}/pull") + async def pull_config_skill( + name: str, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> Response: + try: + payload = await service.pull_config_skill(name=name, authorization=authorization) + return Response(content=payload, media_type="application/zip") + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.get("/config/skills/{name}/inspect") + async def inspect_config_skill( + name: str, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> dict[str, object]: + try: + return await service.inspect_config_skill(name=name, authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.get("/config/files/{name}/pull") + async def pull_config_file( + name: str, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> Response: + try: + payload = await service.pull_config_file(name=name, authorization=authorization) + return Response(content=payload, media_type="application/octet-stream") + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.post("/config/push", response_model=AgentStubConfigPushResponse) + async def push_config( + request: AgentStubConfigPushRequest, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubConfigPushResponse: + try: + return await service.push_config(request=request, authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.patch("/config/env") + async def update_config_env( + request: AgentStubConfigEnvUpdateRequest, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> dict[str, object]: + try: + return await service.update_config_env(env_text=request.env_text, authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.put("/config/note") + async def update_config_note( + request: AgentStubConfigNoteUpdateRequest, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> dict[str, object]: + try: + return await service.update_config_note(note=request.note, authorization=authorization) + 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 = "", diff --git a/dify-agent/src/dify_agent/layers/config/__init__.py b/dify-agent/src/dify_agent/layers/config/__init__.py new file mode 100644 index 00000000000..f5c3d0c4b3a --- /dev/null +++ b/dify-agent/src/dify_agent/layers/config/__init__.py @@ -0,0 +1,19 @@ +"""Client-safe exports for the Dify config runtime catalog DTOs.""" + +from dify_agent.layers.config.configs import ( + DIFY_CONFIG_LAYER_TYPE_ID, + DifyConfigFileConfig, + DifyConfigLayerConfig, + DifyConfigRuntimeState, + DifyConfigSkillConfig, + DifyConfigVersionConfig, +) + +__all__ = [ + "DIFY_CONFIG_LAYER_TYPE_ID", + "DifyConfigFileConfig", + "DifyConfigLayerConfig", + "DifyConfigRuntimeState", + "DifyConfigSkillConfig", + "DifyConfigVersionConfig", +] diff --git a/dify-agent/src/dify_agent/layers/config/configs.py b/dify-agent/src/dify_agent/layers/config/configs.py new file mode 100644 index 00000000000..c3d20c878f1 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/config/configs.py @@ -0,0 +1,86 @@ +"""Client-safe DTOs for the Dify config declaration layer.""" + +from typing import ClassVar, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from agenton.layers import LayerConfig + + +DIFY_CONFIG_LAYER_TYPE_ID: Final[str] = "dify.config" + + +class DifyConfigVersionConfig(BaseModel): + """Agent config version metadata visible to the runtime prompt.""" + + id: str | None = None + kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" + writable: bool = False + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyConfigSkillConfig(BaseModel): + """Prompt-safe summary of one Agent Soul config skill.""" + + name: str + description: str = "" + size: int | None = None + mime_type: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyConfigFileConfig(BaseModel): + """Prompt-safe summary of one Agent Soul config file.""" + + name: str + size: int | None = None + mime_type: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyConfigLayerConfig(LayerConfig): + """Agent Soul config context plus eager-pull instructions for prompt mentions.""" + + agent_id: str | None = None + config_version: DifyConfigVersionConfig | None = None + skills: list[DifyConfigSkillConfig] = Field(default_factory=list) + files: list[DifyConfigFileConfig] = Field(default_factory=list) + env_keys: list[str] = Field(default_factory=list) + note: str = "" + mentioned_skill_names: list[str] = Field(default_factory=list) + mentioned_file_names: list[str] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyConfigRuntimeState(BaseModel): + """Serializable config-layer values computed once during context entry. + + The ``push_spec_*`` fields are compatibility leftovers from the removed root + JSON-spec config mutation workflow. This change keeps them in the runtime-state + schema to avoid snapshot churn, but new code should treat them as inert + compatibility fields rather than active prompt data. + """ + + pulled_skill_outputs: dict[str, str] = Field(default_factory=dict) + pulled_file_outputs: dict[str, str] = Field(default_factory=dict) + config_context_json: str = "" + config_cli_help: dict[str, str] = Field(default_factory=dict) + push_spec_semantics: str = "" + push_spec_json_schema: str = "" + push_spec_example: str = "" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True) + + +__all__ = [ + "DIFY_CONFIG_LAYER_TYPE_ID", + "DifyConfigFileConfig", + "DifyConfigLayerConfig", + "DifyConfigRuntimeState", + "DifyConfigSkillConfig", + "DifyConfigVersionConfig", +] diff --git a/dify-agent/src/dify_agent/layers/config/layer.py b/dify-agent/src/dify_agent/layers/config/layer.py new file mode 100644 index 00000000000..5bec83d6173 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/config/layer.py @@ -0,0 +1,227 @@ +"""Runtime Dify config layer with shell-backed eager pulls.""" + +from __future__ import annotations + +import asyncio +import shlex +from dataclasses import dataclass +from typing import ClassVar + +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.agent_stub.cli.main import render_agent_stub_cli_help +from dify_agent.layers.config.configs import ( + DIFY_CONFIG_LAYER_TYPE_ID, + DifyConfigLayerConfig, + DifyConfigRuntimeState, +) +from dify_agent.layers.shell.layer import DifyShellLayer + +_CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:" +_CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated +from the same `dify-agent` CLI definitions available in shell jobs. + +Local edits to config files, skills, env, or notes are not saved by themselves. Config changes are saved only by a +matching resource mutation command. Those commands are available only when the Agent config context reports +`config_version.kind` as `build_draft` and `config_version.writable` as true.""" +_CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = { + "dify-agent config --help": ("config",), + "dify-agent config manifest --help": ("config", "manifest"), + "dify-agent config skills pull --help": ("config", "skills", "pull"), + "dify-agent config files pull --help": ("config", "files", "pull"), + "dify-agent config env pull --help": ("config", "env", "pull"), + "dify-agent config note pull --help": ("config", "note", "pull"), +} +_CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = { + "dify-agent config note push --help": ("config", "note", "push"), + "dify-agent config env push --help": ("config", "env", "push"), + "dify-agent config files push --help": ("config", "files", "push"), + "dify-agent config files delete --help": ("config", "files", "delete"), + "dify-agent config skills push --help": ("config", "skills", "push"), + "dify-agent config skills delete --help": ("config", "skills", "delete"), +} +_CONFIG_CONTEXT_EXCLUDE = {"mentioned_skill_names": True, "mentioned_file_names": True} + + +class DifyConfigLayerError(RuntimeError): + """Raised when one eager-pull config operation fails.""" + + +class DifyConfigDeps(LayerDeps): + shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConfigRuntimeState]): + """Config runtime layer that materializes prompt-mentioned targets via shell.""" + + type_id: ClassVar[str | None] = DIFY_CONFIG_LAYER_TYPE_ID + + config: DifyConfigLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyConfigLayerConfig) -> Self: + return cls(config=DifyConfigLayerConfig.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._initialize_context() + + @override + async def on_context_resume(self) -> None: + return None + + async def _initialize_context(self) -> None: + self._initialize_runtime_prompt_state() + await self._pull_mentioned_targets() + + def _initialize_runtime_prompt_state(self) -> None: + command_paths = dict(_CONFIG_CLI_HELP_COMMANDS) + if self._config_writable: + command_paths.update(_CONFIG_CLI_MUTATION_HELP_COMMANDS) + self.runtime_state.config_context_json = self._format_config_context_json() + self.runtime_state.config_cli_help = { + command: render_agent_stub_cli_help(args) for command, args in command_paths.items() + } + self.runtime_state.push_spec_semantics = "" + self.runtime_state.push_spec_json_schema = "" + self.runtime_state.push_spec_example = "" + + def build_prompt_context(self) -> str: + sections: list[str] = [] + + loaded_skill_sections = [] + for name in self.config.mentioned_skill_names: + output = self.runtime_state.pulled_skill_outputs.get(name) + if output is None: + continue + loaded_skill_sections.append(f"Name: {name}\nPull output:\n{output}") + if loaded_skill_sections: + sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections)) + + mentioned_file_sections = [ + f"Name: {name}\nPull output:\n{self.runtime_state.pulled_file_outputs[name]}" + for name in self.config.mentioned_file_names + if name in self.runtime_state.pulled_file_outputs + ] + if mentioned_file_sections: + sections.append("Mentioned files pulled locally:\n\n" + "\n\n".join(mentioned_file_sections)) + + return "\n\n".join(section for section in sections if section) + + def build_suffix_prompt(self) -> str: + sections: list[str] = [] + if self.runtime_state.config_context_json: + sections.append(f"{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}") + usage_lines = [_CONFIG_CLI_USAGE_PROMPT] + if cli_help := self._format_config_cli_help(): + usage_lines.append(cli_help) + sections.append("\n".join(usage_lines)) + return "\n\n".join(section for section in sections if section) + + @property + def _config_writable(self) -> bool: + return self.config.config_version is not None and self.config.config_version.writable + + def _format_config_cli_help(self) -> str: + commands = list(_CONFIG_CLI_HELP_COMMANDS) + if self._config_writable: + commands.extend(_CONFIG_CLI_MUTATION_HELP_COMMANDS) + command_sections = [ + f"$ {command}\n{self.runtime_state.config_cli_help[command]}" + for command in commands + if command in self.runtime_state.config_cli_help + ] + if not command_sections: + return "" + return "Agent config CLI help:\n" + "\n\n".join(command_sections) + + def _format_config_context_json(self) -> str: + return self.config.model_dump_json(exclude=_CONFIG_CONTEXT_EXCLUDE, exclude_none=True) + + async def _pull_mentioned_targets(self) -> None: + self.runtime_state.pulled_skill_outputs = {} + self.runtime_state.pulled_file_outputs = {} + if not self.config.mentioned_skill_names and not self.config.mentioned_file_names: + return + + tasks = [ + *(self._pull_mentioned_skill(name) for name in self.config.mentioned_skill_names), + *(self._pull_mentioned_file(name) for name in self.config.mentioned_file_names), + ] + await asyncio.gather(*tasks) + + async def _pull_mentioned_skill(self, name: str) -> None: + result = await self.deps.shell.run_remote_script( + self._build_shell_skill_pull_script(name), + inject_agent_stub_env=True, + ) + if result.exit_code != 0: + raise DifyConfigLayerError( + "config mentioned skill pull failed in shell: " + f"{result.status} exit_code={result.exit_code}\n{result.output}" + ) + if not result.output_complete: + reason = result.incomplete_reason or "unknown" + raise DifyConfigLayerError( + f"config mentioned skill pull output was incomplete before the payload finished: {reason}" + ) + output = result.output.strip() + if not output: + raise DifyConfigLayerError(f"missing pull output for mentioned config skill {name}") + self.runtime_state.pulled_skill_outputs = { + **self.runtime_state.pulled_skill_outputs, + name: output, + } + + async def _pull_mentioned_file(self, name: str) -> None: + result = await self.deps.shell.run_remote_script( + self._build_shell_file_pull_script(name), + inject_agent_stub_env=True, + ) + if result.exit_code != 0: + raise DifyConfigLayerError( + "config mentioned file pull failed in shell: " + f"{result.status} exit_code={result.exit_code}\n{result.output}" + ) + if not result.output_complete: + reason = result.incomplete_reason or "unknown" + raise DifyConfigLayerError( + f"config mentioned file pull output was incomplete before the payload finished: {reason}" + ) + output = result.output.strip() + if not output: + raise DifyConfigLayerError(f"missing pull output for mentioned config file {name}") + self.runtime_state.pulled_file_outputs = { + **self.runtime_state.pulled_file_outputs, + name: output, + } + + def _build_shell_skill_pull_script(self, name: str) -> str: + lines = [ + "set -eu", + f"dify-agent config skills pull {shlex.quote(name)}", + ] + return "\n".join(lines) + + def _build_shell_file_pull_script(self, name: str) -> str: + lines = [ + "set -eu", + f"dify-agent config files pull {shlex.quote(name)}", + ] + return "\n".join(lines) + + +__all__ = ["DifyConfigLayer", "DifyConfigLayerError"] diff --git a/dify-agent/src/dify_agent/layers/dify_core_tools/__init__.py b/dify-agent/src/dify_agent/layers/dify_core_tools/__init__.py new file mode 100644 index 00000000000..dd8b09574cf --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_core_tools/__init__.py @@ -0,0 +1,15 @@ +"""Client-safe exports for the Dify core-tools layer DTOs and type ids.""" + +from dify_agent.layers.dify_core_tools.configs import ( + DIFY_CORE_TOOLS_LAYER_TYPE_ID, + DifyCoreToolConfig, + DifyCoreToolProviderType, + DifyCoreToolsLayerConfig, +) + +__all__ = [ + "DIFY_CORE_TOOLS_LAYER_TYPE_ID", + "DifyCoreToolConfig", + "DifyCoreToolProviderType", + "DifyCoreToolsLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/dify_core_tools/client.py b/dify-agent/src/dify_agent/layers/dify_core_tools/client.py new file mode 100644 index 00000000000..387185724ef --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_core_tools/client.py @@ -0,0 +1,208 @@ +"""Async client for the Dify API inner core-tools invoke endpoint.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import ClassVar + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +class DifyCoreToolsClientError(RuntimeError): + """Raised when the inner core-tools HTTP boundary fails.""" + + status_code: int | None + error_code: str | None + retryable: bool + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + error_code: str | None = None, + retryable: bool, + ) -> None: + self.status_code = status_code + self.error_code = error_code + self.retryable = retryable + super().__init__(message) + + +class DifyCoreToolsClientConfigurationError(DifyCoreToolsClientError): + """Raised for local layer/configuration precondition failures before HTTP I/O.""" + + +class _DifyCoreToolsCaller(BaseModel): + tenant_id: str + user_id: str + user_from: str + app_id: str + invoke_from: str + conversation_id: str | None = None + workflow_id: str | None = None + workflow_run_id: str | None = None + node_id: str | None = None + node_execution_id: str | None = None + agent_id: str | None = None + agent_config_version_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class _DifyCoreToolsRequestTool(BaseModel): + """Inner-API tool envelope. + + `runtime_parameters` are API-prepared hidden/form/runtime values attached + to the tool declaration. `tool_parameters` are the live LLM/user arguments + passed by the runtime when invoking the tool. + """ + + provider_type: str + provider_id: str + tool_name: str + credential_id: str | None = None + tool_parameters: dict[str, JsonValue] = Field(default_factory=dict) + runtime_parameters: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class _DifyCoreToolsInvokeRequest(BaseModel): + caller: _DifyCoreToolsCaller + tool: _DifyCoreToolsRequestTool + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyCoreToolsInvokeResponse(BaseModel): + messages: list[dict[str, JsonValue]] = Field(default_factory=list) + observation: str + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class DifyCoreToolsClient: + """Boundary client for `POST /inner/api/agent/tools/invoke`.""" + + base_url: str + api_key: str = field(repr=False) + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.base_url = self.base_url.rstrip("/") + + async def invoke( + self, + *, + execution_context: DifyExecutionContextLayerConfig, + tool_config: DifyCoreToolConfig, + tool_parameters: dict[str, JsonValue], + ) -> DifyCoreToolsInvokeResponse: + _validate_execution_context(execution_context) + + request_payload = _DifyCoreToolsInvokeRequest( + caller=_DifyCoreToolsCaller( + tenant_id=execution_context.tenant_id, + user_id=execution_context.user_id, + user_from=execution_context.user_from, + app_id=execution_context.app_id, + invoke_from=execution_context.invoke_from, + conversation_id=execution_context.conversation_id, + workflow_id=execution_context.workflow_id, + workflow_run_id=execution_context.workflow_run_id, + node_id=execution_context.node_id, + node_execution_id=execution_context.node_execution_id, + agent_id=execution_context.agent_id, + agent_config_version_id=execution_context.agent_config_version_id, + ), + tool=_DifyCoreToolsRequestTool( + provider_type=tool_config.provider_type, + provider_id=tool_config.provider_id, + tool_name=tool_config.tool_name, + credential_id=tool_config.credential_id, + tool_parameters=tool_parameters, + runtime_parameters=tool_config.runtime_parameters, + ), + ) + + try: + response = await self.http_client.post( + f"{self.base_url}/inner/api/agent/tools/invoke", + headers={ + "X-Inner-Api-Key": self.api_key, + "Content-Type": "application/json", + }, + json=request_payload.model_dump(mode="json"), + ) + except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc: + raise DifyCoreToolsClientError(f"Core tools are misconfigured: {exc}", retryable=False) from exc + except httpx.TimeoutException as exc: + raise DifyCoreToolsClientError("Core tool invocation timed out.", retryable=True) from exc + except httpx.RequestError as exc: + raise DifyCoreToolsClientError(f"Core tool invocation request failed: {exc}", retryable=True) from exc + + if response.status_code >= 400: + raise _build_http_error(response) + + try: + return DifyCoreToolsInvokeResponse.model_validate_json(response.text) + except ValidationError as exc: + raise DifyCoreToolsClientError( + "Invalid core tool response from Dify API.", + status_code=response.status_code, + error_code="invalid_response", + retryable=False, + ) from exc + + +def _validate_execution_context(execution_context: DifyExecutionContextLayerConfig) -> None: + missing_fields = [ + field_name + for field_name in ("user_id", "user_from", "app_id") + if getattr(execution_context, field_name) is None + ] + if missing_fields: + missing = ", ".join(missing_fields) + raise DifyCoreToolsClientConfigurationError( + f"Missing required execution context fields: {missing}.", + error_code="missing_execution_context", + retryable=False, + ) + + +def _build_http_error(response: httpx.Response) -> DifyCoreToolsClientError: + detail = _decode_error_detail(response) + retryable = response.status_code >= 500 or response.status_code == 429 + message = detail["message"] or f"HTTP {response.status_code}" + return DifyCoreToolsClientError( + message, + status_code=response.status_code, + error_code=detail["error_code"], + retryable=retryable, + ) + + +def _decode_error_detail(response: httpx.Response) -> dict[str, str | None]: + raw_body = response.text + try: + payload = response.json() + except json.JSONDecodeError: + payload = None + + if isinstance(payload, dict): + error_code = payload.get("code") + message = payload.get("message") + return { + "error_code": error_code if isinstance(error_code, str) else None, + "message": message if isinstance(message, str) and message else raw_body or f"HTTP {response.status_code}", + } + + return {"error_code": None, "message": raw_body or f"HTTP {response.status_code}"} diff --git a/dify-agent/src/dify_agent/layers/dify_core_tools/configs.py b/dify-agent/src/dify_agent/layers/dify_core_tools/configs.py new file mode 100644 index 00000000000..47184d04b56 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_core_tools/configs.py @@ -0,0 +1,47 @@ +"""Client-safe DTOs for the Dify core-tools Agenton layer. + +This layer exposes API-routed tool invocations for the Dify-owned provider +families that should execute inside the API service boundary: `plugin`, +`builtin`, `api`, `workflow`, and `mcp`. Prepared parameter declarations and +LLM-facing JSON schema are still sent by the API side so the agent runtime does +not need to inspect provider internals or storage state. +""" + +from __future__ import annotations + +from typing import ClassVar, Final, Literal + +from pydantic import ConfigDict, Field, JsonValue + +from agenton.layers import LayerConfig +from dify_agent.layers.dify_plugin.configs import DifyPluginToolParameter + +type DifyCoreToolProviderType = Literal["plugin", "builtin", "api", "workflow", "mcp"] + +DIFY_CORE_TOOLS_LAYER_TYPE_ID: Final[str] = "dify.core.tools" + + +class DifyCoreToolConfig(LayerConfig): + """Prepared API-routed tool declaration exposed to the model.""" + + provider_type: DifyCoreToolProviderType + provider_id: str + tool_name: str + credential_id: str | None = None + name: str | None = None + description: str | None = None + runtime_parameters: dict[str, JsonValue] = Field(default_factory=dict) + parameters: list[DifyPluginToolParameter] = Field(default_factory=list) + parameters_json_schema: dict[str, JsonValue] = Field( + default_factory=lambda: {"type": "object", "properties": {}, "required": []} + ) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +class DifyCoreToolsLayerConfig(LayerConfig): + """Public config for the Dify core-tools layer.""" + + tools: list[DifyCoreToolConfig] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) diff --git a/dify-agent/src/dify_agent/layers/dify_core_tools/layer.py b/dify-agent/src/dify_agent/layers/dify_core_tools/layer.py new file mode 100644 index 00000000000..86e60ba86d6 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_core_tools/layer.py @@ -0,0 +1,163 @@ +"""Dify core-tools layer for API-routed agent-accessible tools. + +This layer consumes API-prepared tool declarations for provider families that +must execute inside the Dify API service boundary. The runtime keeps the same +prepared-parameter contract as the direct plugin layer, but invocation itself +is delegated to `POST /inner/api/agent/tools/invoke` so credentials and +provider-local state stay in the API process. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import ClassVar + +import httpx +from pydantic_ai import RunContext, Tool +from pydantic_ai.tools import ToolDefinition +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.layers.dify_core_tools.client import ( + DifyCoreToolsClient, + DifyCoreToolsClientConfigurationError, + DifyCoreToolsClientError, +) +from dify_agent.layers.dify_core_tools.configs import ( + DIFY_CORE_TOOLS_LAYER_TYPE_ID, + DifyCoreToolConfig, + DifyCoreToolsLayerConfig, +) +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer + +CORE_TOOL_STRICT = False +TEMPORARY_UNAVAILABLE_OBSERVATION = "Tool is temporarily unavailable. Please continue without it if possible." + + +class DifyCoreToolsDeps(LayerDeps): + """Dependencies required by `DifyCoreToolsLayer`.""" + + execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyCoreToolsLayer(PlainLayer[DifyCoreToolsDeps, DifyCoreToolsLayerConfig]): + """Layer that resolves API-routed Dify tools into Pydantic AI tools.""" + + type_id: ClassVar[str | None] = DIFY_CORE_TOOLS_LAYER_TYPE_ID + + config: DifyCoreToolsLayerConfig + inner_api_url: str + inner_api_key: str + + @classmethod + @override + def from_config(cls, config: DifyCoreToolsLayerConfig) -> Self: + del config + raise TypeError("DifyCoreToolsLayer requires server-side Dify API settings and must use a provider factory.") + + @classmethod + def from_config_with_settings( + cls, + config: DifyCoreToolsLayerConfig, + *, + inner_api_url: str, + inner_api_key: str, + ) -> Self: + return cls( + config=DifyCoreToolsLayerConfig.model_validate(config), + inner_api_url=inner_api_url, + inner_api_key=inner_api_key, + ) + + async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + if http_client.is_closed: + raise RuntimeError("DifyCoreToolsLayer.get_tools() requires an open shared HTTP client.") + + execution_context = self.deps.execution_context.config + client = DifyCoreToolsClient( + base_url=self.inner_api_url, + api_key=self.inner_api_key, + http_client=http_client, + ) + tools: list[Tool[object]] = [] + for tool_config in self.config.tools: + tools.append( + self._build_tool( + client=client, + execution_context=execution_context, + tool_config=tool_config, + ) + ) + return tools + + @staticmethod + def _build_tool( + *, + client: DifyCoreToolsClient, + execution_context: DifyExecutionContextLayerConfig, + tool_config: DifyCoreToolConfig, + ) -> Tool[object]: + tool_name = tool_config.name or tool_config.tool_name + tool_description = tool_config.description or tool_name + tool_schema = deepcopy(tool_config.parameters_json_schema) + + async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str: + try: + response = await client.invoke( + execution_context=execution_context, + tool_config=tool_config, + tool_parameters=tool_arguments, + ) + return response.observation + except DifyCoreToolsClientConfigurationError: + return "Tool is unavailable because required execution context is missing." + except DifyCoreToolsClientError as exc: + return _tool_error_text(tool_name=tool_name, error=exc) + + async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition: + return ToolDefinition( + name=tool_def.name, + description=tool_def.description, + parameters_json_schema=tool_schema, + strict=CORE_TOOL_STRICT, + sequential=tool_def.sequential, + metadata=tool_def.metadata, + timeout=tool_def.timeout, + defer_loading=tool_def.defer_loading, + kind=tool_def.kind, + return_schema=tool_def.return_schema, + include_return_schema=tool_def.include_return_schema, + ) + + return Tool( + invoke_tool, + takes_ctx=True, + name=tool_name, + description=tool_description, + prepare=prepare_tool_definition, + ) + + +def _tool_error_text(*, tool_name: str, error: DifyCoreToolsClientError) -> str: + if error.retryable: + return TEMPORARY_UNAVAILABLE_OBSERVATION + error_code = error.error_code or "" + if error_code == "app_not_found": + return "Tool is unavailable because its app context no longer exists." + if error_code == "app_tenant_mismatch": + return "Tool is unavailable because its app context is invalid." + if error_code == "agent_tool_credential_invalid": + return "Please check your tool provider credentials" + if error_code == "agent_tool_declaration_not_found": + return f"there is not a tool named {tool_name}" + if error_code == "tool_parameters_invalid": + return f"tool parameters validation error: {error}, please check your tool parameters" + if error_code == "agent_tool_invoke_failed": + return f"tool invoke error: {error}" + return f"tool invoke error: {error}" + + +__all__ = ["DifyCoreToolsDeps", "DifyCoreToolsLayer"] diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py index e6941b50cbe..fec1109e74d 100644 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -5,9 +5,9 @@ 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 ``dify-agent drive`` and -``dify-agent file`` usage so the model has concrete Agent Stub commands for -materializing drive content and workflow files. +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 @@ -24,26 +24,11 @@ from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer -_AGENT_STUB_CLI_USAGE_PROMPT = """Agent Stub CLI usage is available inside shell jobs: - -Drive assets are Agent Soul versioned assets: - -- List drive assets: `dify-agent drive list [REMOTE_PREFIX]` -- Pull drive assets: `dify-agent drive pull [REMOTE ...] [--to LOCAL_DIR]` - With no remote, pulls the whole visible drive. Pull overwrites local files. - Defaults to `$DIFY_AGENT_STUB_DRIVE_BASE`; use `--to .` for cwd. - `--to` is a local root; remote keys keep their path under it. - Skill archives are automatically extracted after pull. -- Push one file: `dify-agent drive push LOCAL_FILE REMOTE_PATH` -- Push a skill package: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind skill` -- Push a raw directory: `dify-agent drive push LOCAL_DIR REMOTE_PATH --kind dir` - -Workflow file mappings: - -- Download a mapping: `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [--to LOCAL_DIR]` -- Or pass a mapping object: `dify-agent file download --mapping '{"transfer_method":"tool_file","reference":"..."}'` -- Upload an output file: `dify-agent file upload PATH` - Prints JSON like `{"transfer_method":"tool_file","reference":"..."}`.""" +_AGENT_STUB_FILE_HELP_COMMANDS = ( + "dify-agent file --help", + "dify-agent file upload --help", + "dify-agent file download --help", +) class DifyDriveLayerError(RuntimeError): @@ -63,6 +48,7 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim 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 @@ -81,10 +67,12 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim @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: @@ -127,20 +115,31 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim if skill.skill_md_key not in mentioned_skill_keys ] if other_skills: - pull_and_read_command = ( - '`skill_dir="$(dify-agent drive pull --to /tmp/drive)"; ' - + 'printf "%s\\n" "$skill_dir"; cat "$skill_dir/SKILL.md"`' - ) - sections.append( - "Other available skills:\n" - + "\n".join(other_skills) - + "\n\nTo use one, pull it and read its SKILL.md in one command: " - + pull_and_read_command - + "." - ) - sections.append(_AGENT_STUB_CLI_USAGE_PROMPT) + 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 = [ + f"$ {command}\n{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 help:\n" + "\n\n".join(command_sections) + + 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 = {} @@ -149,21 +148,30 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim return script = self._build_shell_pull_script(targets=targets) - result = await self.deps.shell.run_remote_script(script, inject_agent_stub_env=True) + result = await self.deps.shell.run_remote_script_complete(script, inject_agent_stub_env=True) if result.exit_code != 0: raise DifyDriveLayerError( - f"drive mentioned pull failed in shell: {result.status} exit_code={result.exit_code}\n{result.output}" + "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}" ) - if result.truncated: - raise DifyDriveLayerError("drive mentioned pull output was truncated before SKILL.md content was loaded") - - 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 + 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)) diff --git a/dify-agent/src/dify_agent/layers/execution_context/__init__.py b/dify-agent/src/dify_agent/layers/execution_context/__init__.py index aaf031501b3..45575faf3e2 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/__init__.py +++ b/dify-agent/src/dify_agent/layers/execution_context/__init__.py @@ -8,6 +8,7 @@ DTO, but that runtime implementation still lives in sibling modules. from dify_agent.layers.execution_context.configs import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextAgentConfigVersionKind, DifyExecutionContextAgentMode, DifyExecutionContextInvokeFrom, DifyExecutionContextLayerConfig, @@ -16,6 +17,7 @@ from dify_agent.layers.execution_context.configs import ( __all__ = [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentConfigVersionKind", "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextLayerConfig", diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py index 21abe9b9481..afddd7b9344 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/configs.py +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -26,6 +26,7 @@ DifyExecutionContextAgentMode: TypeAlias = Literal[ "babysit", "fasten", ] +DifyExecutionContextAgentConfigVersionKind: TypeAlias = Literal["snapshot", "draft", "build_draft"] DifyExecutionContextUserFrom: TypeAlias = Literal["account", "end-user"] DifyExecutionContextInvokeFrom: TypeAlias = Literal[ "service-api", @@ -53,6 +54,7 @@ class DifyExecutionContextLayerConfig(LayerConfig): conversation_id: str | None = None agent_id: str | None = None agent_config_version_id: str | None = None + agent_config_version_kind: DifyExecutionContextAgentConfigVersionKind | None = None agent_mode: DifyExecutionContextAgentMode invoke_from: DifyExecutionContextInvokeFrom trace_id: str | None = None @@ -62,6 +64,7 @@ class DifyExecutionContextLayerConfig(LayerConfig): __all__ = [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentConfigVersionKind", "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextUserFrom", diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index b54e0bb4d26..d514025aa45 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -1,54 +1,49 @@ -"""Shell layer backed by the shell adapter provisioner/executor mechanism. - -``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly -``shell_run``, ``shell_wait``, ``shell_input``, and ``shell_interrupt``. The -layer persists only JSON-safe shell session state in ``runtime_state`` and keeps -its live ``ShellctlHandle`` on the layer instance only while -``resource_context()`` is active. Agenton enters that resource scope before -``on_context_create`` or ``on_context_resume`` and exits it after -``on_context_suspend`` or ``on_context_delete``, so business hooks and shell -tools can rely on live resources without ever serializing them into snapshots. - -The layer delegates workspace lifecycle to ``ShellProvisionProtocol``: -``provision`` allocates a fresh workspace, ``reattach`` rebuilds a live handle -for an existing workspace from a serialized descriptor, and ``destroy`` tears -the workspace down. User-facing shell tools call the shellctl client obtained -from the handle directly; trusted server-owned scripts go through -``ShellctlExecutor`` which auto-cleans completed jobs. -""" +"""Shell runtime layer backed by a live shell provider resource.""" from __future__ import annotations -from collections.abc import AsyncGenerator, Callable, Sequence +from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager import json import logging import re +import secrets +import time from dataclasses import dataclass -from typing import ClassVar, NotRequired, Protocol, TypedDict, cast +from typing import ClassVar, Literal, NotRequired, TypedDict from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator from pydantic_ai import Tool -from shell_session_manager.shellctl.client import ShellctlClientError -from shell_session_manager.shellctl.shared import ( - DEFAULT_TERMINATE_GRACE_SECONDS, - DEFAULT_TIMEOUT_SECONDS, - JobResult, - JobStatusView, -) from typing_extensions import Self, override from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool -from dify_agent.adapters.shell.protocols import ShellProvisionProtocol -from dify_agent.adapters.shell.shellctl import ShellctlEnvironmentDescriptor, ShellctlExecutor, ShellctlHandle +from dify_agent.adapters.shell.protocols import ( + CompleteShellCommandResult, + ShellCommandProtocol, + ShellCommandResult, + ShellPromptObservation, + ShellProviderProtocol, + ShellResourceProtocol, +) from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig - +from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix logger = logging.getLogger(__name__) +DEFAULT_TIMEOUT_SECONDS = 30.0 +DEFAULT_TERMINATE_GRACE_SECONDS = 10.0 _WORKSPACE_ROOT = "~/workspace" +_WORKSPACE_COLLISION_EXIT_CODE = 17 +_SESSION_TIME_HEX_MASK = 0xFFFFF +_SESSION_RANDOM_HEX_LENGTH = 2 +_SESSION_ID_ATTEMPT_LIMIT = 256 +_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") +_SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024 +_SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES +_REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024 +_REMOTE_COMMAND_TIMEOUT_SECONDS = 60.0 _SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: 1. shell_run @@ -95,6 +90,16 @@ Usage rules: - Use shell_input only when the job is running and waiting for stdin. - Use shell_interrupt when a job is stuck or should be stopped. +Workspace persistence rules: + +- The current workspace cwd is stable during this agent run, but it is temporary and may be deleted later. +- Do not use the current workspace cwd as persistent storage. +- $HOME outside the current workspace cwd is persistent storage. In build draft mode, when Agent config context reports + `config_version.kind` as `build_draft` and `config_version.writable` as true, changes there can be persisted for + later runs. In non-build-draft modes, those changes are rolled back. +- Saving config files, skills, env, or notes still requires the corresponding Agent config CLI mutation command; follow + the Agent config CLI help in the config layer. Shell file edits alone do not save config. + The script argument of shell_run can be a normal shell script, or a shebang script. If the first line is a shebang, the shell layer executes the script directly. @@ -120,113 +125,20 @@ response = httpx.get("https://example.com", timeout=10) print(f"[green]status:[/green] {response.status_code}")""" -class ShellJobObservation(TypedDict): - """JSON-safe output-oriented shell tool observation.""" - - job_id: str - status: str - done: bool - exit_code: int | None - output: str - offset: int - truncated: bool - output_path: str - - -class ShellJobStatusObservation(TypedDict): - """JSON-safe status-only shell tool observation.""" - - job_id: str - status: str - done: bool - exit_code: int | None - offset: int - - class ShellToolErrorObservation(TypedDict): - """Tool-visible failure payload for expected shell-layer errors.""" - error: str job_id: NotRequired[str] -type ShellRunToolResult = ShellJobObservation | ShellToolErrorObservation -type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObservation +type ShellRunToolResult = str | ShellToolErrorObservation +type ShellInterruptToolResult = str | ShellToolErrorObservation class DifyShellLayerDeps(LayerDeps): - """Optional direct-layer dependencies used by the shell runtime layer. - - The execution context supplies the token principal. The drive ref used for - Agent Stub CLI commands is passed through config so the drive layer can - depend on shell for eager materialization without a dependency cycle. - """ - execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable] -class ShellctlClientProtocol(Protocol): - """Boundary that the shell layer needs from a shellctl client.""" - - async def run( - self, - script: str, - *, - cwd: str | None = None, - env: dict[str, str] | None = None, - timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> JobResult: ... - - async def wait( - self, - job_id: str, - *, - offset: int, - timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> JobResult: ... - - async def input( - self, - job_id: str, - text: str, - *, - offset: int, - timeout: float = DEFAULT_TIMEOUT_SECONDS, - ) -> JobResult: ... - - async def terminate( - self, - job_id: str, - grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, - ) -> JobStatusView: ... - - async def delete( - self, - job_id: str, - *, - force: bool = False, - grace_seconds: float | None = None, - ) -> object: ... - - async def close(self) -> None: ... - - -type ShellctlClientFactory = Callable[[str], ShellctlClientProtocol] - - class DifyShellRuntimeState(BaseModel): - """Serializable shell session state stored in Agenton snapshots. - - ``job_ids`` and ``job_offsets`` contain both user-facing jobs and internal - lifecycle jobs so resumed sessions can still clean up shellctl state that was - created before suspension. Callers should replace the stored list/dict values - rather than mutating them in place so Pydantic assignment validation keeps - guarding the serialized state. Hydrated public snapshots must keep - ``session_id`` and ``workspace_cwd`` consistent with the descriptor returned - by the shell provisioner, so resume and delete paths cannot escape the - isolated workspace root or inject shell syntax into lifecycle commands. - """ - session_id: str | None = None workspace_cwd: str | None = None job_ids: list[str] = Field(default_factory=list) @@ -237,24 +149,19 @@ class DifyShellRuntimeState(BaseModel): @field_validator("session_id") @classmethod def validate_session_id(cls, value: str | None) -> str | None: - """Reject session ids that could escape the workspace root or inject shell syntax.""" if value is None: return value - if not re.fullmatch(r"[0-9a-f]{7,16}", value): - raise ValueError("session_id must be 7 to 16 lowercase hex characters (got an invalid value).") - return value + return _validated_session_id(value) @field_validator("job_ids") @classmethod def validate_job_ids(cls, value: list[str]) -> list[str]: - """Keep tracked shellctl job ids unique within one serialized session.""" if len(value) != len(set(value)): raise ValueError("job_ids must not contain duplicates.") return value @model_validator(mode="after") def validate_workspace_and_offsets(self) -> Self: - """Keep resumed workspace identity and tracked offset keys self-consistent.""" if self.workspace_cwd is not None: if self.session_id is None: raise ValueError("workspace_cwd requires a matching session_id.") @@ -268,67 +175,39 @@ class DifyShellRuntimeState(BaseModel): return self -@dataclass(frozen=True, slots=True) -class RemoteCommandResult: - """Completed remote sandbox command returned to server-owned callers. - - Only fields with live consumers are kept: ``output``/``exit_code`` (read by - every caller), ``truncated`` (drive pull treats a truncated result as a - failure because it needs the command's full output), and ``status`` (used in - drive's human-readable error message). shellctl paging details such as the - job id, completion flag, byte offset, and output path are intentionally not - surfaced here, since no caller reads them. - """ - - status: str - exit_code: int | None - output: str - truncated: bool +CompleteRemoteCommandResult = CompleteShellCommandResult @dataclass(slots=True) class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]): - """Shell tool layer backed by the shell provisioner/executor mechanism. - - The mutable serializable state lives in ``runtime_state``; the live - ``ShellctlHandle`` is intentionally kept off-snapshot. Tool methods update - tracked job ids and output offsets after every successful shellctl response so - later ``shell_wait``/``shell_input`` calls can resume from the last known - offset without exposing offsets as model-controlled inputs. - """ - type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID config: DifyShellLayerConfig - shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor] + shell_provider: ShellProviderProtocol agent_stub_api_base_url: str | None = None agent_stub_token_factory: ShellAgentStubTokenFactory | None = None - _shell_handle: ShellctlHandle | None = None + _shell_resource: ShellResourceProtocol | None = None @classmethod @override def from_config(cls, config: DifyShellLayerConfig) -> Self: - """Reject construction that omits the shell provisioner.""" del config - raise TypeError("DifyShellLayer requires a shell provisioner and must use a provider factory.") + raise TypeError("DifyShellLayer requires a shell provider and must use a provider factory.") @classmethod def from_config_with_settings( cls, config: DifyShellLayerConfig, *, - shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor] | None, + shell_provider: ShellProviderProtocol | None, agent_stub_api_base_url: str | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, ) -> Self: - """Create the layer from public config plus shell provisioner settings.""" - if shell_provisioner is None: - raise ValueError( - "DifyShellLayer requires a non-null shell provisioner when the 'dify.shell' layer is used." - ) + if shell_provider is None: + raise ValueError("DifyShellLayer requires a non-null shell provider when the 'dify.shell' layer is used.") layer = cls( config=config, - shell_provisioner=shell_provisioner, + shell_provider=shell_provider, agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ) @@ -353,126 +232,138 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC @override @asynccontextmanager async def resource_context(self) -> AsyncGenerator[None]: - """Hold the live shell handle scope. - - The actual handle is set in ``on_context_create`` / - ``on_context_resume``. This scope ensures cleanup if a lifecycle hook - fails before the handle is set. - """ + if self._shell_resource is not None: + raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.") + resource = await self.shell_provider.create() + self._shell_resource = resource try: yield finally: - self._shell_handle = None + self._shell_resource = None + await resource.close() @override async def on_context_create(self) -> None: - """Provision a new workspace session using the shell provisioner. - - The provisioner allocates the workspace directory and returns a - ``ShellctlHandle``. The layer then bootstraps the workspace with Agent - Soul env exports and CLI tool install commands. If workspace setup - partially succeeds and this hook later raises, the layer never becomes - ``ACTIVE``. In that path Agenton still exits ``resource_context()``, but - ``on_context_delete()`` will not run, so this hook must clean up any - tracked artifacts before re-raising. - """ + _ = self._require_resource() + session_id: str | None = None try: - handle = cast(ShellctlHandle, await self.shell_provisioner.provision()) - self._shell_handle = handle - descriptor = handle.descriptor() - await self._bootstrap_workspace(descriptor.workspace_cwd) + session_id, workspace_cwd = await self._allocate_workspace() + await self._bootstrap_workspace(workspace_cwd) except BaseException: - await self._cleanup_create_failure() + if session_id is not None: + await self._cleanup_workspace_best_effort(session_id) raise self.runtime_state = DifyShellRuntimeState.model_validate( { **self.runtime_state.model_dump(mode="python"), - "session_id": descriptor.session_id, - "workspace_cwd": descriptor.workspace_cwd, + "session_id": session_id, + "workspace_cwd": workspace_cwd, } ) @override async def on_context_resume(self) -> None: - """Reattach to an existing serialized shell session. - - Builds a ``ShellEnvironmentDescriptor`` from the persisted runtime state - and asks the provisioner to reattach without allocating a new workspace. - If a future resume path adds self-heal side effects before raising, this - hook must compensate for them itself because failed resume attempts never - transition the slot back to ``ACTIVE``. - """ - session_id, workspace_cwd = self._require_session_identity() - descriptor = ShellctlEnvironmentDescriptor( - workspace_cwd=workspace_cwd, - session_id=session_id, - ) - handle = cast(ShellctlHandle, await self.shell_provisioner.reattach(descriptor)) - self._shell_handle = handle + _ = self._require_resource() + _ = self._require_session_identity() @override async def on_context_suspend(self) -> None: - """Close the live client so it does not leak across snapshot boundaries. - - ``reattach`` on the next resume creates a fresh client pointing at the - same workspace. ``resource_context()`` clears the handle reference after - this hook returns. - """ - handle = self._shell_handle - if handle is not None: - await handle.client.close() + _ = self._require_resource() @override async def on_context_delete(self) -> None: - """Best-effort cleanup for tracked shellctl jobs and workspace deletion. - - Tracked shellctl jobs are force-deleted on a best-effort basis before the - handle is destroyed, since job records may outlive the workspace. The - provisioner's ``destroy`` handles workspace removal and client close. - """ - handle = self._shell_handle - if handle is None: - return + _ = self._require_resource() + identity = self._try_session_identity() + if identity is not None: + session_id, _workspace_cwd = identity + result = await self._run_internal_script_complete( + _workspace_cleanup_script(session_id=session_id), cwd=None + ) + if result.exit_code != 0 or not result.output_complete: + logger.warning( + "Shell workspace cleanup for session %s ended with status=%s exit_code=%s output_complete=%s.", + session_id, + result.status, + result.exit_code, + result.output_complete, + ) await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids) self._clear_tracked_jobs() - await self.shell_provisioner.destroy(handle) - self._shell_handle = None async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: - """Start a new shell job inside the session workspace.""" try: - client = self._require_client() - result = await client.run( + result = await self._require_resource().commands.run( _wrap_user_script(script, self.config), cwd=self._require_workspace_cwd(), env=self._build_user_shell_run_env(), timeout=timeout, ) - self._track_job_result(result) - return _job_result_observation(result) - except (RuntimeError, ValueError, ShellctlClientError) as exc: + observation = await render_prompt_observation_from_result( + self._require_resource().commands, + result, + edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, + ) + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, observation.offset) + return _tagged_shell_observation( + _metadata_dict( + job_id=result.job_id, + status=result.status, + done=result.done, + exit_code=result.exit_code, + output_path=observation.output_path, + ), + observation.text, + ) + except (RuntimeError, ValueError) as exc: return _tool_error(str(exc)) async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: - """Wait for more output or completion from a tracked shell job.""" try: - client = self._require_client() offset = self._tracked_offset(job_id) - result = await client.wait(job_id, offset=offset, timeout=timeout) - self._track_job_result(result) - return _job_result_observation(result) - except (RuntimeError, ValueError, ShellctlClientError) as exc: + result = await self._require_resource().commands.wait(job_id, offset=offset, timeout=timeout) + observation = await render_prompt_observation_from_result( + self._require_resource().commands, + result, + edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, + ) + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, observation.offset) + return _tagged_shell_observation( + _metadata_dict( + job_id=result.job_id, + status=result.status, + done=result.done, + exit_code=result.exit_code, + output_path=observation.output_path, + ), + observation.text, + ) + except (RuntimeError, ValueError) as exc: return _tool_error(str(exc), job_id=job_id) async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: - """Send text input to a tracked shell job and wait for output.""" try: - client = self._require_client() offset = self._tracked_offset(job_id) - result = await client.input(job_id, text, offset=offset, timeout=timeout) - self._track_job_result(result) - return _job_result_observation(result) - except (RuntimeError, ValueError, ShellctlClientError) as exc: + result = await self._require_resource().commands.input(job_id, text, offset=offset, timeout=timeout) + observation = await render_prompt_observation_from_result( + self._require_resource().commands, + result, + edge_bytes=_SHELL_OUTPUT_PROMPT_EDGE_BYTES, + ) + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, observation.offset) + return _tagged_shell_observation( + _metadata_dict( + job_id=result.job_id, + status=result.status, + done=result.done, + exit_code=result.exit_code, + output_path=observation.output_path, + ), + observation.text, + ) + except (RuntimeError, ValueError) as exc: return _tool_error(str(exc), job_id=job_id) async def _tool_interrupt( @@ -480,138 +371,127 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC job_id: str, grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, ) -> ShellInterruptToolResult: - """Interrupt a tracked shell job without removing its persisted shellctl state.""" try: - client = self._require_client() self._ensure_tracked_job(job_id) - result = await client.terminate(job_id, grace_seconds=grace_seconds) - self._track_job_status(result) - return _job_status_observation(result) - except (RuntimeError, ValueError, ShellctlClientError) as exc: + result = await self._require_resource().commands.interrupt(job_id, grace_seconds=grace_seconds) + self._remember_job_id(result.job_id) + self._remember_job_offset(result.job_id, result.offset) + output_path: str | None = None + try: + output_path = (await self._require_resource().commands.tail(job_id)).output_path + except (RuntimeError, ValueError) as exc: + logger.warning( + "Failed to fetch output path for interrupted shell job %s in session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + return _tagged_shell_observation( + _metadata_dict( + job_id=result.job_id, + status=result.status, + done=result.done, + exit_code=result.exit_code, + output_path=output_path, + ), + "Job was interrupted.", + ) + except (RuntimeError, ValueError) as exc: return _tool_error(str(exc), job_id=job_id) - async def run_remote_script( + async def run_remote_script_complete( self, script: str, *, - timeout: float = DEFAULT_TIMEOUT_SECONDS, + timeout: float = _REMOTE_COMMAND_TIMEOUT_SECONDS, + max_output_bytes: int = _REMOTE_COMPLETE_OUTPUT_MAX_BYTES, inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: - """Run one trusted server-side script inside the sandbox workspace. - - The sandbox file service uses this boundary for fixed list/read/upload - helpers. Execution, output draining, and transient shellctl job cleanup - are delegated to ``ShellctlExecutor`` from the shell adapter; the layer - owns only the optional Agent Stub env injection and the - ``RemoteCommandResult`` mapping. - - Unlike model-visible ``shell.run``, this server-owned boundary does not - inject Agent Soul shell env. Keeping the user-controlled shell env out - of this path prevents sandbox code from clobbering trusted Agent Stub - env values before ``dify-agent file upload`` executes. - """ + ) -> CompleteRemoteCommandResult: env = None if inject_agent_stub_env: env = self._build_user_shell_run_env() if env is None: raise RuntimeError("Agent Stub environment injection is not available for this shell session.") - handle = self._require_handle() - executor = ShellctlExecutor( - client=handle.client, # pyright: ignore[reportArgumentType] - workspace_cwd=self._require_workspace_cwd(), + return await execute_complete_with_commands( + self._require_resource().commands, + script, + cwd=self._require_workspace_cwd(), + env=env, timeout=timeout, - ) - result = await executor.execute(script, env=env) - return RemoteCommandResult( - status="exited" if not result.truncated() else "running", - exit_code=result.exit_code(), - output=result.stdout(), - truncated=result.truncated(), + max_output_bytes=max_output_bytes, ) - def environment_descriptor(self) -> ShellctlEnvironmentDescriptor: - """Return the serializable workspace seed for the shell adapter. + async def run_remote_script( + self, + script: str, + *, + timeout: float = _REMOTE_COMMAND_TIMEOUT_SECONDS, + inject_agent_stub_env: bool = False, + ) -> CompleteRemoteCommandResult: + return await self.run_remote_script_complete( + script, + timeout=timeout, + inject_agent_stub_env=inject_agent_stub_env, + ) - Bridges this layer's ``runtime_state`` to - ``dify_agent.adapters.shell``: the returned descriptor identifies the - session workspace so an adapter ``ShellProvisionProtocol.reattach`` can - rebuild a live handle pointing at it without re-allocating, and without - re-entering this layer. Raises ``ValueError`` if the session identity is - missing or inconsistent. - """ - session_id, workspace_cwd = self._require_session_identity() - return ShellctlEnvironmentDescriptor(workspace_cwd=workspace_cwd, session_id=session_id) + async def _allocate_workspace(self) -> tuple[str, str]: + for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT): + session_id = _generate_session_id() + result = await self._run_internal_script_complete(_workspace_mkdir_script(session_id=session_id), cwd=None) + if result.exit_code == _WORKSPACE_COLLISION_EXIT_CODE: + continue + if result.exit_code != 0 or not result.output_complete: + raise RuntimeError( + f"Failed to create shell workspace {_workspace_cwd(session_id)}: " + + f"{result.status} exit_code={result.exit_code}" + ) + return session_id, _workspace_cwd(session_id) + raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.") async def _bootstrap_workspace(self, workspace_cwd: str) -> None: - """Apply Agent Soul shell config to the freshly-created workspace.""" bootstrap_script = _workspace_bootstrap_script(self.config) if not bootstrap_script: return - result = await self._run_internal_job_to_completion(bootstrap_script, cwd=workspace_cwd) - if result["exit_code"] != 0: + result = await self._run_internal_script_complete(bootstrap_script, cwd=workspace_cwd) + if result.exit_code != 0 or not result.output_complete: raise RuntimeError( - f"Failed to bootstrap shell workspace {workspace_cwd}: {result['status']} exit_code={result['exit_code']}" + f"Failed to bootstrap shell workspace {workspace_cwd}: {result.status} exit_code={result.exit_code}" ) - async def _cleanup_create_failure(self) -> None: - """Best-effort cleanup for create failures before ACTIVE state. + async def _cleanup_workspace_best_effort(self, session_id: str) -> None: + try: + _ = await self._run_internal_script_complete(_workspace_cleanup_script(session_id=session_id), cwd=None) + except (RuntimeError, ValueError) as exc: + logger.warning("Failed to remove shell workspace for session %s after create failure: %s", session_id, exc) - Agenton only calls ``on_context_delete`` for layers that successfully - entered ``ACTIVE``. If ``on_context_create`` fails after issuing - internal jobs, those tracked job artifacts would otherwise leak because - no later lifecycle hook owns them. The provisioner's ``destroy`` handles - workspace removal and client close. - """ - handle = self._shell_handle - if handle is None: - return - if self.runtime_state.job_ids: - try: - await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids) - finally: - self._clear_tracked_jobs() - await self.shell_provisioner.destroy(handle) - self._shell_handle = None - - async def _run_internal_job_to_completion( + async def _run_internal_script_complete( self, script: str, *, cwd: str | None, - ) -> ShellJobObservation: - """Run an internal lifecycle command, track it, and wait for completion.""" - client = self._require_client() - result = await client.run(script, cwd=cwd, env=None, timeout=DEFAULT_TIMEOUT_SECONDS) - self._track_job_result(result) - while not result.done: - result = await client.wait( - result.job_id, - offset=self._tracked_offset(result.job_id), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self._track_job_result(result) - return _job_result_observation(result) + ) -> CompleteRemoteCommandResult: + return await execute_complete_with_commands( + self._require_resource().commands, + script, + cwd=cwd, + env=None, + timeout=DEFAULT_TIMEOUT_SECONDS, + max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES, + ) - def _require_handle(self) -> ShellctlHandle: - """Return the live handle or reject tool/lifecycle use without one.""" - if self._shell_handle is None: + def _require_resource(self) -> ShellResourceProtocol: + if self._shell_resource is None: raise RuntimeError( - "DifyShellLayer requires an active shell handle inside resource_context(); " + "DifyShellLayer requires an active shell resource inside resource_context(); " + "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()." ) - return self._shell_handle - - def _require_client(self) -> ShellctlClientProtocol: - """Return the live shellctl client from the handle.""" - return cast(ShellctlClientProtocol, self._require_handle().client) + return self._shell_resource def _require_workspace_cwd(self) -> str: - """Return the configured workspace directory for user-facing shell jobs.""" _session_id, workspace_cwd = self._require_session_identity() return workspace_cwd def _require_session_identity(self) -> tuple[str, str]: - """Return the stored session id and workspace path or raise for corrupt state.""" identity = self._try_session_identity() if identity is None: raise ValueError("DifyShellLayer runtime state is missing session_id or workspace_cwd.") @@ -631,30 +511,13 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC return session_id, workspace_cwd def _ensure_tracked_job(self, job_id: str) -> None: - """Reject tool access to job ids not tracked in the current runtime state. - - This first version treats shellctl job ids as opaque strings and uses - membership in ``runtime_state.job_ids`` as the tool-access boundary for - wait/input/interrupt operations. - """ if job_id not in self.runtime_state.job_ids: raise ValueError(f"Unknown shell job id for this session: {job_id}.") def _tracked_offset(self, job_id: str) -> int: - """Return the stored offset for a tracked job, defaulting legacy state to zero.""" self._ensure_tracked_job(job_id) return int(self.runtime_state.job_offsets.get(job_id, 0)) - def _track_job_result(self, result: JobResult) -> None: - """Track one output-oriented shellctl result in serializable runtime state.""" - self._remember_job_id(result.job_id) - self._remember_job_offset(result.job_id, result.offset) - - def _track_job_status(self, result: JobStatusView) -> None: - """Track status-only shellctl results that still carry the latest offset.""" - self._remember_job_id(result.job_id) - self._remember_job_offset(result.job_id, result.offset) - def _remember_job_id(self, job_id: str) -> None: if job_id in self.runtime_state.job_ids: return @@ -666,47 +529,23 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self.runtime_state.job_offsets = job_offsets async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None: - """Force-delete tracked shellctl jobs, ignoring already-missing ones.""" + commands = self._require_resource().commands for job_id in _deduplicate_preserving_order(job_ids): - await self._delete_job_best_effort(job_id) + try: + await commands.delete(job_id, force=True) + except RuntimeError as exc: + logger.warning( + "Failed to delete shell job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) def _clear_tracked_jobs(self) -> None: self.runtime_state.job_offsets = {} self.runtime_state.job_ids = [] - async def _delete_job_best_effort(self, job_id: str) -> None: - client = self._require_client() - try: - _ = await client.delete(job_id, force=True) - except ShellctlClientError as exc: - if exc.code == "job_not_found": - return - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) - except RuntimeError as exc: - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) - - def _forget_tracked_job(self, job_id: str) -> None: - if job_id not in self.runtime_state.job_ids and job_id not in self.runtime_state.job_offsets: - return - job_offsets = dict(self.runtime_state.job_offsets) - _ = job_offsets.pop(job_id, None) - self.runtime_state.job_offsets = job_offsets - self.runtime_state.job_ids = [ - tracked_job_id for tracked_job_id in self.runtime_state.job_ids if tracked_job_id != job_id - ] - def _build_user_shell_run_env(self) -> dict[str, str] | None: - """Build per-command Agent Stub env only for user-visible ``shell.run``.""" execution_context_layer = self.deps.execution_context execution_context = execution_context_layer.config if execution_context_layer is not None else None return build_shell_agent_stub_env( @@ -718,32 +557,129 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC ) +async def execute_complete_with_commands( + commands: ShellCommandProtocol, + script: str, + *, + cwd: str | None, + env: dict[str, str] | None, + timeout: float, + max_output_bytes: int, +) -> CompleteShellCommandResult: + deadline = time.monotonic() + timeout + job_id: str | None = None + result: ShellCommandResult | None = None + output_parts: list[str] = [] + captured_bytes = 0 + incomplete_reason: Literal["output_limit", "timeout"] | None = None + try: + result = await commands.run(script, cwd=cwd, env=env, timeout=_remaining_time(deadline)) + job_id = result.job_id + while True: + remaining_bytes = max(max_output_bytes - captured_bytes, 0) + limited_output = utf8_prefix(result.output, remaining_bytes) + output_parts.append(limited_output) + captured_bytes += len(limited_output.encode("utf-8")) + if limited_output != result.output: + incomplete_reason = "output_limit" + break + if captured_bytes >= max_output_bytes and (result.truncated or not result.done): + incomplete_reason = "output_limit" + break + if result.truncated: + result = await commands.read_output(result.job_id, offset=result.offset) + continue + if result.done: + break + remaining_time = _remaining_time(deadline) + if remaining_time <= 0.0: + incomplete_reason = "timeout" + break + result = await commands.wait(result.job_id, offset=result.offset, timeout=remaining_time) + + assert result is not None + final_status = result.status + final_done = result.done + final_exit_code = result.exit_code + final_offset = result.offset + final_output_path = result.output_path + if incomplete_reason is not None and not result.done: + terminal_status = await commands.interrupt(result.job_id, grace_seconds=DEFAULT_TERMINATE_GRACE_SECONDS) + final_status = terminal_status.status + final_done = terminal_status.done + final_exit_code = terminal_status.exit_code + final_offset = terminal_status.offset + return CompleteShellCommandResult( + job_id=result.job_id, + status=final_status, + done=final_done, + exit_code=final_exit_code, + output="".join(output_parts), + output_complete=incomplete_reason is None, + incomplete_reason=incomplete_reason, + offset=final_offset, + output_path=final_output_path, + ) + finally: + if job_id is not None: + try: + await commands.delete(job_id, force=True) + except RuntimeError as exc: + logger.warning("Failed to delete transient shell job %s: %s", job_id, exc) + + +async def render_prompt_observation_from_result( + commands: ShellCommandProtocol, + result: ShellCommandResult, + *, + edge_bytes: int, +) -> ShellPromptObservation: + output_exceeds_edge_budget = len(result.output.encode("utf-8")) > edge_bytes + tail: str | None = None + output_path = result.output_path + offset = result.offset + if result.truncated: + try: + tail_result = await commands.tail(result.job_id) + except RuntimeError as exc: + logger.warning("Failed to fetch tail for shell job %s: %s", result.job_id, exc) + else: + tail = utf8_suffix(tail_result.output, edge_bytes) + output_path = tail_result.output_path or output_path + offset = tail_result.offset + elif output_exceeds_edge_budget: + tail = utf8_suffix(result.output, edge_bytes) + text = normalized_output_text( + utf8_prefix(result.output, edge_bytes), + tail=tail, + output_path=output_path if (result.truncated or output_exceeds_edge_budget) else None, + max_output_size_bytes=_SHELLCTL_OUTPUT_LIMIT_BYTES, + truncated_in_middle=result.truncated or output_exceeds_edge_budget, + ) + return ShellPromptObservation(text=text, output_path=output_path, offset=offset) + + def _shell_layer_prefix_prompt() -> str: - """Return the static model-facing shell tool usage guidance.""" return _SHELL_LAYER_PREFIX_PROMPT -def _job_result_observation(result: JobResult) -> ShellJobObservation: - return { - "job_id": result.job_id, - "status": result.status.value, - "done": result.done, - "exit_code": result.exit_code, - "output": result.output, - "offset": result.offset, - "truncated": result.truncated, - "output_path": result.output_path, - } - - -def _job_status_observation(result: JobStatusView) -> ShellJobStatusObservation: - return { - "job_id": result.job_id, - "status": result.status.value, - "done": result.done, - "exit_code": result.exit_code, - "offset": result.offset, +def _metadata_dict( + *, + job_id: str, + status: str, + done: bool, + exit_code: int | None, + output_path: str | None, +) -> dict[str, object]: + metadata: dict[str, object] = { + "job_id": job_id, + "status": status, + "done": done, + "exit_code": exit_code, } + if output_path is not None: + metadata["output_path"] = output_path + return metadata def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObservation: @@ -753,28 +689,31 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs return result +def _generate_session_id() -> str: + time_component = int(time.time()) & _SESSION_TIME_HEX_MASK + random_component = secrets.token_hex(1) + if len(random_component) != _SESSION_RANDOM_HEX_LENGTH: + raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.") + return f"{time_component:05x}{random_component}" + + def _workspace_cwd(session_id: str) -> str: - return f"{_WORKSPACE_ROOT}/{session_id}" + return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: - """Return the workspace bootstrap script for CLI tool declarations.""" install_commands = [command for tool in config.cli_tools for command in tool.install_commands] if not install_commands: return "" - lines: list[str] = ["set -eu", *_shell_config_export_lines(config), *install_commands] return "\n".join(lines) def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: - """Return ephemeral Agent Soul shell exports for one shellctl command.""" lines: list[str] = [] for env_var in config.env: lines.append(f"export {env_var.name}={_shquote(env_var.value)}") for secret_ref in config.secret_refs: - # Secret refs are resolved outside this public DTO. Preserve the env var - # name without inventing a value so host-provided env can flow through. lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') for tool in config.cli_tools: for env_var in tool.env: @@ -791,18 +730,37 @@ def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str: - """Inject Agent Soul env before executing a model-requested shell command.""" lines = _shell_config_export_lines(config) if not lines: return script return "\n".join([*lines, script]) +def _workspace_mkdir_script(*, session_id: str) -> str: + safe_session_id = _validated_session_id(session_id) + workspace_dir = f"$HOME/workspace/{safe_session_id}" + return ( + 'mkdir -p "$HOME/workspace"; ' + f'if mkdir "{workspace_dir}"; then exit 0; fi; ' + f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; ' + "exit 1" + ) + + +def _workspace_cleanup_script(*, session_id: str) -> str: + return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"' + + def _shquote(value: str) -> str: - """Single-quote a value for POSIX shells, escaping embedded single quotes.""" return "'" + value.replace("'", "'\\''") + "'" +def _validated_session_id(session_id: str) -> str: + if not _SESSION_ID_PATTERN.fullmatch(session_id): + raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.") + return session_id + + def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] @@ -814,10 +772,22 @@ def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]: return result +def _tagged_shell_observation(metadata: dict[str, object], output: str) -> str: + compact_metadata = json.dumps(metadata, separators=(",", ":")) + return f"\n{compact_metadata}\n\n\n\n{output}\n" + + +def _remaining_time(deadline: float) -> float: + return max(0.0, deadline - time.monotonic()) + + __all__ = [ - "DifyShellLayerDeps", + "CompleteRemoteCommandResult", "DifyShellLayer", + "DifyShellLayerDeps", "DifyShellRuntimeState", - "RemoteCommandResult", - "ShellctlClientProtocol", + "DEFAULT_TERMINATE_GRACE_SECONDS", + "DEFAULT_TIMEOUT_SECONDS", + "execute_complete_with_commands", + "render_prompt_observation_from_result", ] diff --git a/dify-agent/src/dify_agent/layers/shell/output_text.py b/dify-agent/src/dify_agent/layers/shell/output_text.py new file mode 100644 index 00000000000..04ec3329844 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/shell/output_text.py @@ -0,0 +1,55 @@ +"""Helpers for formatting shell output into bounded prompt-safe text. + +The shell layer uses byte budgets, not Python character counts, because +shellctl output windows are byte-limited. These helpers keep UTF-8 boundaries +valid while producing a compact head/tail rendering for model-visible output. +""" + +from __future__ import annotations + + +def utf8_prefix(text: str, max_bytes: int) -> str: + """Return the longest UTF-8-safe prefix that fits within ``max_bytes``.""" + if max_bytes <= 0: + return "" + return text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore") + + +def utf8_suffix(text: str, max_bytes: int) -> str: + """Return the longest UTF-8-safe suffix that fits within ``max_bytes``.""" + if max_bytes <= 0: + return "" + return text.encode("utf-8")[-max_bytes:].decode("utf-8", errors="ignore") + + +def normalized_output_text( + head: str, + *, + tail: str | None, + output_path: str | None, + max_output_size_bytes: int, + truncated_in_middle: bool | None = None, + truncation_message: str | None = None, +) -> str: + """Format bounded shell output with an optional truncation marker and log path.""" + if truncated_in_middle is None: + truncated_in_middle = tail is not None and tail != head + if not truncated_in_middle: + return head + if truncation_message is None: + truncation_message = ( + f"truncated in middle because the max output size is limited to {max_output_size_bytes} bytes" + ) + + parts = [ + head, + f"... ({truncation_message}) ...", + ] + if tail is not None: + parts.append(tail) + if output_path: + parts.append(f"(check the {output_path} for full output)") + return "\n".join(parts) + + +__all__ = ["normalized_output_text", "utf8_prefix", "utf8_suffix"] diff --git a/dify-agent/src/dify_agent/protocol/sandbox.py b/dify-agent/src/dify_agent/protocol/sandbox.py index 3d7b9aad129..83f3d4b0adc 100644 --- a/dify-agent/src/dify_agent/protocol/sandbox.py +++ b/dify-agent/src/dify_agent/protocol/sandbox.py @@ -4,8 +4,8 @@ The sandbox file APIs must rebuild only the minimum runtime needed to re-enter a prior shell session: ``dify.execution_context`` for Dify-owned identity and ``dify.shell`` for the sandbox workspace itself. ``SandboxLocator`` therefore contains a safe composition subset plus the matching filtered session snapshot. -Credential-bearing plugin layers are intentionally excluded from persisted -runtime specs and from sandbox locators. +Credential-bearing or runtime-only tool layers are intentionally excluded from +persisted runtime specs and from sandbox locators. """ from __future__ import annotations @@ -14,12 +14,19 @@ from typing import ClassVar, Literal, cast from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot +from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID from pydantic import BaseModel, ConfigDict, Field, JsonValue from .schemas import CreateRunRequest, RunComposition, RunLayerSpec -_SENSITIVE_LAYER_TYPES = frozenset({DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID}) +_SENSITIVE_LAYER_TYPES = frozenset( + { + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + DIFY_CORE_TOOLS_LAYER_TYPE_ID, + } +) class RuntimeLayerSpec(BaseModel): diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 1a70d306809..9772663c020 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -6,25 +6,28 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the Dify execution-context layer, the stateful Dify shell layer, and the Dify plugin/knowledge business-layer family: -- ``dify.drive`` for drive-backed skill catalog + eager pull, +- ``dify.config`` for Agent Soul-backed config assets + eager pull, - ``dify.execution_context`` for shared tenant/user/run daemon context, - ``dify.shell`` for shellctl-backed shell job control, - ``dify.plugin.llm`` for plugin-backed model selection, - ``dify.plugin.tools`` for prepared plugin tool exposure, and +- ``dify.core.tools`` for API-routed Dify tool exposure, and - ``dify.knowledge_base`` for inner-API-backed knowledge search tools. Public DTOs provide Dify context plus plugin/model/tool data, while server-only plugin daemon settings and Dify API inner settings are injected through provider -factories. Optional shellctl entrypoint/auth token, client factory, and Agent -Stub URL/token issuer are injected for ``DifyShellLayer``. The resulting -``Compositor`` remains Agenton state-only at the snapshot boundary: live -resources such as HTTP clients are injected by runtime-owned providers, may be -held on active layer instances inside ``resource_context()``, and never enter -session snapshots. +factories. Optional shellctl entrypoint/auth token and Agent Stub URL/token +issuer are injected for ``DifyShellLayer``. The resulting ``Compositor`` +remains Agenton state-only at the snapshot boundary: live resources such as +HTTP clients are injected by runtime-owned providers, may be held on active +layer instances inside ``resource_context()``, and never enter session +snapshots. """ +from __future__ import annotations + from collections.abc import Mapping, Sequence -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from pydantic_ai.messages import UserContent @@ -34,8 +37,10 @@ from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory -from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.layers.ask_human.layer import DifyAskHumanLayer +from dify_agent.layers.config.layer import DifyConfigLayer +from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig +from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer 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 @@ -45,10 +50,13 @@ from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output.output_layer import DifyOutputLayer from dify_agent.adapters.shell.config import ShellAdapterSettings -from dify_agent.adapters.shell.factory import create_shell_provisioner +from dify_agent.adapters.shell.factory import create_shell_provider from dify_agent.layers.shell.configs import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer +if TYPE_CHECKING: + from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec + type DifyAgentLayerProvider = LayerProvider[Any] @@ -84,11 +92,23 @@ def create_default_layer_providers( ) agent_stub_token_factory = build_agent_stub_token + shell_provider = ( + create_shell_provider( + ShellAdapterSettings( + shell_provider="shellctl", + shellctl_entrypoint=shellctl_entrypoint, + shellctl_auth_token=shellctl_auth_token, + ) + ) + if shellctl_entrypoint + else None + ) return ( LayerProvider.from_layer_type(PromptLayer), LayerProvider.from_layer_type(PydanticAIHistoryLayer), 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, @@ -102,21 +122,21 @@ def create_default_layer_providers( layer_type=DifyShellLayer, create=lambda config: DifyShellLayer.from_config_with_settings( DifyShellLayerConfig.model_validate(config), - shell_provisioner=create_shell_provisioner( - ShellAdapterSettings( - shell_provider="shellctl", - shellctl_entrypoint=shellctl_entrypoint, - shellctl_auth_token=shellctl_auth_token, - ) - ) - if shellctl_entrypoint - else None, + shell_provider=shell_provider, agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ), ), LayerProvider.from_layer_type(DifyPluginLLMLayer), LayerProvider.from_layer_type(DifyPluginToolsLayer), + LayerProvider.from_factory( + layer_type=DifyCoreToolsLayer, + create=lambda config: DifyCoreToolsLayer.from_config_with_settings( + DifyCoreToolsLayerConfig.model_validate(config), + inner_api_url=inner_api_url, + inner_api_key=inner_api_key, + ), + ), LayerProvider.from_factory( layer_type=DifyKnowledgeBaseLayer, create=lambda config: DifyKnowledgeBaseLayer.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 50f27660e77..ab619b7509a 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -40,6 +40,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: resolved_settings = settings or ServerSettings() agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec() 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() layer_providers = create_default_layer_providers( plugin_daemon_url=resolved_settings.plugin_daemon_url, @@ -111,6 +112,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: create_agent_stub_router( 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, ) ) diff --git a/dify-agent/src/dify_agent/server/sandbox_files.py b/dify-agent/src/dify_agent/server/sandbox_files.py index 034fcf1d06b..78f5210b6aa 100644 --- a/dify-agent/src/dify_agent/server/sandbox_files.py +++ b/dify-agent/src/dify_agent/server/sandbox_files.py @@ -4,7 +4,7 @@ Unlike the removed workspace inspector, this service never talks to shellctl directly and never reads sandbox files outside the shell layer. It rebuilds a minimal compositor from ``SandboxLocator``, enters the saved ``execution_context`` + ``shell`` layers, and executes fixed scripts through -``DifyShellLayer.run_remote_script()``. +``DifyShellLayer.run_remote_script_complete()``. The scripts still frame their structured payloads with a PTY-safe base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can @@ -22,7 +22,8 @@ import textwrap from dataclasses import dataclass from typing import TypeVar, cast -from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult +from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer +from dify_agent.layers.shell.output_text import utf8_suffix from dify_agent.protocol import ( SandboxListRequest, SandboxListResponse, @@ -42,6 +43,7 @@ _READ_TIMEOUT_SECONDS = 15.0 _UPLOAD_TIMEOUT_SECONDS = 30.0 _OUTPUT_BEGIN = "<<>>" _OUTPUT_END = "<<>>" +_SHELL_RESULT_OUTPUT_TAIL_BYTES = 8 * 1024 ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel) _LIST_SCRIPT = """ @@ -294,7 +296,7 @@ class SandboxFileService: async with compositor.enter(configs=layer_configs, session_snapshot=locator.session_snapshot) as run: run.suspend_on_exit() shell_layer = run.get_layer("shell", DifyShellLayer) - result = await shell_layer.run_remote_script( + result = await shell_layer.run_remote_script_complete( _build_python_script_command(script_source=script_source, args=args), timeout=timeout, inject_agent_stub_env=inject_agent_stub_env, @@ -328,16 +330,23 @@ def _build_python_script_command(*, script_source: str, args: list[str]) -> str: return f"python3 - {quoted_args} <<'PY'\n{script}\nPY" -def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]: +def _decode_sandbox_payload(result: CompleteRemoteCommandResult) -> dict[str, object]: if result.exit_code not in (0, None): raise SandboxFileError( "sandbox_command_failed", - f"sandbox command exited with code {result.exit_code}: {_output_tail(result.output)!r}", + "sandbox command exited with code " + f"{result.exit_code}: {_shell_result_details(result)}", status_code=502, ) begin = result.output.find(_OUTPUT_BEGIN) end = result.output.find(_OUTPUT_END, begin + len(_OUTPUT_BEGIN)) if begin != -1 else -1 if begin == -1 or end == -1: + if not result.output_complete: + raise SandboxFileError( + "sandbox_command_failed", + "sandbox command output incomplete before framed payload was captured: " + + _shell_result_details(result), + status_code=502, + ) raise SandboxFileError( "sandbox_command_failed", "sandbox command returned no framed payload", @@ -349,12 +358,25 @@ def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]: decoded = base64.b64decode(compact, validate=True) loaded = cast(object, json.loads(decoded.decode("utf-8"))) except (binascii.Error, ValueError) as exc: + if not result.output_complete: + raise SandboxFileError( + "sandbox_command_failed", + "sandbox command output incomplete while decoding framed payload: " + _shell_result_details(result), + status_code=502, + ) from exc raise SandboxFileError( "sandbox_command_failed", f"sandbox command returned invalid framed payload: {exc}", status_code=502, ) from exc if not isinstance(loaded, dict): + if not result.output_complete: + raise SandboxFileError( + "sandbox_command_failed", + "sandbox command output incomplete while validating framed payload object: " + + _shell_result_details(result), + status_code=502, + ) raise SandboxFileError( "sandbox_command_failed", "sandbox command returned a non-object payload", status_code=502 ) @@ -379,9 +401,22 @@ def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]: return payload -def _output_tail(output: str) -> str: - stripped = output.strip() - return stripped[-200:] +def _shell_result_details(result: CompleteRemoteCommandResult) -> str: + details = ( + f"output_complete={result.output_complete} " + + f"incomplete_reason={result.incomplete_reason} " + + f"output_path={result.output_path}" + ) + if not result.output: + return details + return details + "\n" + _bounded_output_tail(result.output) + + +def _bounded_output_tail(output: str) -> str: + tail = utf8_suffix(output, _SHELL_RESULT_OUTPUT_TAIL_BYTES) + if tail == output: + return output + return f"... (showing last {_SHELL_RESULT_OUTPUT_TAIL_BYTES} bytes of raw output) ...\n{tail}" def _validate_response_model( diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 7c16a8e50b1..e614ed336b4 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -18,6 +18,7 @@ from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator, model_vali from pydantic_settings import BaseSettings, SettingsConfigDict from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url, parse_agent_stub_endpoint +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.grpc_bind import normalize_agent_stub_grpc_bind_address @@ -145,6 +146,16 @@ class ServerSettings(BaseSettings): inner_api_key=self.inner_api_key, ) + def create_agent_stub_config_request_handler(self) -> DifyApiAgentStubConfigRequestHandler | None: + """Return the Dify API config bridge when both Dify API settings are configured.""" + if self.inner_api_key is None: + return None + return DifyApiAgentStubConfigRequestHandler( + inner_api_url=self.inner_api_url, + inner_api_key=self.inner_api_key, + 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. diff --git a/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py b/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py index 74b11175c0d..dca0a9e7376 100644 --- a/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py +++ b/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py @@ -1,14 +1,9 @@ -"""Local tests for the shellctl shell adapter and env-driven provider factory. +"""Local tests for the shellctl shell adapter and env-driven provider factory.""" -These exercise the provider-agnostic boundary contract (provision/execute/wait, -file transfer, optional input/interrupt) against a fake shellctl client, plus the -``DIFY_AGENT_SHELL_PROVIDER`` selection in the factory. They avoid the private -``shell-session-manager`` package by injecting a structural fake client. -""" +from __future__ import annotations import asyncio import base64 -import secrets from collections.abc import Callable from dataclasses import dataclass, field @@ -16,37 +11,30 @@ import pytest from dify_agent.adapters.shell import shellctl from dify_agent.adapters.shell.config import ShellAdapterSettings -from dify_agent.adapters.shell.factory import create_shell_provisioner -from dify_agent.adapters.shell.protocols import ( - ShellEnvironmentDescriptor, -) -from dify_agent.adapters.shell.shellctl import ( - ShellctlEnvironmentDescriptor, - ShellctlProvisioner, - ShellFileTransferError, - ShellProvisionError, -) - -_SESSION_HEX = "deadbeefdeadbeef" -_WORKSPACE_CWD = f"~/workspace/{_SESSION_HEX}" +from dify_agent.adapters.shell.factory import create_shell_provider +from dify_agent.adapters.shell.protocols import ShellCommandResult +from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellProviderError, ShellctlProvider @dataclass(slots=True) class _Job: job_id: str + status: str = "running" done: bool = True output: str = "" offset: int = 0 truncated: bool = False exit_code: int | None = 0 + output_path: str | None = "/tmp/output.log" @dataclass(slots=True) class _Status: job_id: str + status: str = "terminated" done: bool = True offset: int = 0 - exit_code: int | None = 0 + exit_code: int | None = 130 @dataclass(slots=True) @@ -54,278 +42,290 @@ class _RunCall: script: str cwd: str | None env: dict[str, str] | None + timeout: float -type _RunHandler = Callable[[str, str | None, dict[str, str] | None], _Job] -type _WaitHandler = Callable[[str, int], _Job] -type _InputHandler = Callable[[str, str, int], _Job] -type _TerminateHandler = Callable[[str], _Status] +type _RunHandler = Callable[[str, str | None, dict[str, str] | None, float], _Job] +type _WaitHandler = Callable[[str, int, float], _Job] +type _InputHandler = Callable[[str, str, int, float], _Job] +type _TerminateHandler = Callable[[str, float], _Status] @dataclass(slots=True) class FakeShellctlClient: - """Structural shellctl client double recording calls and replaying handlers.""" - run_handler: _RunHandler | None = None wait_handler: _WaitHandler | None = None input_handler: _InputHandler | None = None + tail_handler: Callable[[str], _Job] | None = None terminate_handler: _TerminateHandler | None = None run_calls: list[_RunCall] = field(default_factory=list) - wait_calls: list[tuple[str, int]] = field(default_factory=list) - input_calls: list[tuple[str, str, int]] = field(default_factory=list) + wait_calls: list[tuple[str, int, float]] = field(default_factory=list) + input_calls: list[tuple[str, str, int, float]] = field(default_factory=list) terminate_calls: list[tuple[str, float]] = field(default_factory=list) - delete_calls: list[str] = field(default_factory=list) + delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list) closed: bool = False async def run(self, script, *, cwd=None, env=None, timeout=30.0): - del timeout - self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env)) + self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) if self.run_handler is not None: - return self.run_handler(script, cwd, env) - return _Job(job_id="job", done=True, exit_code=0) + return self.run_handler(script, cwd, env, timeout) + return _Job(job_id="job", status="exited", done=True, exit_code=0) async def wait(self, job_id, *, offset, timeout=30.0): - del timeout - self.wait_calls.append((job_id, offset)) + self.wait_calls.append((job_id, offset, timeout)) if self.wait_handler is not None: - return self.wait_handler(job_id, offset) - return _Job(job_id=job_id, done=True, offset=offset, exit_code=0) + return self.wait_handler(job_id, offset, timeout) + return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0) async def input(self, job_id, text, *, offset, timeout=30.0): - del timeout - self.input_calls.append((job_id, text, offset)) + self.input_calls.append((job_id, text, offset, timeout)) if self.input_handler is not None: - return self.input_handler(job_id, text, offset) - return _Job(job_id=job_id, done=True, offset=offset, exit_code=0) + return self.input_handler(job_id, text, offset, timeout) + return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0) + + async def tail(self, job_id): + if self.tail_handler is not None: + return self.tail_handler(job_id) + return _Job(job_id=job_id, status="exited", done=True, output="", exit_code=0) async def terminate(self, job_id, grace_seconds=10.0): self.terminate_calls.append((job_id, grace_seconds)) if self.terminate_handler is not None: - return self.terminate_handler(job_id) - return _Status(job_id=job_id, done=True, exit_code=130) + return self.terminate_handler(job_id, grace_seconds) + return _Status(job_id=job_id) - async def delete(self, job_id, *, force=False): - del force - self.delete_calls.append(job_id) + async def delete(self, job_id, *, force=False, grace_seconds=None): + self.delete_calls.append((job_id, force, grace_seconds)) return None async def close(self): self.closed = True -@pytest.fixture(autouse=True) -def _fixed_session_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: _SESSION_HEX) - - -def _provisioner(client: FakeShellctlClient) -> ShellctlProvisioner: - return ShellctlProvisioner(client_factory=lambda: client) - - -def test_provision_allocates_workspace_and_execute_drains_merged_output() -> None: - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del env - if script.startswith("mkdir"): - assert cwd is None - return _Job(job_id="mkdir-job", done=True, exit_code=0) - assert cwd == _WORKSPACE_CWD - return _Job(job_id="user-job", done=False, output="par", offset=3, truncated=False, exit_code=None) - - def wait_handler(job_id: str, offset: int) -> _Job: - assert job_id == "user-job" - assert offset == 3 - return _Job(job_id="user-job", done=True, output="tial", offset=7, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) - - async def scenario() -> None: - handle = await _provisioner(client).provision() - assert handle.workspace_cwd == _WORKSPACE_CWD - executor = await handle.get_executor() - result = await executor.execute("pwd", env={"FOO": "bar"}) - assert result.stdout() == "partial" - assert result.stderr() == "" - assert result.exit_code() == 0 - assert result.truncated() is False - - asyncio.run(scenario()) - - assert client.run_calls[0].cwd is None - user_run = next(call for call in client.run_calls if call.script == "pwd") - assert user_run.env == {"FOO": "bar"} - # completed jobs (internal mkdir + user command) are self-cleaned. - assert "mkdir-job" in client.delete_calls - assert "user-job" in client.delete_calls - - -def test_execute_reports_truncated_when_output_window_cap_is_hit() -> None: - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del cwd, env - if script.startswith("mkdir"): - return _Job(job_id="mkdir-job", done=True, exit_code=0) - return _Job(job_id="user-job", done=False, output="x", offset=1, truncated=True, exit_code=None) - - def wait_handler(job_id: str, offset: int) -> _Job: - return _Job(job_id=job_id, done=False, output="x", offset=offset + 1, truncated=True, exit_code=None) - - client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) - - async def scenario() -> bool: - handle = await _provisioner(client).provision() - executor = await handle.get_executor() - result = await executor.execute("tail -f log") - return result.truncated() - - assert asyncio.run(scenario()) is True - # a job that never completed is left intact (not deleted/forgotten). - assert "user-job" not in client.delete_calls - - -def test_provision_failure_closes_client_and_raises() -> None: - client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=1) - ) - - async def scenario() -> None: - with pytest.raises(ShellProvisionError): - await _provisioner(client).provision() - - asyncio.run(scenario()) - assert client.closed is True - - -def test_destroy_runs_cleanup_in_default_cwd_then_closes_client() -> None: - client = FakeShellctlClient(run_handler=lambda _script, _cwd, _env: _Job(job_id="job", done=True, exit_code=0)) - - async def scenario() -> None: - provisioner = _provisioner(client) - handle = await provisioner.provision() - await provisioner.destroy(handle) - - asyncio.run(scenario()) - - cleanup_call = client.run_calls[-1] - assert cleanup_call.cwd is None - assert _SESSION_HEX in cleanup_call.script and cleanup_call.script.startswith("rm -rf") - assert client.closed is True - - -def test_file_transfer_download_decodes_sentinel_framed_base64() -> None: - content = b"hello \x00 world" - encoded = base64.b64encode(content).decode("ascii") - framed = f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}trailing" - - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del env - if script.startswith("mkdir"): - return _Job(job_id="mkdir-job", done=True, exit_code=0) - assert cwd == _WORKSPACE_CWD - return _Job(job_id="dl-job", done=True, output=framed, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - - async def scenario() -> None: - handle = await _provisioner(client).provision() - transfer = await handle.get_file_transfer() - downloaded = await transfer.download(remote_path="report.txt") - assert downloaded == content - - asyncio.run(scenario()) - - -def test_file_transfer_download_missing_file_raises() -> None: - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del cwd, env - if script.startswith("mkdir"): - return _Job(job_id="mkdir-job", done=True, exit_code=0) - return _Job(job_id="dl-job", done=True, output="", exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE) - - client = FakeShellctlClient(run_handler=run_handler) - - async def scenario() -> None: - handle = await _provisioner(client).provision() - transfer = await handle.get_file_transfer() - with pytest.raises(ShellFileTransferError, match="not found"): - await transfer.download(remote_path="missing.txt") - - asyncio.run(scenario()) - - -def test_file_transfer_upload_embeds_base64_and_succeeds() -> None: - content = b"payload-bytes" - encoded = base64.b64encode(content).decode("ascii") - - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del env - if script.startswith('mkdir -p "$HOME'): - return _Job(job_id="mkdir-job", done=True, exit_code=0) - assert cwd == _WORKSPACE_CWD - assert encoded in script - return _Job(job_id="ul-job", done=True, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - - async def scenario() -> None: - handle = await _provisioner(client).provision() - transfer = await handle.get_file_transfer() - await transfer.upload(content=content, remote_path="out.bin") - - asyncio.run(scenario()) - - -def test_provision_exposes_descriptor_seed() -> None: - client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=0) - ) - - async def scenario() -> ShellEnvironmentDescriptor: - handle = await _provisioner(client).provision() - return handle.descriptor() - - descriptor = asyncio.run(scenario()) - assert isinstance(descriptor, ShellctlEnvironmentDescriptor) - assert descriptor.workspace_cwd == _WORKSPACE_CWD - assert descriptor.session_id == _SESSION_HEX - - -def test_reattach_rebuilds_handle_without_mkdir_and_executes_in_same_workspace() -> None: - descriptor = ShellctlEnvironmentDescriptor(workspace_cwd=_WORKSPACE_CWD, session_id=_SESSION_HEX) - - def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job: - del env - assert not script.startswith("mkdir") - assert cwd == _WORKSPACE_CWD - return _Job(job_id="user-job", done=True, output="ok", offset=2, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - - async def scenario() -> str: - handle = await _provisioner(client).reattach(descriptor) - executor = await handle.get_executor() - result = await executor.execute("pwd") - return result.stdout() - - assert asyncio.run(scenario()) == "ok" - # reattach must not allocate a new workspace. - assert all(not call.script.startswith("mkdir") for call in client.run_calls) +def _provider(client: FakeShellctlClient) -> ShellctlProvider: + return ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=lambda: client) def test_factory_unknown_provider_raises() -> None: settings = ShellAdapterSettings(shell_provider="nope") with pytest.raises(ValueError, match="Unknown shell provider"): - create_shell_provisioner(settings) + create_shell_provider(settings) def test_factory_shellctl_requires_entrypoint() -> None: settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint=None) with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"): - create_shell_provisioner(settings) + create_shell_provider(settings) -def test_factory_builds_shellctl_provisioner_from_settings() -> None: - settings = ShellAdapterSettings( - shell_provider="shellctl", - shellctl_entrypoint="http://shellctl.example", +def test_factory_builds_shellctl_provider_from_settings() -> None: + settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint="http://shellctl.example") + provider = create_shell_provider(settings) + assert isinstance(provider, ShellctlProvider) + assert provider.entrypoint == "http://shellctl.example" + assert provider.token == "" + + +def test_provider_create_opens_only_live_resource_and_close_closes_client() -> None: + client = FakeShellctlClient() + + async def scenario() -> None: + resource = await _provider(client).create() + assert client.run_calls == [] + await resource.close() + + asyncio.run(scenario()) + assert client.closed is True + + +def test_commands_forward_parameters_and_map_metadata() -> None: + client = FakeShellctlClient( + run_handler=lambda script, cwd, env, timeout: _Job( + job_id="run-job", + status="running", + done=False, + output="abc", + offset=3, + truncated=True, + exit_code=None, + output_path="/tmp/run.log", + ), + wait_handler=lambda job_id, offset, timeout: _Job( + job_id=job_id, + status="running", + done=False, + output="def", + offset=6, + truncated=False, + exit_code=None, + output_path="/tmp/run.log", + ), + input_handler=lambda job_id, text, offset, timeout: _Job( + job_id=job_id, + status="exited", + done=True, + output="ghi", + offset=9, + truncated=False, + exit_code=0, + output_path="/tmp/run.log", + ), + tail_handler=lambda job_id: _Job( + job_id=job_id, + status="exited", + done=True, + output="tail", + offset=11, + truncated=False, + exit_code=0, + output_path="/tmp/tail.log", + ), + terminate_handler=lambda job_id, grace_seconds: _Status( + job_id=job_id, + status="terminated", + done=True, + offset=12, + exit_code=130, + ), ) - provisioner = create_shell_provisioner(settings) - assert isinstance(provisioner, ShellctlProvisioner) + + async def scenario() -> None: + resource = await _provider(client).create() + run_result = await resource.commands.run("pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5) + wait_result = await resource.commands.wait("run-job", offset=3, timeout=4.0) + read_result = await resource.commands.read_output("run-job", offset=6) + input_result = await resource.commands.input("run-job", "ls\n", offset=6, timeout=5.0) + interrupt_result = await resource.commands.interrupt("run-job", grace_seconds=1.5) + tail_result = await resource.commands.tail("run-job") + await resource.commands.delete("run-job", force=True, grace_seconds=2.0) + await resource.close() + + assert run_result == ShellCommandResult( + job_id="run-job", + status="running", + done=False, + exit_code=None, + output="abc", + offset=3, + truncated=True, + output_path="/tmp/run.log", + ) + assert wait_result.offset == 6 + assert read_result.offset == 6 + assert input_result.exit_code == 0 + assert interrupt_result.status == "terminated" + assert tail_result.output_path == "/tmp/tail.log" + + asyncio.run(scenario()) + + assert client.run_calls == [_RunCall(script="pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5)] + assert client.wait_calls == [ + ("run-job", 3, 4.0), + ("run-job", 6, 0.0), + ] + assert client.input_calls == [("run-job", "ls\n", 6, 5.0)] + assert client.terminate_calls == [("run-job", 1.5)] + assert client.delete_calls == [("run-job", True, 2.0)] + + +def test_files_upload_and_download_still_work() -> None: + content = b"hello \x00 world" + encoded = base64.b64encode(content).decode("ascii") + client = FakeShellctlClient( + run_handler=lambda script, cwd, env, timeout: ( + _Job(job_id="ul-job", status="exited", done=True, exit_code=0) + if "base64 -d" in script + else _Job( + job_id="dl-job", + status="exited", + done=True, + exit_code=0, + output=f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}tail", + ) + ) + ) + + async def scenario() -> None: + resource = await _provider(client).create() + await resource.files.upload(content=content, remote_path="out.bin", cwd="~/workspace/abc12ff") + downloaded = await resource.files.download(remote_path="report.txt", cwd="~/workspace/abc12ff") + assert downloaded == content + + asyncio.run(scenario()) + + +def test_file_transfer_timeout_is_an_end_to_end_budget(monkeypatch: pytest.MonkeyPatch) -> None: + clock = {"value": 100.0} + + def fake_monotonic() -> float: + return clock["value"] + + monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic) + + def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job: + del script, cwd, env + assert timeout == pytest.approx(5.0, rel=0, abs=0.01) + clock["value"] = 103.5 + return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None) + + def wait_handler(job_id: str, offset: int, timeout: float) -> _Job: + assert job_id == "upload-job" + assert offset == 6 + assert timeout == pytest.approx(1.5, rel=0, abs=0.01) + return _Job(job_id=job_id, status="exited", done=True, output="part-2", offset=12, exit_code=0) + + client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) + + async def scenario() -> None: + transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0) + await transfer.upload(content=b"payload", remote_path="out.bin") + + asyncio.run(scenario()) + assert client.delete_calls == [("upload-job", True, None)] + + +def test_file_transfer_timeout_exhaustion_raises_timeout_and_still_deletes_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = {"value": 100.0} + + def fake_monotonic() -> float: + return clock["value"] + + monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic) + + def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job: + del script, cwd, env + assert timeout == pytest.approx(5.0, rel=0, abs=0.01) + clock["value"] = 106.0 + return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None) + + client = FakeShellctlClient(run_handler=run_handler) + + async def scenario() -> None: + transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0) + with pytest.raises(ShellProviderError, match="timed out") as exc_info: + await transfer.upload(content=b"payload", remote_path="out.bin") + assert exc_info.value.code == "timeout" + + asyncio.run(scenario()) + assert client.delete_calls == [("upload-job", True, None)] + + +def test_download_missing_file_raises() -> None: + client = FakeShellctlClient( + run_handler=lambda script, cwd, env, timeout: _Job( + job_id="dl-job", + status="exited", + done=True, + output="", + exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE, + ) + ) + + async def scenario() -> None: + resource = await _provider(client).create() + with pytest.raises(ShellFileTransferError, match="not found"): + await resource.files.download(remote_path="missing.txt") + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py new file mode 100644 index 00000000000..b9fbe015969 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import io +import zipfile +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest + +from dify_agent.agent_stub.cli import _config as config_cli +from dify_agent.agent_stub.cli._env import AgentStubEnvironment +from dify_agent.agent_stub.client._errors import AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import AgentStubConfigManifestResponse, AgentStubConfigPushRequest + + +def _manifest_payload() -> AgentStubConfigManifestResponse: + return AgentStubConfigManifestResponse.model_validate( + { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": {"items": [{"name": "alpha", "description": "Alpha skill"}]}, + "files": {"items": [{"name": "guide.txt"}]}, + "env_keys": ["API_KEY", "JSON_VALUE"], + "note": "Use carefully.", + } + ) + + +def _zip_bytes(members: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, payload in members.items(): + archive.writestr(name, payload) + return buffer.getvalue() + + +def _environment() -> AgentStubEnvironment: + return AgentStubEnvironment(url="https://agent.example.com/agent-stub", auth_jwe="test-jwe") + + +def test_pull_config_skills_from_environment_downloads_and_extracts_archives( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + monkeypatch.setattr(config_cli, "request_agent_stub_config_manifest_sync", lambda **_kwargs: _manifest_payload()) + monkeypatch.setattr( + config_cli, + "request_agent_stub_config_skill_pull_sync", + lambda **_kwargs: _zip_bytes({"SKILL.md": b"# Alpha\n", "refs/spec.md": b"spec"}), + ) + + result = config_cli.pull_config_skills_from_environment(local_dir=str(tmp_path)) + + assert [item.name for item in result.items] == ["alpha"] + assert Path(result.items[0].archive_path).read_bytes() + assert Path(result.items[0].directory_path, "SKILL.md").read_text(encoding="utf-8") == "# Alpha\n" + assert result.items[0].skill_md == "# Alpha\n" + + +def test_pull_config_files_from_environment_downloads_visible_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + monkeypatch.setattr(config_cli, "request_agent_stub_config_manifest_sync", lambda **_kwargs: _manifest_payload()) + monkeypatch.setattr( + config_cli, + "request_agent_stub_config_file_pull_sync", + lambda **_kwargs: b"guide-bytes", + ) + + result = config_cli.pull_config_files_from_environment(local_dir=str(tmp_path)) + + assert result.items == [config_cli.ConfigFilePullResult.Item(name="guide.txt", path=str(tmp_path / "guide.txt"))] + assert (tmp_path / "guide.txt").read_bytes() == b"guide-bytes" + + +def test_pull_config_env_from_environment_writes_only_declared_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload) + monkeypatch.setenv("API_KEY", "plain") + monkeypatch.setenv("JSON_VALUE", "two words") + + result = config_cli.pull_config_env_from_environment(local_path=str(tmp_path / ".env")) + + assert result.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n' + + +def test_pull_config_env_and_note_use_hidden_default_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload) + monkeypatch.setenv("API_KEY", "plain") + monkeypatch.setenv("JSON_VALUE", "two words") + + env_path = config_cli.pull_config_env_from_environment() + note_path = config_cli.pull_config_note_from_environment() + + assert env_path == (tmp_path / ".dify_conf" / ".env").resolve() + assert note_path == (tmp_path / ".dify_conf" / "note.md").resolve() + assert env_path.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n' + assert note_path.read_text(encoding="utf-8") == "Use carefully." + + +def test_push_config_note_from_environment_reads_default_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + note_path = tmp_path / ".dify_conf" / "note.md" + note_path.parent.mkdir() + note_path.write_text("hello", encoding="utf-8") + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + response = config_cli.push_config_note_from_environment(None) + + assert response.agent_id == "agent-1" + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.note == "hello" + assert request.env_text is None + assert request.files == [] + assert request.skills == [] + + +def test_push_config_note_from_environment_reads_explicit_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + note_path = tmp_path / "note.md" + note_path.write_text("explicit", encoding="utf-8") + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_note_from_environment(str(note_path)) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.note == "explicit" + + +def test_push_config_note_from_environment_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + monkeypatch.setattr(config_cli.os, "dup", lambda _fd: 99) + monkeypatch.setattr(config_cli.os, "fdopen", lambda _fd, encoding: io.StringIO("from-stdin")) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_note_from_environment("-") + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.note == "from-stdin" + + +def test_push_config_env_from_environment_reads_default_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + env_path = tmp_path / ".dify_conf" / ".env" + env_path.parent.mkdir() + env_path.write_text("API_KEY=value\n", encoding="utf-8") + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_env_from_environment(None) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.env_text == "API_KEY=value\n" + assert request.note is None + + +def test_push_config_env_from_environment_reads_explicit_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / "custom.env" + env_path.write_text("API_KEY=custom\n", encoding="utf-8") + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_env_from_environment(str(env_path)) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.env_text == "API_KEY=custom\n" + + +def test_push_config_env_from_environment_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + monkeypatch.setattr(config_cli.os, "dup", lambda _fd: 99) + monkeypatch.setattr(config_cli.os, "fdopen", lambda _fd, encoding: io.StringIO("API_KEY=stdin\n")) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_env_from_environment("-") + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.env_text == "API_KEY=stdin\n" + + +def test_push_config_files_from_environment_builds_upload_items( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + file_path = tmp_path / "guide.txt" + file_path.write_text("guide", encoding="utf-8") + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + monkeypatch.setattr( + config_cli, + "upload_tool_file_resource_from_environment", + lambda *, path: SimpleNamespace(tool_file_id=f"tool-file:{Path(path).name}"), + ) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_files_from_environment([str(file_path)], None) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.files[0].name == "guide.txt" + assert request.files[0].file_ref is not None + assert request.files[0].file_ref.id == "tool-file:guide.txt" + assert request.skills == [] + + +def test_push_config_files_from_environment_validates_name_usage(tmp_path: Path) -> None: + first = tmp_path / "a.txt" + second = tmp_path / "b.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + + with pytest.raises(AgentStubValidationError, match="--name requires exactly one PATH"): + config_cli.push_config_files_from_environment([str(first), str(second)], "renamed.txt") + + +def test_push_config_files_from_environment_rejects_empty_paths() -> None: + with pytest.raises(AgentStubValidationError, match="at least one file path is required"): + config_cli.push_config_files_from_environment([], None) + + +def test_delete_config_files_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.delete_config_files_from_environment(["old.txt", "legacy.txt"]) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert [item.name for item in request.files] == ["old.txt", "legacy.txt"] + assert all(item.file_ref is None for item in request.files) + + +def test_delete_config_files_from_environment_rejects_empty_names() -> None: + with pytest.raises(AgentStubValidationError, match="at least one file name is required"): + config_cli.delete_config_files_from_environment([]) + + +def test_push_config_skills_from_environment_builds_archive_upload_items( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "alpha" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Alpha\n", encoding="utf-8") + uploaded_paths: list[str] = [] + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + def fake_upload_tool_file_resource_from_environment(*, path: str): + uploaded_paths.append(path) + return SimpleNamespace(tool_file_id=f"tool-file-{len(uploaded_paths)}") + + monkeypatch.setattr( + config_cli, + "upload_tool_file_resource_from_environment", + fake_upload_tool_file_resource_from_environment, + ) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.push_config_skills_from_environment([str(skill_dir)]) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert request.skills[0].name == "alpha" + assert request.skills[0].file_ref is not None + assert request.skills[0].file_ref.id == "tool-file-1" + assert len(uploaded_paths) == 1 + assert uploaded_paths[0].endswith("/alpha.zip") + + +def test_push_config_skills_from_environment_rejects_empty_paths() -> None: + with pytest.raises(AgentStubValidationError, match="at least one skill directory is required"): + config_cli.push_config_skills_from_environment([]) + + +def test_delete_config_skills_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) + + captured: dict[str, object] = {} + + def fake_push_sync(**kwargs): + captured.update(kwargs) + return _manifest_payload() + + monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) + + config_cli.delete_config_skills_from_environment(["alpha", "beta"]) + + request = cast(AgentStubConfigPushRequest, captured["request"]) + assert [item.name for item in request.skills] == ["alpha", "beta"] + assert all(item.file_ref is None for item in request.skills) + + +def test_delete_config_skills_from_environment_rejects_empty_names() -> None: + with pytest.raises(AgentStubValidationError, match="at least one skill name is required"): + config_cli.delete_config_skills_from_environment([]) + + +def test_build_file_push_item_rejects_non_regular_files(tmp_path: Path) -> None: + with pytest.raises(AgentStubValidationError, match="regular file"): + config_cli._build_file_push_item(item=config_cli._PreparedPushItem(name="bad", path=tmp_path)) + + +def test_build_skill_push_item_rejects_missing_skill_md(tmp_path: Path) -> None: + skill_dir = tmp_path / "alpha" + skill_dir.mkdir() + + with pytest.raises(AgentStubValidationError, match="must contain SKILL.md"): + config_cli._build_skill_push_item(item=config_cli._PreparedPushItem(name="alpha", path=skill_dir)) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py index 8bdb8b2106d..0aedc6f435f 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -9,6 +9,12 @@ import pytest from dify_agent.agent_stub.cli._drive import DrivePullResult from dify_agent.agent_stub.cli.main import main from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubConfigFileItemsResponse, + AgentStubConfigFileItem, + AgentStubConfigManifestResponse, + AgentStubConfigSkillItemsResponse, + AgentStubConfigSkillItem, + AgentStubConfigVersionInfo, AgentStubDriveCommitResponse, AgentStubDriveItem, AgentStubDriveManifestResponse, @@ -21,6 +27,17 @@ def _reference(record_id: str) -> str: return f"dify-file-ref:{payload}" +def _config_manifest_response() -> AgentStubConfigManifestResponse: + return AgentStubConfigManifestResponse( + agent_id="agent-1", + config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True), + skills=AgentStubConfigSkillItemsResponse(items=[]), + files=AgentStubConfigFileItemsResponse(items=[]), + env_keys=[], + note="Runtime note.", + ) + + def test_cli_connect_reports_missing_environment_variables(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exc_info: main(["connect"]) @@ -91,6 +108,63 @@ def test_cli_connect_help_routes_to_typer_help(capsys: pytest.CaptureFixture[str assert exc_info.value.code == 0 assert "Establish one Agent Stub connection" in captured.out assert "--json" in captured.out + assert "╭" not in captured.out + assert "─" not in captured.out + + +def test_cli_config_push_is_not_a_valid_command(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + main(["config", "push"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert "No such command 'push'" in captured.err + assert "╭" not in captured.err + assert "─" not in captured.err + + +def test_cli_config_help_lists_plural_groups_and_no_root_push(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + main(["config", "--help"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert "Usage: dify-agent config" in captured.out + assert "manifest" in captured.out + assert "files" in captured.out + assert "skills" in captured.out + assert "env" in captured.out + assert "note" in captured.out + + +@pytest.mark.parametrize("argv", [["config", "files", "--help"], ["config", "skills", "--help"]]) +def test_cli_plural_config_groups_expose_pull_push_and_delete( + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + with pytest.raises(SystemExit) as exc_info: + main(argv) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert "pull" in captured.out + assert "push" in captured.out + assert "delete" in captured.out + + +@pytest.mark.parametrize("argv", [["config", "file", "--help"], ["config", "skill", "--help"]]) +def test_cli_hidden_singular_alias_help_exposes_pull_only( + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + with pytest.raises(SystemExit) as exc_info: + main(argv) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert "pull" in captured.out + assert "push" not in captured.out + assert "delete" not in captured.out def test_cli_reports_invalid_agent_stub_api_base_url_environment_value( @@ -154,6 +228,205 @@ def test_cli_connect_accepts_grpc_agent_stub_api_base_url( assert captured.out.strip() == "connected conn-1" +def test_cli_config_manifest_omits_hash_fields( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.manifest_from_environment", + lambda: AgentStubConfigManifestResponse( + agent_id="agent-1", + config_version=AgentStubConfigVersionInfo(id="cfg-1", kind="build_draft", writable=True), + skills=AgentStubConfigSkillItemsResponse( + items=[ + AgentStubConfigSkillItem( + name="alpha", + description="Alpha skill.", + size=12, + hash="sha256:skill", + mime_type="application/zip", + ) + ] + ), + files=AgentStubConfigFileItemsResponse( + items=[ + AgentStubConfigFileItem( + name="guide.txt", + size=34, + hash="sha256:file", + mime_type="text/plain", + ) + ] + ), + env_keys=["RUNTIME_KEY"], + note="Runtime note.", + ), + ) + + with pytest.raises(SystemExit) as exc_info: + main(["config", "manifest"]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert exc_info.value.code == 0 + assert payload["skills"] == { + "items": [ + { + "name": "alpha", + "description": "Alpha skill.", + "size": 12, + "mime_type": "application/zip", + } + ] + } + assert payload["files"] == { + "items": [ + { + "name": "guide.txt", + "size": 34, + "mime_type": "text/plain", + } + ] + } + + +@pytest.mark.parametrize( + ("argv", "helper_name", "expected_kwargs"), + [ + (["config", "note", "push"], "push_config_note_from_environment", {"local_path": None}), + ( + ["config", "note", "push", "/tmp/note.md"], + "push_config_note_from_environment", + {"local_path": "/tmp/note.md"}, + ), + (["config", "env", "push"], "push_config_env_from_environment", {"local_path": None}), + (["config", "env", "push", "/tmp/.env"], "push_config_env_from_environment", {"local_path": "/tmp/.env"}), + ( + ["config", "files", "push", "/tmp/guide.txt", "--name", "runtime.txt"], + "push_config_files_from_environment", + {"paths": ["/tmp/guide.txt"], "name": "runtime.txt"}, + ), + ( + ["config", "files", "delete", "old.txt", "legacy.txt"], + "delete_config_files_from_environment", + {"names": ["old.txt", "legacy.txt"]}, + ), + ( + ["config", "skills", "push", "/tmp/alpha", "/tmp/beta"], + "push_config_skills_from_environment", + {"paths": ["/tmp/alpha", "/tmp/beta"]}, + ), + ( + ["config", "skills", "delete", "alpha", "beta"], + "delete_config_skills_from_environment", + {"names": ["alpha", "beta"]}, + ), + ], +) +def test_cli_config_mutation_commands_forward_and_print_manifest_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + helper_name: str, + expected_kwargs: dict[str, object], +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_helper(**kwargs): + captured_kwargs.update(kwargs) + return _config_manifest_response() + + monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper) + + with pytest.raises(SystemExit) as exc_info: + main(argv) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out) == json.loads(_config_manifest_response().model_dump_json()) + assert captured_kwargs == expected_kwargs + + +@pytest.mark.parametrize( + ("argv", "helper_name"), + [ + (["config", "skills", "pull", "alpha", "--json"], "pull_config_skills_from_environment"), + (["config", "skill", "pull", "alpha", "--json"], "pull_config_skills_from_environment"), + (["config", "files", "pull", "guide.txt", "--json"], "pull_config_files_from_environment"), + (["config", "file", "pull", "guide.txt", "--json"], "pull_config_files_from_environment"), + ], +) +def test_cli_config_pull_commands_support_plural_and_hidden_singular_aliases( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + helper_name: str, +) -> None: + if "skills" in helper_name: + expected_json = { + "items": [ + { + "name": "alpha", + "archive_path": "/tmp/alpha.zip", + "directory_path": "/tmp/alpha", + "skill_md": "# Alpha\n", + } + ] + } + response = type( + "Response", + (), + {"model_dump_json": lambda self: json.dumps(expected_json)}, + )() + expected_kwargs = {"names": ["alpha"], "local_dir": None} + else: + expected_json = {"items": [{"name": "guide.txt", "path": "/tmp/guide.txt"}]} + response = type( + "Response", + (), + {"model_dump_json": lambda self: json.dumps(expected_json)}, + )() + expected_kwargs = {"names": ["guide.txt"], "local_dir": None} + + captured_kwargs: dict[str, object] = {} + + def fake_helper(*, names, local_dir): + captured_kwargs["names"] = names + captured_kwargs["local_dir"] = local_dir + return response + + monkeypatch.setattr(f"dify_agent.agent_stub.cli.main.{helper_name}", fake_helper) + + with pytest.raises(SystemExit) as exc_info: + main(argv) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out) == expected_json + assert captured_kwargs == expected_kwargs + + +@pytest.mark.parametrize( + "argv", + [ + ["config", "skill", "push", "/tmp/alpha"], + ["config", "skill", "delete", "alpha"], + ["config", "file", "push", "/tmp/guide.txt"], + ["config", "file", "delete", "guide.txt"], + ], +) +def test_cli_hidden_singular_aliases_do_not_expose_mutation_commands( + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + with pytest.raises(SystemExit) as exc_info: + main(argv) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert "No such command" in captured.err + + def test_cli_file_upload_prints_uploaded_tool_file_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_config_client.py b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_config_client.py new file mode 100644 index 00000000000..ed2fbf5a678 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_config_client.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from dify_agent.agent_stub.client._agent_stub import ( + request_agent_stub_config_env_update_sync, + request_agent_stub_config_file_pull_sync, + request_agent_stub_config_manifest_sync, + request_agent_stub_config_note_update_sync, + request_agent_stub_config_push_sync, + request_agent_stub_config_skill_inspect_sync, + request_agent_stub_config_skill_pull_sync, +) +from dify_agent.agent_stub.client._agent_stub_http import ( + request_agent_stub_config_env_update_http_sync, + request_agent_stub_config_file_pull_http_sync, + request_agent_stub_config_manifest_http_sync, + request_agent_stub_config_note_update_http_sync, + request_agent_stub_config_push_http_sync, + request_agent_stub_config_skill_inspect_http_sync, + request_agent_stub_config_skill_pull_http_sync, +) +from dify_agent.agent_stub.client._errors import AgentStubClientError, AgentStubHTTPError, AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import AgentStubConfigPushRequest + + +def _manifest_payload() -> dict[str, object]: + return { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True}, + "skills": {"items": [{"name": "alpha", "description": "Alpha skill"}]}, + "files": {"items": [{"name": "guide.txt"}]}, + "env_keys": ["API_KEY"], + "note": "Use carefully.", + } + + +def test_config_manifest_sync_normalizes_url_and_uses_http_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://agent.example.com/agent-stub/config/manifest" + assert request.headers["Authorization"] == "Bearer test-jwe" + return httpx.Response(200, json=_manifest_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + response = request_agent_stub_config_manifest_sync( + url="https://agent.example.com/agent-stub/", + auth_jwe="test-jwe", + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert response.agent_id == "agent-1" + + +@pytest.mark.parametrize( + ("func", "kwargs"), + [ + (request_agent_stub_config_manifest_sync, {}), + (request_agent_stub_config_skill_pull_sync, {"name": "alpha"}), + (request_agent_stub_config_skill_inspect_sync, {"name": "alpha"}), + (request_agent_stub_config_file_pull_sync, {"name": "guide.txt"}), + (request_agent_stub_config_push_sync, {"request": AgentStubConfigPushRequest(note="hello")}), + (request_agent_stub_config_env_update_sync, {"env_text": "API_KEY=value\n"}), + (request_agent_stub_config_note_update_sync, {"note": "hello"}), + ], +) +def test_config_sync_entrypoints_reject_grpc_urls(func, kwargs: dict[str, object]) -> None: + with pytest.raises(AgentStubValidationError, match="require an HTTP Agent Stub URL"): + func( + url="grpc://agent.example.com:9091", + auth_jwe="test-jwe", + **kwargs, + ) + + +def test_request_agent_stub_config_manifest_http_sync_gets_manifest() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://agent.example.com/agent-stub/config/manifest" + assert request.headers["Authorization"] == "Bearer test-jwe" + return httpx.Response(200, json=_manifest_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + response = request_agent_stub_config_manifest_http_sync( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert response.agent_id == "agent-1" + assert response.config_version.kind == "build_draft" + + +def test_request_agent_stub_config_skill_pull_http_sync_returns_binary_payload() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://agent.example.com/agent-stub/config/skills/alpha/pull" + return httpx.Response(200, content=b"zip-bytes") + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + payload = request_agent_stub_config_skill_pull_http_sync( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + name="alpha", + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert payload == b"zip-bytes" + + +def test_request_agent_stub_config_skill_inspect_http_sync_returns_json_dict() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://agent.example.com/agent-stub/config/skills/alpha/inspect" + return httpx.Response(200, json={"name": "alpha", "files": ["SKILL.md"]}) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + payload = request_agent_stub_config_skill_inspect_http_sync( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + name="alpha", + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert payload == {"name": "alpha", "files": ["SKILL.md"]} + + +def test_request_agent_stub_config_file_pull_http_sync_returns_binary_payload() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://agent.example.com/agent-stub/config/files/guide.txt/pull" + return httpx.Response(200, content=b"file-bytes") + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + payload = request_agent_stub_config_file_pull_http_sync( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + name="guide.txt", + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert payload == b"file-bytes" + + +def test_request_agent_stub_config_push_http_sync_posts_json_and_validates_response() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "https://agent.example.com/agent-stub/config/push" + assert json.loads(request.content) == {"note": "hello"} + return httpx.Response(200, json=_manifest_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + response = request_agent_stub_config_push_http_sync( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + request=AgentStubConfigPushRequest(note="hello"), + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert response.note == "Use carefully." + + +@pytest.mark.parametrize( + ("func", "kwargs", "expected_path", "expected_body", "expected_response"), + [ + ( + request_agent_stub_config_env_update_http_sync, + {"env_text": "API_KEY=value\n"}, + "https://agent.example.com/agent-stub/config/env", + {"env_text": "API_KEY=value\n"}, + {"env_keys": ["API_KEY"]}, + ), + ( + request_agent_stub_config_note_update_http_sync, + {"note": "hello"}, + "https://agent.example.com/agent-stub/config/note", + {"note": "hello"}, + {"note": "hello"}, + ), + ], +) +def test_config_update_http_entrypoints_round_trip_json( + func, + kwargs: dict[str, object], + expected_path: str, + expected_body: dict[str, object], + expected_response: dict[str, object], +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == expected_path + assert json.loads(request.content) == expected_body + return httpx.Response(200, json=expected_response) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + payload = func( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + sync_http_client=http_client, + **kwargs, + ) + finally: + http_client.close() + + assert payload == expected_response + + +@pytest.mark.parametrize( + ("func", "kwargs", "response", "expected_error", "expected_match"), + [ + ( + request_agent_stub_config_manifest_http_sync, + {}, + httpx.Response(401, json={"detail": "denied"}), + AgentStubHTTPError, + "401", + ), + ( + request_agent_stub_config_manifest_http_sync, + {}, + httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}), + AgentStubClientError, + "invalid JSON", + ), + ( + request_agent_stub_config_push_http_sync, + {"request": AgentStubConfigPushRequest(note="hello")}, + httpx.Response(200, json={"bad": "shape"}), + AgentStubValidationError, + "config push response", + ), + ( + request_agent_stub_config_skill_pull_http_sync, + {"name": "alpha"}, + httpx.Response(404, json={"detail": "missing"}), + AgentStubHTTPError, + "404", + ), + ( + request_agent_stub_config_skill_inspect_http_sync, + {"name": "alpha"}, + httpx.Response(200, json=["bad"]), + AgentStubValidationError, + "config skill inspect response", + ), + ( + request_agent_stub_config_file_pull_http_sync, + {"name": "guide.txt"}, + httpx.Response(404, json={"detail": "missing"}), + AgentStubHTTPError, + "404", + ), + ( + request_agent_stub_config_env_update_http_sync, + {"env_text": "API_KEY=value\n"}, + httpx.Response(200, json=["bad"]), + AgentStubValidationError, + "config env update response", + ), + ( + request_agent_stub_config_note_update_http_sync, + {"note": "hello"}, + httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}), + AgentStubClientError, + "invalid JSON", + ), + ], +) +def test_config_http_entrypoints_map_http_json_and_validation_errors( + func, + kwargs: dict[str, object], + response: httpx.Response, + expected_error: type[Exception], + expected_match: str, +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return response + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(expected_error, match=expected_match): + func( + base_url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + sync_http_client=http_client, + **kwargs, + ) + finally: + http_client.close() diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py new file mode 100644 index 00000000000..7846fcded42 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import base64 +import json +import secrets +import time +from typing import cast + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubConfigManifestResponse, + AgentStubConfigPushRequest, + AgentStubConfigPushResponse, +) +from dify_agent.agent_stub.server.agent_stub_config import ( + AgentStubConfigRequestError, + AgentStubConfigRequestHandler, + DifyApiAgentStubConfigRequestHandler, +) +from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService +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 AgentStubPrincipal, AgentStubTokenCodec +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +def _base64url_secret(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _token_codec() -> AgentStubTokenCodec: + return AgentStubTokenCodec.from_server_secret(_base64url_secret(secrets.token_bytes(32))) + + +def _execution_context(**updates: object) -> DifyExecutionContextLayerConfig: + payload = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "user_from": "account", + "agent_mode": "workflow_run", + "invoke_from": "service-api", + "agent_id": "agent-1", + "agent_config_version_id": "cfg-1", + "agent_config_version_kind": "build_draft", + } + payload.update(updates) + return DifyExecutionContextLayerConfig.model_validate(payload) + + +def _principal(**context_updates: object) -> AgentStubPrincipal: + return AgentStubPrincipal( + execution_context=_execution_context(**context_updates), + session_id=None, + scope=["agent_stub:connect"], + token_id="token-1", + ) + + +def _manifest_payload() -> dict[str, object]: + return { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": True, "source": "inner-api"}, + "skills": {"items": [{"id": "alpha", "name": "alpha", "description": "Alpha skill", "file_id": "tool-file-1"}]}, + "files": {"items": [{"id": "guide.txt", "name": "guide.txt", "file_id": "upload-file-1"}]}, + "env_keys": ["API_KEY"], + "note": "Use carefully.", + "ignored": True, + } + + +@pytest.mark.anyio +async def test_dify_api_handler_manifest_success(monkeypatch: pytest.MonkeyPatch) -> None: + original_async_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ( + "https://api.example.com/inner/api/agent-config/agent-1/manifest" + "?tenant_id=tenant-1&config_version_id=cfg-1&config_version_kind=build_draft&user_id=user-1" + ) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + return httpx.Response(200, json=_manifest_payload()) + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + response = await DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + ).manifest(principal=_principal()) + + assert isinstance(response, AgentStubConfigManifestResponse) + assert response.agent_id == "agent-1" + assert response.config_version.kind == "build_draft" + assert response.skills.items[0].model_dump() == { + "name": "alpha", + "description": "Alpha skill", + "size": None, + "hash": None, + "mime_type": None, + } + assert response.files.items[0].model_dump() == { + "name": "guide.txt", + "size": None, + "hash": None, + "mime_type": None, + } + + +@pytest.mark.anyio +async def test_dify_api_handler_pull_endpoints_return_bytes(monkeypatch: pytest.MonkeyPatch) -> None: + original_async_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["user_id"] == "user-1" + if request.url.path.endswith("/skills/alpha/pull"): + return httpx.Response(200, content=b"zip-bytes") + if request.url.path.endswith("/files/guide.txt/pull"): + return httpx.Response(200, content=b"file-bytes") + raise AssertionError(f"unexpected path: {request.url.path}") + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + request_handler = DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + ) + + assert await request_handler.pull_skill(principal=_principal(), name="alpha") == b"zip-bytes" + assert await request_handler.pull_file(principal=_principal(), name="guide.txt") == b"file-bytes" + + +@pytest.mark.anyio +async def test_dify_api_handler_push_env_and_note_success(monkeypatch: pytest.MonkeyPatch) -> None: + original_async_client = httpx.AsyncClient + captured: list[tuple[str, dict[str, object]]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + captured.append((request.url.path, payload)) + if request.url.path.endswith("/push"): + return httpx.Response(200, json=_manifest_payload()) + if request.url.path.endswith("/env"): + return httpx.Response(200, json={"env_keys": ["API_KEY"]}) + if request.url.path.endswith("/note"): + return httpx.Response(200, json={"note": "updated"}) + raise AssertionError(f"unexpected path: {request.url.path}") + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + request_handler = DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + ) + + push_response = await request_handler.push( + principal=_principal(), + request=AgentStubConfigPushRequest(note="hello"), + ) + env_response = await request_handler.update_env(principal=_principal(), env_text="API_KEY=value\n") + note_response = await request_handler.update_note(principal=_principal(), note="updated") + + assert isinstance(push_response, AgentStubConfigPushResponse) + assert env_response == {"env_keys": ["API_KEY"]} + assert note_response == {"note": "updated"} + assert captured == [ + ( + "/inner/api/agent-config/agent-1/push", + { + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "files": [], + "skills": [], + "note": "hello", + }, + ), + ( + "/inner/api/agent-config/agent-1/env", + { + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "env_text": "API_KEY=value\n", + }, + ), + ( + "/inner/api/agent-config/agent-1/note", + { + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + "note": "updated", + }, + ), + ] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("method_name", "path", "response", "expected_status", "expected_message"), + [ + ( + "manifest", + "/inner/api/agent-config/agent-1/manifest", + httpx.Response(200, json={"bad": "shape"}), + 502, + "manifest", + ), + ( + "pull_skill", + "/inner/api/agent-config/agent-1/skills/alpha/pull", + httpx.Response(404, json={"detail": "missing"}), + 404, + "missing", + ), + ("push", "/inner/api/agent-config/agent-1/push", httpx.Response(200, json={"bad": "shape"}), 502, "push"), + ("update_env", "/inner/api/agent-config/agent-1/env", httpx.Response(200, json=["bad"]), 502, "env"), + ( + "update_note", + "/inner/api/agent-config/agent-1/note", + httpx.Response(200, text="not-json"), + 502, + "invalid JSON", + ), + ], +) +async def test_dify_api_handler_maps_error_cases( + monkeypatch: pytest.MonkeyPatch, + method_name: str, + path: str, + response: httpx.Response, + expected_status: int, + expected_message: str, +) -> None: + original_async_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == path + return response + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + request_handler = DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + ) + + with pytest.raises(AgentStubConfigRequestError, match=expected_message) as exc_info: + match method_name: + case "manifest": + await request_handler.manifest(principal=_principal()) + case "pull_skill": + await request_handler.pull_skill(principal=_principal(), name="alpha") + case "push": + await request_handler.push(principal=_principal(), request=AgentStubConfigPushRequest()) + case "update_env": + await request_handler.update_env(principal=_principal(), env_text="API_KEY=value\n") + case "update_note": + await request_handler.update_note(principal=_principal(), note="hello") + case _: + raise AssertionError(f"unexpected method: {method_name}") + + assert exc_info.value.status_code == expected_status + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("context_updates", "expected_message"), + [ + ({"agent_id": None}, "agent_id is required"), + ({"agent_config_version_id": None}, "agent_config_version_id is required"), + ({"agent_config_version_kind": None}, "agent_config_version_kind is required"), + ({"user_id": None}, "user_id is required"), + ], +) +async def test_dify_api_handler_validates_required_execution_context_fields( + context_updates: dict[str, object], + expected_message: str, +) -> None: + request_handler = DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + ) + + with pytest.raises(AgentStubConfigRequestError, match=expected_message) as exc_info: + if context_updates.get("user_id", "user-1") is None: + await request_handler.push(principal=_principal(**context_updates), request=AgentStubConfigPushRequest()) + else: + await request_handler.manifest(principal=_principal(**context_updates)) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "method_name", + [ + "get_config_manifest", + "pull_config_skill", + "inspect_config_skill", + "pull_config_file", + "push_config", + "update_config_env", + "update_config_note", + ], +) +async def test_control_plane_maps_config_request_errors(method_name: str) -> None: + codec = _token_codec() + authorization = f"Bearer {codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)}" + + class FakeHandler: + async def manifest(self, *, principal): + del principal + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def pull_skill(self, *, principal, name): + del principal, name + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def inspect_skill(self, *, principal, name): + del principal, name + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def pull_file(self, *, principal, name): + del principal, name + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def push(self, *, principal, request): + del principal, request + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def update_env(self, *, principal, env_text): + del principal, env_text + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + async def update_note(self, *, principal, note): + del principal, note + raise AgentStubConfigRequestError(409, {"code": "conflict"}) + + service = AgentStubControlPlaneService( + codec, config_request_handler=cast(AgentStubConfigRequestHandler, FakeHandler()) + ) + + with pytest.raises(AgentStubControlPlaneError) as exc_info: + match method_name: + case "get_config_manifest": + await service.get_config_manifest(authorization=authorization) + case "pull_config_skill": + await service.pull_config_skill(name="alpha", authorization=authorization) + case "inspect_config_skill": + await service.inspect_config_skill(name="alpha", authorization=authorization) + case "pull_config_file": + await service.pull_config_file(name="guide.txt", authorization=authorization) + case "push_config": + await service.push_config(request=AgentStubConfigPushRequest(), authorization=authorization) + case "update_config_env": + await service.update_config_env(env_text="API_KEY=value\n", authorization=authorization) + case "update_config_note": + await service.update_config_note(note="hello", authorization=authorization) + case _: + raise AssertionError(f"unexpected method: {method_name}") + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == {"code": "conflict"} + + +@pytest.mark.parametrize( + ("path", "method", "body", "expected_status", "expected_detail"), + [ + ("/agent-stub/config/manifest", "get", None, 200, {"agent_id": "agent-1"}), + ("/agent-stub/config/skills/alpha/pull", "get", None, 200, b"zip-bytes"), + ("/agent-stub/config/skills/alpha/inspect", "get", None, 200, {"name": "alpha", "files": ["SKILL.md"]}), + ("/agent-stub/config/files/guide.txt/pull", "get", None, 200, b"file-bytes"), + ("/agent-stub/config/push", "post", {"note": "hello"}, 200, {"agent_id": "agent-1"}), + ("/agent-stub/config/env", "patch", {"env_text": "API_KEY=value\n"}, 200, {"env_keys": ["API_KEY"]}), + ("/agent-stub/config/note", "put", {"note": "hello"}, 200, {"note": "hello"}), + ], +) +def test_http_config_routes_forward_requests( + path: str, + method: str, + body: dict[str, object] | None, + expected_status: int, + expected_detail: dict[str, object] | bytes, +) -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + captured: dict[str, object] = {} + + class FakeHandler: + async def manifest(self, *, principal): + captured["manifest_agent_id"] = principal.execution_context.agent_id + return AgentStubConfigManifestResponse.model_validate(_manifest_payload()) + + async def pull_skill(self, *, principal, name): + del principal + captured["skill_name"] = name + return b"zip-bytes" + + async def inspect_skill(self, *, principal, name): + del principal + captured["inspect_name"] = name + return {"name": name, "files": ["SKILL.md"]} + + async def pull_file(self, *, principal, name): + del principal + captured["file_name"] = name + return b"file-bytes" + + async def push(self, *, principal, request): + del principal + captured["push_note"] = request.note + return AgentStubConfigPushResponse.model_validate(_manifest_payload()) + + async def update_env(self, *, principal, env_text): + del principal + captured["env_text"] = env_text + return {"env_keys": ["API_KEY"]} + + async def update_note(self, *, principal, note): + del principal + captured["note"] = note + return {"note": note} + + app = FastAPI() + app.include_router( + create_agent_stub_http_router(codec, config_request_handler=cast(AgentStubConfigRequestHandler, FakeHandler())) + ) + client = TestClient(app) + headers = {"Authorization": f"Bearer {token}"} + response = client.request(method.upper(), path, headers=headers, json=body) + + assert response.status_code == expected_status + if isinstance(expected_detail, bytes): + assert response.content == expected_detail + else: + for key, value in expected_detail.items(): + assert response.json()[key] == value + + if path.endswith("/manifest"): + assert captured["manifest_agent_id"] == "agent-1" + elif path.endswith("/skills/alpha/pull"): + assert captured["skill_name"] == "alpha" + assert response.headers["content-type"] == "application/zip" + elif path.endswith("/skills/alpha/inspect"): + assert captured["inspect_name"] == "alpha" + elif path.endswith("/files/guide.txt/pull"): + assert captured["file_name"] == "guide.txt" + assert response.headers["content-type"] == "application/octet-stream" + elif path.endswith("/config/push"): + assert captured["push_note"] == "hello" + elif path.endswith("/config/env"): + assert captured["env_text"] == "API_KEY=value\n" + elif path.endswith("/config/note"): + assert captured["note"] == "hello" + + +def test_http_config_routes_map_handler_errors() -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + + class ErrorHandler: + async def manifest(self, *, principal): + del principal + raise AgentStubConfigRequestError(422, {"code": "invalid_request"}) + + async def pull_skill(self, *, principal, name): + del principal, name + raise AssertionError("unexpected route") + + async def inspect_skill(self, *, principal, name): + del principal, name + raise AssertionError("unexpected route") + + async def pull_file(self, *, principal, name): + del principal, name + raise AssertionError("unexpected route") + + async def push(self, *, principal, request): + del principal, request + raise AssertionError("unexpected route") + + async def update_env(self, *, principal, env_text): + del principal, env_text + raise AssertionError("unexpected route") + + async def update_note(self, *, principal, note): + del principal, note + raise AssertionError("unexpected route") + + app = FastAPI() + app.include_router( + create_agent_stub_http_router(codec, config_request_handler=cast(AgentStubConfigRequestHandler, ErrorHandler())) + ) + client = TestClient(app) + response = client.get("/agent-stub/config/manifest", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 422 + assert response.json()["detail"] == {"code": "invalid_request"} 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 index 11e56f62d67..c636fc0a0fd 100644 --- 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 @@ -100,6 +100,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit( "key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, "value_owned_by_drive": True, + "is_skill": False, } ], } 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 new file mode 100644 index 00000000000..0ea4bf1195f --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py @@ -0,0 +1,202 @@ +"""Behavior tests for the runtime Dify config layer.""" + +from __future__ import annotations + +import asyncio +from typing import Literal + +import pytest + +from dify_agent.adapters.shell.shellctl import ShellctlProvider +from dify_agent.layers.config import DifyConfigLayerConfig +from dify_agent.layers.config.layer import DifyConfigLayer, DifyConfigLayerError +from dify_agent.layers.shell import DifyShellLayerConfig +from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer + + +def _unused_client_factory(): + raise AssertionError("shellctl client should not be used by these config-layer tests") + + +def _shell_layer() -> DifyShellLayer: + return DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), + shell_provider=ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=_unused_client_factory), + ) + + +def _build_layer(*, writable: bool = True) -> DifyConfigLayer: + layer = DifyConfigLayer.from_config( + DifyConfigLayerConfig.model_validate( + { + "agent_id": "agent-1", + "config_version": {"id": "cfg-1", "kind": "build_draft", "writable": writable}, + "skills": [{"name": "runtime-skill", "description": "Runtime skill."}], + "files": [{"name": "runtime-file.txt"}], + "env_keys": ["RUNTIME_KEY"], + "note": "Runtime note.", + "mentioned_skill_names": ["alpha"], + "mentioned_file_names": ["guide.txt"], + } + ) + ) + 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-config-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/config-pull-output.log", + ) + + +def _skill_pull_output(*, include_skill: bool = True) -> str: + if not include_skill: + return "" + return "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it.\n" + + +def _file_pull_output(*, include_file: bool = True) -> str: + if not include_file: + return "" + return "/workspace/.dify_conf/files/guide.txt\n" + + +def test_build_shell_pull_scripts_include_targets() -> None: + layer = _build_layer() + + skill_script = layer._build_shell_skill_pull_script("alpha") + file_script = layer._build_shell_file_pull_script("guide.txt") + + assert skill_script == "set -eu\ndify-agent config skills pull alpha" + assert "__DIFY_CONFIG_SKILLS_BEGIN__" not in skill_script + assert file_script == "set -eu\ndify-agent config files pull guide.txt" + assert "__DIFY_CONFIG_FILES_BEGIN__" not in file_script + + +@pytest.mark.anyio +async def test_on_context_create_computes_runtime_fields_and_pulls_mentioned_assets_in_parallel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + layer = _build_layer() + captured_scripts: list[str] = [] + active_commands = 0 + max_active_commands = 0 + + async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0): + nonlocal active_commands, max_active_commands + del self, timeout + assert inject_agent_stub_env is True + assert "--help" not in script + assert "dify-agent config manifest" not in script + captured_scripts.append(script) + active_commands += 1 + max_active_commands = max(max_active_commands, active_commands) + await asyncio.sleep(0) + active_commands -= 1 + if "skills pull" in script: + return _remote_result(_skill_pull_output()) + if "files pull" in script: + return _remote_result(_file_pull_output()) + raise AssertionError(f"unexpected script: {script}") + + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) + + await layer.on_context_create() + + assert max_active_commands > 1 + assert len(captured_scripts) == 2 + assert sorted(captured_scripts) == [ + "set -eu\ndify-agent config files pull guide.txt", + "set -eu\ndify-agent config skills pull alpha", + ] + assert layer.runtime_state.pulled_skill_outputs == {"alpha": "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it."} + assert layer.runtime_state.pulled_file_outputs == {"guide.txt": "/workspace/.dify_conf/files/guide.txt"} + assert "dify-agent config note push --help" in layer.runtime_state.config_cli_help + assert layer.runtime_state.push_spec_json_schema == "" + + +@pytest.mark.anyio +async def test_on_context_resume_does_not_recompute_or_pull(monkeypatch: pytest.MonkeyPatch) -> None: + layer = _build_layer() + layer.runtime_state.config_context_json = "cached" + layer.runtime_state.config_cli_help = {"cached": "help"} + + async def fail_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0): + del self, script, inject_agent_stub_env, timeout + raise AssertionError("resume must not run config shell commands") + + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fail_run_remote_script) + + await layer.on_context_resume() + + assert layer.runtime_state.config_context_json == "cached" + assert layer.runtime_state.config_cli_help == {"cached": "help"} + + +@pytest.mark.anyio +async def test_on_context_create_raises_when_shell_output_is_truncated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + layer = _build_layer() + + async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0): + del self, inject_agent_stub_env, timeout + if "skills pull" in script: + return _remote_result(_skill_pull_output(), output_complete=False, incomplete_reason="output_limit") + return _remote_result(_file_pull_output()) + + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) + + with pytest.raises(DifyConfigLayerError, match="output was incomplete"): + await layer.on_context_create() + + +@pytest.mark.anyio +async def test_on_context_create_raises_when_mentioned_skill_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + layer = _build_layer() + + async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0): + del self, inject_agent_stub_env, timeout + if "skills pull" in script: + return _remote_result(_skill_pull_output(include_skill=False)) + return _remote_result(_file_pull_output()) + + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) + + with pytest.raises(DifyConfigLayerError, match="missing pull output"): + await layer.on_context_create() + + +@pytest.mark.anyio +async def test_on_context_create_raises_when_mentioned_file_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + layer = _build_layer() + + async def fake_run_remote_script(self, script: str, *, inject_agent_stub_env: bool = False, timeout: float = 10.0): + del self, inject_agent_stub_env, timeout + if "skills pull" in script: + return _remote_result(_skill_pull_output()) + return _remote_result(_file_pull_output(include_file=False)) + + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) + + with pytest.raises(DifyConfigLayerError, match="missing pull output"): + await layer.on_context_create() diff --git a/dify-agent/tests/local/dify_agent/layers/conftest.py b/dify-agent/tests/local/dify_agent/layers/conftest.py new file mode 100644 index 00000000000..9d393301962 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/conftest.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import sys +import types + +from agenton.layers import EmptyLayerConfig, EmptyRuntimeState, NoLayerDeps, PlainLayer +from pydantic import BaseModel + + +class _BaseSettings(BaseModel): + """Minimal test stub for environments without pydantic-settings installed.""" + + +class _SettingsConfigDict(dict[str, object]): + """Minimal callable mapping used by ShellAdapterSettings.model_config.""" + + +if "pydantic_settings" not in sys.modules: + stub = types.ModuleType("pydantic_settings") + setattr(stub, "BaseSettings", _BaseSettings) + setattr(stub, "SettingsConfigDict", _SettingsConfigDict) + sys.modules["pydantic_settings"] = stub + + +if "dify_agent.layers.execution_context.layer" not in sys.modules: + execution_context_stub = types.ModuleType("dify_agent.layers.execution_context.layer") + + class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]): + """Minimal test stub for shell-layer type-only imports.""" + + setattr(execution_context_stub, "DifyExecutionContextLayer", DifyExecutionContextLayer) + sys.modules["dify_agent.layers.execution_context.layer"] = execution_context_stub diff --git a/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py new file mode 100644 index 00000000000..1453ded7fd6 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py @@ -0,0 +1,165 @@ +import json + +import httpx +import pytest + +from dify_agent.layers.dify_core_tools.client import DifyCoreToolsClient, DifyCoreToolsClientError +from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +def _execution_context() -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-exec-1", + conversation_id="conversation-1", + agent_id="agent-1", + agent_config_version_id="snapshot-1", + agent_mode="workflow_run", + invoke_from="service-api", + ) + + +def _tool_config() -> DifyCoreToolConfig: + return DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + credential_id="credential-1", + runtime_parameters={"language": "en"}, + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + + +def test_core_tools_client_posts_inner_api_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "http://dify-api/inner/api/agent/tools/invoke" + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + payload = json.loads(request.content.decode("utf-8")) + assert payload["caller"]["tenant_id"] == "tenant-1" + assert payload["tool"]["provider_type"] == "builtin" + assert payload["tool"]["runtime_parameters"] == {"language": "en"} + assert payload["tool"]["tool_parameters"] == {"audio_url": "https://example.com/a.mp3"} + return httpx.Response( + 200, + json={ + "messages": [{"type": "text", "message": {"text": "ok"}}], + "observation": "ok", + "metadata": {"provider_type": "builtin"}, + }, + ) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client) + response = await client.invoke( + execution_context=_execution_context(), + tool_config=_tool_config(), + tool_parameters={"audio_url": "https://example.com/a.mp3"}, + ) + assert response.observation == "ok" + + import asyncio + + asyncio.run(scenario()) + + +def test_core_tools_client_marks_retryable_http_failures() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(502, json={"code": "tool_failed"})) + ) as http_client: + client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client) + with pytest.raises(DifyCoreToolsClientError) as exc_info: + _ = await client.invoke( + execution_context=_execution_context(), + tool_config=_tool_config(), + tool_parameters={"audio_url": "https://example.com/a.mp3"}, + ) + assert exc_info.value.retryable is True + + import asyncio + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("error_factory", "expected_substring"), + [ + ( + lambda request: httpx.ReadTimeout("timed out", request=request), + "timed out", + ), + ( + lambda request: httpx.ConnectError("connect failed", request=request), + "request failed", + ), + ], +) +def test_core_tools_client_marks_transport_failures_retryable(error_factory, expected_substring: str) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise error_factory(request) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client) + with pytest.raises(DifyCoreToolsClientError) as exc_info: + _ = await client.invoke( + execution_context=_execution_context(), + tool_config=_tool_config(), + tool_parameters={"audio_url": "https://example.com/a.mp3"}, + ) + assert exc_info.value.retryable is True + assert expected_substring in str(exc_info.value) + + import asyncio + + asyncio.run(scenario()) + + +def test_core_tools_client_validates_execution_context_prerequisites_separately() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={})) + ) as http_client: + client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client) + with pytest.raises(DifyCoreToolsClientError) as exc_info: + _ = await client.invoke( + execution_context=_execution_context().model_copy(update={"app_id": None}), + tool_config=_tool_config(), + tool_parameters={"audio_url": "https://example.com/a.mp3"}, + ) + assert exc_info.value.error_code == "missing_execution_context" + assert exc_info.value.retryable is False + assert "app_id" in str(exc_info.value) + + import asyncio + + asyncio.run(scenario()) + + +def test_core_tools_client_raises_invalid_response_for_malformed_success_body() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"messages": []})) + ) as http_client: + client = DifyCoreToolsClient(base_url="http://dify-api", api_key="inner-secret", http_client=http_client) + with pytest.raises(DifyCoreToolsClientError) as exc_info: + _ = await client.invoke( + execution_context=_execution_context(), + tool_config=_tool_config(), + tool_parameters={"audio_url": "https://example.com/a.mp3"}, + ) + assert exc_info.value.error_code == "invalid_response" + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_configs.py b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_configs.py new file mode 100644 index 00000000000..8e795112853 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_configs.py @@ -0,0 +1,62 @@ +import pytest +from pydantic import ValidationError + +import dify_agent.layers.dify_core_tools as dify_core_tools_exports +from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig + + +def test_dify_core_tools_package_exports_client_safe_config_symbols_only() -> None: + assert dify_core_tools_exports.__all__ == [ + "DIFY_CORE_TOOLS_LAYER_TYPE_ID", + "DifyCoreToolConfig", + "DifyCoreToolProviderType", + "DifyCoreToolsLayerConfig", + ] + assert dify_core_tools_exports.DIFY_CORE_TOOLS_LAYER_TYPE_ID == "dify.core.tools" + + +def test_dify_core_tools_layer_config_accepts_supported_provider_types() -> None: + config = DifyCoreToolsLayerConfig.model_validate( + { + "tools": [ + { + "provider_type": "plugin", + "provider_id": "langgenius/search/search", + "tool_name": "search", + }, + { + "provider_type": "builtin", + "provider_id": "audio", + "tool_name": "transcribe", + }, + { + "provider_type": "api", + "provider_id": "weather", + "tool_name": "forecast", + }, + { + "provider_type": "workflow", + "provider_id": "workflow-tool", + "tool_name": "run_workflow", + }, + { + "provider_type": "mcp", + "provider_id": "server-1", + "tool_name": "search_docs", + }, + ] + } + ) + + assert [tool.provider_type for tool in config.tools] == ["plugin", "builtin", "api", "workflow", "mcp"] + + +def test_dify_core_tool_config_rejects_unsupported_provider_types() -> None: + with pytest.raises(ValidationError, match="provider_type"): + _ = DifyCoreToolConfig.model_validate( + { + "provider_type": "app", + "provider_id": "app-tool", + "tool_name": "run", + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py new file mode 100644 index 00000000000..4f46cd0a394 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py @@ -0,0 +1,290 @@ +import asyncio +import sys +import types + +import httpx +import pytest + +from agenton.compositor import Compositor, LayerNode, LayerProvider + + +def _install_graphon_stubs() -> None: + if "graphon.model_runtime.entities.llm_entities" in sys.modules: + return + + graphon_module = types.ModuleType("graphon") + model_runtime_module = types.ModuleType("graphon.model_runtime") + entities_module = types.ModuleType("graphon.model_runtime.entities") + llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities") + message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities") + + llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {}) + llm_entities_module.LLMUsage = type("LLMUsage", (), {}) + + for name in ( + "AssistantPromptMessage", + "AudioPromptMessageContent", + "DocumentPromptMessageContent", + "ImagePromptMessageContent", + "PromptMessage", + "PromptMessageContentUnionTypes", + "PromptMessageTool", + "SystemPromptMessage", + "TextPromptMessageContent", + "ToolPromptMessage", + "UserPromptMessage", + "VideoPromptMessageContent", + ): + setattr(message_entities_module, name, type(name, (), {})) + + sys.modules["graphon"] = graphon_module + sys.modules["graphon.model_runtime"] = model_runtime_module + sys.modules["graphon.model_runtime.entities"] = entities_module + sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module + sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module + + graphon_module.model_runtime = model_runtime_module + model_runtime_module.entities = entities_module + entities_module.llm_entities = llm_entities_module + entities_module.message_entities = message_entities_module + + +_install_graphon_stubs() + +from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig, DifyCoreToolsLayerConfig # noqa: E402 +from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer # noqa: E402 +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig # noqa: E402 +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer # noqa: E402 + + +def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]: + return LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + + +def _core_tools_provider() -> LayerProvider[DifyCoreToolsLayer]: + return LayerProvider.from_factory( + layer_type=DifyCoreToolsLayer, + create=lambda config: DifyCoreToolsLayer.from_config_with_settings( + DifyCoreToolsLayerConfig.model_validate(config), + inner_api_url="http://dify-api", + inner_api_key="inner-secret", + ), + ) + + +def _execution_context_config() -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-exec-1", + conversation_id="conversation-1", + agent_id="agent-1", + agent_config_version_id="snapshot-1", + agent_mode="workflow_run", + invoke_from="service-api", + ) + + +def test_core_tools_layer_exposes_pydantic_ai_tool_and_returns_inner_api_observation() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("core_tools", _core_tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 200, + json={ + "messages": [{"type": "text", "message": {"text": "done"}}], + "observation": "done", + "metadata": {"provider_type": "builtin"}, + }, + ) + ) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "core_tools": DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + } + ) as run: + layer = run.get_layer("core_tools", DifyCoreToolsLayer) + tool = (await layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({}, None) # pyright: ignore[reportArgumentType] + assert result == "done" + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + ( + httpx.Response(404, json={"code": "app_not_found", "message": "App not found."}), + "Tool is unavailable because its app context no longer exists.", + ), + ( + httpx.Response(403, json={"code": "app_tenant_mismatch", "message": "App does not belong to tenant."}), + "Tool is unavailable because its app context is invalid.", + ), + ( + httpx.Response(404, json={"code": "agent_tool_declaration_not_found", "message": "tool missing"}), + "there is not a tool named transcribe", + ), + ( + httpx.Response( + 422, + json={"code": "agent_tool_credential_invalid", "message": "credential region is required"}, + ), + "Please check your tool provider credentials", + ), + ( + httpx.Response(422, json={"code": "tool_parameters_invalid", "message": "query is required"}), + "tool parameters validation error: query is required, please check your tool parameters", + ), + ( + httpx.Response(422, json={"code": "agent_tool_invoke_failed", "message": "workflow crashed"}), + "tool invoke error: workflow crashed", + ), + ], +) +def test_core_tools_layer_maps_specific_inner_api_error_codes_to_observations( + response: httpx.Response, + expected: str, +) -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("core_tools", _core_tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: response)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "core_tools": DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + } + ) as run: + layer = run.get_layer("core_tools", DifyCoreToolsLayer) + tool = (await layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({}, None) # pyright: ignore[reportArgumentType] + assert result == expected + + asyncio.run(scenario()) + + +def test_core_tools_layer_reports_missing_execution_context_without_parameter_validation_text() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("core_tools", _core_tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"observation": "unused"})) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config().model_copy(update={"app_id": None}), + "core_tools": DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + } + ) as run: + layer = run.get_layer("core_tools", DifyCoreToolsLayer) + tool = (await layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({}, None) # pyright: ignore[reportArgumentType] + assert result == "Tool is unavailable because required execution context is missing." + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "response", [httpx.Response(429, json={"code": "knowledge_rate_limited"}), httpx.Response(502)] +) +def test_core_tools_layer_converts_retryable_failures_to_temporary_unavailable_observation( + response: httpx.Response, +) -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("core_tools", _core_tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: response)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "core_tools": DifyCoreToolsLayerConfig( + tools=[ + DifyCoreToolConfig( + provider_type="builtin", + provider_id="audio", + tool_name="transcribe", + name="transcribe", + description="Transcribe audio.", + parameters=[], + parameters_json_schema={"type": "object", "properties": {}, "required": []}, + ) + ] + ), + } + ) as run: + layer = run.get_layer("core_tools", DifyCoreToolsLayer) + tool = (await layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({}, None) # pyright: ignore[reportArgumentType] + assert result == "Tool is temporarily unavailable. Please continue without it if possible." + + asyncio.run(scenario()) 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 index f41ce2de61f..86b04f95297 100644 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py @@ -2,13 +2,15 @@ from __future__ import annotations +from typing import Literal + import pytest +from dify_agent.adapters.shell.shellctl import ShellctlProvider from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError -from dify_agent.adapters.shell.shellctl import ShellctlProvisioner from dify_agent.layers.shell import DifyShellLayerConfig -from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult +from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer def _unused_client_factory(): @@ -18,7 +20,7 @@ def _unused_client_factory(): def _shell_layer() -> DifyShellLayer: return DifyShellLayer.from_config_with_settings( DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), - shell_provisioner=ShellctlProvisioner(client_factory=_unused_client_factory), + shell_provider=ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=_unused_client_factory), ) @@ -54,13 +56,19 @@ def _remote_result( output: str, *, exit_code: int | None = 0, - truncated: bool = False, -) -> RemoteCommandResult: - return RemoteCommandResult( + 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, - truncated=truncated, + output_complete=output_complete, + incomplete_reason=incomplete_reason, + offset=len(output), + output_path="/tmp/output.log", ) @@ -77,38 +85,12 @@ def _pulled_output() -> str: ) -def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None: - layer = _build_layer() - - assert len(layer.suffix_prompts) == 1 - prompt = layer.suffix_prompts[0] - assert "Other available skills" in prompt - assert "other-skill: Other Skill — Fallback catalog entry." in prompt - assert "`dify-agent drive pull other-skill/`" not in prompt - assert ( - '`skill_dir="$(dify-agent drive pull --to /tmp/drive)"; ' - 'printf "%s\\n" "$skill_dir"; cat "$skill_dir/SKILL.md"`' - ) in prompt - assert "dify-agent drive list [REMOTE_PREFIX]" in prompt - assert "dify-agent drive pull [REMOTE ...] [--to LOCAL_DIR]" in prompt - assert "--to ." in prompt - assert "dify-agent drive push LOCAL_FILE REMOTE_PATH" in prompt - assert "dify-agent drive push LOCAL_DIR REMOTE_PATH --kind skill" in prompt - assert "dify-agent drive push LOCAL_DIR REMOTE_PATH --kind dir" in prompt - assert "dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [--to LOCAL_DIR]" in prompt - assert "dify-agent file download --mapping" in prompt - assert "dify-agent file upload PATH" in prompt - assert '{"transfer_method":"tool_file","reference":"..."}' in prompt - assert "--recursive" not in prompt - assert "--drive-base" not in prompt +def _file_help_output(command: str) -> str: + return f"Usage: {command.removesuffix(' --help')} [OPTIONS]\n\nAgent Stub file command help.\n" -@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] = {} +def _patch_file_help(monkeypatch: pytest.MonkeyPatch) -> list[str]: + captured_scripts: list[str] = [] async def fake_run_remote_script( self: DifyShellLayer, @@ -116,134 +98,142 @@ async def test_on_context_create_pulls_mentioned_targets_through_shell( *, timeout: float = 10.0, inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: + ) -> 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"), + } + + 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 help" in prompt + assert "$ dify-agent file upload --help" 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", fake_run_remote_script) + 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 "base=/mnt/drive/agent-1" in script assert 'dify-agent drive pull tender-analyzer/ files/report.pdf --to "$base"' in script - assert "cat /mnt/drive/agent-1/tender-analyzer/SKILL.md" in script prompt = layer.build_prompt_context() assert "Loaded mentioned skills" in prompt - assert "Path: tender-analyzer" in prompt - assert "Local path: /mnt/drive/agent-1/tender-analyzer" in prompt - assert "Name: Tender Analyzer" not in prompt assert "# Tender Analyzer\nUse carefully." in prompt - assert "files/report.pdf -> /mnt/drive/agent-1/files/report.pdf" in prompt - assert "Other available skills" not in prompt @pytest.mark.anyio -async def test_on_context_resume_repulls_mentioned_targets_through_shell( - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_on_context_create_raises_when_shell_pull_fails(monkeypatch: pytest.MonkeyPatch) -> None: layer = _build_layer() - calls = 0 + _patch_file_help(monkeypatch) - async def fake_run_remote_script( + async def fake_run_remote_script_complete( self: DifyShellLayer, script: str, *, timeout: float = 10.0, inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: - del self, script, timeout - nonlocal calls - calls += 1 - assert inject_agent_stub_env is True - return _remote_result(_pulled_output()) - - monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) - - await layer.on_context_resume() - - assert calls == 1 - assert "Loaded mentioned skills" in layer.build_prompt_context() - - -@pytest.mark.anyio -async def test_on_context_create_raises_when_mentioned_file_is_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - layer = _build_layer() - - async def fake_run_remote_script( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: - 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" - "__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n" - "# Tender Analyzer\n" - "__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n" - ) - return _remote_result(output) - - monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) - - with pytest.raises(DifyDriveLayerError, match="missing pulled file"): - await layer.on_context_create() - - -@pytest.mark.anyio -async def test_on_context_create_raises_when_shell_pull_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - layer = _build_layer() - - async def fake_run_remote_script( - self: DifyShellLayer, - script: str, - *, - timeout: float = 10.0, - inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: + ) -> CompleteRemoteCommandResult: del self, script, timeout, inject_agent_stub_env return _remote_result("permission denied\n", exit_code=1) - monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) + monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - with pytest.raises(DifyDriveLayerError, match="drive mentioned pull failed in shell"): + 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_shell_output_is_truncated( +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( + async def fake_run_remote_script_complete( self: DifyShellLayer, script: str, *, timeout: float = 10.0, inject_agent_stub_env: bool = False, - ) -> RemoteCommandResult: + ) -> CompleteRemoteCommandResult: del self, script, timeout, inject_agent_stub_env - return _remote_result(_pulled_output(), truncated=True) + 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", fake_run_remote_script) + monkeypatch.setattr(DifyShellLayer, "run_remote_script_complete", fake_run_remote_script_complete) - with pytest.raises(DifyDriveLayerError, match="output was truncated"): + with pytest.raises(DifyDriveLayerError, match="missing pulled SKILL.md"): await layer.on_context_create() -def test_parse_shell_pull_output_rejects_unclosed_skill_marker() -> None: +@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) - with pytest.raises(DifyDriveLayerError, match="omitted SKILL.md end marker"): - layer._parse_shell_pull_output("__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n# Tender\n") + 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_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index 58aae61bd75..f4bf3fa8b99 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -1,50 +1,42 @@ +from __future__ import annotations + import asyncio +import json from collections.abc import Callable, Mapping -import secrets -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import cast import pytest -from agenton.compositor import Compositor, LayerNode, LayerProvider -from agenton.layers import LifecycleState -from dify_agent.agent_stub.server.shell_agent_stub_env import ( - AGENT_STUB_AUTH_JWE_ENV_VAR, - AGENT_STUB_DRIVE_BASE_ENV_VAR, - AGENT_STUB_API_BASE_URL_ENV_VAR, +import dify_agent.layers.shell.layer as shell_layer_module +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell.layer import ( + CompleteRemoteCommandResult, + DEFAULT_TERMINATE_GRACE_SECONDS, + DifyShellLayer, + DifyShellRuntimeState, ) -from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig -from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer -from dify_agent.layers.shell import ( - DIFY_SHELL_LAYER_TYPE_ID, - DifyShellCliToolConfig, - DifyShellEnvVarConfig, - DifyShellLayerConfig, - DifyShellSandboxConfig, - DifyShellSecretRefConfig, +from dify_agent.adapters.shell.protocols import ( + ShellCommandResult, + ShellCommandStatus, + ShellFileTransferProtocol, + ShellProviderProtocol, + ShellResourceProtocol, ) -from dify_agent.adapters.shell.shellctl import ( - ShellctlEnvironmentDescriptor, - ShellctlHandle, - ShellctlProvisioner, - ShellProvisionError, -) -from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState -from shell_session_manager.shellctl.shared import JobResult, JobStatusName, JobStatusView -def _job_result( +def _command_result( job_id: str, *, - status: JobStatusName = JobStatusName.RUNNING, + status: str = "running", done: bool = False, exit_code: int | None = None, output: str = "", offset: int = 0, truncated: bool = False, - output_path: str = "/tmp/output.log", -) -> JobResult: - return JobResult( + output_path: str | None = "/tmp/output.log", +) -> ShellCommandResult: + return ShellCommandResult( job_id=job_id, status=status, done=done, @@ -56,34 +48,36 @@ def _job_result( ) -def _job_status( +def _command_status( job_id: str, *, - status: JobStatusName = JobStatusName.RUNNING, - done: bool = False, - exit_code: int | None = None, + status: str = "terminated", + done: bool = True, + exit_code: int | None = 130, offset: int = 0, -) -> JobStatusView: - return JobStatusView( - job_id=job_id, - status=status, - done=done, - exit_code=exit_code, - created_at="2026-05-28T12:00:00Z", - started_at="2026-05-28T12:00:01Z", - ended_at="2026-05-28T12:00:02Z" if done else None, - offset=offset, - ) +) -> ShellCommandStatus: + return ShellCommandStatus(job_id=job_id, status=status, done=done, exit_code=exit_code, offset=offset) + + +def _parse_tagged_observation(result: object) -> tuple[dict[str, object], str]: + assert isinstance(result, str) + metadata_tag = "\n\n\n\n" + assert result.startswith("\n") + assert result.endswith("\n") + metadata_block, output_block = result.split(metadata_tag, 1) + metadata = json.loads(metadata_block.removeprefix("\n")) + assert isinstance(metadata, dict) + output = output_block.removesuffix("\n") + return cast(dict[str, object], metadata), output def _assert_error_observation(result: object, *, job_id: str | None = None, includes: str | None = None) -> None: assert isinstance(result, dict) assert isinstance(result.get("error"), str) - assert result["error"] if job_id is None: assert "job_id" not in result else: - assert result.get("job_id") == job_id + assert result["job_id"] == job_id if includes is not None: assert includes in result["error"] @@ -92,8 +86,8 @@ def _assert_error_observation(result: object, *, job_id: str | None = None, incl class RunCall: script: str cwd: str | None - timeout: float env: Mapping[str, str] | None + timeout: float @dataclass(slots=True) @@ -112,7 +106,12 @@ class InputCall: @dataclass(slots=True) -class TerminateCall: +class TailCall: + job_id: str + + +@dataclass(slots=True) +class InterruptCall: job_id: str grace_seconds: float @@ -121,479 +120,246 @@ class TerminateCall: class DeleteCall: job_id: str force: bool + grace_seconds: float | None -class FakeShellctlClient: - run_calls: list[RunCall] - wait_calls: list[WaitCall] - input_calls: list[InputCall] - terminate_calls: list[TerminateCall] - delete_calls: list[DeleteCall] - events: list[tuple[str, str]] - closed: bool +class FakeFiles(ShellFileTransferProtocol): + async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: + raise AssertionError("resource.files should not be used by production shell layer logic") - def __init__( - self, - *, - run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult] | None = None, - wait_handler: Callable[[str, int, float], JobResult] | None = None, - input_handler: Callable[[str, str, int, float], JobResult] | None = None, - terminate_handler: Callable[[str, float], JobStatusView] | None = None, - delete_handler: Callable[[str, bool, float | None], object] | None = None, - ) -> None: - self._run_handler = run_handler - self._wait_handler = wait_handler - self._input_handler = input_handler - self._terminate_handler = terminate_handler - self._delete_handler = delete_handler - self.run_calls = [] - self.wait_calls = [] - self.input_calls = [] - self.terminate_calls = [] - self.delete_calls = [] - self.events = [] - self.closed = False + async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: + raise AssertionError("resource.files should not be used by production shell layer logic") - async def run( - self, - script: str, - *, - cwd: str | None = None, - env: Mapping[str, str] | None = None, - timeout: float = 10.0, - ) -> JobResult: - self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout, env=env)) - self.events.append(("run", script)) - if self._run_handler is None: + +@dataclass(slots=True) +class FakeCommands: + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], ShellCommandResult] | None = None + wait_handler: Callable[[str, int, float], ShellCommandResult] | None = None + input_handler: Callable[[str, str, int, float], ShellCommandResult] | None = None + tail_handler: Callable[[str], ShellCommandResult] | None = None + interrupt_handler: Callable[[str, float], ShellCommandStatus] | None = None + delete_handler: Callable[[str, bool, float | None], None] | None = None + run_calls: list[RunCall] = field(default_factory=list) + wait_calls: list[WaitCall] = field(default_factory=list) + input_calls: list[InputCall] = field(default_factory=list) + tail_calls: list[TailCall] = field(default_factory=list) + interrupt_calls: list[InterruptCall] = field(default_factory=list) + delete_calls: list[DeleteCall] = field(default_factory=list) + + async def run(self, script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, timeout: float): + self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) + if self.run_handler is None: raise AssertionError("Unexpected run() call") - return self._run_handler(script, cwd, env, timeout) + return self.run_handler(script, cwd, env, timeout) - async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: + async def wait(self, job_id: str, *, offset: int, timeout: float): self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=timeout)) - self.events.append(("wait", job_id)) - if self._wait_handler is None: + if self.wait_handler is None: raise AssertionError("Unexpected wait() call") - return self._wait_handler(job_id, offset, timeout) + return self.wait_handler(job_id, offset, timeout) - async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + async def read_output(self, job_id: str, *, offset: int): + self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=0.0)) + if self.wait_handler is None: + raise AssertionError("Unexpected read_output() call") + return self.wait_handler(job_id, offset, 0.0) + + async def input(self, job_id: str, text: str, *, offset: int, timeout: float): self.input_calls.append(InputCall(job_id=job_id, text=text, offset=offset, timeout=timeout)) - self.events.append(("input", job_id)) - if self._input_handler is None: + if self.input_handler is None: raise AssertionError("Unexpected input() call") - return self._input_handler(job_id, text, offset, timeout) + return self.input_handler(job_id, text, offset, timeout) - async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView: - self.terminate_calls.append(TerminateCall(job_id=job_id, grace_seconds=grace_seconds)) - self.events.append(("terminate", job_id)) - if self._terminate_handler is None: - raise AssertionError("Unexpected terminate() call") - return self._terminate_handler(job_id, grace_seconds) + async def interrupt(self, job_id: str, *, grace_seconds: float): + self.interrupt_calls.append(InterruptCall(job_id=job_id, grace_seconds=grace_seconds)) + if self.interrupt_handler is None: + raise AssertionError("Unexpected interrupt() call") + return self.interrupt_handler(job_id, grace_seconds) - async def delete( - self, - job_id: str, - *, - force: bool = False, - ) -> object: - self.delete_calls.append(DeleteCall(job_id=job_id, force=force)) - self.events.append(("delete", job_id)) - if self._delete_handler is None: - return None - return self._delete_handler(job_id, force, None) + async def tail(self, job_id: str): + self.tail_calls.append(TailCall(job_id=job_id)) + if self.tail_handler is None: + raise AssertionError("Unexpected tail() call") + return self.tail_handler(job_id) + + async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None: + self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds)) + if self.delete_handler is not None: + self.delete_handler(job_id, force, grace_seconds) + + +@dataclass(slots=True) +class FakeResource(ShellResourceProtocol): + commands: FakeCommands + files: FakeFiles = field(default_factory=FakeFiles) + closed: bool = False async def close(self) -> None: self.closed = True - self.events.append(("close", "client")) -def _shell_layer(*, client: FakeShellctlClient, config: DifyShellLayerConfig | None = None) -> DifyShellLayer: - return DifyShellLayer.from_config_with_settings( - config or DifyShellLayerConfig(), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - ) +@dataclass(slots=True) +class FakeProvider(ShellProviderProtocol): + resource: FakeResource + create_calls: int = 0 + + async def create(self) -> ShellResourceProtocol: + self.create_calls += 1 + return self.resource -def _execution_context_layer() -> DifyExecutionContextLayer: - return DifyExecutionContextLayer.from_config_with_settings( - DifyExecutionContextLayerConfig( - tenant_id="tenant-1", - user_id="user-1", - user_from="account", - app_id="app-1", - workflow_id="workflow-1", - workflow_run_id="workflow-run-1", - node_id="node-1", - node_execution_id="node-execution-1", - conversation_id="conversation-1", - agent_id="agent-1", - agent_config_version_id="agent-config-version-1", - agent_mode="workflow_run", - invoke_from="service-api", - trace_id="trace-1", - ), - daemon_url="http://plugin-daemon", - daemon_api_key="daemon-secret", - ) - - -def _shell_provider(*, client: FakeShellctlClient) -> LayerProvider[DifyShellLayer]: - return LayerProvider.from_factory( - layer_type=DifyShellLayer, - create=lambda config: DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig.model_validate(config), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - ), - ) +def _layer( + *, commands: FakeCommands, config: DifyShellLayerConfig | None = None +) -> tuple[DifyShellLayer, FakeProvider]: + provider = FakeProvider(resource=FakeResource(commands=commands)) + layer = DifyShellLayer.from_config_with_settings(config or DifyShellLayerConfig(), shell_provider=provider) + return layer, provider def test_shell_type_id_constant_matches_implementation_class() -> None: assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id -def test_environment_descriptor_returns_workspace_seed_from_runtime_state() -> None: - layer = _shell_layer(client=FakeShellctlClient()) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") +def test_resource_context_calls_provider_create_and_resource_close() -> None: + layer, provider = _layer(commands=FakeCommands()) - descriptor = layer.environment_descriptor() + async def scenario() -> None: + async with layer.resource_context(): + assert provider.create_calls == 1 + assert provider.resource.closed is False + assert provider.resource.closed is True - assert descriptor == ShellctlEnvironmentDescriptor(workspace_cwd="~/workspace/abc12ff", session_id="abc12ff") + asyncio.run(scenario()) -def test_environment_descriptor_raises_without_session_identity() -> None: - layer = _shell_layer(client=FakeShellctlClient()) +def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16)) + monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff") - with pytest.raises(ValueError, match="session_id or workspace_cwd"): - _ = layer.environment_descriptor() - - -def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "deadbeefdeadbeef") - - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: assert env is None if cwd is None: - assert 'mkdir -p "$HOME/workspace/deadbeefdeadbeef"' in script - return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) - raise AssertionError(f"Unexpected script with cwd={cwd}: {script}") + assert 'mkdir -p "$HOME/workspace"' in script + return _command_result("mkdir-job", status="exited", done=True, exit_code=0) + assert cwd == "~/workspace/abc12ff" + assert "apt-get install -y ripgrep" in script + return _command_result("bootstrap-job", status="exited", done=True, exit_code=0) - client = FakeShellctlClient(run_handler=run_handler) - layer = _shell_layer(client=client) + layer, provider = _layer( + commands=FakeCommands(run_handler=run_handler), + config=DifyShellLayerConfig( + cli_tools=[{"name": "ripgrep", "install_commands": ["apt-get install -y ripgrep"]}], + ), + ) async def scenario() -> None: async with layer.resource_context(): await layer.on_context_create() - assert client.closed is False + assert provider.resource.closed is False asyncio.run(scenario()) - assert layer.runtime_state.session_id == "deadbeefdeadbeef" - assert layer.runtime_state.workspace_cwd == "~/workspace/deadbeefdeadbeef" + assert layer.runtime_state == DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + assert [call.job_id for call in provider.resource.commands.delete_calls] == ["mkdir-job", "bootstrap-job"] -def test_shell_layer_suspend_closes_client_before_resource_context_exits() -> None: - client = FakeShellctlClient() - layer = _shell_layer(client=client) +def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> None: + layer, provider = _layer(commands=FakeCommands()) layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") async def scenario() -> None: async with layer.resource_context(): - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) await layer.on_context_suspend() - assert client.closed is True + assert provider.resource.closed is False + assert provider.resource.closed is True asyncio.run(scenario()) -def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None: - first_client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env, _timeout: _job_result( - "mkdir-job", - status=JobStatusName.EXITED, - done=True, - exit_code=0, - ) +def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del env, timeout + assert cwd is None + assert script == 'rm -rf -- "$HOME/workspace/abc12ff"' + return _command_result("cleanup-job", status="exited", done=True, exit_code=0) + + commands = FakeCommands(run_handler=run_handler) + layer, provider = _layer(commands=commands) + layer.runtime_state = DifyShellRuntimeState( + session_id="abc12ff", + workspace_cwd="~/workspace/abc12ff", + job_ids=["user-job"], + job_offsets={"user-job": 9}, ) - second_client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env, _timeout: _job_result( - "cleanup-job", - status=JobStatusName.EXITED, - done=True, - exit_code=0, - ) - ) - clients = iter([first_client, second_client]) - - def factory() -> FakeShellctlClient: - return next(clients) - - provisioner = ShellctlProvisioner(client_factory=factory) - - def make_provider(c: FakeShellctlClient) -> LayerProvider[DifyShellLayer]: - return LayerProvider.from_factory( - layer_type=DifyShellLayer, - create=lambda config: DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig.model_validate(config), - shell_provisioner=provisioner, - ), - ) - - compositor = Compositor([LayerNode("shell", make_provider(first_client))]) - - async def scenario() -> None: - async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run: - shell_layer = run.get_layer("shell", DifyShellLayer) - initial_session_id = shell_layer.runtime_state.session_id - assert initial_session_id is not None - assert shell_layer.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}" - shell_layer.runtime_state.job_ids = [*shell_layer.runtime_state.job_ids, "user-job"] - shell_layer.runtime_state.job_offsets = { - **shell_layer.runtime_state.job_offsets, - "user-job": 42, - } - assert first_client.closed is False - run.suspend_layer_on_exit("shell") - - assert run.session_snapshot is not None - assert first_client.closed is True - assert run.session_snapshot.layers[0].lifecycle_state is LifecycleState.SUSPENDED - - async with compositor.enter( - configs={"shell": DifyShellLayerConfig()}, - session_snapshot=run.session_snapshot, - ) as resumed_run: - resumed_shell = resumed_run.get_layer("shell", DifyShellLayer) - assert second_client.closed is False - assert resumed_shell.runtime_state.session_id == initial_session_id - assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}" - assert set(resumed_shell.runtime_state.job_ids) == {"user-job"} - assert resumed_shell.runtime_state.job_offsets == {"user-job": 42} - resumed_run.suspend_layer_on_exit("shell") - - assert second_client.closed is True - - asyncio.run(scenario()) - - -def test_shell_layer_delete_force_deletes_tracked_jobs_then_destroys_workspace() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - del cwd, env, timeout - return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - layer = _shell_layer(client=client) async def scenario() -> None: async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer.runtime_state.job_ids = ["user-job", "mkdir-job"] - layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1} - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) await layer.on_context_delete() asyncio.run(scenario()) - deleted_job_ids = {call.job_id for call in client.delete_calls} - assert {"user-job", "mkdir-job"}.issubset(deleted_job_ids) - assert all(call.force is True for call in client.delete_calls) + assert [call.job_id for call in commands.delete_calls] == ["cleanup-job", "user-job"] assert layer.runtime_state.job_ids == [] assert layer.runtime_state.job_offsets == {} - assert client.closed is True + assert provider.resource.closed is True -def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None: - client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env, _timeout: _job_result( - "mkdir-failed", - status=JobStatusName.EXITED, - done=True, - exit_code=1, - ) - ) - layer = _shell_layer(client=client) - - async def scenario() -> None: - with pytest.raises(ShellProvisionError, match="Failed to create shell workspace"): - async with layer.resource_context(): - await layer.on_context_create() - - asyncio.run(scenario()) - - assert client.closed is True - - -def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff") - - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert env is None - if cwd is None: - return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) - assert cwd == "~/workspace/abc12ffabc12ff" - assert "export PROJECT_NAME='demo project'" in script - assert "export QUOTED='it'\\''s ok'" in script - assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script - assert "export RG_CONFIG_PATH='.ripgreprc'" in script - assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script - assert "export DIFY_SANDBOX_PROVIDER='independent'" in script - assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script - assert "apt-get install -y ripgrep" in script - return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - layer = _shell_layer( - client=client, - config=DifyShellLayerConfig( - cli_tools=[ - DifyShellCliToolConfig( - name="ripgrep", - install_commands=["apt-get install -y ripgrep"], - env=[DifyShellEnvVarConfig(name="RG_CONFIG_PATH", value=".ripgreprc")], - secret_refs=[DifyShellSecretRefConfig(name="GITHUB_TOKEN", ref="secret-2")], - ) - ], - env=[ - DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project"), - DifyShellEnvVarConfig(name="QUOTED", value="it's ok"), - ], - secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")], - sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), - ), - ) - - async def scenario() -> None: - async with layer.resource_context(): - await layer.on_context_create() - - asyncio.run(scenario()) - - assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"] - - -def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff") - - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - del timeout - assert env is None - if cwd is None: - return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) - - assert cwd == "~/workspace/abc12ffabc12ff" - assert "export PROJECT_NAME='demo project'" in script - assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script - assert "export DIFY_SANDBOX_PROVIDER='independent'" in script - assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script - assert script.endswith("\npwd") - return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - layer = _shell_layer( - client=client, - config=DifyShellLayerConfig( - env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")], - secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")], - sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), - ), - ) - tools = {tool.name: tool for tool in layer.tools} - - async def scenario() -> None: - async with layer.resource_context(): - await layer.on_context_create() - run_result = cast( - Mapping[str, object], - await tools["shell_run"].function_schema.call( - {"script": "pwd"}, - None, # pyright: ignore[reportArgumentType] - ), - ) - assert run_result["job_id"] == "user-job" - - asyncio.run(scenario()) - - assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"] - - -def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: +def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: assert script == "pwd" assert cwd == "~/workspace/abc12ff" assert env is None - assert timeout == 2.5 - return _job_result( + return _command_result( "user-job", - status=JobStatusName.RUNNING, + status="running", done=False, + output="head-output\n", offset=10, - output="/home/test\n", + truncated=True, + output_path="/tmp/initial.log", ) - def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + def wait_handler(job_id: str, offset: int, timeout: float) -> ShellCommandResult: assert job_id == "user-job" - assert offset == 10 - assert timeout == 4.0 - return _job_result( - "user-job", - status=JobStatusName.RUNNING, - done=False, - offset=18, - output="more\n", - ) + if timeout == 4.0: + assert offset == 22 + return _command_result("user-job", status="running", done=False, output="more\n", offset=30) + raise AssertionError(f"Unexpected wait/read_output: offset={offset} timeout={timeout}") - def input_handler(job_id: str, text: str, offset: int, timeout: float) -> JobResult: + def input_handler(job_id: str, text: str, offset: int, timeout: float) -> ShellCommandResult: + assert (job_id, text, offset, timeout) == ("user-job", "ls\n", 30, 5.0) + return _command_result("user-job", status="exited", done=True, exit_code=0, output="file.txt\n", offset=34) + + def tail_handler(job_id: str) -> ShellCommandResult: assert job_id == "user-job" - assert text == "ls\n" - assert offset == 18 - assert timeout == 5.0 - return _job_result( - "user-job", - status=JobStatusName.EXITED, + return _command_result( + job_id, + status="exited", done=True, exit_code=0, + output="tail-output\n", offset=22, - output="file.txt\n", + output_path="/tmp/resolved.log", ) - def terminate_handler(job_id: str, grace_seconds: float) -> JobStatusView: - assert job_id == "user-job" - assert grace_seconds == 1.5 - return _job_status( - "user-job", - status=JobStatusName.TERMINATED, - done=True, - exit_code=130, - offset=22, - ) + def interrupt_handler(job_id: str, grace_seconds: float) -> ShellCommandStatus: + assert (job_id, grace_seconds) == ("user-job", 1.5) + return _command_status("user-job", status="terminated", done=True, exit_code=130, offset=34) - client = FakeShellctlClient( + commands = FakeCommands( run_handler=run_handler, wait_handler=wait_handler, input_handler=input_handler, - terminate_handler=terminate_handler, + tail_handler=tail_handler, + interrupt_handler=interrupt_handler, ) - layer = _shell_layer(client=client) + layer, provider = _layer(commands=commands) tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") async def scenario() -> None: async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - - run_tool_def = await tools["shell_run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - wait_tool_def = await tools["shell_wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - input_tool_def = await tools["shell_input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - interrupt_tool_def = await tools["shell_interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - - run_result = await tools["shell_run"].function_schema.call( - {"script": "pwd", "timeout": 2.5}, - None, # pyright: ignore[reportArgumentType] - ) + run_result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] wait_result = await tools["shell_wait"].function_schema.call( {"job_id": "user-job", "timeout": 4.0}, None, # pyright: ignore[reportArgumentType] @@ -607,324 +373,296 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - None, # pyright: ignore[reportArgumentType] ) - assert run_tool_def is not None - assert wait_tool_def is not None - assert input_tool_def is not None - assert interrupt_tool_def is not None - assert "offset" not in run_tool_def.parameters_json_schema.get("properties", {}) - assert "offset" not in wait_tool_def.parameters_json_schema.get("properties", {}) - assert "offset" not in input_tool_def.parameters_json_schema.get("properties", {}) - assert "offset" not in interrupt_tool_def.parameters_json_schema.get("properties", {}) - assert set(tools) == {"shell_run", "shell_wait", "shell_input", "shell_interrupt"} - assert run_result["job_id"] == "user-job" - assert run_result["offset"] == 10 - assert wait_result["offset"] == 18 - assert input_result["offset"] == 22 - assert interrupt_result == { + run_metadata, run_output = _parse_tagged_observation(run_result) + wait_metadata, wait_output = _parse_tagged_observation(wait_result) + input_metadata, input_output = _parse_tagged_observation(input_result) + interrupt_metadata, interrupt_output = _parse_tagged_observation(interrupt_result) + + assert run_metadata == { + "job_id": "user-job", + "status": "running", + "done": False, + "exit_code": None, + "output_path": "/tmp/resolved.log", + } + assert "head-output" in run_output + assert "tail-output" in run_output + assert wait_metadata["job_id"] == "user-job" + assert wait_output == "more\n" + assert input_metadata["exit_code"] == 0 + assert input_output == "file.txt\n" + assert interrupt_metadata == { "job_id": "user-job", "status": "terminated", "done": True, "exit_code": 130, - "offset": 22, + "output_path": "/tmp/resolved.log", } - assert client.closed is False + assert interrupt_output == "Job was interrupted." asyncio.run(scenario()) - assert layer.runtime_state.job_ids == ["user-job"] - assert layer.runtime_state.job_offsets == {"user-job": 22} - assert client.closed is False + assert layer.runtime_state.job_offsets == {"user-job": 34} + assert commands.tail_calls == [TailCall(job_id="user-job"), TailCall(job_id="user-job")] + assert provider.resource.closed is True -def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - del cwd, timeout - if script == "pwd": - assert env is not None - return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) - assert env is None - return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) - - client = FakeShellctlClient(run_handler=run_handler) - layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - agent_stub_api_base_url="https://agent.example.com/agent-stub", - agent_stub_token_factory=lambda execution_context, *, session_id: ( - f"token-for:{execution_context.tenant_id}:{session_id}" - ), - ) - layer.deps = layer.deps_type(execution_context=_execution_context_layer()) - tools = {tool.name: tool for tool in layer.tools} - - async def scenario() -> None: - async with layer.resource_context(): - await layer.on_context_create() - run_result = await tools["shell_run"].function_schema.call( - {"script": "pwd"}, - None, # pyright: ignore[reportArgumentType] - ) - assert run_result["job_id"] == "user-job" - - asyncio.run(scenario()) - - user_run_call = next(call for call in client.run_calls if call.script == "pwd") - internal_run_calls = [call for call in client.run_calls if call.script != "pwd"] - - assert user_run_call.env == { - AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", - AGENT_STUB_AUTH_JWE_ENV_VAR: f"token-for:tenant-1:{layer.runtime_state.session_id}", - AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1", - } - assert internal_run_calls - assert all(call.env is None for call in internal_run_calls) - - -def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert '. ".dify/env.sh"' not in script - assert script == "printf 'hello world'" +def test_shell_run_keeps_original_offset_when_tail_lookup_fails_for_truncated_output() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + assert script == "pwd" assert cwd == "~/workspace/abc12ff" assert env is None - assert timeout == 7.5 - return _job_result( - "remote-job", - status=JobStatusName.RUNNING, - done=False, - output="hello ", - offset=6, - truncated=True, - ) - - def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: - assert job_id == "remote-job" - assert offset == 6 - assert timeout == 7.5 - return _job_result( - "remote-job", - status=JobStatusName.EXITED, - done=True, - exit_code=0, - output="world", - offset=11, - ) - - client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) - layer = _shell_layer(client=client) - - async def scenario() -> None: - async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - result = await layer.run_remote_script("printf 'hello world'", timeout=7.5) - assert result.output == "hello world" - assert result.exit_code == 0 - assert result.truncated is False - - asyncio.run(scenario()) - - assert [call.job_id for call in client.delete_calls] == ["remote-job"] - assert layer.runtime_state.job_ids == [] - assert layer.runtime_state.job_offsets == {} - - -def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert script == "exit 17" - assert cwd == "~/workspace/abc12ff" - assert env is None - assert timeout == 3.0 - return _job_result( - "remote-failed-job", - status=JobStatusName.EXITED, - done=True, - exit_code=17, - output="failed\n", - offset=7, - ) - - client = FakeShellctlClient(run_handler=run_handler) - layer = _shell_layer(client=client) - - async def scenario() -> None: - async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - result = await layer.run_remote_script("exit 17", timeout=3.0) - assert result.exit_code == 17 - assert result.output == "failed\n" - - asyncio.run(scenario()) - - assert [call.job_id for call in client.delete_calls] == ["remote-failed-job"] - assert layer.runtime_state.job_ids == [] - assert layer.runtime_state.job_offsets == {} - - -def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert script == "dify-agent file upload report.txt" - assert '. ".dify/env.sh"' not in script - del timeout - assert cwd == "~/workspace/abc12ff" - assert env == { - AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", - AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", - AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1", - } - return _job_result("remote-upload", status=JobStatusName.EXITED, done=True, exit_code=0, output="{}") - - client = FakeShellctlClient(run_handler=run_handler) - layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - agent_stub_api_base_url="https://agent.example.com/agent-stub", - agent_stub_token_factory=lambda execution_context, *, session_id: ( - f"token-for:{execution_context.tenant_id}:{session_id}" - ), - ) - layer.deps = layer.deps_type(execution_context=_execution_context_layer()) - - async def scenario() -> None: - async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - _ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) - - asyncio.run(scenario()) - - assert [call.job_id for call in client.delete_calls] == ["remote-upload"] - - -def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None: - client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env, _timeout: _job_result( - "unexpected-run", - status=JobStatusName.EXITED, - done=True, - exit_code=0, - ) - ) - layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - agent_stub_api_base_url="https://agent.example.com/agent-stub", - agent_stub_token_factory=lambda execution_context, *, session_id: ( - f"token-for:{execution_context.tenant_id}:{session_id}" - ), - ) - - async def scenario() -> None: - async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"): - await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) - - asyncio.run(scenario()) - - assert client.run_calls == [] - - -def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency() -> None: - client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _env, _timeout: _job_result( + return _command_result( "user-job", - status=JobStatusName.EXITED, + status="running", + done=False, + output="head-output\n", + offset=10, + truncated=True, + output_path="/tmp/current.log", + ) + + def tail_handler(job_id: str) -> ShellCommandResult: + raise RuntimeError(f"tail unavailable for {job_id}") + + commands = FakeCommands(run_handler=run_handler, tail_handler=tail_handler) + layer, provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + async def scenario() -> None: + async with layer.resource_context(): + run_result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + metadata, output = _parse_tagged_observation(run_result) + assert metadata == { + "job_id": "user-job", + "status": "running", + "done": False, + "exit_code": None, + "output_path": "/tmp/current.log", + } + assert "head-output" in output + assert set(metadata) == {"job_id", "status", "done", "exit_code", "output_path"} + + asyncio.run(scenario()) + + assert layer.runtime_state.job_offsets == {"user-job": 10} + assert commands.tail_calls == [TailCall(job_id="user-job")] + assert provider.resource.closed is True + + +def test_shell_run_formats_large_non_truncated_output_without_tail_lookup() -> None: + large_output = ("head-" + ("x" * 9000) + "-tail").replace("head-x", "head-y", 1) + commands = FakeCommands( + run_handler=lambda script, cwd, env, timeout: _command_result( + "user-job", + status="exited", done=True, exit_code=0, + output=large_output, + offset=len(large_output), + output_path="/tmp/large.log", ) ) - layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), - agent_stub_api_base_url="https://agent.example.com/agent-stub", - agent_stub_token_factory=lambda execution_context, *, session_id: ( - f"token-for:{execution_context.tenant_id}:{session_id}" + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_run"].function_schema.call({"script": "cat large.log"}, None) # pyright: ignore[reportArgumentType] + metadata, output = _parse_tagged_observation(result) + assert metadata["output_path"] == "/tmp/large.log" + assert output.startswith("head-y") + assert output.endswith("(check the /tmp/large.log for full output)") + assert "-tail" in output + + asyncio.run(scenario()) + assert commands.tail_calls == [] + + +def test_shell_interrupt_succeeds_when_tail_lookup_fails() -> None: + commands = FakeCommands( + interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, offset=22), + tail_handler=lambda job_id: (_ for _ in ()).throw(RuntimeError("tail unavailable")), + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState( + session_id="abc12ff", + workspace_cwd="~/workspace/abc12ff", + job_ids=["user-job"], + job_offsets={"user-job": 22}, + ) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_interrupt"].function_schema.call({"job_id": "user-job"}, None) # pyright: ignore[reportArgumentType] + metadata, output = _parse_tagged_observation(result) + assert metadata == { + "job_id": "user-job", + "status": "terminated", + "done": True, + "exit_code": 130, + } + assert output == "Job was interrupted." + + asyncio.run(scenario()) + + +def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job() -> None: + events: list[str] = [] + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + events.append("run") + assert script == "printf 'abcdefghi'" + assert cwd == "~/workspace/abc12ff" + assert env is None + return _command_result("remote-job", status="running", done=False, output="abc", offset=3, truncated=True) + + def wait_handler(job_id: str, offset: int, timeout: float) -> ShellCommandResult: + if timeout == 0.0: + events.append("read_output") + assert offset == 3 + return _command_result(job_id, status="running", done=False, output="def", offset=6) + events.append("wait") + assert offset == 6 + return _command_result(job_id, status="exited", done=True, exit_code=0, output="ghi", offset=9) + + commands = FakeCommands(run_handler=run_handler, wait_handler=wait_handler) + layer, _provider = _layer(commands=commands) + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + async def scenario() -> None: + async with layer.resource_context(): + result = await layer.run_remote_script_complete("printf 'abcdefghi'") + assert isinstance(result, CompleteRemoteCommandResult) + assert result.output == "abcdefghi" + assert result.output_complete is True + assert result.incomplete_reason is None + + asyncio.run(scenario()) + assert events == ["run", "read_output", "wait"] + assert [call.job_id for call in commands.delete_calls] == ["remote-job"] + + +def test_run_remote_script_complete_returns_incomplete_reason_for_output_limit() -> None: + commands = FakeCommands( + run_handler=lambda script, cwd, env, timeout: _command_result( + "remote-job", + status="running", + done=False, + output="hello world", + offset=11, + truncated=True, + ), + interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, status="terminated", offset=11), + ) + layer, _provider = _layer(commands=commands) + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + + async def scenario() -> None: + async with layer.resource_context(): + result = await layer.run_remote_script_complete("printf 'hello world'", max_output_bytes=5) + assert result.output == "hello" + assert result.output_complete is False + assert result.incomplete_reason == "output_limit" + assert result.status == "terminated" + + asyncio.run(scenario()) + assert commands.wait_calls == [] + assert commands.interrupt_calls == [ + InterruptCall(job_id="remote-job", grace_seconds=DEFAULT_TERMINATE_GRACE_SECONDS) + ] + + +def test_run_remote_script_complete_returns_incomplete_reason_for_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = 100.0 + + def fake_monotonic() -> float: + return now + + monkeypatch.setattr(shell_layer_module.time, "monotonic", fake_monotonic) + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + nonlocal now + assert script == "sleep 10" + assert cwd == "~/workspace/abc12ff" + assert env is None + assert timeout == pytest.approx(60.0, rel=0, abs=0.01) + now = 161.0 + return _command_result("remote-job", status="running", done=False, output="hello", offset=5) + + commands = FakeCommands( + run_handler=run_handler, + interrupt_handler=lambda job_id, grace_seconds: _command_status( + job_id, + status="terminated", + done=True, + exit_code=130, + offset=5, ), ) - tools = {tool.name: tool for tool in layer.tools} + layer, _provider = _layer(commands=commands) + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") async def scenario() -> None: async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - _ = await tools["shell_run"].function_schema.call( - {"script": "pwd"}, - None, # pyright: ignore[reportArgumentType] - ) + result = await layer.run_remote_script_complete("sleep 10", timeout=60.0) + assert result.output == "hello" + assert result.output_complete is False + assert result.incomplete_reason == "timeout" + assert result.status == "terminated" + assert result.exit_code == 130 asyncio.run(scenario()) - - assert client.run_calls[0].env is None + assert commands.wait_calls == [] + assert commands.interrupt_calls == [ + InterruptCall(job_id="remote-job", grace_seconds=DEFAULT_TERMINATE_GRACE_SECONDS) + ] + assert [call.job_id for call in commands.delete_calls] == ["remote-job"] -def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None: - client = FakeShellctlClient() - layer = _shell_layer(client=client) +def test_shell_layer_rejects_untracked_job_ids_without_provider_calls() -> None: + commands = FakeCommands() + layer, _provider = _layer(commands=commands) tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") async def scenario() -> None: async with layer.resource_context(): - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - layer._shell_handle = ShellctlHandle( - client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff" - ) - - wait_result = await tools["shell_wait"].function_schema.call( - {"job_id": "missing-job"}, - None, # pyright: ignore[reportArgumentType] - ) + wait_result = await tools["shell_wait"].function_schema.call({"job_id": "missing"}, None) # pyright: ignore[reportArgumentType] input_result = await tools["shell_input"].function_schema.call( - {"job_id": "missing-job", "text": "hello"}, + {"job_id": "missing", "text": "hello"}, None, # pyright: ignore[reportArgumentType] ) - interrupt_result = await tools["shell_interrupt"].function_schema.call( - {"job_id": "missing-job"}, - None, # pyright: ignore[reportArgumentType] - ) - - _assert_error_observation(wait_result, job_id="missing-job") - _assert_error_observation(input_result, job_id="missing-job") - _assert_error_observation(interrupt_result, job_id="missing-job") + interrupt_result = await tools["shell_interrupt"].function_schema.call({"job_id": "missing"}, None) # pyright: ignore[reportArgumentType] + _assert_error_observation(wait_result, job_id="missing") + _assert_error_observation(input_result, job_id="missing") + _assert_error_observation(interrupt_result, job_id="missing") asyncio.run(scenario()) - - assert client.wait_calls == [] - assert client.input_calls == [] - assert client.terminate_calls == [] + assert commands.wait_calls == [] + assert commands.input_calls == [] + assert commands.interrupt_calls == [] def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None: - client = FakeShellctlClient() - layer = _shell_layer(client=client) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer, _provider = _layer(commands=FakeCommands()) tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") async def scenario() -> None: - run_result = await tools["shell_run"].function_schema.call( - {"script": "pwd"}, - None, # pyright: ignore[reportArgumentType] - ) - _assert_error_observation(run_result, includes="shell handle") + result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + _assert_error_observation(result, includes="shell resource") asyncio.run(scenario()) - assert client.run_calls == [] - -def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None: - with pytest.raises(ValueError, match="session_id must be 7 or 16 lowercase hex characters"): +def test_shell_runtime_state_validates_workspace_identity_and_offset_keys() -> None: + with pytest.raises(ValueError, match="5\\+2 lowercase hex format"): _ = DifyShellRuntimeState.model_validate( { "session_id": "../../tmp", @@ -944,8 +682,6 @@ def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None } ) - -def test_shell_runtime_state_treats_job_ids_as_opaque_strings_and_rejects_unknown_offset_keys() -> None: state = DifyShellRuntimeState.model_validate( { "session_id": "abc12ff", @@ -954,10 +690,7 @@ def test_shell_runtime_state_treats_job_ids_as_opaque_strings_and_rejects_unknow "job_offsets": {'job"bad with spaces': 0}, } ) - assert state.job_ids == ['job"bad with spaces'] - assert state.job_offsets == {'job"bad with spaces': 0} - with pytest.raises(ValueError, match="unknown job ids"): _ = DifyShellRuntimeState.model_validate( { diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_output_text.py b/dify-agent/tests/local/dify_agent/layers/shell/test_output_text.py new file mode 100644 index 00000000000..15e195d6fcd --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_output_text.py @@ -0,0 +1,77 @@ +from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix + + +def test_normalized_output_text_includes_marker_and_log_guidance() -> None: + assert normalized_output_text( + "head", + tail="tail", + output_path="/tmp/output.log", + max_output_size_bytes=16, + ) == ( + "head\n" + "... (truncated in middle because the max output size is limited to 16 bytes) ...\n" + "tail\n" + "(check the /tmp/output.log for full output)" + ) + + +def test_normalized_output_text_avoids_duplicate_tail_for_short_output() -> None: + assert ( + normalized_output_text( + "short output", + tail="short output", + output_path="/tmp/output.log", + max_output_size_bytes=16, + ) + == "short output" + ) + + +def test_normalized_output_text_can_force_middle_truncation_when_head_matches_tail() -> None: + assert normalized_output_text( + "repeat", + tail="repeat", + output_path="/tmp/output.log", + max_output_size_bytes=16, + truncated_in_middle=True, + ) == ( + "repeat\n" + "... (truncated in middle because the max output size is limited to 16 bytes) ...\n" + "repeat\n" + "(check the /tmp/output.log for full output)" + ) + + +def test_utf8_helpers_do_not_split_multibyte_characters() -> None: + text = "A🙂B" + + assert utf8_prefix(text, 1) == "A" + assert utf8_prefix(text, 2) == "A" + assert utf8_prefix(text, 5) == "A🙂" + assert utf8_suffix(text, 1) == "B" + assert utf8_suffix(text, 2) == "B" + assert utf8_suffix(text, 5) == "🙂B" + + +def test_normalized_output_text_omits_log_guidance_without_output_path() -> None: + assert normalized_output_text( + "head", + tail="tail", + output_path=None, + max_output_size_bytes=16, + ) == ("head\n... (truncated in middle because the max output size is limited to 16 bytes) ...\ntail") + + +def test_normalized_output_text_can_use_custom_truncation_message() -> None: + assert normalized_output_text( + "head", + tail="tail", + output_path="/tmp/output.log", + max_output_size_bytes=16, + truncation_message="command timed out before full output was captured", + ) == ( + "head\n" + "... (command timed out before full output was captured) ...\n" + "tail\n" + "(check the /tmp/output.log for full output)" + ) diff --git a/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py index a717c6b899b..6de05187708 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py @@ -5,6 +5,7 @@ from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.dify_core_tools import DIFY_CORE_TOOLS_LAYER_TYPE_ID from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig @@ -167,3 +168,11 @@ def test_build_sandbox_locator_from_layer_specs_rejects_sensitive_runtime_specs( layer_specs=[RuntimeLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID)], session_snapshot=CompositorSessionSnapshot(layers=[]), ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_sensitive_core_tool_runtime_specs() -> None: + with pytest.raises(ValueError, match="sensitive"): + build_sandbox_locator_from_layer_specs( + layer_specs=[RuntimeLayerSpec(name="core_tools", type=DIFY_CORE_TOOLS_LAYER_TYPE_ID)], + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) 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 2482c635d74..30ad414e45a 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 @@ -1,41 +1,119 @@ +import sys +import types from typing import cast import pytest +from pydantic import BaseModel + + +if "pydantic_settings" not in sys.modules: + pydantic_settings = types.ModuleType("pydantic_settings") + pydantic_settings.BaseSettings = BaseModel + pydantic_settings.SettingsConfigDict = dict + sys.modules["pydantic_settings"] = pydantic_settings + +if "graphon.model_runtime.entities.llm_entities" not in sys.modules: + graphon_module = types.ModuleType("graphon") + model_runtime_module = types.ModuleType("graphon.model_runtime") + entities_module = types.ModuleType("graphon.model_runtime.entities") + llm_entities_module = types.ModuleType("graphon.model_runtime.entities.llm_entities") + message_entities_module = types.ModuleType("graphon.model_runtime.entities.message_entities") + + llm_entities_module.LLMResultChunk = type("LLMResultChunk", (), {}) + llm_entities_module.LLMUsage = type("LLMUsage", (), {}) + + for name in ( + "AssistantPromptMessage", + "AudioPromptMessageContent", + "DocumentPromptMessageContent", + "ImagePromptMessageContent", + "PromptMessage", + "PromptMessageContentUnionTypes", + "PromptMessageTool", + "SystemPromptMessage", + "TextPromptMessageContent", + "ToolPromptMessage", + "UserPromptMessage", + "VideoPromptMessageContent", + ): + setattr(message_entities_module, name, type(name, (), {})) + + sys.modules["graphon"] = graphon_module + sys.modules["graphon.model_runtime"] = model_runtime_module + sys.modules["graphon.model_runtime.entities"] = entities_module + sys.modules["graphon.model_runtime.entities.llm_entities"] = llm_entities_module + sys.modules["graphon.model_runtime.entities.message_entities"] = message_entities_module + + graphon_module.model_runtime = model_runtime_module + model_runtime_module.entities = entities_module + entities_module.llm_entities = llm_entities_module + entities_module.message_entities = message_entities_module + +if "jsonschema" not in sys.modules: + jsonschema_module = types.ModuleType("jsonschema") + jsonschema_exceptions_module = types.ModuleType("jsonschema.exceptions") + jsonschema_protocols_module = types.ModuleType("jsonschema.protocols") + jsonschema_validators_module = types.ModuleType("jsonschema.validators") + + class _SchemaError(Exception): + pass + + class _ValidationError(Exception): + path: tuple[object, ...] = () + + class _Validator: + @staticmethod + def check_schema(schema): + return None + + def __init__(self, schema): + self.schema = schema + + def iter_errors(self, value): + return iter(()) + + def _validator_for(schema): + return _Validator + + jsonschema_module.SchemaError = _SchemaError + jsonschema_exceptions_module.ValidationError = _ValidationError + jsonschema_protocols_module.Validator = _Validator + jsonschema_validators_module.validator_for = _validator_for + + sys.modules["jsonschema"] = jsonschema_module + sys.modules["jsonschema.exceptions"] = jsonschema_exceptions_module + sys.modules["jsonschema.protocols"] = jsonschema_protocols_module + sys.modules["jsonschema.validators"] = jsonschema_validators_module import dify_agent.runtime.compositor_factory as compositor_factory_module from dify_agent.adapters.shell.config import ShellAdapterSettings -from dify_agent.adapters.shell.protocols import ShellProvisionProtocol -from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec +from dify_agent.adapters.shell.protocols import ShellProviderProtocol +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.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import create_default_layer_providers -class FakeProvisioner: - """No-op provisioner for tests that never actually provision a workspace.""" +class FakeProvider: + """No-op provider for tests that never actually open a shell resource.""" - async def provision(self) -> object: - raise AssertionError("provision should not be called by these tests") - - async def reattach(self, descriptor: object) -> object: - raise AssertionError("reattach should not be called by these tests") - - async def destroy(self, handle: object) -> None: - raise AssertionError("destroy should not be called by these tests") + async def create(self) -> object: + raise AssertionError("create should not be called by these tests") def test_default_layer_providers_register_shell_layer_with_configured_token_factory( monkeypatch: pytest.MonkeyPatch, ) -> None: captured_settings: list[ShellAdapterSettings] = [] - fake_provisioner = FakeProvisioner() + fake_provider = FakeProvider() - def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol: + def fake_create_shell_provider(settings: ShellAdapterSettings) -> ShellProviderProtocol: captured_settings.append(settings) - return cast(ShellProvisionProtocol, fake_provisioner) + return cast(ShellProviderProtocol, fake_provider) - monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner) + monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider) providers = create_default_layer_providers( shellctl_entrypoint="http://shellctl.example", @@ -45,7 +123,7 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) assert isinstance(shell_layer, DifyShellLayer) - assert shell_layer.shell_provisioner is fake_provisioner + assert shell_layer.shell_provider is fake_provider assert len(captured_settings) == 1 assert captured_settings[0].shellctl_entrypoint == "http://shellctl.example" assert captured_settings[0].shellctl_auth_token == "shell-secret" @@ -56,11 +134,11 @@ def test_default_layer_providers_keep_empty_shellctl_token_by_default( ) -> None: captured_settings: list[ShellAdapterSettings] = [] - def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol: + def fake_create_shell_provider(settings: ShellAdapterSettings) -> ShellProviderProtocol: captured_settings.append(settings) - return cast(ShellProvisionProtocol, FakeProvisioner()) + return cast(ShellProviderProtocol, FakeProvider()) - monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner) + monkeypatch.setattr(compositor_factory_module, "create_shell_provider", fake_create_shell_provider) providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example") shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) @@ -70,15 +148,17 @@ def test_default_layer_providers_keep_empty_shellctl_token_by_default( assert captured_settings[0].shellctl_auth_token is None -def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None: - providers = create_default_layer_providers(shellctl_entrypoint=" ") - shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) - +def test_shell_provider_rejects_blank_settings_entrypoint_when_default_providers_are_built() -> None: with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"): - _ = shell_provider.create_layer(DifyShellLayerConfig()) + _ = create_default_layer_providers(shellctl_entrypoint=" ") def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_codec() -> None: + AgentStubTokenCodec = pytest.importorskip( + "dify_agent.agent_stub.server.tokens.agent_stub", + reason="jwcrypto is not available in this local test environment", + ).AgentStubTokenCodec + codec = AgentStubTokenCodec.from_server_secret("MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE") providers = create_default_layer_providers( @@ -102,3 +182,16 @@ def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_ assert isinstance(token, str) assert token + + +def test_default_layer_providers_register_core_tools_layer() -> None: + providers = create_default_layer_providers(inner_api_url="http://dify-api", inner_api_key="inner-secret") + + core_provider = next(provider for provider in providers if provider.type_id == DIFY_CORE_TOOLS_LAYER_TYPE_ID) + layer = core_provider.create_layer(DifyCoreToolsLayerConfig()) + + assert isinstance(layer, DifyCoreToolsLayer) + assert layer.type_id == DIFY_CORE_TOOLS_LAYER_TYPE_ID + assert layer.inner_api_url == "http://dify-api" + assert layer.inner_api_key == "inner-secret" + assert layer.config == DifyCoreToolsLayerConfig() diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index 6730624fdca..af95aaf535c 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -29,7 +29,7 @@ from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig -from dify_agent.adapters.shell.shellctl import ShellctlProvisioner +from dify_agent.adapters.shell.shellctl import ShellctlProvider from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.layers.dify_plugin.configs import ( DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, @@ -66,10 +66,12 @@ class StaticToolsTestLayer(ToolsLayer): class FakeRunnerShellctlClient: run_calls: list[tuple[str, str | None, Mapping[str, str] | None, float]] + delete_calls: list[tuple[str, bool, float | None]] closed: bool def __init__(self) -> None: self.run_calls = [] + self.delete_calls = [] self.closed = False async def run( @@ -114,8 +116,8 @@ class FakeRunnerShellctlClient: force: bool = False, grace_seconds: float | None = None, ) -> DeleteJobResponse: - del job_id, force, grace_seconds - raise AssertionError("delete() should not be called in this test") + self.delete_calls.append((job_id, force, grace_seconds)) + return DeleteJobResponse(job_id=job_id) def _request( @@ -1347,7 +1349,11 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( layer_type=DifyShellLayer, create=lambda config: DifyShellLayer.from_config_with_settings( DifyShellLayerConfig.model_validate(config), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: shell_client), + shell_provider=ShellctlProvider( + entrypoint="http://shellctl", + token="", + client_factory=lambda: shell_client, + ), ), ) layer_providers = tuple( @@ -1426,6 +1432,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( asyncio.run(scenario()) assert create_agent_called is False + assert shell_client.delete_calls == [("mkdir-job", True, None)] assert shell_client.closed is True assert [event.type for event in sink.events["run-shell-duplicate-tools"]] == ["run_started", "run_failed"] assert sink.statuses["run-shell-duplicate-tools"] == "failed" @@ -2342,7 +2349,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None: async def scenario() -> None: async with httpx.AsyncClient() as client: - with pytest.raises(AgentRunValidationError, match="non-null shell provisioner"): + with pytest.raises(AgentRunValidationError, match="non-null shell provider"): await AgentRunRunner( sink=sink, request=request, 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 ea83ec30080..c02cdf1cafd 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -8,7 +8,7 @@ import httpx import pytest from fastapi.testclient import TestClient -from dify_agent.adapters.shell.shellctl import ShellctlProvisioner +from dify_agent.adapters.shell.shellctl import ShellctlProvider import dify_agent.server.app as app_module from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig @@ -246,7 +246,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer) assert knowledge_layer.inner_api_url == "http://dify-api" assert knowledge_layer.inner_api_key == "inner-secret" - assert isinstance(shell_layer.shell_provisioner, ShellctlProvisioner) + assert isinstance(shell_layer.shell_provider, ShellctlProvider) assert shell_layer.agent_stub_api_base_url == "https://agent.example.com/agent-stub" http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py index 72d70940a83..eae7f945bfb 100644 --- a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -5,41 +5,52 @@ import base64 import json from collections.abc import Callable, Mapping from dataclasses import dataclass +from typing import Literal import pytest from agenton.compositor import CompositorSessionSnapshot, LayerProvider from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState +from dify_agent.adapters.shell.shellctl import ShellctlProvider from dify_agent.agent_stub.server.shell_agent_stub_env import ( + AGENT_STUB_API_BASE_URL_ENV_VAR, AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_DRIVE_BASE_ENV_VAR, - AGENT_STUB_API_BASE_URL_ENV_VAR, ) from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell import DifyShellLayerConfig -from dify_agent.layers.shell.layer import DifyShellLayer -from dify_agent.adapters.shell.shellctl import ShellctlProvisioner +from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer from dify_agent.protocol import ( CreateRunRequest, RunComposition, RunLayerSpec, - SandboxLocator, SandboxListRequest, + SandboxLocator, SandboxReadRequest, SandboxUploadRequest, build_sandbox_locator_from_run_request, ) -from dify_agent.server.routes.sandbox_files import create_sandbox_files_router from dify_agent.server.sandbox_files import ( SandboxFileError, SandboxFileService, _OUTPUT_BEGIN, _OUTPUT_END, + _decode_sandbox_payload, + _shell_result_details, ) -from fastapi import FastAPI -from fastapi.testclient import TestClient -from shell_session_manager.shellctl.shared import JobResult, JobStatusName + + +@dataclass(slots=True) +class _Job: + job_id: str + status: str = "exited" + done: bool = True + exit_code: int | None = 0 + output: str = "" + offset: int = 0 + truncated: bool = False + output_path: str | None = "/tmp/sandbox-job.out" @dataclass(slots=True) @@ -51,42 +62,31 @@ class RunCall: class FakeShellctlClient: - def __init__( - self, - *, - run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], - ) -> None: + def __init__(self, *, run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job]) -> None: self.run_handler = run_handler self.run_calls: list[RunCall] = [] self.delete_calls: list[str] = [] async def run( - self, - script: str, - *, - cwd: str | None = None, - env: Mapping[str, str] | None = None, - timeout: float = 10.0, - ) -> JobResult: + self, script: str, *, cwd: str | None = None, env: Mapping[str, str] | None = None, timeout: float = 10.0 + ): self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) return self.run_handler(script, cwd, env, timeout) - async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: - return self.run_handler("", None, None, timeout) + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0): + raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}") - async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0): raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}") - async def terminate(self, job_id: str, grace_seconds: float = 2.0): + async def tail(self, job_id: str): + raise AssertionError(f"Unexpected tail() call for {job_id}") + + async def terminate(self, job_id: str, grace_seconds: float = 10.0): raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}") - async def delete( - self, - job_id: str, - *, - force: bool = False, - ) -> object: - del force + async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None): + del force, grace_seconds self.delete_calls.append(job_id) return None @@ -100,32 +100,27 @@ def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = False) blob = "\n".join(blob[index : index + pty_wrap] for index in range(0, len(blob), pty_wrap)) framed = f"{_OUTPUT_BEGIN}{blob}{_OUTPUT_END}\n" if noise: - framed = f"user@host:~/workspace/abc12ff$ python3 - ...\r\n{framed}user@host:~/workspace/abc12ff$ \r\n" + framed = f"user@host$ python3 - ...\r\n{framed}user@host$ \r\n" return framed -def _job_result(*, output: dict[str, object] | str, job_id: str = "sandbox-job") -> JobResult: - return JobResult( +def _complete_result( + *, + output: str, + exit_code: int | None = 0, + output_complete: bool = True, + incomplete_reason: Literal["output_limit", "timeout"] | None = None, + job_id: str = "sandbox-job", +) -> CompleteRemoteCommandResult: + return CompleteRemoteCommandResult( job_id=job_id, - status=JobStatusName.EXITED, - done=True, - exit_code=0, - output=_wrap(output) if isinstance(output, dict) else output, - offset=0, - truncated=False, - output_path="/tmp/sandbox-job.out", - ) - - -def _failed_job_result(*, output: str, exit_code: int, job_id: str = "sandbox-job") -> JobResult: - return JobResult( - job_id=job_id, - status=JobStatusName.EXITED, + status="exited", done=True, exit_code=exit_code, output=output, - offset=0, - truncated=False, + output_complete=output_complete, + incomplete_reason=incomplete_reason, + offset=len(output), output_path="/tmp/sandbox-job.out", ) @@ -153,16 +148,14 @@ def _locator() -> SandboxLocator: name="shell", type="dify.shell", deps={"execution_context": "execution_context"}, - config=DifyShellLayerConfig(), + config=DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), ), ] ), session_snapshot=CompositorSessionSnapshot( layers=[ LayerSessionSnapshot( - name="execution_context", - lifecycle_state=LifecycleState.SUSPENDED, - runtime_state={}, + name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} ), LayerSessionSnapshot( name="shell", @@ -176,7 +169,7 @@ def _locator() -> SandboxLocator: def _service( - run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job], ) -> tuple[SandboxFileService, FakeShellctlClient]: client = FakeShellctlClient(run_handler=run_handler) execution_context_provider = LayerProvider.from_factory( @@ -191,7 +184,11 @@ def _service( layer_type=DifyShellLayer, create=lambda config: DifyShellLayer.from_config_with_settings( DifyShellLayerConfig.model_validate(config), - shell_provisioner=ShellctlProvisioner(client_factory=lambda: client), + shell_provider=ShellctlProvider( + entrypoint="http://shellctl", + token="", + client_factory=lambda: client, + ), agent_stub_api_base_url="https://agent.example.com/agent-stub", agent_stub_token_factory=lambda execution_context, *, session_id: ( f"token-for:{execution_context.tenant_id}:{session_id}" @@ -203,12 +200,15 @@ def _service( def test_list_files_runs_fixed_script_and_parses_response() -> None: service, client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "path": ".", - "entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}], - "truncated": False, - } + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap( + { + "path": ".", + "entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}], + "truncated": False, + } + ), ) ) @@ -223,228 +223,72 @@ def test_list_files_runs_fixed_script_and_parses_response() -> None: @pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"]) def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: - service, client = _service( - lambda script, cwd, env, timeout: _job_result( - output={"path": ".", "entries": [], "truncated": False}, - ) - ) + service, client = _service(lambda script, cwd, env, timeout: _Job(job_id="sandbox-job", output="unused")) - with pytest.raises(SandboxFileError) as exc_info: + with pytest.raises(SandboxFileError, match="path"): asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=bad_path))) - assert exc_info.value.code == "invalid_sandbox_path" assert client.run_calls == [] -def test_decode_tolerates_pty_wrapped_base64_and_shell_noise() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result( +def test_decode_payload_reports_incomplete_capture_when_frame_is_missing() -> None: + with pytest.raises(SandboxFileError, match="incomplete before framed payload was captured"): + _decode_sandbox_payload( + _complete_result(output="partial", output_complete=False, incomplete_reason="output_limit") + ) + + +def test_decode_payload_reports_incomplete_capture_when_frame_is_corrupt() -> None: + broken = f"{_OUTPUT_BEGIN}%%%%{_OUTPUT_END}" + with pytest.raises(SandboxFileError, match="incomplete while decoding framed payload"): + _decode_sandbox_payload(_complete_result(output=broken, output_complete=False, incomplete_reason="timeout")) + + +def test_upload_injects_agent_stub_env_and_returns_mapping() -> None: + service, client = _service( + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", output=_wrap( { - "path": "note.txt", - "size": 40, - "truncated": False, - "binary": False, - "text": "hello from sandbox\n" * 4, + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "file-ref"}, }, - pty_wrap=12, noise=True, - ) + ), ) ) - result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) - - assert result.text == "hello from sandbox\n" * 4 - - -def test_read_file_maps_script_error_codes() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result( - output={"error": "sandbox_path_not_found", "message": "path not found in sandbox"} - ) - ) - - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="missing.txt"))) - - assert exc_info.value.code == "sandbox_path_not_found" - assert exc_info.value.status_code == 404 - - -@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) -def test_read_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: - service, client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "path": "should-not-run", - "size": 1, - "truncated": False, - "binary": False, - "text": "x", - } - ) - ) - - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path=bad_path))) - - assert exc_info.value.code == "invalid_sandbox_path" - assert client.run_calls == [] - - -def test_read_file_returns_binary_payload_without_text() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "path": "blob.bin", - "size": 64, - "truncated": False, - "binary": True, - "text": None, - } - ) - ) - - result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="blob.bin"))) - - assert result.binary is True - assert result.text is None - assert result.truncated is False - - -def test_read_file_preserves_truncated_flag_for_large_text() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "path": "large.txt", - "size": 1024, - "truncated": True, - "binary": False, - "text": "partial preview", - } - ) - ) - - result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="large.txt"))) - - assert result.binary is False - assert result.text == "partial preview" - assert result.truncated is True - - -def test_upload_file_injects_agent_stub_env_and_returns_mapping() -> None: - def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert cwd == "~/workspace/abc12ff" - assert timeout == 30.0 - assert env == { - AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", - AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", - AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive", - } - assert 'dify-agent", "file", "upload"' in script - return _job_result( - output={ - "path": "report.txt", - "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, - }, - job_id="upload-job", - ) - - service, client = _service(run_handler) - result = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) - assert result.file.reference == "dify-file-ref:file-1" - assert client.delete_calls == ["upload-job"] + assert result.file.transfer_method == "tool_file" + assert result.file.reference == "file-ref" + assert client.run_calls[0].cwd == "~/workspace/abc12ff" + assert client.run_calls[0].env == { + AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", + AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1", + } -def test_upload_file_maps_agent_stub_upload_failed_payload() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "error": "agent_stub_upload_failed", - "message": "upload returned invalid JSON", - }, - job_id="upload-failed-job", - ) +def test_shell_result_details_include_output_metadata_and_tail() -> None: + details = _shell_result_details( + _complete_result(output="hello", output_complete=False, incomplete_reason="output_limit") ) - - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) - - assert exc_info.value.code == "agent_stub_upload_failed" - assert exc_info.value.status_code == 502 + assert "output_complete=False" in details + assert "incomplete_reason=output_limit" in details + assert "output_path=/tmp/sandbox-job.out" in details + assert details.endswith("hello") -def test_read_file_maps_non_zero_command_exit_to_sandbox_command_failed() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _failed_job_result( - output="python traceback or stderr tail", - exit_code=17, - ) - ) - - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) - - assert exc_info.value.code == "sandbox_command_failed" - assert exc_info.value.status_code == 502 - - -def test_upload_file_maps_missing_framed_payload_to_sandbox_command_failed() -> None: - service, _client = _service( - lambda script, cwd, env, timeout: _job_result(output="plain output without sentinel framing") - ) - - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) - - assert exc_info.value.code == "sandbox_command_failed" - assert exc_info.value.status_code == 502 - - -@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) -def test_upload_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: +def test_read_file_uses_complete_mode_and_parses_response() -> None: service, client = _service( - lambda script, cwd, env, timeout: _job_result( - output={ - "path": "should-not-run", - "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, - } + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap({"path": "notes.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}), ) ) - with pytest.raises(SandboxFileError) as exc_info: - asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path=bad_path))) + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="notes.txt", max_bytes=8))) - assert exc_info.value.code == "invalid_sandbox_path" - assert client.run_calls == [] - - -def _client(service: SandboxFileService | None) -> TestClient: - app = FastAPI() - app.include_router(create_sandbox_files_router(lambda: service)) - return TestClient(app) - - -def test_router_list_ok() -> None: - service, _client_instance = _service( - lambda script, cwd, env, timeout: _job_result(output={"path": ".", "entries": [], "truncated": False}) - ) - - response = _client(service).post( - "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} - ) - - assert response.status_code == 200 - assert response.json()["path"] == "." - - -def test_router_returns_503_when_service_unconfigured() -> None: - response = _client(None).post( - "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} - ) - - assert response.status_code == 503 - assert response.json()["detail"]["code"] == "sandbox_backend_unavailable" + assert result.text == "hello" + assert "python3 - notes.txt 8 <<'PY'" in client.run_calls[0].script diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index 84ee53ba2dd..c91ce3a7af4 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -23,7 +23,7 @@ SERVER_RUNTIME_DEPENDENCIES = { "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", "pydantic-settings>=2.12.0,<3.0.0", "redis>=7.4.0,<8.0.0", - "shell-session-manager==2.2.1", + "shell-session-manager==2.3.0", "uvicorn[standard]==0.46.0", } @@ -66,24 +66,43 @@ def test_default_package_discovery_excludes_example_packages() -> None: assert "dify_agent_examples*" not in find_config["include"] -def test_project_declares_console_script_and_local_sandbox_docker_version() -> None: +def test_project_declares_console_scripts() -> None: pyproject = _read_pyproject() scripts = pyproject["project"]["scripts"] - dockerfile = (PROJECT_ROOT / "docker" / "local-sandbox" / "Dockerfile").read_text(encoding="utf-8") assert scripts["dify-agent"] == "dify_agent.agent_stub.cli.main:main" assert scripts["dify-agent-stub-server"] == "dify_agent.agent_stub.server.cli:main" - assert "SHELL_SESSION_MANAGER_VERSION=2.2.1" in dockerfile def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None: dockerfile = (PROJECT_ROOT / "docker" / "local-sandbox" / "Dockerfile").read_text(encoding="utf-8") - assert "uv sync --frozen --no-dev --no-editable --extra grpc" in dockerfile - assert "SHELL_SESSION_MANAGER_VERSION=2.2.1" in dockerfile - assert "shell-session-manager==${SHELL_SESSION_MANAGER_VERSION}" in dockerfile + assert "ARG NODE_VERSION=22.22.1" in dockerfile + assert "ARG PNPM_VERSION=11.9.0" in dockerfile + assert "ARG UV_VERSION=0.8.9" in dockerfile + assert "ARG DIFY_AGENT_TOOL_SPEC=.[grpc]" in dockerfile + assert "ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager" in dockerfile + assert "UV_TOOL_DIR=/opt/dify-agent-tools/envs" in dockerfile + assert "UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin" in dockerfile + assert "git" in dockerfile + assert "jq" in dockerfile + assert "openssh-client" in dockerfile + assert "ripgrep" in dockerfile + assert 'node_dist="node-v${NODE_VERSION}-linux-${node_arch}"' in dockerfile + assert 'curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"' in dockerfile + assert 'tar -xJf "${node_dist}.tar.xz" -C /usr/local --strip-components=1' in dockerfile + assert 'npm install --global "pnpm@${PNPM_VERSION}"' in dockerfile + assert "uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes" in dockerfile + assert "> /tmp/dify-agent-constraints.txt" in dockerfile + assert '--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}"' in dockerfile + assert ( + '--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}"' + in dockerfile + ) assert "DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive" in dockerfile - assert "ln -s ${VIRTUAL_ENV}/bin/dify-agent /usr/local/bin/dify-agent" in dockerfile - assert "ln -s ${VIRTUAL_ENV}/bin/shellctl /usr/local/bin/shellctl" in dockerfile + assert "COPY --from=tools ${UV_TOOL_DIR} ${UV_TOOL_DIR}" in dockerfile + assert "COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR}" in dockerfile + assert "VIRTUAL_ENV" not in dockerfile + assert "uv sync" not in dockerfile assert "mkdir -p /mnt/drive" in dockerfile assert '["shellctl", "serve", "--listen", "0.0.0.0:5004"]' in dockerfile diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 22348434e5b..df40ea9fe1a 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -650,7 +650,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, - { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" }, + { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.3.0" }, { name = "typer", specifier = ">=0.16.1,<0.17" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, @@ -3503,7 +3503,7 @@ wheels = [ [[package]] name = "shell-session-manager" -version = "2.2.1" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -3515,9 +3515,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/c3/83701914c5194e0390b93a05685ddfceda425348523254f43fdcb5024a37/shell_session_manager-2.2.1.tar.gz", hash = "sha256:421531c8bca5a586e9245282e13fdbe2566fca34e72a7320749c745cc2a935ee", size = 51380, upload-time = "2026-06-19T12:21:58.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/27/64b6086f54a700836c3fe6fc403e1a180a5c0285b528588663d0de951a6b/shell_session_manager-2.3.0.tar.gz", hash = "sha256:59598f4824623053405a6d0ba85c931e263597752aa6b91e67b0e523474dccf5", size = 51361, upload-time = "2026-06-28T18:32:57.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/b6/b8d84ff7e59661cef85a1f021f500c798451121775f5aafd7b670ba63117/shell_session_manager-2.2.1-py3-none-any.whl", hash = "sha256:b7452dd5d50b5f55d2a108a2c422ed9883a817e2145f884844d072ec95dcaff8", size = 48895, upload-time = "2026-06-19T12:21:57.183Z" }, + { url = "https://files.pythonhosted.org/packages/fc/26/07e4815e630316f1840e36b686f91bd389940437e38b897a0231c487298f/shell_session_manager-2.3.0-py3-none-any.whl", hash = "sha256:7727cc328661d93dd40d6f42d2c6b46c95d49717698558e81d78b41a42501b4f", size = 48900, upload-time = "2026-06-28T18:32:55.937Z" }, ] [[package]] diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index f8f9ccaa785..c813a2f5bc6 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -8,6 +8,12 @@ import { zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse, zDeleteAgentByAgentIdBuildDraftPath, zDeleteAgentByAgentIdBuildDraftResponse, + zDeleteAgentByAgentIdConfigFilesByNamePath, + zDeleteAgentByAgentIdConfigFilesByNameQuery, + zDeleteAgentByAgentIdConfigFilesByNameResponse, + zDeleteAgentByAgentIdConfigSkillsByNamePath, + zDeleteAgentByAgentIdConfigSkillsByNameQuery, + zDeleteAgentByAgentIdConfigSkillsByNameResponse, zDeleteAgentByAgentIdFilesPath, zDeleteAgentByAgentIdFilesQuery, zDeleteAgentByAgentIdFilesResponse, @@ -30,6 +36,35 @@ import { zGetAgentByAgentIdComposerCandidatesResponse, zGetAgentByAgentIdComposerPath, zGetAgentByAgentIdComposerResponse, + zGetAgentByAgentIdConfigFilesByNameDownloadPath, + zGetAgentByAgentIdConfigFilesByNameDownloadQuery, + zGetAgentByAgentIdConfigFilesByNameDownloadResponse, + zGetAgentByAgentIdConfigFilesByNamePreviewPath, + zGetAgentByAgentIdConfigFilesByNamePreviewQuery, + zGetAgentByAgentIdConfigFilesByNamePreviewResponse, + zGetAgentByAgentIdConfigFilesPath, + zGetAgentByAgentIdConfigFilesQuery, + zGetAgentByAgentIdConfigFilesResponse, + zGetAgentByAgentIdConfigManifestPath, + zGetAgentByAgentIdConfigManifestQuery, + zGetAgentByAgentIdConfigManifestResponse, + zGetAgentByAgentIdConfigSkillsByNameDownloadPath, + zGetAgentByAgentIdConfigSkillsByNameDownloadQuery, + zGetAgentByAgentIdConfigSkillsByNameDownloadResponse, + zGetAgentByAgentIdConfigSkillsByNameFilesContentPath, + zGetAgentByAgentIdConfigSkillsByNameFilesContentResponse, + zGetAgentByAgentIdConfigSkillsByNameFilesDownloadPath, + zGetAgentByAgentIdConfigSkillsByNameFilesDownloadQuery, + zGetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse, + zGetAgentByAgentIdConfigSkillsByNameFilesPreviewPath, + zGetAgentByAgentIdConfigSkillsByNameFilesPreviewQuery, + zGetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse, + zGetAgentByAgentIdConfigSkillsByNameInspectPath, + zGetAgentByAgentIdConfigSkillsByNameInspectQuery, + zGetAgentByAgentIdConfigSkillsByNameInspectResponse, + zGetAgentByAgentIdConfigSkillsPath, + zGetAgentByAgentIdConfigSkillsQuery, + zGetAgentByAgentIdConfigSkillsResponse, zGetAgentByAgentIdDriveFilesDownloadPath, zGetAgentByAgentIdDriveFilesDownloadQuery, zGetAgentByAgentIdDriveFilesDownloadResponse, @@ -80,6 +115,8 @@ import { zPostAgentByAgentIdApiEnableResponse, zPostAgentByAgentIdApiKeysPath, zPostAgentByAgentIdApiKeysResponse, + zPostAgentByAgentIdBuildChatFinalizePath, + zPostAgentByAgentIdBuildChatFinalizeResponse, zPostAgentByAgentIdBuildDraftApplyPath, zPostAgentByAgentIdBuildDraftApplyResponse, zPostAgentByAgentIdBuildDraftCheckoutBody, @@ -90,6 +127,14 @@ import { zPostAgentByAgentIdComposerValidateBody, zPostAgentByAgentIdComposerValidatePath, zPostAgentByAgentIdComposerValidateResponse, + zPostAgentByAgentIdConfigFilesBody, + zPostAgentByAgentIdConfigFilesPath, + zPostAgentByAgentIdConfigFilesQuery, + zPostAgentByAgentIdConfigFilesResponse, + zPostAgentByAgentIdConfigSkillsUploadBody, + zPostAgentByAgentIdConfigSkillsUploadPath, + zPostAgentByAgentIdConfigSkillsUploadQuery, + zPostAgentByAgentIdConfigSkillsUploadResponse, zPostAgentByAgentIdCopyBody, zPostAgentByAgentIdCopyPath, zPostAgentByAgentIdCopyResponse, @@ -221,7 +266,30 @@ export const apiKeys = { byApiKeyId, } +/** + * Run a build-draft Agent App turn that asks the agent to push config updates + */ export const post3 = oc + .route({ + description: 'Run a build-draft Agent App turn that asks the agent to push config updates', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdBuildChatFinalize', + path: '/agent/{agent_id}/build-chat/finalize', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdBuildChatFinalizePath })) + .output(zPostAgentByAgentIdBuildChatFinalizeResponse) + +export const finalize = { + post: post3, +} + +export const buildChat = { + finalize, +} + +export const post4 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -233,10 +301,10 @@ export const post3 = oc .output(zPostAgentByAgentIdBuildDraftApplyResponse) export const apply = { - post: post3, + post: post4, } -export const post4 = oc +export const post5 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -253,7 +321,7 @@ export const post4 = oc .output(zPostAgentByAgentIdBuildDraftCheckoutResponse) export const checkout = { - post: post4, + post: post5, } export const delete2 = oc @@ -325,7 +393,7 @@ export const byMessageId = { /** * Stop a running Agent App chat message generation */ -export const post5 = oc +export const post6 = oc .route({ description: 'Stop a running Agent App chat message generation', inputStructure: 'detailed', @@ -338,7 +406,7 @@ export const post5 = oc .output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post5, + post: post6, } export const byTaskId = { @@ -386,7 +454,7 @@ export const candidates = { get: get7, } -export const post6 = oc +export const post7 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -403,7 +471,7 @@ export const post6 = oc .output(zPostAgentByAgentIdComposerValidateResponse) export const validate = { - post: post6, + post: post7, } export const get8 = oc @@ -435,7 +503,303 @@ export const composer = { validate, } -export const post7 = oc +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigFilesByNameDownload', + path: '/agent/{agent_id}/config/files/{name}/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigFilesByNameDownloadPath, + query: zGetAgentByAgentIdConfigFilesByNameDownloadQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigFilesByNameDownloadResponse) + +export const download = { + get: get9, +} + +export const get10 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigFilesByNamePreview', + path: '/agent/{agent_id}/config/files/{name}/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigFilesByNamePreviewPath, + query: zGetAgentByAgentIdConfigFilesByNamePreviewQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigFilesByNamePreviewResponse) + +export const preview = { + get: get10, +} + +export const delete3 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdConfigFilesByName', + path: '/agent/{agent_id}/config/files/{name}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAgentByAgentIdConfigFilesByNamePath, + query: zDeleteAgentByAgentIdConfigFilesByNameQuery.optional(), + }), + ) + .output(zDeleteAgentByAgentIdConfigFilesByNameResponse) + +export const byName = { + delete: delete3, + download, + preview, +} + +export const get11 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigFiles', + path: '/agent/{agent_id}/config/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigFilesPath, + query: zGetAgentByAgentIdConfigFilesQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigFilesResponse) + +export const post8 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdConfigFiles', + path: '/agent/{agent_id}/config/files', + successStatus: 201, + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdConfigFilesBody, + params: zPostAgentByAgentIdConfigFilesPath, + query: zPostAgentByAgentIdConfigFilesQuery.optional(), + }), + ) + .output(zPostAgentByAgentIdConfigFilesResponse) + +export const files = { + get: get11, + post: post8, + byName, +} + +export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigManifest', + path: '/agent/{agent_id}/config/manifest', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigManifestPath, + query: zGetAgentByAgentIdConfigManifestQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigManifestResponse) + +export const manifest = { + get: get12, +} + +export const post9 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdConfigSkillsUpload', + path: '/agent/{agent_id}/config/skills/upload', + successStatus: 201, + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdConfigSkillsUploadBody, + params: zPostAgentByAgentIdConfigSkillsUploadPath, + query: zPostAgentByAgentIdConfigSkillsUploadQuery.optional(), + }), + ) + .output(zPostAgentByAgentIdConfigSkillsUploadResponse) + +export const upload = { + post: post9, +} + +export const get13 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkillsByNameDownload', + path: '/agent/{agent_id}/config/skills/{name}/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigSkillsByNameDownloadPath, + query: zGetAgentByAgentIdConfigSkillsByNameDownloadQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigSkillsByNameDownloadResponse) + +export const download2 = { + get: get13, +} + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkillsByNameFilesContent', + path: '/agent/{agent_id}/config/skills/{name}/files/content', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdConfigSkillsByNameFilesContentPath })) + .output(zGetAgentByAgentIdConfigSkillsByNameFilesContentResponse) + +export const content = { + get: get14, +} + +export const get15 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkillsByNameFilesDownload', + path: '/agent/{agent_id}/config/skills/{name}/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigSkillsByNameFilesDownloadPath, + query: zGetAgentByAgentIdConfigSkillsByNameFilesDownloadQuery, + }), + ) + .output(zGetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse) + +export const download3 = { + get: get15, +} + +export const get16 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkillsByNameFilesPreview', + path: '/agent/{agent_id}/config/skills/{name}/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigSkillsByNameFilesPreviewPath, + query: zGetAgentByAgentIdConfigSkillsByNameFilesPreviewQuery, + }), + ) + .output(zGetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse) + +export const preview2 = { + get: get16, +} + +export const files2 = { + content, + download: download3, + preview: preview2, +} + +export const get17 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkillsByNameInspect', + path: '/agent/{agent_id}/config/skills/{name}/inspect', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigSkillsByNameInspectPath, + query: zGetAgentByAgentIdConfigSkillsByNameInspectQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigSkillsByNameInspectResponse) + +export const inspect = { + get: get17, +} + +export const delete4 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdConfigSkillsByName', + path: '/agent/{agent_id}/config/skills/{name}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAgentByAgentIdConfigSkillsByNamePath, + query: zDeleteAgentByAgentIdConfigSkillsByNameQuery.optional(), + }), + ) + .output(zDeleteAgentByAgentIdConfigSkillsByNameResponse) + +export const byName2 = { + delete: delete4, + download: download2, + files: files2, + inspect, +} + +export const get18 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdConfigSkills', + path: '/agent/{agent_id}/config/skills', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdConfigSkillsPath, + query: zGetAgentByAgentIdConfigSkillsQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdConfigSkillsResponse) + +export const skills = { + get: get18, + upload, + byName: byName2, +} + +export const config = { + files, + manifest, + skills, +} + +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -448,10 +812,10 @@ export const post7 = oc .output(zPostAgentByAgentIdCopyResponse) export const copy = { - post: post7, + post: post10, } -export const post8 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -463,7 +827,7 @@ export const post8 = oc .output(zPostAgentByAgentIdDebugConversationRefreshResponse) export const refresh = { - post: post8, + post: post11, } export const debugConversation = { @@ -473,7 +837,7 @@ export const debugConversation = { /** * Time-limited external signed URL for one Agent App drive value */ -export const get9 = oc +export const get19 = oc .route({ description: 'Time-limited external signed URL for one Agent App drive value', inputStructure: 'detailed', @@ -490,14 +854,14 @@ export const get9 = oc ) .output(zGetAgentByAgentIdDriveFilesDownloadResponse) -export const download = { - get: get9, +export const download4 = { + get: get19, } /** * Truncated text preview of one Agent App drive value */ -export const get10 = oc +export const get20 = oc .route({ description: 'Truncated text preview of one Agent App drive value', inputStructure: 'detailed', @@ -514,14 +878,14 @@ export const get10 = oc ) .output(zGetAgentByAgentIdDriveFilesPreviewResponse) -export const preview = { - get: get10, +export const preview3 = { + get: get20, } /** * List agent drive entries for an Agent App */ -export const get11 = oc +export const get21 = oc .route({ description: 'List agent drive entries for an Agent App', inputStructure: 'detailed', @@ -538,16 +902,16 @@ export const get11 = oc ) .output(zGetAgentByAgentIdDriveFilesResponse) -export const files = { - get: get11, - download, - preview, +export const files3 = { + get: get21, + download: download4, + preview: preview3, } /** * Inspect one drive-backed skill for slash-menu hover/detail UI */ -export const get12 = oc +export const get22 = oc .route({ description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', inputStructure: 'detailed', @@ -559,18 +923,18 @@ export const get12 = oc .input(z.object({ params: zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath })) .output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse) -export const inspect = { - get: get12, +export const inspect2 = { + get: get22, } export const bySkillPath = { - inspect, + inspect: inspect2, } /** * List drive-backed skills for an Agent App */ -export const get13 = oc +export const get23 = oc .route({ description: 'List drive-backed skills for an Agent App', inputStructure: 'detailed', @@ -582,20 +946,20 @@ export const get13 = oc .input(z.object({ params: zGetAgentByAgentIdDriveSkillsPath })) .output(zGetAgentByAgentIdDriveSkillsResponse) -export const skills = { - get: get13, +export const skills2 = { + get: get23, bySkillPath, } export const drive = { - files, - skills, + files: files3, + skills: skills2, } /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ -export const post9 = oc +export const post12 = oc .route({ description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', inputStructure: 'detailed', @@ -610,13 +974,13 @@ export const post9 = oc .output(zPostAgentByAgentIdFeaturesResponse) export const features = { - post: post9, + post: post12, } /** * Create or update Agent App message feedback */ -export const post10 = oc +export const post13 = oc .route({ description: 'Create or update Agent App message feedback', inputStructure: 'detailed', @@ -631,13 +995,13 @@ export const post10 = oc .output(zPostAgentByAgentIdFeedbacksResponse) export const feedbacks = { - post: post10, + post: post13, } /** * Delete one Agent App drive file by key */ -export const delete3 = oc +export const delete5 = oc .route({ description: 'Delete one Agent App drive file by key', inputStructure: 'detailed', @@ -654,7 +1018,7 @@ export const delete3 = oc /** * Commit an uploaded file into the Agent App drive under files/ */ -export const post11 = oc +export const post14 = oc .route({ description: 'Commit an uploaded file into the Agent App drive under files/', inputStructure: 'detailed', @@ -667,12 +1031,12 @@ export const post11 = oc .input(z.object({ body: zPostAgentByAgentIdFilesBody, params: zPostAgentByAgentIdFilesPath })) .output(zPostAgentByAgentIdFilesResponse) -export const files2 = { - delete: delete3, - post: post11, +export const files4 = { + delete: delete5, + post: post14, } -export const get14 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -684,10 +1048,10 @@ export const get14 = oc .output(zGetAgentByAgentIdLogSourcesResponse) export const logSources = { - get: get14, + get: get24, } -export const get15 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -704,14 +1068,14 @@ export const get15 = oc .output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse) export const messages = { - get: get15, + get: get25, } export const byConversationId = { messages, } -export const get16 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -725,14 +1089,14 @@ export const get16 = oc .output(zGetAgentByAgentIdLogsResponse) export const logs = { - get: get16, + get: get26, byConversationId, } /** * Get Agent App message details by ID */ -export const get17 = oc +export const get27 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -745,14 +1109,14 @@ export const get17 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get17, + get: get27, } export const messages2 = { byMessageId: byMessageId2, } -export const post12 = oc +export const post15 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -764,13 +1128,13 @@ export const post12 = oc .output(zPostAgentByAgentIdPublishResponse) export const publish = { - post: post12, + post: post15, } /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get18 = oc +export const get28 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -783,13 +1147,13 @@ export const get18 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get18, + get: get28, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get19 = oc +export const get29 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -807,13 +1171,13 @@ export const get19 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get19, + get: get29, } /** * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const post13 = oc +export const post16 = oc .route({ description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -830,14 +1194,14 @@ export const post13 = oc ) .output(zPostAgentByAgentIdSandboxFilesUploadResponse) -export const upload = { - post: post13, +export const upload2 = { + post: post16, } /** * List a directory in an Agent App conversation sandbox */ -export const get20 = oc +export const get30 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -854,20 +1218,20 @@ export const get20 = oc ) .output(zGetAgentByAgentIdSandboxFilesResponse) -export const files3 = { - get: get20, +export const files5 = { + get: get30, read, - upload, + upload: upload2, } export const sandbox = { - files: files3, + files: files5, } /** * Upload + standardize a Skill into an Agent App drive */ -export const post14 = oc +export const post17 = oc .route({ description: 'Upload + standardize a Skill into an Agent App drive', inputStructure: 'detailed', @@ -885,14 +1249,14 @@ export const post14 = oc ) .output(zPostAgentByAgentIdSkillsUploadResponse) -export const upload2 = { - post: post14, +export const upload3 = { + post: post17, } /** * Infer CLI tool + ENV suggestions from a standardized Agent App skill */ -export const post15 = oc +export const post18 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', inputStructure: 'detailed', @@ -905,13 +1269,13 @@ export const post15 = oc .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) export const inferTools = { - post: post15, + post: post18, } /** * Delete a standardized skill from an Agent App drive */ -export const delete4 = oc +export const delete6 = oc .route({ description: 'Delete a standardized skill from an Agent App drive', inputStructure: 'detailed', @@ -924,16 +1288,16 @@ export const delete4 = oc .output(zDeleteAgentByAgentIdSkillsBySlugResponse) export const bySlug = { - delete: delete4, + delete: delete6, inferTools, } -export const skills2 = { - upload: upload2, +export const skills3 = { + upload: upload3, bySlug, } -export const get21 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -950,14 +1314,14 @@ export const get21 = oc .output(zGetAgentByAgentIdStatisticsSummaryResponse) export const summary = { - get: get21, + get: get31, } export const statistics = { summary, } -export const post16 = oc +export const post19 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -969,10 +1333,10 @@ export const post16 = oc .output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse) export const restore = { - post: post16, + post: post19, } -export const get22 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -984,11 +1348,11 @@ export const get22 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get22, + get: get32, restore, } -export const get23 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1000,11 +1364,11 @@ export const get23 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get23, + get: get33, byVersionId, } -export const delete5 = oc +export const delete7 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1016,7 +1380,7 @@ export const delete5 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get24 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1039,33 +1403,35 @@ export const put3 = oc .output(zPutAgentByAgentIdResponse) export const byAgentId = { - delete: delete5, - get: get24, + delete: delete7, + get: get34, put: put3, apiAccess, apiEnable, apiKeys, + buildChat, buildDraft, chatMessages, composer, + config, copy, debugConversation, drive, features, feedbacks, - files: files2, + files: files4, logSources, logs, messages: messages2, publish, referencingWorkflows, sandbox, - skills: skills2, + skills: skills3, statistics, versions, } -export const get25 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1076,7 +1442,7 @@ export const get25 = oc .input(z.object({ query: zGetAgentQuery.optional() })) .output(zGetAgentResponse) -export const post17 = oc +export const post20 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1089,8 +1455,8 @@ export const post17 = oc .output(zPostAgentResponse) export const agent = { - get: get25, - post: post17, + get: get35, + post: post20, 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 bd4918a22ce..08b9fe90e1d 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -30,7 +30,9 @@ export type AgentAppDetailWithSite = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_has_messages?: boolean debug_conversation_id?: string | null + debug_conversation_message_count?: number deleted_tools?: Array description?: string | null enable_api: boolean @@ -109,6 +111,10 @@ export type ApiKeyItem = { type: string } +export type SimpleResultResponse = { + result: string +} + export type AgentSimpleResultResponse = { result: string } @@ -162,10 +168,6 @@ export type SuggestedQuestionsResponse = { data: Array } -export type SimpleResultResponse = { - result: string -} - export type AgentAppComposerResponse = { active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null agent: AgentComposerAgentResponse @@ -195,6 +197,82 @@ export type AgentComposerValidateResponse = { warnings?: Array } +export type AgentConfigFileListResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + items?: Array +} + +export type AgentConfigFileUploadPayload = { + upload_file_id: string +} + +export type AgentConfigFileUploadResponse = { + config_version: AgentConfigVersionResponse + file: AgentConfigFileItemResponse +} + +export type AgentConfigDeleteResponse = { + removed_names?: Array + result: 'success' +} + +export type AgentConfigDownloadResponse = { + url: string +} + +export type AgentConfigFilePreviewResponse = { + binary: boolean + name: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentConfigManifestResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + env_keys?: Array + files?: AgentConfigFileItemsResponse + note?: string + skills?: AgentConfigSkillItemsResponse +} + +export type AgentConfigSkillListResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + items?: Array +} + +export type AgentConfigSkillUploadResponse = { + config_version: AgentConfigVersionResponse + skill: AgentConfigSkillItemResponse +} + +export type AgentConfigSkillFilePreviewResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentConfigSkillInspectResponse = { + description?: string + file_tree?: Array<{ + [key: string]: unknown + }> | null + files?: Array + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null + skill_md: AgentConfigSkillMarkdownResponse + source: 'config_skill_zip' + warnings?: Array +} + export type AgentAppCopyPayload = { description?: string | null icon?: string | null @@ -205,7 +283,9 @@ export type AgentAppCopyPayload = { } export type AgentDebugConversationRefreshResponse = { + debug_conversation_has_messages?: boolean debug_conversation_id: string + debug_conversation_message_count?: number } export type AgentDriveListResponse = { @@ -554,6 +634,9 @@ export type AgentInviteOptionResponse = { export type AgentSoulConfig = { app_features?: AgentSoulAppFeaturesConfig app_variables?: Array + config_files?: Array + config_note?: string + config_skills?: Array env?: AgentSoulEnvConfig files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig @@ -675,6 +758,55 @@ export type ComposerValidationWarningResponse = { surface?: string | null } +export type AgentConfigVersionResponse = { + id: string + kind: 'build_draft' | 'draft' | 'snapshot' + writable: boolean +} + +export type AgentConfigFileItemResponse = { + file_id?: string | null + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigFileItemsResponse = { + items?: Array +} + +export type AgentConfigSkillItemsResponse = { + items?: Array +} + +export type AgentConfigSkillItemResponse = { + description?: string + file_id?: string | null + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigSkillFileResponse = { + downloadable: boolean + name: string + path: string + previewable: boolean + type: 'directory' | 'file' +} + +export type AgentConfigSkillMarkdownResponse = { + binary: false + path: 'SKILL.md' + size?: number | null + text: string + truncated: boolean +} + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1004,6 +1136,25 @@ export type AppVariableConfig = { type: string } +export type AgentConfigFileRefConfig = { + file_id: string + file_kind: 'tool_file' | 'upload_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigSkillRefConfig = { + description?: string + file_id: string + file_kind?: 'tool_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + export type AgentSoulEnvConfig = { secret_refs?: Array variables?: Array @@ -1690,7 +1841,9 @@ export type AgentAppDetailWithSiteWritable = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_has_messages?: boolean debug_conversation_id?: string | null + debug_conversation_message_count?: number deleted_tools?: Array description?: string | null enable_api: boolean @@ -1989,6 +2142,27 @@ export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = { export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse = DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses] +export type PostAgentByAgentIdBuildChatFinalizeData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-chat/finalize' +} + +export type PostAgentByAgentIdBuildChatFinalizeErrors = { + 400: unknown + 404: unknown +} + +export type PostAgentByAgentIdBuildChatFinalizeResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdBuildChatFinalizeResponse + = PostAgentByAgentIdBuildChatFinalizeResponses[keyof PostAgentByAgentIdBuildChatFinalizeResponses] + export type DeleteAgentByAgentIdBuildDraftData = { body?: never path: { @@ -2195,6 +2369,284 @@ export type PostAgentByAgentIdComposerValidateResponses = { export type PostAgentByAgentIdComposerValidateResponse = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] +export type GetAgentByAgentIdConfigFilesData = { + body?: never + path: { + agent_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/files' +} + +export type GetAgentByAgentIdConfigFilesResponses = { + 200: AgentConfigFileListResponse +} + +export type GetAgentByAgentIdConfigFilesResponse + = GetAgentByAgentIdConfigFilesResponses[keyof GetAgentByAgentIdConfigFilesResponses] + +export type PostAgentByAgentIdConfigFilesData = { + body: AgentConfigFileUploadPayload + path: { + agent_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/files' +} + +export type PostAgentByAgentIdConfigFilesResponses = { + 201: AgentConfigFileUploadResponse +} + +export type PostAgentByAgentIdConfigFilesResponse + = PostAgentByAgentIdConfigFilesResponses[keyof PostAgentByAgentIdConfigFilesResponses] + +export type DeleteAgentByAgentIdConfigFilesByNameData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/files/{name}' +} + +export type DeleteAgentByAgentIdConfigFilesByNameResponses = { + 200: AgentConfigDeleteResponse +} + +export type DeleteAgentByAgentIdConfigFilesByNameResponse + = DeleteAgentByAgentIdConfigFilesByNameResponses[keyof DeleteAgentByAgentIdConfigFilesByNameResponses] + +export type GetAgentByAgentIdConfigFilesByNameDownloadData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/files/{name}/download' +} + +export type GetAgentByAgentIdConfigFilesByNameDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAgentByAgentIdConfigFilesByNameDownloadResponse + = GetAgentByAgentIdConfigFilesByNameDownloadResponses[keyof GetAgentByAgentIdConfigFilesByNameDownloadResponses] + +export type GetAgentByAgentIdConfigFilesByNamePreviewData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/files/{name}/preview' +} + +export type GetAgentByAgentIdConfigFilesByNamePreviewResponses = { + 200: AgentConfigFilePreviewResponse +} + +export type GetAgentByAgentIdConfigFilesByNamePreviewResponse + = GetAgentByAgentIdConfigFilesByNamePreviewResponses[keyof GetAgentByAgentIdConfigFilesByNamePreviewResponses] + +export type GetAgentByAgentIdConfigManifestData = { + body?: never + path: { + agent_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/manifest' +} + +export type GetAgentByAgentIdConfigManifestResponses = { + 200: AgentConfigManifestResponse +} + +export type GetAgentByAgentIdConfigManifestResponse + = GetAgentByAgentIdConfigManifestResponses[keyof GetAgentByAgentIdConfigManifestResponses] + +export type GetAgentByAgentIdConfigSkillsData = { + body?: never + path: { + agent_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/skills' +} + +export type GetAgentByAgentIdConfigSkillsResponses = { + 200: AgentConfigSkillListResponse +} + +export type GetAgentByAgentIdConfigSkillsResponse + = GetAgentByAgentIdConfigSkillsResponses[keyof GetAgentByAgentIdConfigSkillsResponses] + +export type PostAgentByAgentIdConfigSkillsUploadData = { + body: { + file: Blob | File + } + path: { + agent_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/skills/upload' +} + +export type PostAgentByAgentIdConfigSkillsUploadResponses = { + 201: AgentConfigSkillUploadResponse +} + +export type PostAgentByAgentIdConfigSkillsUploadResponse + = PostAgentByAgentIdConfigSkillsUploadResponses[keyof PostAgentByAgentIdConfigSkillsUploadResponses] + +export type DeleteAgentByAgentIdConfigSkillsByNameData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/skills/{name}' +} + +export type DeleteAgentByAgentIdConfigSkillsByNameResponses = { + 200: AgentConfigDeleteResponse +} + +export type DeleteAgentByAgentIdConfigSkillsByNameResponse + = DeleteAgentByAgentIdConfigSkillsByNameResponses[keyof DeleteAgentByAgentIdConfigSkillsByNameResponses] + +export type GetAgentByAgentIdConfigSkillsByNameDownloadData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/skills/{name}/download' +} + +export type GetAgentByAgentIdConfigSkillsByNameDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAgentByAgentIdConfigSkillsByNameDownloadResponse + = GetAgentByAgentIdConfigSkillsByNameDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameDownloadResponses] + +export type GetAgentByAgentIdConfigSkillsByNameFilesContentData = { + body?: never + path: { + agent_id: string + name: string + } + query?: never + url: '/agent/{agent_id}/config/skills/{name}/files/content' +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesContentResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesContentResponse + = GetAgentByAgentIdConfigSkillsByNameFilesContentResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesContentResponses] + +export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadData = { + body?: never + path: { + agent_id: string + name: string + } + query: { + draft_type?: 'debug_build' | 'draft' + path: string + version_id?: string + } + url: '/agent/{agent_id}/config/skills/{name}/files/download' +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse + = GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesDownloadResponses] + +export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewData = { + body?: never + path: { + agent_id: string + name: string + } + query: { + draft_type?: 'debug_build' | 'draft' + path: string + version_id?: string + } + url: '/agent/{agent_id}/config/skills/{name}/files/preview' +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses = { + 200: AgentConfigSkillFilePreviewResponse +} + +export type GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse + = GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses[keyof GetAgentByAgentIdConfigSkillsByNameFilesPreviewResponses] + +export type GetAgentByAgentIdConfigSkillsByNameInspectData = { + body?: never + path: { + agent_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + version_id?: string + } + url: '/agent/{agent_id}/config/skills/{name}/inspect' +} + +export type GetAgentByAgentIdConfigSkillsByNameInspectResponses = { + 200: AgentConfigSkillInspectResponse +} + +export type GetAgentByAgentIdConfigSkillsByNameInspectResponse + = GetAgentByAgentIdConfigSkillsByNameInspectResponses[keyof GetAgentByAgentIdConfigSkillsByNameInspectResponses] + export type PostAgentByAgentIdCopyData = { body: AgentAppCopyPayload path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 712f9c54a08..d14cd5c7e89 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -47,6 +47,13 @@ export const zApiKeyList = z.object({ data: z.array(zApiKeyItem), }) +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + /** * AgentSimpleResultResponse */ @@ -86,17 +93,56 @@ export const zSuggestedQuestionsResponse = z.object({ }) /** - * SimpleResultResponse + * AgentConfigFileUploadPayload */ -export const zSimpleResultResponse = z.object({ - result: z.string(), +export const zAgentConfigFileUploadPayload = z.object({ + upload_file_id: z.string(), +}) + +/** + * AgentConfigDeleteResponse + */ +export const zAgentConfigDeleteResponse = z.object({ + removed_names: z.array(z.string()).optional(), + result: z.literal('success'), +}) + +/** + * AgentConfigDownloadResponse + */ +export const zAgentConfigDownloadResponse = z.object({ + url: z.string(), +}) + +/** + * AgentConfigFilePreviewResponse + */ +export const zAgentConfigFilePreviewResponse = z.object({ + binary: z.boolean(), + name: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentConfigSkillFilePreviewResponse + */ +export const zAgentConfigSkillFilePreviewResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), }) /** * AgentDebugConversationRefreshResponse */ export const zAgentDebugConversationRefreshResponse = z.object({ + debug_conversation_has_messages: z.boolean().optional().default(false), debug_conversation_id: z.string(), + debug_conversation_message_count: z.int().optional().default(0), }) /** @@ -407,6 +453,139 @@ export const zComposerValidationFindingsResponse = z.object({ warnings: z.array(zComposerValidationWarningResponse).optional(), }) +/** + * AgentConfigVersionResponse + */ +export const zAgentConfigVersionResponse = z.object({ + id: z.string(), + kind: z.enum(['build_draft', 'draft', 'snapshot']), + writable: z.boolean(), +}) + +/** + * AgentConfigFileItemResponse + */ +export const zAgentConfigFileItemResponse = z.object({ + file_id: z.string().nullish(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentConfigFileListResponse + */ +export const zAgentConfigFileListResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + items: z.array(zAgentConfigFileItemResponse).optional(), +}) + +/** + * AgentConfigFileUploadResponse + */ +export const zAgentConfigFileUploadResponse = z.object({ + config_version: zAgentConfigVersionResponse, + file: zAgentConfigFileItemResponse, +}) + +/** + * AgentConfigFileItemsResponse + */ +export const zAgentConfigFileItemsResponse = z.object({ + items: z.array(zAgentConfigFileItemResponse).optional(), +}) + +/** + * AgentConfigSkillItemResponse + */ +export const zAgentConfigSkillItemResponse = z.object({ + description: z.string().optional().default(''), + file_id: z.string().nullish(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentConfigSkillListResponse + */ +export const zAgentConfigSkillListResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + items: z.array(zAgentConfigSkillItemResponse).optional(), +}) + +/** + * AgentConfigSkillUploadResponse + */ +export const zAgentConfigSkillUploadResponse = z.object({ + config_version: zAgentConfigVersionResponse, + skill: zAgentConfigSkillItemResponse, +}) + +/** + * AgentConfigSkillItemsResponse + */ +export const zAgentConfigSkillItemsResponse = z.object({ + items: z.array(zAgentConfigSkillItemResponse).optional(), +}) + +/** + * AgentConfigManifestResponse + */ +export const zAgentConfigManifestResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + env_keys: z.array(z.string()).optional(), + files: zAgentConfigFileItemsResponse.optional(), + note: z.string().optional().default(''), + skills: zAgentConfigSkillItemsResponse.optional(), +}) + +/** + * AgentConfigSkillFileResponse + */ +export const zAgentConfigSkillFileResponse = z.object({ + downloadable: z.boolean(), + name: z.string(), + path: z.string(), + previewable: z.boolean(), + type: z.enum(['directory', 'file']), +}) + +/** + * AgentConfigSkillMarkdownResponse + */ +export const zAgentConfigSkillMarkdownResponse = z.object({ + binary: z.literal(false), + path: z.literal('SKILL.md'), + size: z.int().nullish(), + text: z.string(), + truncated: z.boolean(), +}) + +/** + * AgentConfigSkillInspectResponse + */ +export const zAgentConfigSkillInspectResponse = z.object({ + description: z.string().optional().default(''), + file_tree: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z.array(zAgentConfigSkillFileResponse).optional(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), + skill_md: zAgentConfigSkillMarkdownResponse, + source: z.literal('config_skill_zip'), + warnings: z.array(z.string()).optional(), +}) + /** * AgentDriveItemResponse */ @@ -864,7 +1043,9 @@ export const zAgentAppDetailWithSite = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_has_messages: z.boolean().optional().default(false), debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(), @@ -1016,6 +1197,35 @@ export const zAppVariableConfig = z.object({ type: z.string().min(1).max(64), }) +/** + * AgentConfigFileRefConfig + * + * Stable Agent Soul reference to one config file payload. + */ +export const zAgentConfigFileRefConfig = z.object({ + file_id: z.string().min(1).max(255), + file_kind: z.enum(['tool_file', 'upload_file']), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + +/** + * AgentConfigSkillRefConfig + * + * Stable Agent Soul reference to one normalized skill archive. + */ +export const zAgentConfigSkillRefConfig = z.object({ + description: z.string().optional().default(''), + file_id: z.string().min(1).max(255), + file_kind: z.literal('tool_file').optional().default('tool_file'), + hash: z.string().nullish(), + mime_type: z.string().nullish().default('application/zip'), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + /** * AgentSoulPromptConfig */ @@ -1806,12 +2016,15 @@ export const zAgentSoulDifyToolCredentialRef = z.object({ /** * AgentSoulDifyToolConfig * - * One Dify Plugin Tool configured on Agent Soul. + * One Dify tool configured on Agent Soul. * * The API backend prepares this persisted product shape into - * ``DifyPluginToolConfig`` before sending a run request to Agent backend. - * ``provider_id`` keeps compatibility with existing Agent tool config payloads; - * new callers should send ``plugin_id`` + ``provider`` when available. + * either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a + * run request to Agent backend. ``plugin`` providers keep the direct + * ``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` / + * ``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id`` + * keeps compatibility with existing Agent tool config payloads; new callers + * should send ``plugin_id`` + ``provider`` when available. */ export const zAgentSoulDifyToolConfig = z.object({ credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), @@ -2155,6 +2368,9 @@ export const zAgentSoulKnowledgeConfig = z.object({ export const zAgentSoulConfig = z.object({ app_features: zAgentSoulAppFeaturesConfig.optional(), app_variables: z.array(zAppVariableConfig).optional(), + config_files: z.array(zAgentConfigFileRefConfig).optional(), + config_note: z.string().optional().default(''), + config_skills: z.array(zAgentConfigSkillRefConfig).optional(), env: zAgentSoulEnvConfig.optional(), files: zAgentSoulFilesConfig.optional(), human: zAgentSoulHumanConfig.optional(), @@ -2435,7 +2651,9 @@ export const zAgentAppDetailWithSiteWritable = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_has_messages: z.boolean().optional().default(false), debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(), @@ -2588,6 +2806,15 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({ */ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() +export const zPostAgentByAgentIdBuildChatFinalizePath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Success + */ +export const zPostAgentByAgentIdBuildChatFinalizeResponse = zSimpleResultResponse + export const zDeleteAgentByAgentIdBuildDraftPath = z.object({ agent_id: z.uuid(), }) @@ -2713,6 +2940,219 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({ */ export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse +export const zGetAgentByAgentIdConfigFilesPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdConfigFilesQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config files + */ +export const zGetAgentByAgentIdConfigFilesResponse = zAgentConfigFileListResponse + +export const zPostAgentByAgentIdConfigFilesBody = zAgentConfigFileUploadPayload + +export const zPostAgentByAgentIdConfigFilesPath = z.object({ + agent_id: z.uuid(), +}) + +export const zPostAgentByAgentIdConfigFilesQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Uploaded config file + */ +export const zPostAgentByAgentIdConfigFilesResponse = zAgentConfigFileUploadResponse + +export const zDeleteAgentByAgentIdConfigFilesByNamePath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zDeleteAgentByAgentIdConfigFilesByNameQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config file deleted + */ +export const zDeleteAgentByAgentIdConfigFilesByNameResponse = zAgentConfigDeleteResponse + +export const zGetAgentByAgentIdConfigFilesByNameDownloadPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigFilesByNameDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config file download URL + */ +export const zGetAgentByAgentIdConfigFilesByNameDownloadResponse = zAgentConfigDownloadResponse + +export const zGetAgentByAgentIdConfigFilesByNamePreviewPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigFilesByNamePreviewQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Preview + */ +export const zGetAgentByAgentIdConfigFilesByNamePreviewResponse = zAgentConfigFilePreviewResponse + +export const zGetAgentByAgentIdConfigManifestPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdConfigManifestQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Agent config manifest + */ +export const zGetAgentByAgentIdConfigManifestResponse = zAgentConfigManifestResponse + +export const zGetAgentByAgentIdConfigSkillsPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdConfigSkillsQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config skills + */ +export const zGetAgentByAgentIdConfigSkillsResponse = zAgentConfigSkillListResponse + +export const zPostAgentByAgentIdConfigSkillsUploadBody = z.object({ + file: z.custom(), +}) + +export const zPostAgentByAgentIdConfigSkillsUploadPath = z.object({ + agent_id: z.uuid(), +}) + +export const zPostAgentByAgentIdConfigSkillsUploadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Uploaded config skill + */ +export const zPostAgentByAgentIdConfigSkillsUploadResponse = zAgentConfigSkillUploadResponse + +export const zDeleteAgentByAgentIdConfigSkillsByNamePath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zDeleteAgentByAgentIdConfigSkillsByNameQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill deleted + */ +export const zDeleteAgentByAgentIdConfigSkillsByNameResponse = zAgentConfigDeleteResponse + +export const zGetAgentByAgentIdConfigSkillsByNameDownloadPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigSkillsByNameDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill download URL + */ +export const zGetAgentByAgentIdConfigSkillsByNameDownloadResponse = zAgentConfigDownloadResponse + +export const zGetAgentByAgentIdConfigSkillsByNameFilesContentPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +/** + * Success + */ +export const zGetAgentByAgentIdConfigSkillsByNameFilesContentResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + path: z.string(), + version_id: z.string().optional(), +}) + +/** + * Config skill file download URL + */ +export const zGetAgentByAgentIdConfigSkillsByNameFilesDownloadResponse + = zAgentConfigDownloadResponse + +export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + path: z.string(), + version_id: z.string().optional(), +}) + +/** + * Config skill file preview + */ +export const zGetAgentByAgentIdConfigSkillsByNameFilesPreviewResponse + = zAgentConfigSkillFilePreviewResponse + +export const zGetAgentByAgentIdConfigSkillsByNameInspectPath = z.object({ + agent_id: z.uuid(), + name: z.string(), +}) + +export const zGetAgentByAgentIdConfigSkillsByNameInspectQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill inspect view + */ +export const zGetAgentByAgentIdConfigSkillsByNameInspectResponse = zAgentConfigSkillInspectResponse + export const zPostAgentByAgentIdCopyBody = zAgentAppCopyPayload export const zPostAgentByAgentIdCopyPath = z.object({ diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 59783646462..83637302055 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -4,6 +4,12 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zDeleteAppsByAppIdAgentConfigFilesByNamePath, + zDeleteAppsByAppIdAgentConfigFilesByNameQuery, + zDeleteAppsByAppIdAgentConfigFilesByNameResponse, + zDeleteAppsByAppIdAgentConfigSkillsByNamePath, + zDeleteAppsByAppIdAgentConfigSkillsByNameQuery, + zDeleteAppsByAppIdAgentConfigSkillsByNameResponse, zDeleteAppsByAppIdAgentFilesPath, zDeleteAppsByAppIdAgentFilesQuery, zDeleteAppsByAppIdAgentFilesResponse, @@ -45,6 +51,35 @@ import { zGetAppsByAppIdAdvancedChatWorkflowRunsPath, zGetAppsByAppIdAdvancedChatWorkflowRunsQuery, zGetAppsByAppIdAdvancedChatWorkflowRunsResponse, + zGetAppsByAppIdAgentConfigFilesByNameDownloadPath, + zGetAppsByAppIdAgentConfigFilesByNameDownloadQuery, + zGetAppsByAppIdAgentConfigFilesByNameDownloadResponse, + zGetAppsByAppIdAgentConfigFilesByNamePreviewPath, + zGetAppsByAppIdAgentConfigFilesByNamePreviewQuery, + zGetAppsByAppIdAgentConfigFilesByNamePreviewResponse, + zGetAppsByAppIdAgentConfigFilesPath, + zGetAppsByAppIdAgentConfigFilesQuery, + zGetAppsByAppIdAgentConfigFilesResponse, + zGetAppsByAppIdAgentConfigManifestPath, + zGetAppsByAppIdAgentConfigManifestQuery, + zGetAppsByAppIdAgentConfigManifestResponse, + zGetAppsByAppIdAgentConfigSkillsByNameDownloadPath, + zGetAppsByAppIdAgentConfigSkillsByNameDownloadQuery, + zGetAppsByAppIdAgentConfigSkillsByNameDownloadResponse, + zGetAppsByAppIdAgentConfigSkillsByNameFilesContentPath, + zGetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse, + zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadPath, + zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery, + zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse, + zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath, + zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery, + zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse, + zGetAppsByAppIdAgentConfigSkillsByNameInspectPath, + zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery, + zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse, + zGetAppsByAppIdAgentConfigSkillsPath, + zGetAppsByAppIdAgentConfigSkillsQuery, + zGetAppsByAppIdAgentConfigSkillsResponse, zGetAppsByAppIdAgentDriveFilesDownloadPath, zGetAppsByAppIdAgentDriveFilesDownloadQuery, zGetAppsByAppIdAgentDriveFilesDownloadResponse, @@ -271,6 +306,14 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse, + zPostAppsByAppIdAgentConfigFilesBody, + zPostAppsByAppIdAgentConfigFilesPath, + zPostAppsByAppIdAgentConfigFilesQuery, + zPostAppsByAppIdAgentConfigFilesResponse, + zPostAppsByAppIdAgentConfigSkillsUploadBody, + zPostAppsByAppIdAgentConfigSkillsUploadPath, + zPostAppsByAppIdAgentConfigSkillsUploadQuery, + zPostAppsByAppIdAgentConfigSkillsUploadResponse, zPostAppsByAppIdAgentFilesBody, zPostAppsByAppIdAgentFilesPath, zPostAppsByAppIdAgentFilesQuery, @@ -796,10 +839,306 @@ export const advancedChat = { workflows: workflows2, } +export const get5 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigFilesByNameDownload', + path: '/apps/{app_id}/agent/config/files/{name}/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigFilesByNameDownloadPath, + query: zGetAppsByAppIdAgentConfigFilesByNameDownloadQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigFilesByNameDownloadResponse) + +export const download = { + get: get5, +} + +export const get6 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigFilesByNamePreview', + path: '/apps/{app_id}/agent/config/files/{name}/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigFilesByNamePreviewPath, + query: zGetAppsByAppIdAgentConfigFilesByNamePreviewQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigFilesByNamePreviewResponse) + +export const preview2 = { + get: get6, +} + +export const delete_ = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentConfigFilesByName', + path: '/apps/{app_id}/agent/config/files/{name}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentConfigFilesByNamePath, + query: zDeleteAppsByAppIdAgentConfigFilesByNameQuery.optional(), + }), + ) + .output(zDeleteAppsByAppIdAgentConfigFilesByNameResponse) + +export const byName = { + delete: delete_, + download, + preview: preview2, +} + +export const get7 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigFiles', + path: '/apps/{app_id}/agent/config/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigFilesPath, + query: zGetAppsByAppIdAgentConfigFilesQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigFilesResponse) + +export const post9 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentConfigFiles', + path: '/apps/{app_id}/agent/config/files', + successStatus: 201, + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentConfigFilesBody, + params: zPostAppsByAppIdAgentConfigFilesPath, + query: zPostAppsByAppIdAgentConfigFilesQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentConfigFilesResponse) + +export const files = { + get: get7, + post: post9, + byName, +} + +export const get8 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigManifest', + path: '/apps/{app_id}/agent/config/manifest', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigManifestPath, + query: zGetAppsByAppIdAgentConfigManifestQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigManifestResponse) + +export const manifest = { + get: get8, +} + +export const post10 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentConfigSkillsUpload', + path: '/apps/{app_id}/agent/config/skills/upload', + successStatus: 201, + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentConfigSkillsUploadBody, + params: zPostAppsByAppIdAgentConfigSkillsUploadPath, + query: zPostAppsByAppIdAgentConfigSkillsUploadQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentConfigSkillsUploadResponse) + +export const upload = { + post: post10, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkillsByNameDownload', + path: '/apps/{app_id}/agent/config/skills/{name}/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigSkillsByNameDownloadPath, + query: zGetAppsByAppIdAgentConfigSkillsByNameDownloadQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigSkillsByNameDownloadResponse) + +export const download2 = { + get: get9, +} + +export const get10 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkillsByNameFilesContent', + path: '/apps/{app_id}/agent/config/skills/{name}/files/content', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdAgentConfigSkillsByNameFilesContentPath })) + .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse) + +export const content = { + get: get10, +} + +export const get11 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkillsByNameFilesDownload', + path: '/apps/{app_id}/agent/config/skills/{name}/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadPath, + query: zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery, + }), + ) + .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse) + +export const download3 = { + get: get11, +} + +export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkillsByNameFilesPreview', + path: '/apps/{app_id}/agent/config/skills/{name}/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath, + query: zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery, + }), + ) + .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse) + +export const preview3 = { + get: get12, +} + +export const files2 = { + content, + download: download3, + preview: preview3, +} + +export const get13 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkillsByNameInspect', + path: '/apps/{app_id}/agent/config/skills/{name}/inspect', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigSkillsByNameInspectPath, + query: zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse) + +export const inspect = { + get: get13, +} + +export const delete2 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentConfigSkillsByName', + path: '/apps/{app_id}/agent/config/skills/{name}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentConfigSkillsByNamePath, + query: zDeleteAppsByAppIdAgentConfigSkillsByNameQuery.optional(), + }), + ) + .output(zDeleteAppsByAppIdAgentConfigSkillsByNameResponse) + +export const byName2 = { + delete: delete2, + download: download2, + files: files2, + inspect, +} + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentConfigSkills', + path: '/apps/{app_id}/agent/config/skills', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentConfigSkillsPath, + query: zGetAppsByAppIdAgentConfigSkillsQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentConfigSkillsResponse) + +export const skills = { + get: get14, + upload, + byName: byName2, +} + +export const config = { + files, + manifest, + skills, +} + /** * Time-limited external signed URL for one drive value (no streaming proxy) */ -export const get5 = oc +export const get15 = oc .route({ description: 'Time-limited external signed URL for one drive value (no streaming proxy)', inputStructure: 'detailed', @@ -816,14 +1155,14 @@ export const get5 = oc ) .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) -export const download = { - get: get5, +export const download4 = { + get: get15, } /** * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) */ -export const get6 = oc +export const get16 = oc .route({ description: 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', @@ -841,14 +1180,14 @@ export const get6 = oc ) .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) -export const preview2 = { - get: get6, +export const preview4 = { + get: get16, } /** * List agent drive entries (read-only inspector; one endpoint for both tabs) */ -export const get7 = oc +export const get17 = oc .route({ description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', inputStructure: 'detailed', @@ -865,16 +1204,16 @@ export const get7 = oc ) .output(zGetAppsByAppIdAgentDriveFilesResponse) -export const files = { - get: get7, - download, - preview: preview2, +export const files3 = { + get: get17, + download: download4, + preview: preview4, } /** * Inspect one drive-backed skill for slash-menu hover/detail UI */ -export const get8 = oc +export const get18 = oc .route({ description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', inputStructure: 'detailed', @@ -891,18 +1230,18 @@ export const get8 = oc ) .output(zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse) -export const inspect = { - get: get8, +export const inspect2 = { + get: get18, } export const bySkillPath = { - inspect, + inspect: inspect2, } /** * List drive-backed skills for the bound agent */ -export const get9 = oc +export const get19 = oc .route({ description: 'List drive-backed skills for the bound agent', inputStructure: 'detailed', @@ -919,20 +1258,20 @@ export const get9 = oc ) .output(zGetAppsByAppIdAgentDriveSkillsResponse) -export const skills = { - get: get9, +export const skills2 = { + get: get19, bySkillPath, } export const drive = { - files, - skills, + files: files3, + skills: skills2, } /** * Delete one drive file by key via drive commit-null semantics */ -export const delete_ = oc +export const delete3 = oc .route({ description: 'Delete one drive file by key via drive commit-null semantics', inputStructure: 'detailed', @@ -954,7 +1293,7 @@ export const delete_ = oc * * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) */ -export const post9 = oc +export const post11 = oc .route({ description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', inputStructure: 'detailed', @@ -974,9 +1313,9 @@ export const post9 = oc ) .output(zPostAppsByAppIdAgentFilesResponse) -export const files2 = { - delete: delete_, - post: post9, +export const files4 = { + delete: delete3, + post: post11, } /** @@ -984,7 +1323,7 @@ export const files2 = { * * Get agent execution logs for an application */ -export const get10 = oc +export const get20 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -998,7 +1337,7 @@ export const get10 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get10, + get: get20, } /** @@ -1006,7 +1345,7 @@ export const logs = { * * Upload + standardize a Skill into the agent drive */ -export const post10 = oc +export const post12 = oc .route({ description: 'Upload + standardize a Skill into the agent drive', inputStructure: 'detailed', @@ -1026,8 +1365,8 @@ export const post10 = oc ) .output(zPostAppsByAppIdAgentSkillsUploadResponse) -export const upload = { - post: post10, +export const upload2 = { + post: post12, } /** @@ -1036,7 +1375,7 @@ export const upload = { * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) * Saving still goes through composer validation. */ -export const post11 = oc +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.', @@ -1056,13 +1395,13 @@ export const post11 = oc .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) export const inferTools = { - post: post11, + post: post13, } /** * Delete a standardized skill by removing its known drive keys via commit-null */ -export const delete2 = oc +export const delete4 = oc .route({ description: 'Delete a standardized skill by removing its known drive keys via commit-null', inputStructure: 'detailed', @@ -1080,26 +1419,27 @@ export const delete2 = oc .output(zDeleteAppsByAppIdAgentSkillsBySlugResponse) export const bySlug = { - delete: delete2, + delete: delete4, inferTools, } -export const skills2 = { - upload, +export const skills3 = { + upload: upload2, bySlug, } export const agent = { + config, drive, - files: files2, + files: files4, logs, - skills: skills2, + skills: skills3, } /** * Get status of annotation reply action job */ -export const get11 = oc +export const get21 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1112,7 +1452,7 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get11, + get: get21, } export const status = { @@ -1122,7 +1462,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post12 = oc +export const post14 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1140,7 +1480,7 @@ export const post12 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post12, + post: post14, status, } @@ -1151,7 +1491,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get12 = oc +export const get22 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1164,13 +1504,13 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get12, + get: get22, } /** * Update annotation settings for an app */ -export const post13 = oc +export const post15 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1188,7 +1528,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post13, + post: post15, } export const annotationSettings = { @@ -1198,7 +1538,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post14 = oc +export const post16 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1211,13 +1551,13 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post14, + post: post16, } /** * Get status of batch import job */ -export const get13 = oc +export const get23 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1230,7 +1570,7 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get13, + get: get23, } export const batchImportStatus = { @@ -1240,7 +1580,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get14 = oc +export const get24 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1253,13 +1593,13 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get14, + get: get24, } /** * Export all annotations for an app with CSV injection protection */ -export const get15 = oc +export const get25 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1272,13 +1612,13 @@ export const get15 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get15, + get: get25, } /** * Get hit histories for an annotation */ -export const get16 = oc +export const get26 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1296,10 +1636,10 @@ export const get16 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get16, + get: get26, } -export const delete3 = oc +export const delete5 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1314,7 +1654,7 @@ export const delete3 = oc /** * Update or delete an annotation */ -export const post15 = oc +export const post17 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1332,12 +1672,12 @@ export const post15 = oc .output(zPostAppsByAppIdAnnotationsByAnnotationIdResponse) export const byAnnotationId = { - delete: delete3, - post: post15, + delete: delete5, + post: post17, hitHistories, } -export const delete4 = oc +export const delete6 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1352,7 +1692,7 @@ export const delete4 = oc /** * Get annotations for an app with pagination */ -export const get17 = oc +export const get27 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1372,7 +1712,7 @@ export const get17 = oc /** * Create a new annotation for an app */ -export const post16 = oc +export const post18 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1388,9 +1728,9 @@ export const post16 = oc .output(zPostAppsByAppIdAnnotationsResponse) export const annotations = { - delete: delete4, - get: get17, - post: post16, + delete: delete6, + get: get27, + post: post18, batchImport, batchImportStatus, count: count2, @@ -1401,7 +1741,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post17 = oc +export const post19 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1414,13 +1754,13 @@ export const post17 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post17, + post: post19, } /** * Transcript audio to text for chat messages */ -export const post18 = oc +export const post20 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1433,13 +1773,13 @@ export const post18 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post18, + post: post20, } /** * Delete a chat conversation */ -export const delete5 = oc +export const delete7 = oc .route({ description: 'Delete a chat conversation', inputStructure: 'detailed', @@ -1455,7 +1795,7 @@ export const delete5 = oc /** * Get chat conversation details */ -export const get18 = oc +export const get28 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1468,14 +1808,14 @@ export const get18 = oc .output(zGetAppsByAppIdChatConversationsByConversationIdResponse) export const byConversationId = { - delete: delete5, - get: get18, + delete: delete7, + get: get28, } /** * Get chat conversations with pagination, filtering and summary */ -export const get19 = oc +export const get29 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1493,14 +1833,14 @@ export const get19 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get19, + get: get29, byConversationId, } /** * Get suggested questions for a message */ -export const get20 = oc +export const get30 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1513,7 +1853,7 @@ export const get20 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get20, + get: get30, } export const byMessageId = { @@ -1523,7 +1863,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post19 = oc +export const post21 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1536,7 +1876,7 @@ export const post19 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post19, + post: post21, } export const byTaskId = { @@ -1546,7 +1886,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get21 = oc +export const get31 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1561,7 +1901,7 @@ export const get21 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get21, + get: get31, byMessageId, byTaskId, } @@ -1569,7 +1909,7 @@ export const chatMessages = { /** * Delete a completion conversation */ -export const delete6 = oc +export const delete8 = oc .route({ description: 'Delete a completion conversation', inputStructure: 'detailed', @@ -1585,7 +1925,7 @@ export const delete6 = oc /** * Get completion conversation details with messages */ -export const get22 = oc +export const get32 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1598,14 +1938,14 @@ export const get22 = oc .output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse) export const byConversationId2 = { - delete: delete6, - get: get22, + delete: delete8, + get: get32, } /** * Get completion conversations with pagination and filtering */ -export const get23 = oc +export const get33 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1623,14 +1963,14 @@ export const get23 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get23, + get: get33, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post20 = oc +export const post22 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1643,7 +1983,7 @@ export const post20 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post20, + post: post22, } export const byTaskId2 = { @@ -1653,7 +1993,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post21 = oc +export const post23 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -1671,14 +2011,14 @@ export const post21 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post21, + post: post23, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get24 = oc +export const get34 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1696,7 +2036,7 @@ export const get24 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get24, + get: get34, } /** @@ -1706,7 +2046,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post22 = oc +export const post24 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1726,7 +2066,7 @@ export const post22 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post22, + post: post24, } /** @@ -1734,7 +2074,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post23 = oc +export const post25 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -1749,7 +2089,7 @@ export const post23 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post23, + post: post25, } /** @@ -1757,7 +2097,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get25 = oc +export const get35 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1773,13 +2113,13 @@ export const get25 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get25, + get: get35, } /** * Export user feedback data for Google Sheets */ -export const get26 = oc +export const get36 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -1797,13 +2137,13 @@ export const get26 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get26, + get: get36, } /** * Create or update message feedback (like/dislike) */ -export const post24 = oc +export const post26 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1816,14 +2156,14 @@ export const post24 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post24, + post: post26, export: export3, } /** * Update application icon */ -export const post25 = oc +export const post27 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -1836,13 +2176,13 @@ export const post25 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post25, + post: post27, } /** * Get message details by ID */ -export const get27 = oc +export const get37 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -1855,7 +2195,7 @@ export const get27 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get27, + get: get37, } export const messages = { @@ -1867,7 +2207,7 @@ export const messages = { * * Update application model configuration */ -export const post26 = oc +export const post28 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -1883,13 +2223,13 @@ export const post26 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post26, + post: post28, } /** * Check if app name is available */ -export const post27 = oc +export const post29 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -1902,13 +2242,13 @@ export const post27 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post27, + post: post29, } /** * Publish app to Creators Platform */ -export const post28 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1921,13 +2261,13 @@ export const post28 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post28, + post: post30, } /** * Get MCP server configuration for an application */ -export const get28 = oc +export const get38 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -1942,7 +2282,7 @@ export const get28 = oc /** * Create MCP server configuration for an application */ -export const post29 = oc +export const post31 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -1971,15 +2311,15 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get28, - post: post29, + get: get38, + post: post31, put, } /** * Reset access token for application site */ -export const post30 = oc +export const post32 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1992,13 +2332,13 @@ export const post30 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post30, + post: post32, } /** * Update application site configuration */ -export const post31 = oc +export const post33 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2011,14 +2351,14 @@ export const post31 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post31, + post: post33, accessTokenReset, } /** * Enable or disable app site */ -export const post32 = oc +export const post34 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2031,13 +2371,13 @@ export const post32 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post32, + post: post34, } /** * Remove the current account's star from an application */ -export const delete7 = oc +export const delete9 = oc .route({ description: 'Remove the current account\'s star from an application', inputStructure: 'detailed', @@ -2052,7 +2392,7 @@ export const delete7 = oc /** * Star an application for the current account */ -export const post33 = oc +export const post35 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2065,14 +2405,14 @@ export const post33 = oc .output(zPostAppsByAppIdStarResponse) export const star = { - delete: delete7, - post: post33, + delete: delete9, + post: post35, } /** * Get average response time statistics for an application */ -export const get29 = oc +export const get39 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2090,13 +2430,13 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get29, + get: get39, } /** * Get average session interaction statistics for an application */ -export const get30 = oc +export const get40 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2114,13 +2454,13 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get30, + get: get40, } /** * Get daily conversation statistics for an application */ -export const get31 = oc +export const get41 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2138,13 +2478,13 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get31, + get: get41, } /** * Get daily terminal/end-user statistics for an application */ -export const get32 = oc +export const get42 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2162,13 +2502,13 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get32, + get: get42, } /** * Get daily message statistics for an application */ -export const get33 = oc +export const get43 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2186,13 +2526,13 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get33, + get: get43, } /** * Get daily token cost statistics for an application */ -export const get34 = oc +export const get44 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2210,13 +2550,13 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get34, + get: get44, } /** * Get tokens per second statistics for an application */ -export const get35 = oc +export const get45 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2234,13 +2574,13 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get35, + get: get45, } /** * Get user satisfaction rate statistics for an application */ -export const get36 = oc +export const get46 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2258,7 +2598,7 @@ export const get36 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get36, + get: get46, } export const statistics = { @@ -2275,7 +2615,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get37 = oc +export const get47 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2293,13 +2633,13 @@ export const get37 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get37, + get: get47, } /** * Convert text to speech for chat messages */ -export const post34 = oc +export const post36 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2314,7 +2654,7 @@ export const post34 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post34, + post: post36, voices, } @@ -2323,7 +2663,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get38 = oc +export const get48 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2339,7 +2679,7 @@ export const get38 = oc /** * Update app tracing configuration */ -export const post35 = oc +export const post37 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2352,8 +2692,8 @@ export const post35 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get38, - post: post35, + get: get48, + post: post37, } /** @@ -2361,7 +2701,7 @@ export const trace = { * * Delete an existing tracing configuration for an application */ -export const delete8 = oc +export const delete10 = oc .route({ description: 'Delete an existing tracing configuration for an application', inputStructure: 'detailed', @@ -2383,7 +2723,7 @@ export const delete8 = oc /** * Get tracing configuration for an application */ -export const get39 = oc +export const get49 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2422,7 +2762,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post36 = oc +export const post38 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2439,16 +2779,16 @@ export const post36 = oc .output(zPostAppsByAppIdTraceConfigResponse) export const traceConfig = { - delete: delete8, - get: get39, + delete: delete10, + get: get49, patch, - post: post36, + post: post38, } /** * Update app trigger (enable/disable) */ -export const post37 = oc +export const post39 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2466,13 +2806,13 @@ export const post37 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post37, + post: post39, } /** * Get app triggers list */ -export const get40 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2485,7 +2825,7 @@ export const get40 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get40, + get: get50, } /** @@ -2493,7 +2833,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get41 = oc +export const get51 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2512,7 +2852,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get41, + get: get51, } /** @@ -2520,7 +2860,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get42 = oc +export const get52 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2539,7 +2879,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get42, + get: get52, } /** @@ -2547,7 +2887,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get43 = oc +export const get53 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2566,7 +2906,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get43, + get: get53, } /** @@ -2574,7 +2914,7 @@ export const count3 = { * * Stop running workflow task */ -export const post38 = oc +export const post40 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2588,7 +2928,7 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post38, + post: post40, } export const byTaskId3 = { @@ -2602,7 +2942,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get44 = oc +export const get54 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2615,7 +2955,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get44, + get: get54, } /** @@ -2623,7 +2963,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get45 = oc +export const get55 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2637,7 +2977,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get45, + get: get55, } /** @@ -2645,7 +2985,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get46 = oc +export const get56 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2659,7 +2999,7 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get46, + get: get56, export: export4, nodeExecutions, } @@ -2667,7 +3007,7 @@ export const byRunId = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get47 = oc +export const get57 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2685,13 +3025,13 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read = { - get: get47, + get: get57, } /** * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const post39 = oc +export const post41 = oc .route({ description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -2708,14 +3048,14 @@ export const post39 = oc ) .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) -export const upload2 = { - post: post39, +export const upload3 = { + post: post41, } /** * List a directory in a workflow Agent node sandbox */ -export const get48 = oc +export const get58 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2733,14 +3073,14 @@ export const get48 = oc ) .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) -export const files3 = { - get: get48, +export const files5 = { + get: get58, read, - upload: upload2, + upload: upload3, } export const sandbox = { - files: files3, + files: files5, } export const byNodeId4 = { @@ -2760,7 +3100,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get49 = oc +export const get59 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2779,7 +3119,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get49, + get: get59, count: count3, tasks, byRunId, @@ -2791,7 +3131,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get50 = oc +export const get60 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2805,7 +3145,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get50, + get: get60, } /** @@ -2813,7 +3153,7 @@ export const mentionUsers = { * * Delete a comment reply */ -export const delete9 = oc +export const delete11 = oc .route({ description: 'Delete a comment reply', inputStructure: 'detailed', @@ -2851,7 +3191,7 @@ export const put2 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse) export const byReplyId = { - delete: delete9, + delete: delete11, put: put2, } @@ -2860,7 +3200,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post40 = oc +export const post42 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2880,7 +3220,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post40, + post: post42, byReplyId, } @@ -2889,7 +3229,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post41 = oc +export const post43 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2903,7 +3243,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post41, + post: post43, } /** @@ -2911,7 +3251,7 @@ export const resolve = { * * Delete a workflow comment */ -export const delete10 = oc +export const delete12 = oc .route({ description: 'Delete a workflow comment', inputStructure: 'detailed', @@ -2930,7 +3270,7 @@ export const delete10 = oc * * Get a specific workflow comment */ -export const get51 = oc +export const get61 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2967,8 +3307,8 @@ export const put3 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse) export const byCommentId = { - delete: delete10, - get: get51, + delete: delete12, + get: get61, put: put3, replies, resolve, @@ -2979,7 +3319,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get52 = oc +export const get62 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2997,7 +3337,7 @@ export const get52 = oc * * Create a new workflow comment */ -export const post42 = oc +export const post44 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3017,8 +3357,8 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get52, - post: post42, + get: get62, + post: post44, mentionUsers, byCommentId, } @@ -3026,7 +3366,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get53 = oc +export const get63 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3044,13 +3384,13 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get53, + get: get63, } /** * Get workflow daily runs statistics */ -export const get54 = oc +export const get64 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3068,13 +3408,13 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get54, + get: get64, } /** * Get workflow daily terminals statistics */ -export const get55 = oc +export const get65 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3092,13 +3432,13 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get55, + get: get65, } /** * Get workflow daily token cost statistics */ -export const get56 = oc +export const get66 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3116,7 +3456,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get56, + get: get66, } export const statistics2 = { @@ -3136,7 +3476,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get57 = oc +export const get67 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3155,7 +3495,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get57, + get: get67, } /** @@ -3163,7 +3503,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get58 = oc +export const get68 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3177,14 +3517,14 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get58, + get: get68, byBlockType, } /** * Get conversation variables for workflow */ -export const get59 = oc +export const get69 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3199,7 +3539,7 @@ export const get59 = oc /** * Update conversation variables for workflow draft */ -export const post43 = oc +export const post45 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3217,8 +3557,8 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get59, - post: post43, + get: get69, + post: post45, } /** @@ -3226,7 +3566,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get60 = oc +export const get70 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3242,7 +3582,7 @@ export const get60 = oc /** * Update environment variables for workflow draft */ -export const post44 = oc +export const post46 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3260,14 +3600,14 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get60, - post: post44, + get: get70, + post: post46, } /** * Update draft workflow features */ -export const post45 = oc +export const post47 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3285,7 +3625,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post45, + post: post47, } /** @@ -3293,7 +3633,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post46 = oc +export const post48 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3312,7 +3652,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post46, + post: post48, } /** @@ -3320,7 +3660,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post47 = oc +export const post49 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3338,8 +3678,8 @@ export const post47 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview3 = { - post: post47, +export const preview5 = { + post: post49, } /** @@ -3347,7 +3687,7 @@ export const preview3 = { * * Submit human input form preview for workflow */ -export const post48 = oc +export const post50 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3366,11 +3706,11 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post48, + post: post50, } export const form2 = { - preview: preview3, + preview: preview5, run: run5, } @@ -3392,7 +3732,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post49 = oc +export const post51 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3411,7 +3751,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post49, + post: post51, } export const byNodeId6 = { @@ -3431,7 +3771,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post50 = oc +export const post52 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3450,7 +3790,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post50, + post: post52, } export const byNodeId7 = { @@ -3465,7 +3805,7 @@ export const loop2 = { nodes: nodes6, } -export const get61 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3479,10 +3819,10 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get61, + get: get71, } -export const post51 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3499,10 +3839,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) export const copyFromRoster = { - post: post51, + post: post53, } -export const post52 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3519,10 +3859,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post52, + post: post54, } -export const post53 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3539,10 +3879,10 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post53, + post: post55, } -export const post54 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3559,10 +3899,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post54, + post: post56, } -export const get62 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3595,7 +3935,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get62, + get: get72, put: put4, candidates, copyFromRoster, @@ -3607,7 +3947,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get63 = oc +export const get73 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3620,7 +3960,7 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get63, + get: get73, } /** @@ -3628,7 +3968,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post55 = oc +export const post57 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3647,7 +3987,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post55, + post: post57, } /** @@ -3655,7 +3995,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post56 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3669,7 +4009,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post56, + post: post58, } export const trigger = { @@ -3679,7 +4019,7 @@ export const trigger = { /** * Delete all variables for a specific node */ -export const delete11 = oc +export const delete13 = oc .route({ description: 'Delete all variables for a specific node', inputStructure: 'detailed', @@ -3695,7 +4035,7 @@ export const delete11 = oc /** * Get variables for a specific node */ -export const get64 = oc +export const get74 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3708,8 +4048,8 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) export const variables = { - delete: delete11, - get: get64, + delete: delete13, + get: get74, } export const byNodeId8 = { @@ -3729,7 +4069,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post57 = oc +export const post59 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3748,13 +4088,13 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post57, + post: post59, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get65 = oc +export const get75 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3767,13 +4107,13 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get65, + get: get75, } /** * Full value for one declared output, including signed download URL for files. */ -export const get66 = oc +export const get76 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3789,18 +4129,18 @@ export const get66 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview4 = { - get: get66, +export const preview6 = { + get: get76, } export const byOutputName = { - preview: preview4, + preview: preview6, } /** * One node's declared outputs for a draft workflow run. */ -export const get67 = oc +export const get77 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3813,14 +4153,14 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get67, + get: get77, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get68 = oc +export const get78 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3833,7 +4173,7 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get68, + get: get78, events, byNodeId: byNodeId9, } @@ -3849,7 +4189,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get69 = oc +export const get79 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3862,7 +4202,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get69, + get: get79, } /** @@ -3870,7 +4210,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post58 = oc +export const post60 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3889,7 +4229,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post58, + post: post60, } /** @@ -3897,7 +4237,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post59 = oc +export const post61 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3916,7 +4256,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post59, + post: post61, } export const trigger2 = { @@ -3946,7 +4286,7 @@ export const reset = { /** * Delete a workflow variable */ -export const delete12 = oc +export const delete14 = oc .route({ description: 'Delete a workflow variable', inputStructure: 'detailed', @@ -3962,7 +4302,7 @@ export const delete12 = oc /** * Get a specific workflow variable */ -export const get70 = oc +export const get80 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -3995,8 +4335,8 @@ export const patch2 = oc .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) export const byVariableId = { - delete: delete12, - get: get70, + delete: delete14, + get: get80, patch: patch2, reset, } @@ -4004,7 +4344,7 @@ export const byVariableId = { /** * Delete all draft workflow variables */ -export const delete13 = oc +export const delete15 = oc .route({ description: 'Delete all draft workflow variables', inputStructure: 'detailed', @@ -4022,7 +4362,7 @@ export const delete13 = oc * * Get draft workflow variables */ -export const get71 = oc +export const get81 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4041,8 +4381,8 @@ export const get71 = oc .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) export const variables2 = { - delete: delete13, - get: get71, + delete: delete15, + get: get81, byVariableId, } @@ -4051,7 +4391,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get72 = oc +export const get82 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4069,7 +4409,7 @@ export const get72 = oc * * Sync draft workflow configuration */ -export const post60 = oc +export const post62 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4088,8 +4428,8 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get72, - post: post60, + get: get82, + post: post62, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4109,7 +4449,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get73 = oc +export const get83 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4125,7 +4465,7 @@ export const get73 = oc /** * Publish workflow */ -export const post61 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4143,14 +4483,14 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get73, - post: post61, + get: get83, + post: post63, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get74 = oc +export const get84 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4163,13 +4503,13 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get74, + get: get84, } /** * Full value for one declared output of a published run. */ -export const get75 = oc +export const get85 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4189,18 +4529,18 @@ export const get75 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview5 = { - get: get75, +export const preview7 = { + get: get85, } export const byOutputName2 = { - preview: preview5, + preview: preview7, } /** * One node's declared outputs for a published workflow run. */ -export const get76 = oc +export const get86 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4213,14 +4553,14 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get76, + get: get86, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get77 = oc +export const get87 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4233,7 +4573,7 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get77, + get: get87, events: events2, byNodeId: byNodeId10, } @@ -4253,7 +4593,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get78 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4271,7 +4611,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get78, + get: get88, } export const triggers2 = { @@ -4281,7 +4621,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post62 = oc +export const post64 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4294,13 +4634,13 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post62, + post: post64, } /** * Delete workflow */ -export const delete14 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4337,7 +4677,7 @@ export const patch3 = oc .output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { - delete: delete14, + delete: delete16, patch: patch3, restore, } @@ -4347,7 +4687,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get79 = oc +export const get89 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4366,7 +4706,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get79, + get: get89, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4380,7 +4720,7 @@ export const workflows3 = { * * Delete application */ -export const delete15 = oc +export const delete17 = oc .route({ description: 'Delete application', inputStructure: 'detailed', @@ -4399,7 +4739,7 @@ export const delete15 = oc * * Get application details */ -export const get80 = oc +export const get90 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4431,8 +4771,8 @@ export const put6 = oc .output(zPutAppsByAppIdResponse) export const byAppId2 = { - delete: delete15, - get: get80, + delete: delete17, + get: get90, put: put6, advancedChat, agent, @@ -4478,7 +4818,7 @@ export const byAppId2 = { * * Delete an API key for an app */ -export const delete16 = oc +export const delete18 = oc .route({ description: 'Delete an API key for an app', inputStructure: 'detailed', @@ -4493,7 +4833,7 @@ export const delete16 = oc .output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse) export const byApiKeyId = { - delete: delete16, + delete: delete18, } /** @@ -4501,7 +4841,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get81 = oc +export const get91 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4519,7 +4859,7 @@ export const get81 = oc * * Create a new API key for an app */ -export const post63 = oc +export const post65 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4534,8 +4874,8 @@ export const post63 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get81, - post: post63, + get: get91, + post: post65, byApiKeyId, } @@ -4546,7 +4886,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get82 = oc +export const get92 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4559,7 +4899,7 @@ export const get82 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get82, + get: get92, } export const server2 = { @@ -4575,7 +4915,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get83 = oc +export const get93 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4593,7 +4933,7 @@ export const get83 = oc * * Create a new application */ -export const post64 = oc +export const post66 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4608,8 +4948,8 @@ export const post64 = oc .output(zPostAppsResponse) export const apps = { - get: get83, - post: post64, + get: get93, + post: post66, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index b8f44b27f12..0354f6cde86 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -177,6 +177,82 @@ export type AdvancedChatWorkflowRunPayload = { query?: string } +export type AgentConfigFileListResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + items?: Array +} + +export type AgentConfigFileUploadPayload = { + upload_file_id: string +} + +export type AgentConfigFileUploadResponse = { + config_version: AgentConfigVersionResponse + file: AgentConfigFileItemResponse +} + +export type AgentConfigDeleteResponse = { + removed_names?: Array + result: 'success' +} + +export type AgentConfigDownloadResponse = { + url: string +} + +export type AgentConfigFilePreviewResponse = { + binary: boolean + name: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentConfigManifestResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + env_keys?: Array + files?: AgentConfigFileItemsResponse + note?: string + skills?: AgentConfigSkillItemsResponse +} + +export type AgentConfigSkillListResponse = { + agent_id: string + config_version: AgentConfigVersionResponse + items?: Array +} + +export type AgentConfigSkillUploadResponse = { + config_version: AgentConfigVersionResponse + skill: AgentConfigSkillItemResponse +} + +export type AgentConfigSkillFilePreviewResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentConfigSkillInspectResponse = { + description?: string + file_tree?: Array<{ + [key: string]: unknown + }> | null + files?: Array + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null + skill_md: AgentConfigSkillMarkdownResponse + source: 'config_skill_zip' + warnings?: Array +} + export type AgentDriveListResponse = { items?: Array } @@ -975,6 +1051,9 @@ export type WorkflowAgentComposerResponse = { backing_app_id?: string | null binding?: AgentComposerBindingResponse | null chat_endpoint?: string | null + debug_conversation_has_messages?: boolean + debug_conversation_id?: string | null + debug_conversation_message_count?: number effective_declared_outputs?: Array hidden_app_backed?: boolean impact_summary?: AgentComposerImpactResponse | null @@ -1318,6 +1397,55 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } +export type AgentConfigVersionResponse = { + id: string + kind: 'build_draft' | 'draft' | 'snapshot' + writable: boolean +} + +export type AgentConfigFileItemResponse = { + file_id?: string | null + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigFileItemsResponse = { + items?: Array +} + +export type AgentConfigSkillItemsResponse = { + items?: Array +} + +export type AgentConfigSkillItemResponse = { + description?: string + file_id?: string | null + hash?: string | null + id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigSkillFileResponse = { + downloadable: boolean + name: string + path: string + previewable: boolean + type: 'directory' | 'file' +} + +export type AgentConfigSkillMarkdownResponse = { + binary: false + path: 'SKILL.md' + size?: number | null + text: string + truncated: boolean +} + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1820,6 +1948,9 @@ export type AgentComposerAgentResponse = { export type AgentSoulConfig = { app_features?: AgentSoulAppFeaturesConfig app_variables?: Array + config_files?: Array + config_note?: string + config_skills?: Array env?: AgentSoulEnvConfig files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig @@ -2152,6 +2283,25 @@ export type AppVariableConfig = { type: string } +export type AgentConfigFileRefConfig = { + file_id: string + file_kind: 'tool_file' | 'upload_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigSkillRefConfig = { + description?: string + file_id: string + file_kind?: 'tool_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + export type AgentSoulEnvConfig = { secret_refs?: Array variables?: Array @@ -3227,6 +3377,297 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses = { export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] +export type GetAppsByAppIdAgentConfigFilesData = { + body?: never + path: { + app_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/files' +} + +export type GetAppsByAppIdAgentConfigFilesResponses = { + 200: AgentConfigFileListResponse +} + +export type GetAppsByAppIdAgentConfigFilesResponse + = GetAppsByAppIdAgentConfigFilesResponses[keyof GetAppsByAppIdAgentConfigFilesResponses] + +export type PostAppsByAppIdAgentConfigFilesData = { + body: AgentConfigFileUploadPayload + path: { + app_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/files' +} + +export type PostAppsByAppIdAgentConfigFilesResponses = { + 201: AgentConfigFileUploadResponse +} + +export type PostAppsByAppIdAgentConfigFilesResponse + = PostAppsByAppIdAgentConfigFilesResponses[keyof PostAppsByAppIdAgentConfigFilesResponses] + +export type DeleteAppsByAppIdAgentConfigFilesByNameData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/files/{name}' +} + +export type DeleteAppsByAppIdAgentConfigFilesByNameResponses = { + 200: AgentConfigDeleteResponse +} + +export type DeleteAppsByAppIdAgentConfigFilesByNameResponse + = DeleteAppsByAppIdAgentConfigFilesByNameResponses[keyof DeleteAppsByAppIdAgentConfigFilesByNameResponses] + +export type GetAppsByAppIdAgentConfigFilesByNameDownloadData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/files/{name}/download' +} + +export type GetAppsByAppIdAgentConfigFilesByNameDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAppsByAppIdAgentConfigFilesByNameDownloadResponse + = GetAppsByAppIdAgentConfigFilesByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigFilesByNameDownloadResponses] + +export type GetAppsByAppIdAgentConfigFilesByNamePreviewData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/files/{name}/preview' +} + +export type GetAppsByAppIdAgentConfigFilesByNamePreviewResponses = { + 200: AgentConfigFilePreviewResponse +} + +export type GetAppsByAppIdAgentConfigFilesByNamePreviewResponse + = GetAppsByAppIdAgentConfigFilesByNamePreviewResponses[keyof GetAppsByAppIdAgentConfigFilesByNamePreviewResponses] + +export type GetAppsByAppIdAgentConfigManifestData = { + body?: never + path: { + app_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/manifest' +} + +export type GetAppsByAppIdAgentConfigManifestResponses = { + 200: AgentConfigManifestResponse +} + +export type GetAppsByAppIdAgentConfigManifestResponse + = GetAppsByAppIdAgentConfigManifestResponses[keyof GetAppsByAppIdAgentConfigManifestResponses] + +export type GetAppsByAppIdAgentConfigSkillsData = { + body?: never + path: { + app_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills' +} + +export type GetAppsByAppIdAgentConfigSkillsResponses = { + 200: AgentConfigSkillListResponse +} + +export type GetAppsByAppIdAgentConfigSkillsResponse + = GetAppsByAppIdAgentConfigSkillsResponses[keyof GetAppsByAppIdAgentConfigSkillsResponses] + +export type PostAppsByAppIdAgentConfigSkillsUploadData = { + body: { + file: Blob | File + } + path: { + app_id: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/upload' +} + +export type PostAppsByAppIdAgentConfigSkillsUploadResponses = { + 201: AgentConfigSkillUploadResponse +} + +export type PostAppsByAppIdAgentConfigSkillsUploadResponse + = PostAppsByAppIdAgentConfigSkillsUploadResponses[keyof PostAppsByAppIdAgentConfigSkillsUploadResponses] + +export type DeleteAppsByAppIdAgentConfigSkillsByNameData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/{name}' +} + +export type DeleteAppsByAppIdAgentConfigSkillsByNameResponses = { + 200: AgentConfigDeleteResponse +} + +export type DeleteAppsByAppIdAgentConfigSkillsByNameResponse + = DeleteAppsByAppIdAgentConfigSkillsByNameResponses[keyof DeleteAppsByAppIdAgentConfigSkillsByNameResponses] + +export type GetAppsByAppIdAgentConfigSkillsByNameDownloadData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/{name}/download' +} + +export type GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAppsByAppIdAgentConfigSkillsByNameDownloadResponse + = GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameDownloadResponses] + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentData = { + body?: never + path: { + app_id: string + name: string + } + query?: never + url: '/apps/{app_id}/agent/config/skills/{name}/files/content' +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse + = GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesContentResponses] + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadData = { + body?: never + path: { + app_id: string + name: string + } + query: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + path: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/{name}/files/download' +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses = { + 200: AgentConfigDownloadResponse +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse + = GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses] + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewData = { + body?: never + path: { + app_id: string + name: string + } + query: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + path: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/{name}/files/preview' +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses = { + 200: AgentConfigSkillFilePreviewResponse +} + +export type GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse + = GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses] + +export type GetAppsByAppIdAgentConfigSkillsByNameInspectData = { + body?: never + path: { + app_id: string + name: string + } + query?: { + draft_type?: 'debug_build' | 'draft' + node_id?: string + version_id?: string + } + url: '/apps/{app_id}/agent/config/skills/{name}/inspect' +} + +export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponses = { + 200: AgentConfigSkillInspectResponse +} + +export type GetAppsByAppIdAgentConfigSkillsByNameInspectResponse + = GetAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetAppsByAppIdAgentConfigSkillsByNameInspectResponses] + export type GetAppsByAppIdAgentDriveFilesData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 89afe8f3ddf..bff73e75da9 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -98,6 +98,50 @@ export const zAdvancedChatWorkflowRunPayload = z.object({ query: z.string().optional().default(''), }) +/** + * AgentConfigFileUploadPayload + */ +export const zAgentConfigFileUploadPayload = z.object({ + upload_file_id: z.string(), +}) + +/** + * AgentConfigDeleteResponse + */ +export const zAgentConfigDeleteResponse = z.object({ + removed_names: z.array(z.string()).optional(), + result: z.literal('success'), +}) + +/** + * AgentConfigDownloadResponse + */ +export const zAgentConfigDownloadResponse = z.object({ + url: z.string(), +}) + +/** + * AgentConfigFilePreviewResponse + */ +export const zAgentConfigFilePreviewResponse = z.object({ + binary: z.boolean(), + name: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentConfigSkillFilePreviewResponse + */ +export const zAgentConfigSkillFilePreviewResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + /** * AgentDriveDownloadResponse */ @@ -932,6 +976,139 @@ export const zImport = z.object({ status: zImportStatus, }) +/** + * AgentConfigVersionResponse + */ +export const zAgentConfigVersionResponse = z.object({ + id: z.string(), + kind: z.enum(['build_draft', 'draft', 'snapshot']), + writable: z.boolean(), +}) + +/** + * AgentConfigFileItemResponse + */ +export const zAgentConfigFileItemResponse = z.object({ + file_id: z.string().nullish(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentConfigFileListResponse + */ +export const zAgentConfigFileListResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + items: z.array(zAgentConfigFileItemResponse).optional(), +}) + +/** + * AgentConfigFileUploadResponse + */ +export const zAgentConfigFileUploadResponse = z.object({ + config_version: zAgentConfigVersionResponse, + file: zAgentConfigFileItemResponse, +}) + +/** + * AgentConfigFileItemsResponse + */ +export const zAgentConfigFileItemsResponse = z.object({ + items: z.array(zAgentConfigFileItemResponse).optional(), +}) + +/** + * AgentConfigSkillItemResponse + */ +export const zAgentConfigSkillItemResponse = z.object({ + description: z.string().optional().default(''), + file_id: z.string().nullish(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentConfigSkillListResponse + */ +export const zAgentConfigSkillListResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + items: z.array(zAgentConfigSkillItemResponse).optional(), +}) + +/** + * AgentConfigSkillUploadResponse + */ +export const zAgentConfigSkillUploadResponse = z.object({ + config_version: zAgentConfigVersionResponse, + skill: zAgentConfigSkillItemResponse, +}) + +/** + * AgentConfigSkillItemsResponse + */ +export const zAgentConfigSkillItemsResponse = z.object({ + items: z.array(zAgentConfigSkillItemResponse).optional(), +}) + +/** + * AgentConfigManifestResponse + */ +export const zAgentConfigManifestResponse = z.object({ + agent_id: z.string(), + config_version: zAgentConfigVersionResponse, + env_keys: z.array(z.string()).optional(), + files: zAgentConfigFileItemsResponse.optional(), + note: z.string().optional().default(''), + skills: zAgentConfigSkillItemsResponse.optional(), +}) + +/** + * AgentConfigSkillFileResponse + */ +export const zAgentConfigSkillFileResponse = z.object({ + downloadable: z.boolean(), + name: z.string(), + path: z.string(), + previewable: z.boolean(), + type: z.enum(['directory', 'file']), +}) + +/** + * AgentConfigSkillMarkdownResponse + */ +export const zAgentConfigSkillMarkdownResponse = z.object({ + binary: z.literal(false), + path: z.literal('SKILL.md'), + size: z.int().nullish(), + text: z.string(), + truncated: z.boolean(), +}) + +/** + * AgentConfigSkillInspectResponse + */ +export const zAgentConfigSkillInspectResponse = z.object({ + description: z.string().optional().default(''), + file_tree: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z.array(zAgentConfigSkillFileResponse).optional(), + hash: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), + skill_md: zAgentConfigSkillMarkdownResponse, + source: z.literal('config_skill_zip'), + warnings: z.array(z.string()).optional(), +}) + /** * AgentDriveItemResponse */ @@ -2542,6 +2719,35 @@ export const zAppVariableConfig = z.object({ type: z.string().min(1).max(64), }) +/** + * AgentConfigFileRefConfig + * + * Stable Agent Soul reference to one config file payload. + */ +export const zAgentConfigFileRefConfig = z.object({ + file_id: z.string().min(1).max(255), + file_kind: z.enum(['tool_file', 'upload_file']), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + +/** + * AgentConfigSkillRefConfig + * + * Stable Agent Soul reference to one normalized skill archive. + */ +export const zAgentConfigSkillRefConfig = z.object({ + description: z.string().optional().default(''), + file_id: z.string().min(1).max(255), + file_kind: z.literal('tool_file').optional().default('tool_file'), + hash: z.string().nullish(), + mime_type: z.string().nullish().default('application/zip'), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + /** * AgentSoulPromptConfig */ @@ -3232,12 +3438,15 @@ export const zAgentSoulDifyToolCredentialRef = z.object({ /** * AgentSoulDifyToolConfig * - * One Dify Plugin Tool configured on Agent Soul. + * One Dify tool configured on Agent Soul. * * The API backend prepares this persisted product shape into - * ``DifyPluginToolConfig`` before sending a run request to Agent backend. - * ``provider_id`` keeps compatibility with existing Agent tool config payloads; - * new callers should send ``plugin_id`` + ``provider`` when available. + * either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a + * run request to Agent backend. ``plugin`` providers keep the direct + * ``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` / + * ``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id`` + * keeps compatibility with existing Agent tool config payloads; new callers + * should send ``plugin_id`` + ``provider`` when available. */ export const zAgentSoulDifyToolConfig = z.object({ credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), @@ -3635,6 +3844,9 @@ export const zAgentSoulKnowledgeConfig = z.object({ export const zAgentSoulConfig = z.object({ app_features: zAgentSoulAppFeaturesConfig.optional(), app_variables: z.array(zAppVariableConfig).optional(), + config_files: z.array(zAgentConfigFileRefConfig).optional(), + config_note: z.string().optional().default(''), + config_skills: z.array(zAgentConfigSkillRefConfig).optional(), env: zAgentSoulEnvConfig.optional(), files: zAgentSoulFilesConfig.optional(), human: zAgentSoulHumanConfig.optional(), @@ -3659,6 +3871,9 @@ export const zWorkflowAgentComposerResponse = z.object({ backing_app_id: z.string().nullish(), binding: zAgentComposerBindingResponse.nullish(), chat_endpoint: z.string().nullish(), + debug_conversation_has_messages: z.boolean().optional().default(false), + debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), hidden_app_backed: z.boolean().optional().default(false), impact_summary: zAgentComposerImpactResponse.nullish(), @@ -4110,6 +4325,233 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath = z.object({ */ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = zGeneratedAppResponse +export const zGetAppsByAppIdAgentConfigFilesPath = z.object({ + app_id: z.uuid(), +}) + +export const zGetAppsByAppIdAgentConfigFilesQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config files + */ +export const zGetAppsByAppIdAgentConfigFilesResponse = zAgentConfigFileListResponse + +export const zPostAppsByAppIdAgentConfigFilesBody = zAgentConfigFileUploadPayload + +export const zPostAppsByAppIdAgentConfigFilesPath = z.object({ + app_id: z.uuid(), +}) + +export const zPostAppsByAppIdAgentConfigFilesQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Uploaded config file + */ +export const zPostAppsByAppIdAgentConfigFilesResponse = zAgentConfigFileUploadResponse + +export const zDeleteAppsByAppIdAgentConfigFilesByNamePath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zDeleteAppsByAppIdAgentConfigFilesByNameQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config file deleted + */ +export const zDeleteAppsByAppIdAgentConfigFilesByNameResponse = zAgentConfigDeleteResponse + +export const zGetAppsByAppIdAgentConfigFilesByNameDownloadPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigFilesByNameDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config file download URL + */ +export const zGetAppsByAppIdAgentConfigFilesByNameDownloadResponse = zAgentConfigDownloadResponse + +export const zGetAppsByAppIdAgentConfigFilesByNamePreviewPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigFilesByNamePreviewQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Preview + */ +export const zGetAppsByAppIdAgentConfigFilesByNamePreviewResponse = zAgentConfigFilePreviewResponse + +export const zGetAppsByAppIdAgentConfigManifestPath = z.object({ + app_id: z.uuid(), +}) + +export const zGetAppsByAppIdAgentConfigManifestQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Agent config manifest + */ +export const zGetAppsByAppIdAgentConfigManifestResponse = zAgentConfigManifestResponse + +export const zGetAppsByAppIdAgentConfigSkillsPath = z.object({ + app_id: z.uuid(), +}) + +export const zGetAppsByAppIdAgentConfigSkillsQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config skills + */ +export const zGetAppsByAppIdAgentConfigSkillsResponse = zAgentConfigSkillListResponse + +export const zPostAppsByAppIdAgentConfigSkillsUploadBody = z.object({ + file: z.custom(), +}) + +export const zPostAppsByAppIdAgentConfigSkillsUploadPath = z.object({ + app_id: z.uuid(), +}) + +export const zPostAppsByAppIdAgentConfigSkillsUploadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Uploaded config skill + */ +export const zPostAppsByAppIdAgentConfigSkillsUploadResponse = zAgentConfigSkillUploadResponse + +export const zDeleteAppsByAppIdAgentConfigSkillsByNamePath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zDeleteAppsByAppIdAgentConfigSkillsByNameQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill deleted + */ +export const zDeleteAppsByAppIdAgentConfigSkillsByNameResponse = zAgentConfigDeleteResponse + +export const zGetAppsByAppIdAgentConfigSkillsByNameDownloadPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigSkillsByNameDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill download URL + */ +export const zGetAppsByAppIdAgentConfigSkillsByNameDownloadResponse = zAgentConfigDownloadResponse + +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesContentPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +/** + * Success + */ +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + path: z.string(), + version_id: z.string().optional(), +}) + +/** + * Config skill file download URL + */ +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse + = zAgentConfigDownloadResponse + +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + path: z.string(), + version_id: z.string().optional(), +}) + +/** + * Config skill file preview + */ +export const zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse + = zAgentConfigSkillFilePreviewResponse + +export const zGetAppsByAppIdAgentConfigSkillsByNameInspectPath = z.object({ + app_id: z.uuid(), + name: z.string(), +}) + +export const zGetAppsByAppIdAgentConfigSkillsByNameInspectQuery = z.object({ + draft_type: z.enum(['debug_build', 'draft']).optional(), + node_id: z.string().optional(), + version_id: z.string().optional(), +}) + +/** + * Config skill inspect view + */ +export const zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse + = zAgentConfigSkillInspectResponse + export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ app_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index 89b2865fd77..035163b9fb7 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -4,44 +4,67 @@ export const contractLoaders = { account: () => import('./account/orpc.gen').then(({ account }) => ({ account })), activate: () => import('./activate/orpc.gen').then(({ activate }) => ({ activate })), agent: () => import('./agent/orpc.gen').then(({ agent }) => ({ agent })), - allWorkspaces: () => import('./all-workspaces/orpc.gen').then(({ allWorkspaces }) => ({ allWorkspaces })), - apiBasedExtension: () => import('./api-based-extension/orpc.gen').then(({ apiBasedExtension }) => ({ apiBasedExtension })), + allWorkspaces: () => + import('./all-workspaces/orpc.gen').then(({ allWorkspaces }) => ({ allWorkspaces })), + apiBasedExtension: () => + import('./api-based-extension/orpc.gen').then(({ apiBasedExtension }) => ({ + apiBasedExtension, + })), apiKeyAuth: () => import('./api-key-auth/orpc.gen').then(({ apiKeyAuth }) => ({ apiKeyAuth })), app: () => import('./app/orpc.gen').then(({ app }) => ({ app })), - appDslVersion: () => import('./app-dsl-version/orpc.gen').then(({ appDslVersion }) => ({ appDslVersion })), + appDslVersion: () => + import('./app-dsl-version/orpc.gen').then(({ appDslVersion }) => ({ appDslVersion })), apps: () => import('./apps/orpc.gen').then(({ apps }) => ({ apps })), auth: () => import('./auth/orpc.gen').then(({ auth }) => ({ auth })), billing: () => import('./billing/orpc.gen').then(({ billing }) => ({ billing })), - codeBasedExtension: () => import('./code-based-extension/orpc.gen').then(({ codeBasedExtension }) => ({ codeBasedExtension })), + codeBasedExtension: () => + import('./code-based-extension/orpc.gen').then(({ codeBasedExtension }) => ({ + codeBasedExtension, + })), compliance: () => import('./compliance/orpc.gen').then(({ compliance }) => ({ compliance })), dataSource: () => import('./data-source/orpc.gen').then(({ dataSource }) => ({ dataSource })), datasets: () => import('./datasets/orpc.gen').then(({ datasets }) => ({ datasets })), - emailCodeLogin: () => import('./email-code-login/orpc.gen').then(({ emailCodeLogin }) => ({ emailCodeLogin })), - emailRegister: () => import('./email-register/orpc.gen').then(({ emailRegister }) => ({ emailRegister })), + emailCodeLogin: () => + import('./email-code-login/orpc.gen').then(({ emailCodeLogin }) => ({ emailCodeLogin })), + emailRegister: () => + import('./email-register/orpc.gen').then(({ emailRegister }) => ({ emailRegister })), explore: () => import('./explore/orpc.gen').then(({ explore }) => ({ explore })), features: () => import('./features/orpc.gen').then(({ features }) => ({ features })), files: () => import('./files/orpc.gen').then(({ files }) => ({ files })), - forgotPassword: () => import('./forgot-password/orpc.gen').then(({ forgotPassword }) => ({ forgotPassword })), + forgotPassword: () => + import('./forgot-password/orpc.gen').then(({ forgotPassword }) => ({ forgotPassword })), form: () => import('./form/orpc.gen').then(({ form }) => ({ form })), info: () => import('./info/orpc.gen').then(({ info }) => ({ info })), - installedApps: () => import('./installed-apps/orpc.gen').then(({ installedApps }) => ({ installedApps })), - instructionGenerate: () => import('./instruction-generate/orpc.gen').then(({ instructionGenerate }) => ({ instructionGenerate })), + installedApps: () => + import('./installed-apps/orpc.gen').then(({ installedApps }) => ({ installedApps })), + instructionGenerate: () => + import('./instruction-generate/orpc.gen').then(({ instructionGenerate }) => ({ + instructionGenerate, + })), login: () => import('./login/orpc.gen').then(({ login }) => ({ login })), logout: () => import('./logout/orpc.gen').then(({ logout }) => ({ logout })), - notification: () => import('./notification/orpc.gen').then(({ notification }) => ({ notification })), + notification: () => + import('./notification/orpc.gen').then(({ notification }) => ({ notification })), notion: () => import('./notion/orpc.gen').then(({ notion }) => ({ notion })), oauth: () => import('./oauth/orpc.gen').then(({ oauth }) => ({ oauth })), rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })), - refreshToken: () => import('./refresh-token/orpc.gen').then(({ refreshToken }) => ({ refreshToken })), + refreshToken: () => + import('./refresh-token/orpc.gen').then(({ refreshToken }) => ({ refreshToken })), remoteFiles: () => import('./remote-files/orpc.gen').then(({ remoteFiles }) => ({ remoteFiles })), - resetPassword: () => import('./reset-password/orpc.gen').then(({ resetPassword }) => ({ resetPassword })), - ruleCodeGenerate: () => import('./rule-code-generate/orpc.gen').then(({ ruleCodeGenerate }) => ({ ruleCodeGenerate })), - ruleGenerate: () => import('./rule-generate/orpc.gen').then(({ ruleGenerate }) => ({ ruleGenerate })), + resetPassword: () => + import('./reset-password/orpc.gen').then(({ resetPassword }) => ({ resetPassword })), + ruleCodeGenerate: () => + import('./rule-code-generate/orpc.gen').then(({ ruleCodeGenerate }) => ({ ruleCodeGenerate })), + ruleGenerate: () => + import('./rule-generate/orpc.gen').then(({ ruleGenerate }) => ({ ruleGenerate })), ruleStructuredOutputGenerate: () => - import('./rule-structured-output-generate/orpc.gen').then(({ ruleStructuredOutputGenerate }) => ({ ruleStructuredOutputGenerate })), + import('./rule-structured-output-generate/orpc.gen').then( + ({ ruleStructuredOutputGenerate }) => ({ ruleStructuredOutputGenerate }), + ), snippets: () => import('./snippets/orpc.gen').then(({ snippets }) => ({ snippets })), spec: () => import('./spec/orpc.gen').then(({ spec }) => ({ spec })), - systemFeatures: () => import('./system-features/orpc.gen').then(({ systemFeatures }) => ({ systemFeatures })), + systemFeatures: () => + import('./system-features/orpc.gen').then(({ systemFeatures }) => ({ systemFeatures })), tagBindings: () => import('./tag-bindings/orpc.gen').then(({ tagBindings }) => ({ tagBindings })), tags: () => import('./tags/orpc.gen').then(({ tags }) => ({ tags })), test: () => import('./test/orpc.gen').then(({ test }) => ({ test })), @@ -49,6 +72,7 @@ export const contractLoaders = { trialModels: () => import('./trial-models/orpc.gen').then(({ trialModels }) => ({ trialModels })), website: () => import('./website/orpc.gen').then(({ website }) => ({ website })), workflow: () => import('./workflow/orpc.gen').then(({ workflow }) => ({ workflow })), - workflowGenerate: () => import('./workflow-generate/orpc.gen').then(({ workflowGenerate }) => ({ workflowGenerate })), + workflowGenerate: () => + import('./workflow-generate/orpc.gen').then(({ workflowGenerate }) => ({ workflowGenerate })), workspaces: () => import('./workspaces/orpc.gen').then(({ workspaces }) => ({ workspaces })), } diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 9b8eddbce25..ebcb6a1d1eb 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -1,7 +1,7 @@ import type { ChatConfig, ChatItemInTree } from '../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import { act, renderHook } from '@testing-library/react' -import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' import { useChat } from '../hooks' @@ -353,6 +353,74 @@ describe('useChat', () => { expect(result.current.chatList[1]!.id).toBe('m-1') }) + it('should process inputs with the per-send input form override', () => { + vi.mocked(ssePost).mockImplementation(async () => undefined) + const { result } = renderHook(() => useChat(undefined, { + inputs: {}, + inputsForm: [{ + type: InputVarType.textInput, + label: 'City', + variable: 'city', + required: true, + hide: false, + }], + })) + + act(() => { + result.current.handleSend('test-url', { + query: 'hello', + inputs: { + enabled: undefined, + }, + overrideInputsForm: [{ + type: InputVarType.checkbox, + label: 'Enabled', + variable: 'enabled', + required: true, + hide: false, + }], + }, {}) + }) + + expect(ssePost).toHaveBeenCalledWith( + 'test-url', + expect.objectContaining({ + body: expect.objectContaining({ + inputs: { + enabled: false, + }, + }), + }), + expect.any(Object), + ) + }) + + it('should settle a send once when the SSE stream errors', () => { + let callbacks: HookCallbacks + const onSendSettled = vi.fn() + + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const { result } = renderHook(() => useChat()) + + act(() => { + result.current.handleSend('test-url', { query: 'hello' }, { + onSendSettled, + }) + }) + + act(() => { + callbacks.onError() + callbacks.onCompleted(true) + }) + + expect(result.current.isResponding).toBe(false) + expect(onSendSettled).toHaveBeenCalledTimes(1) + expect(onSendSettled).toHaveBeenCalledWith(true) + }) + it('should handle onThought and different workflow events', async () => { let callbacks: HookCallbacks @@ -511,6 +579,7 @@ describe('useChat', () => { answer_tokens: 10, message_tokens: 5, provider_response_latency: 0.5, + workflow_run_id: 'workflow-run-from-history', inputs: {}, query: 'hi', }], @@ -545,7 +614,7 @@ describe('useChat', () => { expect(onGetConversationMessages).toHaveBeenCalled() expect(onGetSuggestedQuestions).toHaveBeenCalled() - expect(onConversationComplete).toHaveBeenCalledWith('c-1') + expect(onConversationComplete).toHaveBeenCalledWith('c-1', 'workflow-run-from-history') const updatedResponse = result.current.chatList[1] expect(updatedResponse!.content).toBe('Updated answer from history') // Fetched from mock @@ -1106,6 +1175,44 @@ describe('useChat', () => { expect(result.current.isResponding).toBe(false) }) + it('should settle a resume once when the event stream errors', () => { + let callbacks: HookCallbacks + const onSendSettled = vi.fn() + vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const prevChatTree = [{ + id: 'q-1', + content: 'query', + isAnswer: false, + children: [{ + id: 'm-resume-error', + content: 'initial', + isAnswer: true, + siblingIndex: 0, + }], + }] + + const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + + act(() => { + result.current.handleResume('m-resume-error', 'wr-error', { + isPublicAPI: true, + onSendSettled, + }) + }) + + act(() => { + callbacks.onError() + callbacks.onCompleted(true) + }) + + expect(onSendSettled).toHaveBeenCalledTimes(1) + expect(onSendSettled).toHaveBeenCalledWith(true) + expect(result.current.isResponding).toBe(false) + }) + it('should abort previous workflow event stream when resuming again', () => { const callbacksList: HookCallbacks[] = [] vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => { @@ -1215,7 +1322,7 @@ describe('useChat', () => { await callbacks.onCompleted() }) - expect(onConversationComplete).toHaveBeenCalledWith('c-resume') + expect(onConversationComplete).toHaveBeenCalledWith('c-resume', 'wr-1') expect(onGetSuggestedQuestions).toHaveBeenCalled() expect(result.current.suggestedQuestions).toEqual([]) }) @@ -1489,6 +1596,45 @@ describe('useChat', () => { expect(clearChatListCallback).toHaveBeenCalledWith(false) }) + + it('should keep the first send after a reset acknowledgement', () => { + let clearChatList = true + const clearChatListCallback = vi.fn((nextClearChatList: boolean) => { + clearChatList = nextClearChatList + }) + const { rerender, result } = renderHook(() => useChat( + undefined, + undefined, + undefined, + undefined, + clearChatList, + clearChatListCallback, + )) + + expect(clearChatListCallback).toHaveBeenCalledWith(false) + + rerender() + + act(() => { + result.current.handleSend('test-url', { query: 'first after reset' }, {}) + }) + + expect(ssePost).toHaveBeenCalledWith( + 'test-url', + expect.objectContaining({ + body: expect.objectContaining({ + query: 'first after reset', + }), + }), + expect.any(Object), + ) + expect(result.current.chatList).toEqual(expect.arrayContaining([ + expect.objectContaining({ + content: 'first after reset', + isAnswer: false, + }), + ])) + }) }) describe('annotations and siblings', () => { diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index dbb11d9454c..a10b7652fdf 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -361,6 +361,32 @@ describe('ChatInputArea', () => { expect(textarea).toHaveValue('') }) + it('should keep the textarea when async send is rejected by the owner', async () => { + const user = userEvent.setup({ delay: null }) + const onSend = vi.fn().mockResolvedValue(false) + render() + const textarea = getTextarea()! + + await user.type(textarea, 'Keep this message') + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) + + await waitFor(() => expect(onSend).toHaveBeenCalled()) + expect(textarea).toHaveValue('Keep this message') + }) + + it('should keep the textarea when async send fails', async () => { + const user = userEvent.setup({ delay: null }) + const onSend = vi.fn().mockRejectedValue(new Error('send failed')) + render() + const textarea = getTextarea()! + + await user.type(textarea, 'Retry this message') + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) + + await waitFor(() => expect(onSend).toHaveBeenCalled()) + expect(textarea).toHaveValue('Retry this message') + }) + it('should call onSend and reset the input when pressing Enter', async () => { const user = userEvent.setup({ delay: null }) const onSend = vi.fn() diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index e05aa8648e3..1fbb779c5ee 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -24,6 +24,8 @@ type AudioRecorderWithPermission = typeof Recorder & { getPermission: () => Promise } +type SendAcceptance = void | boolean | Promise + type ChatInputAreaProps = { readonly?: boolean botName?: string @@ -62,10 +64,19 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s const historyRef = useRef(['']) const [currentIndex, setCurrentIndex] = useState(-1) const isComposingRef = useRef(false) + const queryRef = useRef('') const handleQueryChange = useCallback((value: string) => { + queryRef.current = value setQuery(value) setTimeout(handleTextareaResize, 0) }, [handleTextareaResize]) + const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType['files']) => { + const { files, setFiles } = filesStore.getState() + if (queryRef.current === acceptedQuery) + handleQueryChange('') + if (files === acceptedFiles) + setFiles([]) + }, [filesStore, handleQueryChange]) const handleSend = () => { if (!canSend) return @@ -75,15 +86,23 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s return } if (onSend) { - const { files, setFiles } = filesStore.getState() + const { files } = filesStore.getState() if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) { toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' })) return } if (checkInputsForm(inputs, inputsForm)) { - onSend(query, files) - handleQueryChange('') - setFiles([]) + const sendResult = onSend(query, files) as SendAcceptance + if (sendResult instanceof Promise) { + sendResult.then((accepted) => { + if (accepted !== false) + resetAcceptedMessage(query, files) + }).catch(noop) + return + } + + if (sendResult !== false) + resetAcceptedMessage(query, files) } } } diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 72233118d12..dbb770af849 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -51,7 +51,8 @@ type GetAbortController = (abortController: AbortController) => void type SendCallback = { onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise - onConversationComplete?: (conversationId: string) => void + onConversationComplete?: (conversationId: string, workflowRunId?: string) => void + onSendSettled?: (hasError?: boolean) => void isPublicAPI?: boolean } @@ -256,10 +257,19 @@ export const useChat = ( { onGetSuggestedQuestions, onConversationComplete, + onSendSettled, isPublicAPI, }: SendCallback, ) => { const getOrCreatePlayer = createAudioPlayerManager() + let hasSettled = false + const settleSend = (hasError?: boolean) => { + if (hasSettled) + return + + hasSettled = true + onSendSettled?.(hasError) + } // Re-subscribe to workflow events for the specific message const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true` @@ -303,25 +313,29 @@ export const useChat = ( async onCompleted(hasError?: boolean) { handleResponding(false) - if (hasError) - return + try { + if (hasError) + return - if (onConversationComplete) - onConversationComplete(conversationIdRef.current) + if (onConversationComplete) + onConversationComplete(conversationIdRef.current, workflowRunId) - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { - try { - const { data }: any = await onGetSuggestedQuestions( - messageId, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, - ) - setSuggestedQuestions(data) - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setSuggestedQuestions([]) + if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { + try { + const { data }: any = await onGetSuggestedQuestions( + messageId, + newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, + ) + setSuggestedQuestions(data) + } + catch { + setSuggestedQuestions([]) + } } } + finally { + settleSend(hasError) + } }, onFile(file) { // Convert simple file type to MIME type for non-agent mode @@ -404,6 +418,7 @@ export const useChat = ( }, onError() { handleResponding(false) + settleSend(true) }, onWorkflowStarted: ({ workflow_run_id, task_id }) => { handleResponding(true) @@ -669,6 +684,7 @@ export const useChat = ( onGetConversationMessages, onGetSuggestedQuestions, onConversationComplete, + onSendSettled, isPublicAPI, }: SendCallback, ) => { @@ -721,13 +737,14 @@ export const useChat = ( handleResponding(true) hasStopRespondedRef.current = false - const { query, files, inputs, ...restData } = data + const { query, files, inputs, overrideInputsForm, ...restData } = data + const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? [] const bodyParams = { response_mode: 'streaming', conversation_id: conversationIdRef.current, files: getProcessedFiles(files || []), query, - inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []), + inputs: getProcessedInputs(inputs || {}, requestInputsForm), ...restData, } if (bodyParams?.files?.length) { @@ -744,6 +761,14 @@ export const useChat = ( let isAgentMode = false let hasSetResponseId = false + let hasSettled = false + const settleSend = (hasError?: boolean) => { + if (hasSettled) + return + + hasSettled = true + onSendSettled?.(hasError) + } const getOrCreatePlayer = createAudioPlayerManager() @@ -802,63 +827,70 @@ export const useChat = ( async onCompleted(hasError?: boolean) { handleResponding(false) - if (hasError) - return - - if (onConversationComplete) - onConversationComplete(conversationIdRef.current) - - if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) { - const { data }: any = await onGetConversationMessages( - conversationIdRef.current, - newAbortController => conversationMessagesAbortControllerRef.current = newAbortController, - ) - const newResponseItem = data.find((item: any) => item.id === responseItem.id) - if (!newResponseItem) + try { + if (hasError) return - const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer - updateChatTreeNode(responseItem.id, { - content: isUseAgentThought ? '' : newResponseItem.answer, - log: [ - ...newResponseItem.message, - ...(newResponseItem.message.at(-1).role !== 'assistant' - ? [ - { - role: 'assistant', - text: newResponseItem.answer, - files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], - }, - ] - : []), - ], - more: { - time: formatTime(newResponseItem.created_at, 'hh:mm A'), - tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens, - latency: newResponseItem.provider_response_latency.toFixed(2), - tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined, - }, - // for agent log - conversationId: conversationIdRef.current, - input: { - inputs: newResponseItem.inputs, - query: newResponseItem.query, - }, - }) - } - if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { - try { - const { data }: any = await onGetSuggestedQuestions( - responseItem.id, - newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, + let completedWorkflowRunId = responseItem.workflow_run_id + + if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) { + const { data }: any = await onGetConversationMessages( + conversationIdRef.current, + newAbortController => conversationMessagesAbortControllerRef.current = newAbortController, ) - setSuggestedQuestions(data) + const newResponseItem = data.find((item: any) => item.id === responseItem.id) + completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId + if (!newResponseItem) + return onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) + + const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer + updateChatTreeNode(responseItem.id, { + content: isUseAgentThought ? '' : newResponseItem.answer, + log: [ + ...newResponseItem.message, + ...(newResponseItem.message.at(-1).role !== 'assistant' + ? [ + { + role: 'assistant', + text: newResponseItem.answer, + files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], + }, + ] + : []), + ], + more: { + time: formatTime(newResponseItem.created_at, 'hh:mm A'), + tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens, + latency: newResponseItem.provider_response_latency.toFixed(2), + tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined, + }, + // for agent log + conversationId: conversationIdRef.current, + input: { + inputs: newResponseItem.inputs, + query: newResponseItem.query, + }, + }) } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setSuggestedQuestions([]) + + onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) + + if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) { + try { + const { data }: any = await onGetSuggestedQuestions( + responseItem.id, + newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController, + ) + setSuggestedQuestions(data) + } + catch { + setSuggestedQuestions([]) + } } } + finally { + settleSend(hasError) + } }, onFile(file) { // Convert simple file type to MIME type for non-agent mode @@ -965,6 +997,7 @@ export const useChat = ( }, onError() { handleResponding(false) + settleSend(true) updateCurrentQAOnTree({ placeholderQuestionId, questionItem, diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx index a40a5f3d4ca..9fce0509aa3 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/component.spec.tsx @@ -134,6 +134,7 @@ describe('AgentOutputBlockComponent', () => { }), ]), onChange, + undefined, ) expect(mockNodeReplace).toHaveBeenCalledTimes(1) expect(mockSelectNext).not.toHaveBeenCalled() @@ -202,6 +203,7 @@ describe('AgentOutputBlockComponent', () => { }), ]), onChange, + undefined, ) }) @@ -240,6 +242,7 @@ describe('AgentOutputBlockComponent', () => { }), ]), onChange, + undefined, ) expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ @@ -280,6 +283,7 @@ describe('AgentOutputBlockComponent', () => { }), ]), onChange, + undefined, ) }) @@ -320,6 +324,7 @@ describe('AgentOutputBlockComponent', () => { }), ]), onChange, + undefined, ) }) @@ -366,4 +371,23 @@ describe('AgentOutputBlockComponent', () => { expect(screen.getByText('qna_report_pdf')).toBeInTheDocument() expect(screen.getByText('file')).toBeInTheDocument() }) + + it('requests the output editor when hovering a committed output block', () => { + const onEdit = vi.fn() + + render( + , + ) + + fireEvent.mouseEnter(screen.getByText('qna_report_pdf').parentElement!) + + expect(onEdit).toHaveBeenCalledWith('qna_report_pdf', 'file') + }) }) diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx index 68d128f956c..2305cf817c9 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx @@ -29,6 +29,7 @@ type AgentOutputBlockComponentProps = { isEditing: boolean outputs: DeclaredOutputConfig[] onChange?: (outputs: DeclaredOutputConfig[], prompt?: string) => void + onEdit?: (name: string, outputType: AgentOutputTypeOptionValue) => void } function upsertOutput( @@ -61,6 +62,7 @@ const AgentOutputBlockComponent = ({ isEditing, outputs, onChange, + onEdit, }: AgentOutputBlockComponentProps) => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -92,7 +94,7 @@ const AgentOutputBlockComponent = ({ return const nextOutputType = inferAgentOutputType(trimmedName, nextType) - const nextNode = node.replace($createAgentOutputBlockNode(trimmedName, nextOutputType, false, nextOutputs, onChange)) + const nextNode = node.replace($createAgentOutputBlockNode(trimmedName, nextOutputType, false, nextOutputs, onChange, onEdit)) if (selectAfterCommit) nextNode.selectNext() nextPrompt = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') @@ -111,9 +113,11 @@ const AgentOutputBlockComponent = ({ return ( onEdit?.(name, outputType)} > -