diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 6eadd4ce3d8..639976fddca 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -78,11 +78,22 @@ def _filter_snapshot_to_specs( return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers) -def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]: - deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} - if include_drive: - deps["drive"] = DIFY_DRIVE_LAYER_ID - return deps +def _shell_layer_deps() -> dict[str, str]: + return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + + +def _drive_layer_deps() -> dict[str, str]: + return {"shell": DIFY_SHELL_LAYER_ID} + + +def _shell_config_with_drive_ref( + shell_config: DifyShellLayerConfig | None, + drive_config: DifyDriveLayerConfig | None, +) -> DifyShellLayerConfig: + config = shell_config or DifyShellLayerConfig() + if drive_config is None: + return config + return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref}) class AgentBackendModelConfig(BaseModel): @@ -263,14 +274,29 @@ class AgentBackendRunRequestBuilder: ] ) + include_shell = run_input.include_shell 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. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + deps=_shell_layer_deps(), + metadata=run_input.metadata, + config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + ) + ) + if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): a config-only index; - # the agent pulls listed entries through the back proxy by drive_ref. + # Drive Skills & Files declaration (dify.drive): the catalog plus + # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) @@ -312,7 +338,7 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, @@ -336,21 +362,6 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context - # so the agent server can mint per-command Agent Stub env, and on - # drive when present so that env points at /mnt/drive/. - # shellctl connection itself is server-injected. - layers.append( - RunLayerSpec( - name=DIFY_SHELL_LAYER_ID, - type=DIFY_SHELL_LAYER_TYPE_ID, - deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), - metadata=run_input.metadata, - config=run_input.shell_config or DifyShellLayerConfig(), - ) - ) - if run_input.output is not None: layers.append( RunLayerSpec( @@ -462,14 +473,29 @@ class AgentBackendRunRequestBuilder: ] ) + include_shell = run_input.include_shell 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. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + deps=_shell_layer_deps(), + metadata=run_input.metadata, + config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + ) + ) + if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): a config-only index; - # the agent pulls listed entries through the back proxy by drive_ref. + # Drive Skills & Files declaration (dify.drive): the catalog plus + # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) @@ -513,7 +539,7 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, @@ -537,21 +563,6 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context - # so the agent server can mint per-command Agent Stub env, and on - # drive when present so that env points at /mnt/drive/. - # shellctl connection itself is server-injected. - layers.append( - RunLayerSpec( - name=DIFY_SHELL_LAYER_ID, - type=DIFY_SHELL_LAYER_TYPE_ID, - deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), - metadata=run_input.metadata, - config=run_input.shell_config or DifyShellLayerConfig(), - ) - ) - if run_input.output is not None: layers.append( RunLayerSpec( diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 0d65d3de97e..58228cbfb02 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings): description=( "Inject the dify.drive layer (Skills & Files drive manifest declaration) " "into Agent runs. The declaration is an index only — the agent backend " - "pulls the actual SKILL.md / files through the back proxy. Keep it off " - "until the agent backend registers the dify.drive layer type." + "pulls the actual SKILL.md / files through the back proxy. Set this to " + "false only when temporarily rolling back the drive integration." ), - default=False, + default=True, ) diff --git a/api/controllers/console/agent/app_helpers.py b/api/controllers/console/agent/app_helpers.py index 51adc1e136e..7af38b0164d 100644 --- a/api/controllers/console/agent/app_helpers.py +++ b/api/controllers/console/agent/app_helpers.py @@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App: - """Resolve the hidden Agent App backing an Agent Console resource.""" + """Resolve a roster Agent's public Agent App.""" return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + + +def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App: + """Resolve the App that backs an Agent runtime surface. + + This accepts both roster Agent Apps and workflow-only inline Agents with a + hidden backing App. + """ + + return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index b54cf4b6daf..7413be95d75 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -1,10 +1,10 @@ from uuid import UUID +from flask import request from flask_restx import Resource -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( RBACPermission, @@ -28,9 +28,15 @@ from libs.login import login_required from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload +from services.entities.agent_entities import ( + ComposerSavePayload, + WorkflowAgentComposerQuery, + WorkflowComposerCopyFromRosterPayload, +) -register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload) +register_schema_models( + console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload +) register_response_schema_models( console_ns, AgentAppComposerResponse, @@ -41,27 +47,26 @@ register_response_schema_models( ) -def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str: - return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id - - @console_ns.route("/apps//workflows/draft/nodes//agent-composer") class WorkflowAgentComposerApi(Resource): @console_ns.response( 200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__] ) + @console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery)) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_tenant_id def get(self, tenant_id: str, app_model: App, node_id: str): + query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True)) return dump_response( WorkflowAgentComposerResponse, AgentComposerService.load_workflow_composer( tenant_id=tenant_id, app_id=app_model.id, node_id=node_id, + snapshot_id=query.snapshot_id, ), ) @@ -137,6 +142,7 @@ class WorkflowAgentComposerValidateApi(Resource): def post(self, tenant_id: str, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_publish_payload(payload) + AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -228,10 +234,9 @@ class AgentComposerApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentAppComposerResponse, - AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id), + AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)), ) @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @@ -244,13 +249,12 @@ class AgentComposerApi(Resource): @with_current_user_id @with_current_tenant_id def put(self, tenant_id: str, account_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return dump_response( AgentAppComposerResponse, - AgentComposerService.save_agent_app_composer( + AgentComposerService.save_agent_composer( tenant_id=tenant_id, - app_id=app_id, + agent_id=str(agent_id), account_id=account_id, payload=payload, ), @@ -268,9 +272,10 @@ class AgentComposerValidateApi(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID): - _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) + AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_publish_payload(payload) + AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -290,12 +295,11 @@ class AgentComposerCandidatesApi(Resource): @with_current_user_id @with_current_tenant_id def get(self, tenant_id: str, current_user_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentComposerCandidatesResponse, AgentComposerService.get_agent_app_candidates( tenant_id=tenant_id, - app_id=app_id, + agent_id=str(agent_id), user_id=current_user_id, ), ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index ac3f7ef4824..3b33dc68c72 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -7,7 +7,7 @@ from sqlalchemy import func, select from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource from controllers.console.app.app import ( AppDetailWithSite as GenericAppDetailWithSite, @@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account +from models.agent import Agent, AgentStatus from models.enums import ApiTokenType from models.model import ApiToken, App, IconType +from services.agent.composer_service import AgentComposerService from services.agent.errors import AgentNotFoundError from services.agent.observability_service import ( AgentLogQueryParams, @@ -65,7 +67,7 @@ from services.agent.observability_service import ( from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService -from services.entities.agent_entities import RosterListQuery +from services.entities.agent_entities import ComposerSavePayload, RosterListQuery from services.feature_service import FeatureService @@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel): class AgentAppPartial(GenericAppPartial): app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial): class AgentAppDetailWithSite(GenericAppDetailWithSite): app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_id: str +class AgentPublishPayload(BaseModel): + version_note: str | None = Field(default=None, description="Optional note for this published Agent version") + + +class AgentPublishResponse(BaseModel): + result: str + active_config_snapshot_id: str + active_config_snapshot: dict[str, object] | None = None + draft: dict[str, object] | None = None + + +class AgentBuildDraftCheckoutPayload(BaseModel): + force: bool = Field(default=False, description="Overwrite the existing current-user build draft") + + +class AgentBuildDraftResponse(BaseModel): + variant: str + draft: dict[str, object] + agent_soul: dict[str, object] + + +class AgentBuildDraftApplyResponse(BaseModel): + result: str + draft: dict[str, object] + + +class AgentSimpleResultResponse(BaseModel): + result: str + + class AgentAppPagination(GenericAppPagination): data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] validation_alias=AliasChoices("items", "data") @@ -261,6 +297,9 @@ register_schema_models( AgentAppCreatePayload, AgentAppUpdatePayload, AgentAppCopyPayload, + AgentPublishPayload, + AgentBuildDraftCheckoutPayload, + ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, AgentLogsQuery, @@ -277,6 +316,10 @@ register_response_schema_models( AgentAppDetailWithSite, AgentAppPartial, AgentDebugConversationRefreshResponse, + AgentPublishResponse, + AgentBuildDraftResponse, + AgentBuildDraftApplyResponse, + AgentSimpleResultResponse, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentConfigSnapshotRestoreResponse, @@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: +def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict: """Serialize an Agent App detail using roster-only DTOs. `/agent` responses are roster-shaped rather than raw app-shaped: `id` @@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: roster_service = _agent_roster_service() payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") - agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id)) + agent = ( + db.session.scalar( + select(Agent).where( + Agent.tenant_id == app_model.tenant_id, + Agent.id == agent_id, + Agent.status == AgentStatus.ACTIVE, + ) + ) + if agent_id + else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id)) + ) if not agent: raise AgentNotFoundError() payload.pop("bound_agent_id", None) - payload["app_id"] = str(app_model.id) + payload["app_id"] = agent.app_id + 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( tenant_id=app_model.tenant_id, @@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u agent = agents_by_app_id.get(app_id) if agent: item["app_id"] = app_id + item["backing_app_id"] = agent.backing_app_id or app_id + item["hidden_app_backed"] = False item["id"] = agent.id item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id) item["role"] = agent.role or "" @@ -516,8 +573,8 @@ class AgentAppApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _serialize_agent_app_detail(app_model, current_user=current_user) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id)) @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) @console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource): ) +@console_ns.route("/agent//publish") +class AgentPublishApi(Resource): + @console_ns.expect(console_ns.models[AgentPublishPayload.__name__]) + @console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__]) + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentPublishPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.publish_agent_app_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + version_note=args.version_note, + ) + + +@console_ns.route("/agent//build-draft/checkout") +class AgentBuildDraftCheckoutApi(Resource): + @console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__]) + @console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.checkout_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + force=args.force, + ) + + +@console_ns.route("/agent//build-draft") +class AgentBuildDraftApi(Resource): + @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.load_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, agent_id: UUID): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + payload=payload, + ) + + @console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.discard_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + +@console_ns.route("/agent//build-draft/apply") +class AgentBuildDraftApplyApi(Resource): + @console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.apply_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.route("/agent//copy") class AgentAppCopyApi(Resource): @console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__]) @@ -712,7 +875,7 @@ class AgentLogsApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) query_data["sources"] = _multi_query_values("sources", "source") query_data["statuses"] = _multi_query_values("statuses", "status") @@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) query_data["sources"] = _multi_query_values("sources", "source") query_data["statuses"] = _multi_query_values("statuses", "status") @@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id)) return dump_response(AgentLogSourceListResponse, payload) @@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) timezone = current_user.timezone or "UTC" start, end = _parse_observability_time_range(query.start, query.end, current_user) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 86a3c473547..99164b4755a 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -13,7 +13,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +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, @@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _upload_skill_for_app(current_user=current_user, app_model=app_model) @@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) @console_ns.doc("delete_agent_drive_file_by_agent") @@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource): @with_current_user @with_current_tenant_id def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) @@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource): @with_current_user @with_current_tenant_id def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False) @@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID, slug: str): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _infer_skill_tools_for_app(app_model=app_model, slug=slug) diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 358e552beb0..be6b9b28543 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {}) new_app_model_config = AgentAppFeatureConfigService.update_features( diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index f9bda13c63a..9d92c078bd4 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -22,7 +22,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel @@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxListQuery) try: result = AgentAppSandboxService().list_files( @@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxFileQuery) try: result = AgentAppSandboxService().read_file( @@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) try: result = AgentAppSandboxService().upload_file( diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py index bd639955d9c..473e7364b3e 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -25,7 +25,7 @@ from controllers.common.schema import ( register_response_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel @@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveListByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix) except AgentDriveError as exc: @@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id)) except AgentDriveError as exc: @@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID, skill_path: str): - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: return _json_response( AgentDriveService().inspect_skill( @@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: @@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index ff1eeeb8907..4f7a3594572 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -331,7 +331,7 @@ class ModelConfig(ResponseModel): return to_timestamp(value) -class Site(ResponseModel): +class AppDetailSiteResponse(ResponseModel): access_token: str | None = Field(default=None, validation_alias="code") code: str | None = None title: str | None = None @@ -461,7 +461,7 @@ class AppDetailWithSite(AppDetail): api_base_url: str | None = None max_active_requests: int | None = None deleted_tools: list[DeletedTool] = Field(default_factory=list) - site: Site | None = None + site: AppDetailSiteResponse | None = None # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None # For Agent App responses exposed through /agent. @@ -546,7 +546,7 @@ register_schema_models( WorkflowPartial, ModelConfigPartial, ModelConfig, - Site, + AppDetailSiteResponse, DeletedTool, AppDetail, AppExportResponse, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 545fad34cde..16c1a2f570b 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -11,7 +11,7 @@ import services from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( AppUnavailableError, CompletionRequestError, @@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload): query: str = Field(..., description="User query") conversation_id: str | None = Field(default=None, description="Conversation ID") parent_message_id: str | None = Field(default=None, description="Parent message ID") + draft_type: Literal["draft", "debug_build"] = Field( + default="draft", + description="Agent App debug config source. Use debug_build while the Agent is in build mode.", + ) @field_validator("conversation_id", "parent_message_id") @classmethod @@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource): @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _create_chat_message( current_tenant_id=current_tenant_id, current_user=current_user, @@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 195a41f2888..a9abb7ed22c 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes from controllers.common.fields import SimpleResultResponse, TextFileResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( CompletionRequestError, ProviderModelCurrentlyNotSupportError, @@ -210,7 +210,7 @@ class AgentChatMessageListApi(Resource): @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _list_chat_messages(app_model=app_model, current_user=current_user) @@ -246,7 +246,7 @@ class AgentMessageFeedbackApi(Resource): @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _update_message_feedback(current_user=current_user, app_model=app_model) @@ -311,7 +311,7 @@ class AgentMessageSuggestedQuestionApi(Resource): @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @@ -389,7 +389,7 @@ class AgentMessageApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _get_message_detail(app_model=app_model, message_id=message_id) diff --git a/api/controllers/files/__init__.py b/api/controllers/files/__init__.py index f8976b86b9f..5d26308e430 100644 --- a/api/controllers/files/__init__.py +++ b/api/controllers/files/__init__.py @@ -14,11 +14,12 @@ api = ExternalApi( files_ns = Namespace("files", description="File operations", path="/") -from . import image_preview, tool_files, upload +from . import agent_drive_archive, image_preview, tool_files, upload api.add_namespace(files_ns) __all__ = [ + "agent_drive_archive", "api", "bp", "files_ns", diff --git a/api/controllers/files/agent_drive_archive.py b/api/controllers/files/agent_drive_archive.py new file mode 100644 index 00000000000..afa6ac79483 --- /dev/null +++ b/api/controllers/files/agent_drive_archive.py @@ -0,0 +1,67 @@ +from urllib.parse import quote + +from flask import Response, request +from flask_restx import Resource +from pydantic import BaseModel, Field +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models +from controllers.files import files_ns +from models.agent import AgentDriveFileKind +from services.agent_drive_service import AgentDriveError, AgentDriveService + + +class AgentDriveArchiveMemberQuery(BaseModel): + tenant_id: str = Field(..., description="Tenant ID") + agent_id: str = Field(..., description="Agent ID") + key: str = Field(..., description="Virtual drive key") + archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind") + archive_file_id: str = Field(..., description="Archive file id") + member_path: str = Field(..., description="Zip member path") + timestamp: str = Field(..., description="Unix timestamp") + nonce: str = Field(..., description="Random nonce") + sign: str = Field(..., description="HMAC signature") + as_attachment: bool = Field(default=False, description="Download as attachment") + + +register_schema_models(files_ns, AgentDriveArchiveMemberQuery) + + +@files_ns.route("/agent-drive/archive-member") +class AgentDriveArchiveMemberApi(Resource): + @files_ns.doc("get_agent_drive_archive_member") + @files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters") + def get(self): + args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True)) + if not AgentDriveService.verify_archive_member_signature( + tenant_id=args.tenant_id, + agent_id=args.agent_id, + key=args.key, + archive_file_kind=args.archive_file_kind, + archive_file_id=args.archive_file_id, + member_path=args.member_path, + timestamp=args.timestamp, + nonce=args.nonce, + sign=args.sign, + ): + raise Forbidden("Invalid request.") + try: + payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request( + tenant_id=args.tenant_id, + agent_id=args.agent_id, + key=args.key, + archive_file_kind=args.archive_file_kind, + archive_file_id=args.archive_file_id, + member_path=args.member_path, + ) + except AgentDriveError as exc: + raise NotFound(exc.message) from exc + + response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={}) + response.headers["Content-Length"] = str(len(payload)) + if args.as_attachment and filename: + encoded_filename = quote(filename) + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="") + return response diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index f816b1fa477..4f2c546fb7b 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping from typing import Any from flask import Flask, current_app -from sqlalchemy import select +from sqlalchemy import and_, or_, select from clients.agent_backend import AgentBackendRunEventAdapter from clients.agent_backend.factory import create_agent_backend_run_client @@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access from core.ops.ops_trace_manager import TraceQueueManager from extensions.ext_database import db from models import Account, App, EndUser, Message -from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) from models.agent_config_entities import AgentSoulConfig from services.conversation_service import ConversationService @@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): inputs = args["inputs"] # Resolve the bound roster Agent + its current Agent Soul snapshot. - agent, snapshot, agent_soul = self._resolve_agent(app_model) + agent, agent_config_id, 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=snapshot.id, + snapshot_id=agent_config_id, ) conversation = None @@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, agent_runtime_session_snapshot_id=runtime_session_snapshot_id, ) @@ -179,7 +192,12 @@ 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, snapshot, agent_soul = self._resolve_agent(app_model) + 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 ) @@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator): return False, query - def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + def _resolve_agent( + self, + app_model: App, + *, + invoke_from: InvokeFrom, + draft_type: Any, + user: Account | EndUser, + ) -> tuple[Agent, str, AgentSoulConfig]: agent = db.session.scalar( - select(Agent).where( - Agent.app_id == app_model.id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + select(Agent) + .where( + Agent.tenant_id == app_model.tenant_id, Agent.status == AgentStatus.ACTIVE, + or_( + and_( + Agent.app_id == app_model.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + ), + Agent.backing_app_id == app_model.id, + ), ) + .order_by(Agent.created_at.desc()) + .limit(1) ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") - return self._resolve_agent_by_id( - tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id + if invoke_from == InvokeFrom.DEBUGGER: + draft = self._resolve_debug_draft( + tenant_id=app_model.tenant_id, + agent=agent, + draft_type=draft_type, + 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 + _, 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 @staticmethod def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: """Return the session scope snapshot id for Agent App runtime state. - Console preview/debug chat is an editing workspace: saving Agent Soul - creates replacement snapshots, but the user expects the same preview - conversation to keep context while trying prompt changes. Use a stable - NULL snapshot scope for debugger runs so each turn can use the latest - Agent Soul while reusing the conversation history. Published/web/API - runs keep snapshot-scoped sessions for reproducible runtime state. + Console preview/debug chat uses a stable Agent draft row id; build mode + uses the current user's build-draft row id. Published/web/API runs use + immutable published snapshot ids. This keeps runtime session continuity + inside one editable surface without mixing draft/build/published state. """ - if invoke_from == InvokeFrom.DEBUGGER: - return None return snapshot_id + @staticmethod + def _resolve_debug_draft( + *, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None + ) -> AgentConfigDraft: + effective_draft_type = ( + AgentConfigDraftType.DEBUG_BUILD + if draft_type == AgentConfigDraftType.DEBUG_BUILD.value + else AgentConfigDraftType.DRAFT + ) + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent.id, + AgentConfigDraft.draft_type == effective_draft_type, + ) + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + if not account_id: + raise AgentAppGeneratorError("Build draft requires an account user") + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + if draft is not None: + return draft + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + raise AgentAppGeneratorError("Agent build draft not found") + _, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id( + tenant_id=tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=agent_soul, + created_by=agent.created_by, + updated_by=agent.updated_by, + ) + db.session.add(draft) + db.session.flush() + return draft + @staticmethod def _resolve_agent_by_id( *, tenant_id: str, agent_id: str, snapshot_id: str | None - ) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + ) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]: agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) if agent is None: raise AgentAppGeneratorError("Agent not found") if not snapshot_id: raise AgentAppGeneratorError("Agent has no published version") - snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id)) - if snapshot is None: + snapshot = db.session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == snapshot_id, + ) + ) + if snapshot is not None: + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + return agent, snapshot, agent_soul + draft = db.session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.id == snapshot_id, + ) + ) + if draft is None: raise AgentAppGeneratorError("Agent published version not found") - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - return agent, snapshot, agent_soul + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + return agent, draft, agent_soul __all__ = ["AgentAppGenerator", "AgentAppGeneratorError"] diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index fa7b28cbb0a..8fd2783f61f 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from models.agent_config_entities import AgentSoulConfig +from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( { @@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any ) reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") - reserved_status["knowledge"] = ( - "supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured" - ) + reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured" reserved_status["tools.dify_tools"] = "supported_when_config_valid" reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" reserved_status["env"] = "supported_by_shell_bootstrap" @@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: - """Return the normalized knowledge dataset ids that can produce a runtime layer. + """Return normalized dataset ids selected by Agent v2 knowledge sets. ``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()`` - must stay aligned: both decide knowledge support from this effective, - non-blank dataset-id set rather than from raw - ``agent_soul.knowledge.datasets`` entries. + stay aligned on the set-based contract: DTO validation rejects blank dataset + ids before runtime, so this helper only flattens configured set datasets for + metadata/diagnostic surfaces that still need a dataset-id summary. """ - return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())] + return list_agent_soul_knowledge_dataset_ids(agent_soul) def _get_nested(value: dict[str, Any], path: str) -> Any: 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 e5a541ed350..9eab82a8afc 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -15,7 +15,16 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) -from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig +from dify_agent.layers.knowledge import ( + DifyKnowledgeBaseLayerConfig, + DifyKnowledgeDatasetConfig, + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeModelConfig, + DifyKnowledgeQueryConfig, + DifyKnowledgeRerankingModelConfig, + DifyKnowledgeRetrievalConfig, + DifyKnowledgeSetConfig, +) from dify_agent.layers.shell import ( DifyShellCliToolConfig, DifyShellEnvVarConfig, @@ -40,7 +49,9 @@ from graphon.file import FileTransferMethod from graphon.variables.segments import Segment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( - AgentKnowledgeQueryConfig, + AgentKnowledgeMetadataFilteringConfig, + AgentKnowledgeModelConfig, + AgentKnowledgeRetrievalConfig, AgentSoulConfig, DeclaredArrayItem, DeclaredOutputChildConfig, @@ -64,7 +75,7 @@ from services.agent_drive_service import AgentDriveService, decode_drive_mention from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError -from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids +from .runtime_feature_manifest import build_runtime_feature_manifest _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) _DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") @@ -547,42 +558,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: - """Map Agent Soul knowledge config into the fixed Dify knowledge-base layer. + """Map Agent Soul knowledge sets into one Dify knowledge-base layer. - Normalization intentionally matches the current dify-agent runtime contract: - - - blank or missing dataset ids are ignored; - - if no valid dataset ids remain, no knowledge layer is injected; - - retrieval mode is always forced to ``multiple`` in this first wiring pass; - - ``top_k`` falls back to a stable runtime default when the soul omits it; - - ``score_threshold`` is only forwarded when the product config explicitly - enables it, otherwise the layer keeps the disabled/default ``0.0`` value; - - metadata filtering stays at the layer DTO default (disabled). + Agent Soul DTO validation owns malformed set rejection. Runtime mapping is + intentionally lossless: every configured set is forwarded with its query + policy, dataset refs, retrieval controls, and metadata-filtering controls. + ``score_threshold=None`` means disabled threshold filtering and maps to the + inner retrieval request's ``0.0`` default through the Agent backend DTO. """ - dataset_ids = list_configured_knowledge_dataset_ids(agent_soul) - if not dataset_ids: + if not agent_soul.knowledge.sets: return None - query_config = agent_soul.knowledge.query_config return DifyKnowledgeBaseLayerConfig( - dataset_ids=dataset_ids, - retrieval=DifyKnowledgeRetrievalConfig( - mode="multiple", - top_k=_knowledge_top_k(query_config), - score_threshold=_knowledge_score_threshold(query_config), - ), + sets=[ + DifyKnowledgeSetConfig( + id=knowledge_set.id, + name=knowledge_set.name, + description=knowledge_set.description, + datasets=[ + DifyKnowledgeDatasetConfig( + id=dataset.id or "", + name=dataset.name, + description=dataset.description, + ) + for dataset in knowledge_set.datasets + ], + query=DifyKnowledgeQueryConfig( + mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value), + value=knowledge_set.query.value, + ), + retrieval=_knowledge_retrieval_config(knowledge_set.retrieval), + metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering), + ) + for knowledge_set in agent_soul.knowledge.sets + ], ) -def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int: - top_k = query_config.top_k - return top_k if isinstance(top_k, int) and top_k >= 1 else 4 +def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig: + return DifyKnowledgeRetrievalConfig( + mode=retrieval.mode, + top_k=retrieval.top_k, + score_threshold=retrieval.score_threshold or 0.0, + reranking_mode=retrieval.reranking_mode, + reranking_enable=retrieval.reranking_enable, + reranking_model=DifyKnowledgeRerankingModelConfig( + provider=retrieval.reranking_model.provider, + model=retrieval.reranking_model.model, + ) + if retrieval.reranking_model is not None + else None, + weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True)) + if retrieval.weights is not None + else None, + model=_knowledge_model_config(retrieval.model), + ) -def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float: - if query_config.score_threshold_enabled and query_config.score_threshold is not None: - return query_config.score_threshold - return 0.0 +def _knowledge_metadata_filtering_config( + metadata_filtering: AgentKnowledgeMetadataFilteringConfig, +) -> DifyKnowledgeMetadataFilteringConfig: + return DifyKnowledgeMetadataFilteringConfig( + mode=metadata_filtering.mode, + model_config=_knowledge_model_config(metadata_filtering.metadata_model_config), + conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json")) + if metadata_filtering.conditions is not None + else None, + ) + + +def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None: + if model is None: + return None + return DifyKnowledgeModelConfig( + provider=model.provider, + name=model.name, + mode=model.mode, + completion_params=model.completion_params, + ) def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 2eabac10dd6..7b915fe02be 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -18,6 +18,7 @@ from models.agent_config_entities import ( ) from models.model import UploadFile from models.workflow import Workflow +from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids from .entities import DifyAgentNodeData @@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator: ) cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul) cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul) + cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul) node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology) @@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator: ) cli_tool_names.add(normalized_name) + @classmethod + def _validate_agent_soul_knowledge( + cls, + *, + binding: WorkflowAgentNodeBinding, + agent_soul: AgentSoulConfig, + ) -> None: + """Validate knowledge set dataset rows against the publishing tenant.""" + missing_ids = list_missing_tenant_knowledge_dataset_ids( + tenant_id=binding.tenant_id, + agent_soul=agent_soul, + ) + if missing_ids: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: " + f"{', '.join(missing_ids)}." + ) + @classmethod def _validate_agent_soul_env( cls, diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index e60a6b01426..e045ee39afc 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -6,6 +6,7 @@ from pydantic import Field, field_validator from fields.base import ResponseModel from libs.helper import to_timestamp from models.agent import ( + AgentConfigDraftType, AgentConfigRevisionOperation, AgentIconType, AgentKind, @@ -47,6 +48,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel): created_at: int | None = None +class AgentConfigDraftSummaryResponse(ResponseModel): + id: str + agent_id: str + draft_type: AgentConfigDraftType + account_id: str | None = None + base_snapshot_id: str | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: int | None = None + updated_at: int | None = None + + class AgentPublishedReferenceResponse(ResponseModel): app_id: str app_name: str @@ -72,6 +85,8 @@ class AgentRosterResponse(ResponseModel): scope: AgentScope source: AgentSource app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False workflow_id: str | None = None workflow_node_id: str | None = None active_config_snapshot_id: str | None = None @@ -292,14 +307,24 @@ class AgentConfigSnapshotListResponse(ResponseModel): class AgentConfigSnapshotRestoreResponse(ResponseModel): result: Literal["success"] active_config_snapshot_id: str + draft_config_id: str | None = None + restored_version_id: str | None = None class AgentComposerAgentResponse(ResponseModel): id: str name: str description: str + role: str | None = None + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None scope: AgentScope + source: AgentSource | None = None status: AgentStatus + app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False active_config_snapshot_id: str | None = None @@ -343,6 +368,9 @@ class WorkflowAgentComposerResponse(ResponseModel): impact_summary: AgentComposerImpactResponse | None = None validation: "ComposerValidationFindingsResponse | None" = None app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False + chat_endpoint: str | None = None workflow_id: str | None = None node_id: str | None = None @@ -350,10 +378,15 @@ class WorkflowAgentComposerResponse(ResponseModel): class AgentAppComposerResponse(ResponseModel): variant: Literal[ComposerVariant.AGENT_APP] agent: AgentComposerAgentResponse - active_config_snapshot: AgentConfigSnapshotSummaryResponse + active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + draft: AgentConfigDraftSummaryResponse | None = None agent_soul: AgentSoulConfig save_options: list[ComposerSaveStrategy] validation: "ComposerValidationFindingsResponse | None" = None + app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False + chat_endpoint: str | None = None class ComposerValidationWarningResponse(ResponseModel): @@ -400,10 +433,22 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel): human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list) +class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig): + missing: bool = False + + +class AgentComposerKnowledgeSetCandidateResponse(ResponseModel): + id: str + name: str + description: str | None = None + datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list) + missing_dataset_ids: list[str] = Field(default_factory=list) + + class AgentComposerSoulCandidatesResponse(ResponseModel): dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list) cli_tools: list[AgentCliToolConfig] = Field(default_factory=list) - knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) + knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list) human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list) diff --git a/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py b/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py index 7348e19b3cc..8078c83fa14 100644 --- a/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py +++ b/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py @@ -1,16 +1,5 @@ """add workflow_version to workflow_agent_node_bindings -Restores the stage 1 §5.3 unique key -``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published -workflow bindings can coexist at the same workflow_id once we want to track -them per workflow version. ``workflow_version`` mirrors ``workflows.version`` -("draft" or a published version string). - -Because the New Agent Experience feature is pre-release, this table is empty -in every environment that matters; the ``server_default='draft'`` only exists -to keep developer-local rows valid during the alter and is dropped immediately -afterward so application code must specify ``workflow_version`` explicitly. - Revision ID: 97e2e1a644e8 Revises: f8b6b7e9c421 Create Date: 2026-05-25 11:43:37.611300 @@ -33,10 +22,8 @@ def upgrade(): 'workflow_version', sa.String(length=255), nullable=False, - server_default='draft', ) ) - batch_op.alter_column('workflow_version', server_default=None) batch_op.drop_constraint( batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique' ) diff --git a/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py index 900f7da06fc..ee30abaa45b 100644 --- a/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py +++ b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py @@ -18,8 +18,7 @@ depends_on = None def upgrade(): with op.batch_alter_table("agents", schema=None) as batch_op: - batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default="")) - batch_op.alter_column("role", server_default=None) + batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False)) def downgrade(): diff --git a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py index 3398c2eb018..9dc85d2a89b 100644 --- a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py +++ b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py @@ -6,15 +6,9 @@ Create Date: 2026-06-18 23:00:00.000000 """ -from __future__ import annotations - -import json -from typing import Any - -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import mysql -from sqlalchemy.engine.mock import MockConnection # revision identifiers, used by Alembic. revision = "b2515f9d4c2a" @@ -37,46 +31,9 @@ def upgrade() -> None: "agent_drive_files", ["tenant_id", "agent_id", "is_skill", "key"], ) - _remove_skills_files_from_snapshots() def downgrade() -> None: op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files") op.drop_column("agent_drive_files", "skill_metadata") op.drop_column("agent_drive_files", "is_skill") - - -def _remove_skills_files_from_snapshots() -> None: - connection = op.get_bind() - if connection is None or isinstance(connection, MockConnection): - return - snapshots = sa.table( - "agent_config_snapshots", - sa.column("id", sa.String()), - sa.column("config_snapshot", sa.Text()), - ) - rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall() - for row in rows: - cleaned = _strip_skills_files(row.config_snapshot) - if cleaned is None: - continue - connection.execute( - snapshots.update() - .where(snapshots.c.id == row.id) - .values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True)) - ) - - -def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None: - if raw_snapshot is None: - return None - if isinstance(raw_snapshot, str): - snapshot = json.loads(raw_snapshot) - elif isinstance(raw_snapshot, dict): - snapshot = dict(raw_snapshot) - else: - snapshot = dict(raw_snapshot) - if not isinstance(snapshot, dict) or "skills_files" not in snapshot: - return None - snapshot.pop("skills_files", None) - return snapshot diff --git a/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py new file mode 100644 index 00000000000..fbadead1ab0 --- /dev/null +++ b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py @@ -0,0 +1,56 @@ +"""add agent config drafts + +Revision ID: e4f5a6b7c8d9 +Revises: d9e8f7a6b5c4 +Create Date: 2026-06-24 20:15:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "e4f5a6b7c8d9" +down_revision = "d9e8f7a6b5c4" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "agent_config_drafts", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("draft_type", sa.String(length=32), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=True), + sa.Column("draft_owner_key", sa.String(length=255), nullable=False), + sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("config_snapshot", models.types.LongText(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name=op.f("agent_config_draft_agent_type_account_unique"), + ), + ) + op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"]) + op.create_index( + "agent_config_draft_base_snapshot_idx", + "agent_config_drafts", + ["tenant_id", "base_snapshot_id"], + ) + + +def downgrade(): + op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts") + op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts") + op.drop_table("agent_config_drafts") diff --git a/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py b/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py new file mode 100644 index 00000000000..be19ecf7ed9 --- /dev/null +++ b/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py @@ -0,0 +1,30 @@ +"""add agent backing app id + +Revision ID: a2b3c4d5e6f7 +Revises: e4f5a6b7c8d9 +Create Date: 2026-06-25 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "a2b3c4d5e6f7" +down_revision = "e4f5a6b7c8d9" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True)) + op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"]) + + +def downgrade(): + op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents") + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_column("backing_app_id") diff --git a/api/models/__init__.py b/api/models/__init__.py index 9992de982c4..ac90eef0962 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,8 @@ from .account import ( ) from .agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -154,6 +156,8 @@ __all__ = [ "AccountStatus", "AccountTrialAppRecord", "Agent", + "AgentConfigDraft", + "AgentConfigDraftType", "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", diff --git a/api/models/agent.py b/api/models/agent.py index 46044edd5e7..43547d7a220 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum): SAVE_TO_ROSTER = "save_to_roster" # Switches the Agent's current published config back to an existing version. RESTORE_VERSION = "restore_version" + # Publishes the editable Agent Soul draft as a new immutable version. + PUBLISH_DRAFT = "publish_draft" + + +class AgentConfigDraftType(StrEnum): + """Editable Agent Soul draft workspace type.""" + + # Shared Agent Console draft edited by users before publishing. + DRAFT = "draft" + # Per-editor build draft mutated during debug/build mode. + DEBUG_BUILD = "debug_build" class WorkflowAgentBindingType(StrEnum): @@ -134,6 +145,7 @@ class Agent(DefaultFieldsMixin, Base): Index("agent_tenant_scope_idx", "tenant_id", "scope"), Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), + Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"), Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), Index( "agent_tenant_invitable_idx", @@ -162,6 +174,14 @@ class Agent(DefaultFieldsMixin, Base): scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False) source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False) app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + backing_app_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + comment=( + "Runtime Agent App used for chat/log/monitoring. For workflow-only agents, " + "app_id remains the parent workflow app id and this points to the hidden backing app." + ), + ) workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) @@ -210,6 +230,44 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) +class AgentConfigDraft(DefaultFieldsMixin, Base): + """Editable Agent Soul draft separated from immutable published snapshots.""" + + __tablename__ = "agent_config_drafts" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"), + UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name="agent_config_draft_agent_type_account_unique", + ), + Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"), + Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False) + account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="") + base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + + @property + def config_snapshot_dict(self) -> dict[str, Any]: + if not self.config_snapshot: + return {} + if hasattr(self.config_snapshot, "model_dump"): + return self.config_snapshot.model_dump(mode="json") + if isinstance(self.config_snapshot, str): + return json.loads(self.config_snapshot) + return dict(self.config_snapshot) + + class AgentConfigSnapshot(DefaultFieldsMixin, Base): """Immutable Agent Soul snapshot. @@ -355,9 +413,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): agent_config_snapshot_id / composition_layer_specs`` columns are set. - Agent App conversations: ``owner_type = conversation``; the ``conversation_id`` column is set and the workflow columns stay NULL. - Published/web/API runs scope runtime state by ``agent_config_snapshot_id``; - console debugger runs may keep it NULL so prompt-only draft saves can reuse - the same preview conversation state while executing the latest Agent Soul. + Runtime state is scoped by ``agent_config_snapshot_id``. For published + web/API runs this points to an immutable AgentConfigSnapshot; for console + debugger/build runs it points to the editable AgentConfigDraft row. The snapshot is runtime state returned by Agent backend, kept separate from Agent Soul snapshots and workflow node-job config. diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 2503ba66f06..c1faa5a6975 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -2,10 +2,11 @@ from __future__ import annotations import re from enum import StrEnum -from typing import Annotated, Any, Final, Literal +from typing import Annotated, Any, Final, Literal, Self from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator +from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator from core.workflow.file_reference import is_canonical_file_reference from graphon.file import FileTransferMethod @@ -161,6 +162,11 @@ class AgentSkillRefConfig(AgentFlexibleConfig): manifest_files: list[str] | None = None +class AgentSoulFilesConfig(BaseModel): + skills: list[AgentSkillRefConfig] = Field(default_factory=list) + files: list[AgentFileRefConfig] = Field(default_factory=list) + + class AgentPermissionConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -236,17 +242,161 @@ class AgentCliToolConfig(AgentFlexibleConfig): inferred_from: str | None = Field(default=None, max_length=255) -class AgentKnowledgeDatasetConfig(AgentFlexibleConfig): +class AgentKnowledgeDatasetConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) description: str | None = None -class AgentKnowledgeQueryConfig(AgentFlexibleConfig): - query: str | None = None +class AgentKnowledgeQueryConfig(BaseModel): + """Per-set query policy for Agent v2 knowledge retrieval. + + Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + set owns its own query policy, so ``user_query`` must carry an explicit + ``value`` while ``generated_query`` leaves that value empty. + """ + + model_config = ConfigDict(extra="forbid") + + mode: AgentKnowledgeQueryMode + value: str | None = None + + @model_validator(mode="after") + def validate_query(self) -> Self: + if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip(): + raise ValueError("knowledge query.value is required for user_query mode") + return self + + +class AgentKnowledgeModelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + mode: str = Field(min_length=1, max_length=64) + completion_params: dict[str, Any] = Field(default_factory=dict) + + +class AgentKnowledgeRerankingModelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1, max_length=255) + model: str = Field(min_length=1, max_length=255) + + +class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig): + weight_type: str | None = Field(default=None, max_length=64) + vector_setting: dict[str, Any] | None = None + keyword_setting: dict[str, Any] | None = None + + +class AgentKnowledgeRetrievalConfig(BaseModel): + """Per-set retrieval policy for Agent v2 knowledge retrieval. + + Retrieval settings now live on each knowledge set instead of one shared + flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + ``single`` retrieval with a required model config. + """ + + model_config = ConfigDict(extra="forbid") + + mode: Literal["single", "multiple"] top_k: int | None = Field(default=None, ge=1) score_threshold: float | None = Field(default=None, ge=0, le=1) - score_threshold_enabled: bool | None = None + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: AgentKnowledgeRerankingModelConfig | None = None + weights: AgentKnowledgeWeightedScoreConfig | None = None + model: AgentKnowledgeModelConfig | None = None + + @model_validator(mode="after") + def validate_mode_fields(self) -> Self: + if self.mode == "multiple" and self.top_k is None: + raise ValueError("knowledge retrieval.top_k is required for multiple mode") + if self.mode == "single" and self.model is None: + raise ValueError("knowledge retrieval.model is required for single mode") + return self + + +class AgentKnowledgeMetadataCondition(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + comparison_operator: SupportedComparisonOperator + value: ConditionValue = None + + +class AgentKnowledgeMetadataConditions(BaseModel): + model_config = ConfigDict(extra="forbid") + + logical_operator: Literal["and", "or"] = "and" + conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list) + + +class AgentKnowledgeMetadataFilteringConfig(BaseModel): + """Per-set metadata filtering policy. + + The Python attribute uses ``metadata_model_config`` for clarity because the + model belongs to metadata filtering specifically, while the external API and + generated schema keep the historical ``model_config`` field name via alias. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + # Internal name is explicit; wire format remains ``model_config``. + metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config") + conditions: AgentKnowledgeMetadataConditions | None = None + + @model_validator(mode="after") + def validate_mode_fields(self) -> Self: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + +class AgentKnowledgeSetConfig(BaseModel): + """One explicit knowledge set in Agent v2. + + ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + its datasets plus query, retrieval, and metadata policies. An individual + set must contain at least one dataset id even though the overall knowledge + section may be empty, which is how callers express "no knowledge layer". + """ + + model_config = ConfigDict(extra="forbid") + + id: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + datasets: list[AgentKnowledgeDatasetConfig] + query: AgentKnowledgeQueryConfig + retrieval: AgentKnowledgeRetrievalConfig + metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field( + default_factory=AgentKnowledgeMetadataFilteringConfig + ) + + @field_validator("id", "name") + @classmethod + def validate_non_blank_identity(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("knowledge set id and name must not be blank") + return normalized + + @model_validator(mode="after") + def validate_datasets(self) -> Self: + dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets] + if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids): + raise ValueError("knowledge set requires at least one dataset id") + if len(dataset_ids) != len(set(dataset_ids)): + raise ValueError("knowledge set dataset ids must be unique") + return self class AgentHumanContactConfig(AgentFlexibleConfig): @@ -453,9 +603,28 @@ class AgentSoulToolsConfig(BaseModel): class AgentSoulKnowledgeConfig(BaseModel): - datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) - query_mode: AgentKnowledgeQueryMode | None = None - query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig) + """Top-level Agent v2 knowledge config. + + Agent v2 models knowledge as explicit sets instead of one flat + ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + list means no knowledge layer should be emitted at runtime, while set-name + uniqueness stays case-insensitive because runtime selection addresses sets + by name. + """ + + model_config = ConfigDict(extra="forbid") + + sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_unique_sets(self) -> Self: + set_ids = [item.id.strip() for item in self.sets] + if len(set_ids) != len(set(set_ids)): + raise ValueError("knowledge set ids must be unique") + set_names = [item.name.strip().lower() for item in self.sets] + if len(set_names) != len(set(set_names)): + raise ValueError("knowledge set names must be unique") + return self class AgentSoulHumanConfig(BaseModel): @@ -513,6 +682,7 @@ class AgentSoulConfig(BaseModel): knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) + files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig) sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) model: AgentSoulModelConfig | None = None diff --git a/api/models/model.py b/api/models/model.py index 38d67004de4..e3814ded257 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -487,9 +487,15 @@ class App(Base): agent = db.session.scalar( select(Agent).where( - Agent.app_id == self.id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.tenant_id == self.tenant_id, + sa.or_( + sa.and_( + Agent.app_id == self.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + ), + Agent.backing_app_id == self.id, + ), Agent.status == AgentStatus.ACTIVE, ) ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b3a0b8a6a71..68dbfbd162e 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -465,6 +465,83 @@ Check if activation token is valid | ---- | ----------- | | 204 | Agent service API key deleted | +### [DELETE] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)
| + +### [GET] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + +### [PUT] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + +### [POST] /agent/{agent_id}/build-draft/apply +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)
| + +### [POST] /agent/{agent_id}/build-draft/checkout +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + ### [GET] /agent/{agent_id}/chat-messages Get Agent App chat messages for a conversation with pagination @@ -856,6 +933,26 @@ Get Agent App message details by ID | 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)
| | 404 | Agent or message not found | | +### [POST] /agent/{agent_id}/publish +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)
| +| 403 | Insufficient permissions | | + ### [GET] /agent/{agent_id}/referencing-workflows List workflow apps that reference this Agent App's bound Agent (read-only) @@ -3764,6 +3861,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| snapshot_id | query | | No | string | | app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | @@ -12170,9 +12268,14 @@ Default namespace | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes | +| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes | | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes | +| app_id | string | | No | +| backing_app_id | string | | No | +| chat_endpoint | string | | No | +| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No | +| hidden_app_backed | boolean | | No | | save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes | | validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No | | variant | string | | Yes | @@ -12207,6 +12310,7 @@ Default namespace | active_config_is_published | boolean | | No | | api_base_url | string | | No | | app_id | string | | No | +| backing_app_id | string | | No | | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | @@ -12215,6 +12319,7 @@ Default namespace | description | string | | No | | enable_api | boolean | | Yes | | enable_site | boolean | | Yes | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | @@ -12227,7 +12332,7 @@ Default namespace | name | string | | Yes | | permission_keys | [ string ] | | No | | role | string | | No | -| site | [Site](#site) | | No | +| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -12270,6 +12375,7 @@ default (the config form sends the full desired feature state on save). | active_config_is_published | boolean | | No | | app_id | string | | No | | author_name | string | | No | +| backing_app_id | string | | No | | bound_agent_id | string | | No | | create_user_name | string | | No | | created_at | integer | | No | @@ -12277,6 +12383,7 @@ default (the config form sends the full desired feature state on save). | debug_conversation_id | string | | No | | description | string | | No | | has_draft_trigger | boolean | | No | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | @@ -12335,6 +12442,27 @@ default (the config form sends the full desired feature state on save). | date | string | | Yes | | interactions | number | | Yes | +#### AgentBuildDraftApplyResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft | object | | Yes | +| result | string | | Yes | + +#### AgentBuildDraftCheckoutPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| force | boolean | Overwrite the existing current-user build draft | No | + +#### AgentBuildDraftResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_soul | object | | Yes | +| draft | object | | Yes | +| variant | string | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -12397,10 +12525,18 @@ Risk marker for CLI tool bootstrap commands. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | active_config_snapshot_id | string | | No | +| app_id | string | | No | +| backing_app_id | string | | No | | description | string | | Yes | +| hidden_app_backed | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | | id | string | | Yes | | name | string | | Yes | +| role | string | | No | | scope | [AgentScope](#agentscope) | | Yes | +| source | [AgentSource](#agentsource) | | No | | status | [AgentStatus](#agentstatus) | | Yes | #### AgentComposerBindingResponse @@ -12453,6 +12589,25 @@ Risk marker for CLI tool bootstrap commands. | current_snapshot_id | string | | No | | workflow_node_count | integer | | Yes | +#### AgentComposerKnowledgeDatasetCandidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| id | string | | No | +| missing | boolean | | No | +| name | string | | No | + +#### AgentComposerKnowledgeSetCandidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No | +| description | string | | No | +| id | string | | Yes | +| missing_dataset_ids | [ string ] | | No | +| name | string | | Yes | + #### AgentComposerNodeJobCandidatesResponse | Name | Type | Description | Required | @@ -12468,7 +12623,7 @@ Risk marker for CLI tool bootstrap commands. | cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No | | dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No | | human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No | -| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No | +| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No | #### AgentComposerSoulLockResponse @@ -12487,6 +12642,28 @@ Risk marker for CLI tool bootstrap commands. | result | string | | Yes | | warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No | +#### AgentConfigDraftSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | No | +| agent_id | string | | Yes | +| base_snapshot_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes | +| id | string | | Yes | +| updated_at | integer | | No | +| updated_by | string | | No | + +#### AgentConfigDraftType + +Editable Agent Soul draft workspace type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | | + #### AgentConfigRevisionOperation Audit operation recorded for Agent Soul version/revision changes. @@ -12536,6 +12713,8 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | active_config_snapshot_id | string | | Yes | +| draft_config_id | string | | No | +| restored_version_id | string | | No | | result | string | | Yes | #### AgentConfigSnapshotSummaryResponse @@ -12790,10 +12969,12 @@ Supported icon storage formats for Agent roster entries. | app_id | string | | No | | archived_at | integer | | No | | archived_by | string | | No | +| backing_app_id | string | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | Yes | | existing_node_ids | [ string ] | | No | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | [AgentIconType](#agenticontype) | | No | @@ -12862,14 +13043,57 @@ the current roster/workflow APIs scoped to Dify Agent. | id | string | | No | | name | string | | No | -#### AgentKnowledgeQueryConfig +#### AgentKnowledgeMetadataCondition | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| query | string | | No | -| score_threshold | number | | No | -| score_threshold_enabled | boolean | | No | -| top_k | integer | | No | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | | Yes | +| value | string
[ string ]
number | | No | + +#### AgentKnowledgeMetadataConditions + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No | +| logical_operator | string,
**Available values:** "and", "or",
**Default:** and | *Enum:* `"and"`, `"or"` | No | + +#### AgentKnowledgeMetadataFilteringConfig + +Per-set metadata filtering policy. + +The Python attribute uses ``metadata_model_config`` for clarity because the +model belongs to metadata filtering specifically, while the external API and +generated schema keep the historical ``model_config`` field name via alias. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No | +| mode | string,
**Available values:** "automatic", "disabled", "manual",
**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No | +| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No | + +#### AgentKnowledgeModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_params | object | | No | +| mode | string | | Yes | +| name | string | | Yes | +| provider | string | | Yes | + +#### AgentKnowledgeQueryConfig + +Per-set query policy for Agent v2 knowledge retrieval. + +Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the +legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each +set owns its own query policy, so ``user_query`` must carry an explicit +``value`` while ``generated_query`` leaves that value empty. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes | +| value | string | | No | #### AgentKnowledgeQueryMode @@ -12877,6 +13101,59 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentKnowledgeRerankingModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | +| provider | string | | Yes | + +#### AgentKnowledgeRetrievalConfig + +Per-set retrieval policy for Agent v2 knowledge retrieval. + +Retrieval settings now live on each knowledge set instead of one shared +flat config. A set may use either ``multiple`` retrieval with ``top_k`` or +``single`` retrieval with a required model config. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string,
**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes | +| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No | +| reranking_enable | boolean,
**Default:** true | | No | +| reranking_mode | string,
**Default:** reranking_model | | No | +| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No | +| score_threshold | number | | No | +| top_k | integer | | No | +| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No | + +#### AgentKnowledgeSetConfig + +One explicit knowledge set in Agent v2. + +``knowledge.sets`` replaces the old flat knowledge config. Each set owns +its datasets plus query, retrieval, and metadata policies. An individual +set must contain at least one dataset id even though the overall knowledge +section may be empty, which is how callers express "no knowledge layer". + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes | +| description | string | | No | +| id | string | | Yes | +| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No | +| name | string | | Yes | +| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes | +| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes | + +#### AgentKnowledgeWeightedScoreConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_setting | object | | No | +| vector_setting | object | | No | +| weight_type | string | | No | + #### AgentLogConversationItemResponse | Name | Type | Description | Required | @@ -13060,6 +13337,21 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentProviderResponse | object | | | +#### AgentPublishPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| version_note | string | Optional note for this published Agent version | No | + +#### AgentPublishResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_config_snapshot | object | | No | +| active_config_snapshot_id | string | | Yes | +| draft | object | | No | +| result | string | | Yes | + #### AgentPublishedReferenceResponse | Name | Type | Description | Required | @@ -13117,9 +13409,11 @@ the current roster/workflow APIs scoped to Dify Agent. | app_id | string | | No | | archived_at | integer | | No | | archived_by | string | | No | +| backing_app_id | string | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | Yes | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | [AgentIconType](#agenticontype) | | No | @@ -13187,6 +13481,27 @@ Visibility and lifecycle scope of an Agent record. | enabled | boolean | | No | | type | string | | No | +#### AgentSimpleResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + +#### AgentSkillRefConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| file_id | string | | No | +| full_archive_file_id | string | | No | +| full_archive_key | string | | No | +| id | string | | No | +| manifest_files | [ string ] | | No | +| name | string | | No | +| path | string | | No | +| skill_md_file_id | string | | No | +| skill_md_key | string | | No | + #### AgentSkillUploadResponse | Name | Type | Description | Required | @@ -13213,6 +13528,7 @@ Visibility and lifecycle scope of an Agent record. | app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No | | app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No | | env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No | +| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No | | human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No | | knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No | | memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No | @@ -13267,6 +13583,13 @@ old Agent tool payloads can be read while new payloads stay explicit. | secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No | | variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No | +#### AgentSoulFilesConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No | +| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No | + #### AgentSoulHumanConfig | Name | Type | Description | Required | @@ -13276,11 +13599,17 @@ old Agent tool payloads can be read while new payloads stay explicit. #### AgentSoulKnowledgeConfig +Top-level Agent v2 knowledge config. + +Agent v2 models knowledge as explicit sets instead of one flat +``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` +list means no knowledge layer should be emitted at runtime, while set-name +uniqueness stays case-insensitive because runtime selection addresses sets +by name. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No | -| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No | -| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No | +| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No | #### AgentSoulMemoryConfig @@ -13754,6 +14083,35 @@ Enum class for api provider schema type. | use_icon_as_answer_icon | boolean | | No | | workflow | [WorkflowPartial](#workflowpartial) | | No | +#### AppDetailSiteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | No | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| code | string | | No | +| copyright | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string
[IconType](#icontype) | | No | +| icon_url | string | | Yes | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | + #### AppDetailWithSite | Name | Type | Description | Required | @@ -13779,7 +14137,7 @@ Enum class for api provider schema type. | model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | -| site | [Site](#site) | | No | +| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -14183,6 +14541,7 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation ID | No | +| draft_type | string,
**Available values:** "debug_build", "draft",
**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.
*Enum:* `"debug_build"`, `"draft"` | No | | files | [ object ] | Uploaded files | No | | inputs | object | | Yes | | model_config | object | | No | @@ -20349,6 +20708,12 @@ How a workflow node is bound to an Agent. | ---- | ---- | ----------- | -------- | | WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | | +#### WorkflowAgentComposerQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| snapshot_id | string | | No | + #### WorkflowAgentComposerResponse | Name | Type | Description | Required | @@ -20357,8 +20722,11 @@ How a workflow node is bound to an Agent. | agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No | | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes | | app_id | string | | No | +| backing_app_id | string | | No | | binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No | +| chat_endpoint | string | | No | | effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No | +| hidden_app_backed | boolean | | No | | impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No | | node_id | string | | No | | node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes | diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py index 7868f2a2f63..a650b16e9bc 100644 --- a/api/services/agent/composer_candidates.py +++ b/api/services/agent/composer_candidates.py @@ -25,6 +25,7 @@ from models.agent_config_entities import ( AgentSoulConfig, DeclaredOutputConfig, ) +from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids MAX_CANDIDATES_PER_LIST = 200 @@ -139,19 +140,34 @@ def soul_candidates( cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled] - dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id] + dataset_ids = list_agent_soul_knowledge_dataset_ids(soul) dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {} - knowledge_datasets: list[dict[str, Any]] = [] - for dataset in soul.knowledge.datasets: - if not dataset.id: - continue - row = dataset_rows.get(dataset.id) - knowledge_datasets.append( + knowledge_sets: list[dict[str, Any]] = [] + for knowledge_set in soul.knowledge.sets: + missing_dataset_ids: list[str] = [] + datasets: list[dict[str, Any]] = [] + for dataset in knowledge_set.datasets: + dataset_id = (dataset.id or "").strip() + if not dataset_id: + continue + row = dataset_rows.get(dataset_id) + if row is None: + missing_dataset_ids.append(dataset_id) + datasets.append( + { + "id": dataset_id, + "name": (getattr(row, "name", None) or dataset.name or dataset_id), + "description": getattr(row, "description", None) or dataset.description, + "missing": row is None, + } + ) + knowledge_sets.append( { - "id": dataset.id, - "name": (getattr(row, "name", None) or dataset.name or dataset.id), - "description": getattr(row, "description", None) or dataset.description, - "missing": row is None, + "id": knowledge_set.id, + "name": knowledge_set.name, + "description": knowledge_set.description, + "datasets": datasets, + "missing_dataset_ids": missing_dataset_ids, } ) @@ -161,7 +177,7 @@ def soul_candidates( lists = { "dify_tools": dify_tools, "cli_tools": cli_tools, - "knowledge_datasets": knowledge_datasets, + "knowledge_sets": knowledge_sets, "human_contacts": human_contacts, } capped: dict[str, list[dict[str, Any]]] = {} diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 8c83ee80031..44dd2dd329a 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -11,6 +11,8 @@ from libs.helper import to_timestamp from models import Account from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -37,6 +39,10 @@ from services.agent.errors import ( AgentVersionNotFoundError, InvalidComposerConfigError, ) +from services.agent.knowledge_datasets import ( + get_tenant_knowledge_dataset_rows, + list_missing_tenant_knowledge_dataset_ids, +) from services.agent.roster_service import AgentRosterService from services.app_service import AppService, CreateAppParams from services.entities.agent_entities import ( @@ -92,24 +98,62 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non class AgentComposerService: @classmethod - def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]: + def load_workflow_composer( + cls, *, tenant_id: str, app_id: str, node_id: str, 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) if not binding: + if snapshot_id: + raise AgentVersionNotFoundError() return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id) agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version = cls._workflow_composer_version( + tenant_id=tenant_id, + binding=binding, + agent=agent, + snapshot_id=snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + + @classmethod + def _workflow_composer_version( + cls, + *, + tenant_id: str, + binding: WorkflowAgentNodeBinding, + agent: Agent | None, + snapshot_id: str | None, + ) -> AgentConfigSnapshot | None: + if snapshot_id: + if agent is None: + raise AgentVersionNotFoundError() + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT: + if agent.scope != AgentScope.ROSTER: + raise AgentVersionNotFoundError() + elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or agent.app_id != binding.app_id + or agent.workflow_id != binding.workflow_id + or agent.workflow_node_id != binding.node_id + ): + raise AgentVersionNotFoundError() + else: + raise AgentVersionNotFoundError() + return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id) + version_id = ( agent.active_config_snapshot_id if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT else binding.current_snapshot_id ) - version = cls._get_version_if_present( + return cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id if agent else None, version_id=version_id, ) - return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) @classmethod def save_workflow_composer( @@ -120,6 +164,7 @@ class AgentComposerService: _backfill_cli_tool_ids(payload.agent_soul) _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) 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) @@ -259,31 +304,37 @@ class AgentComposerService: @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) + agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent) + + @classmethod + def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent) + + @classmethod + def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]: + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - if not agent: - raise AgentNotFoundError() - version = cls._require_version( + version = cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id ) return { "variant": ComposerVariant.AGENT_APP.value, "agent": cls._serialize_agent(agent), "active_config_snapshot": cls._serialize_version(version), - "agent_soul": version.config_snapshot_dict, - "save_options": [ - ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, - ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, - ], + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, + "save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value], + "app_id": agent.app_id, + "backing_app_id": agent.backing_app_id or agent.app_id, + "hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), + "chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages", } @classmethod @@ -292,22 +343,17 @@ class AgentComposerService: ) -> dict[str, Any]: if payload.variant != ComposerVariant.AGENT_APP: raise ValueError("Agent App composer endpoint only accepts agent_app variant") - _backfill_cli_tool_ids(payload.agent_soul) - _validate_composer_payload_for_strategy(payload) + if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION: + raise InvalidComposerConfigError( + "Agent App composer only saves the normal draft. Use the publish endpoint to create a version." + ) if payload.agent_soul is None: raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) if not agent: agent = Agent( tenant_id=tenant_id, @@ -317,6 +363,7 @@ class AgentComposerService: scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, app_id=app_id, + backing_app_id=app_id, status=AgentStatus.ACTIVE, created_by=account_id, updated_by=account_id, @@ -327,35 +374,54 @@ class AgentComposerService: except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc + return cls._save_agent_composer_for_agent( + tenant_id=tenant_id, + agent=agent, + account_id=account_id, + payload=payload, + ) - if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: - version = cls._create_config_version( - tenant_id=tenant_id, - agent_id=agent.id, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, - version_note=payload.version_note, + @classmethod + def save_agent_composer( + cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.variant != ComposerVariant.AGENT_APP: + raise ValueError("Agent composer endpoint only accepts agent_app variant") + if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION: + raise InvalidComposerConfigError( + "Agent composer only saves the normal draft. Use the publish endpoint to create a version." ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - else: - current_snapshot = cls._require_version( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id - ) - version = cls._update_current_version( - current_snapshot=current_snapshot, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, - version_note=payload.version_note, - ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - agent.updated_by = account_id + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + return cls._save_agent_composer_for_agent( + tenant_id=tenant_id, + agent=agent, + account_id=account_id, + payload=payload, + ) + + @classmethod + def _save_agent_composer_for_agent( + cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + agent.updated_by = account_id db.session.commit() - state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id) + state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id) state["validation"] = cls.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -363,6 +429,158 @@ class AgentComposerService: ) return state + @classmethod + def publish_agent_app_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP: + raise AgentNotFoundError() + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + ComposerConfigValidator.validate_publish_payload( + ComposerSavePayload( + variant=ComposerVariant.AGENT_APP, + agent_soul=agent_soul, + save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + version_note=version_note, + ) + ) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul) + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, + version_note=version_note, + previous_snapshot_id=agent.active_config_snapshot_id, + ) + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.updated_by = account_id + draft.base_snapshot_id = version.id + draft.updated_by = account_id + db.session.commit() + return { + "result": "success", + "active_config_snapshot_id": version.id, + "active_config_snapshot": cls._serialize_version(version), + "draft": cls._serialize_draft(draft), + } + + @classmethod + def checkout_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + normal_draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None and not force: + return cls._serialize_build_draft_state(build_draft) + if build_draft is None: + build_draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + draft_owner_key=account_id, + created_by=account_id, + ) + db.session.add(build_draft) + build_draft.base_snapshot_id = normal_draft.base_snapshot_id + build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict) + build_draft.updated_by = account_id + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def save_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + ComposerConfigValidator.validate_draft_save_payload(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + build_draft = cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + 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), + account_id_for_audit=account_id, + base_snapshot_id=build_draft.base_snapshot_id, + ) + db.session.delete(build_draft) + db.session.commit() + return {"result": "success", "draft": cls._serialize_draft(normal_draft)} + + @classmethod + def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None: + db.session.delete(build_draft) + db.session.commit() + return {"result": "success"} + @classmethod def collect_validation_findings( cls, @@ -372,19 +590,15 @@ class AgentComposerService: agent_id: str | None = None, ) -> dict[str, Any]: """ENG-617 soft findings, with DB-backed dataset and drive mention checks.""" - from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions - - mentioned_ids: set[str] = set() - if payload.agent_soul is not None: - mentioned_ids |= { - mention.ref_id - for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt) - if mention.kind == MentionKind.KNOWLEDGE - } - existing_dataset_ids: set[str] | None = None - if mentioned_ids: - existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids))) - findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) + existing_knowledge_set_ids = ( + {knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets} + if payload.agent_soul is not None + else None + ) + findings = ComposerConfigValidator.collect_soft_findings( + payload, + existing_knowledge_set_ids=existing_knowledge_set_ids, + ) if agent_id and payload.agent_soul is not None: findings["warnings"].extend( cls._drive_mention_findings( @@ -395,6 +609,24 @@ class AgentComposerService: ) return findings + @classmethod + def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None: + """Hard-validate tenant-scoped knowledge set datasets before saving. + + DTO validators own set shape, duplicate set ids/names, and duplicate + dataset ids within one set. This service-level check owns database + existence and tenant ownership so invalid or cross-tenant datasets fail + before Agent Soul snapshots are persisted. + """ + if agent_soul is None: + return + missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul) + if missing_ids: + raise InvalidComposerConfigError( + "knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: " + + ", ".join(missing_ids) + ) + @classmethod def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None: """The Agent App's bound roster agent id, if any (validate-endpoint context).""" @@ -509,7 +741,7 @@ class AgentComposerService: soul_lists, soul_truncated = soul_candidates( agent_soul=agent_soul, - dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids), + dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids), workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id), ) truncated = truncated or soul_truncated @@ -529,14 +761,14 @@ class AgentComposerService: return response.model_dump(mode="json") @classmethod - def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]: + def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]: """Slash-menu data source for the Agent App (Console) composer (ENG-615).""" from services.agent.composer_candidates import soul_candidates - agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id) + agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id) soul_lists, truncated = soul_candidates( agent_soul=agent_soul, - dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids), + dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids), workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id), ) response = ComposerCandidatesResponse( @@ -568,24 +800,18 @@ class AgentComposerService: return cls._parse_soul_snapshot(version) @classmethod - def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None: + agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id) if agent is None: return None - version = cls._get_version_if_present( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - return cls._parse_soul_snapshot(version) + return AgentSoulConfig.model_validate(draft.config_snapshot_dict) @staticmethod def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None: @@ -629,30 +855,6 @@ class AgentComposerService: variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id) return [(variable.name, variable.value_type.value) for variable in variables.variables] - @staticmethod - def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]: - """Tenant-scoped dataset lookup tolerating malformed ids. - - Mention ids come from user-editable prompt text; a non-UUID id can never - match a dataset row, so it is simply absent from the result (-> missing/ - placeholder semantics) instead of breaking the UUID-typed query. - """ - from uuid import UUID - - from services.dataset_service import DatasetService - - valid_ids: list[str] = [] - for dataset_id in dataset_ids: - try: - UUID(dataset_id) - except (ValueError, TypeError): - continue - valid_ids.append(dataset_id) - if not valid_ids: - return {} - rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id) - return {str(row.id): row for row in rows} - @staticmethod def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]: """Workspace Dify Plugin tools, same source as the tool selector. @@ -1028,6 +1230,15 @@ class AgentComposerService: icon: str | None = None, icon_background: str | None = None, ) -> Agent: + backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent( + tenant_id=tenant_id, + account_id=account_id, + name=name or f"Workflow Agent {node_id}", + description=description, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + ) agent = Agent( tenant_id=tenant_id, name=name or f"Workflow Agent {node_id}", @@ -1040,6 +1251,7 @@ class AgentComposerService: scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id=app_id, + backing_app_id=backing_app.id, workflow_id=workflow_id, workflow_node_id=node_id, status=AgentStatus.ACTIVE, @@ -1285,6 +1497,143 @@ class AgentComposerService: or 0 ) + 1 + @classmethod + def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None: + return db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + + @classmethod + def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent: + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + if agent is None: + raise AgentNotFoundError() + return agent + + @classmethod + def _get_agent_draft( + cls, + *, + tenant_id: str, + agent_id: str, + draft_type: AgentConfigDraftType, + account_id: str | None, + ) -> AgentConfigDraft | None: + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == draft_type, + ) + if draft_type == AgentConfigDraftType.DEBUG_BUILD: + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + + @classmethod + def _get_or_create_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + created_by: str | None, + ) -> AgentConfigDraft: + draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id, + ) + if draft is not None: + return draft + base_snapshot = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + agent_soul = ( + AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict) + if base_snapshot is not None + else AgentSoulConfig() + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None, + draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "", + base_snapshot_id=base_snapshot.id if base_snapshot else None, + config_snapshot=agent_soul, + created_by=created_by, + updated_by=created_by, + ) + db.session.add(draft) + db.session.flush() + return draft + + @classmethod + def _save_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + agent_soul: AgentSoulConfig, + account_id_for_audit: str, + base_snapshot_id: str | None = None, + ) -> AgentConfigDraft: + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=draft_type, + account_id=account_id, + created_by=account_id_for_audit, + ) + draft.config_snapshot = agent_soul + if base_snapshot_id is not None: + draft.base_snapshot_id = base_snapshot_id + elif draft.base_snapshot_id is None: + draft.base_snapshot_id = agent.active_config_snapshot_id + draft.updated_by = account_id_for_audit + db.session.flush() + return draft + + @classmethod + def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None: + if draft is None: + return None + return { + "id": draft.id, + "agent_id": draft.agent_id, + "draft_type": draft.draft_type.value, + "account_id": draft.account_id, + "base_snapshot_id": draft.base_snapshot_id, + "created_by": draft.created_by, + "updated_by": draft.updated_by, + "created_at": to_timestamp(draft.created_at), + "updated_at": to_timestamp(draft.updated_at), + } + + @classmethod + def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]: + return { + "variant": ComposerVariant.AGENT_APP.value, + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, + } + @classmethod def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow: workflow = db.session.scalar( @@ -1471,6 +1820,12 @@ class AgentComposerService: "impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id) if version else None, + "app_id": binding.app_id, + "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, + "workflow_id": binding.workflow_id, + "node_id": binding.node_id, } @classmethod @@ -1479,7 +1834,15 @@ class AgentComposerService: "id": agent.id, "name": agent.name, "description": agent.description, + "role": agent.role, + "icon_type": agent.icon_type, + "icon": agent.icon, + "icon_background": agent.icon_background, "scope": agent.scope.value, + "source": agent.source.value, + "app_id": agent.app_id, + "backing_app_id": agent.backing_app_id or agent.app_id, + "hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), "status": agent.status.value, "active_config_snapshot_id": agent.active_config_snapshot_id, } diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 34b80b8a9d0..4e7d4ff3c63 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -148,15 +148,15 @@ class ComposerConfigValidator: cls, payload: ComposerSavePayload, *, - existing_dataset_ids: set[str] | None = None, + existing_knowledge_set_ids: set[str] | None = None, ) -> dict[str, Any]: """ENG-617 §5.3/§5.4 soft findings — never block save. ``warnings`` carries ``mention_target_missing`` / ``mention_malformed`` - entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge + entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set mentions with a placeholder name (0522 consensus) instead of dropping or - rejecting them. With ``existing_dataset_ids`` provided, configured-but- - deleted datasets surface as placeholders too. + rejecting them. With ``existing_knowledge_set_ids`` provided, mentions + that no longer exist in the current Agent Soul surface as placeholders too. """ warnings: list[dict[str, Any]] = [] placeholders: list[dict[str, str]] = [] @@ -188,7 +188,7 @@ class ComposerConfigValidator: resolved = resolver(mention) if mention.kind == MentionKind.KNOWLEDGE: dangling = resolved is None or ( - existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids + existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids ) if dangling: placeholders.append( diff --git a/api/services/agent/knowledge_datasets.py b/api/services/agent/knowledge_datasets.py new file mode 100644 index 00000000000..962c562ce15 --- /dev/null +++ b/api/services/agent/knowledge_datasets.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from models.agent_config_entities import AgentSoulConfig + + +def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: + """Return normalized unique knowledge dataset ids in config order. + + Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This + helper keeps composer, workflow validation, candidates, and runtime + diagnostics aligned on the same normalization rules: strip whitespace, drop + blanks, preserve first-seen order, and deduplicate. + """ + dataset_ids: list[str] = [] + seen: set[str] = set() + for knowledge_set in agent_soul.knowledge.sets: + for dataset in knowledge_set.datasets: + dataset_id = (dataset.id or "").strip() + if not dataset_id or dataset_id in seen: + continue + seen.add(dataset_id) + dataset_ids.append(dataset_id) + return dataset_ids + + +def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]: + """Return tenant-scoped dataset rows for normalized knowledge dataset ids. + + Knowledge ids come from user-editable config. Malformed ids can never match + a dataset row, so they are treated as missing instead of breaking the + UUID-typed dataset lookup. + """ + from services.dataset_service import DatasetService + + valid_ids: list[str] = [] + for dataset_id in dataset_ids: + try: + UUID(dataset_id) + except (TypeError, ValueError): + continue + valid_ids.append(dataset_id) + + if not valid_ids: + return {} + + rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id) + return {str(row.id): row for row in rows} + + +def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]: + """Return normalized knowledge dataset ids missing from the tenant scope.""" + if agent_soul is None: + return [] + + dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul) + if not dataset_ids: + return [] + + rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids) + return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows] diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 27bed49c53b..cc35979a644 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -6,7 +6,7 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens: ``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent runtime context. For prompt-owned entities that means Agent Soul lists such as -``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists +``tools`` / ``knowledge.sets`` / ``human.contacts`` and workflow job lists such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed ``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is resolved against ``agent_drive_files`` at runtime. ``label`` is an optional @@ -211,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver: if mention.ref_id in (cli_tool.id, cli_tool.name): return cli_tool.name or cli_tool.id case MentionKind.KNOWLEDGE: - for dataset in agent_soul.knowledge.datasets: - if mention.ref_id == dataset.id: - return dataset.name or dataset.id + for knowledge_set in agent_soul.knowledge.sets: + if mention.ref_id == knowledge_set.id: + return knowledge_set.name or knowledge_set.id case MentionKind.HUMAN: return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id) case _: diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 97d91b50770..f16258f3c75 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -3,11 +3,14 @@ from typing import Any, TypedDict from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError +from constants.model_template import default_app_templates from core.app.entities.app_invoke_entities import InvokeFrom from libs.datetime_utils import naive_utc_now from libs.helper import to_timestamp from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -21,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, Conversation, IconType +from models.model import App, AppMode, AppModelConfig, Conversation, IconType from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -98,6 +101,8 @@ class AgentRosterService: "scope": agent.scope.value, "source": agent.source.value, "app_id": agent.app_id, + "backing_app_id": agent.backing_app_id, + "hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), "debug_conversation_id": None, "workflow_id": agent.workflow_id, "workflow_node_id": agent.workflow_node_id, @@ -363,6 +368,7 @@ class AgentRosterService: source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, app_id=app_id, + backing_app_id=app_id, created_by=account_id, updated_by=account_id, ) @@ -398,6 +404,53 @@ class AgentRosterService: self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) return agent + def create_hidden_backing_app_for_workflow_agent( + self, + *, + tenant_id: str, + account_id: str | None, + name: str, + description: str = "", + icon_type: Any = None, + icon: str | None = None, + icon_background: str | None = None, + ) -> App: + """Create an internal Agent App used only to back a workflow-only Agent. + + This deliberately bypasses AppService.create_app because that public + creation path also creates a roster Agent. Inline Agents need App runtime + infrastructure for chat/logs/monitoring, but must stay hidden from the + workspace Agent Roster until explicitly saved to roster. + """ + + app_template = dict(default_app_templates[AppMode.AGENT]["app"]) + app = App(**app_template) + app.name = name + app.description = description or "" + app.mode = AppMode.AGENT + normalized_icon_type = self._normalize_app_icon_type(icon_type) + app.icon_type = IconType(normalized_icon_type) if normalized_icon_type else IconType.EMOJI + app.icon = icon + app.icon_background = icon_background + app.tenant_id = tenant_id + app.enable_site = False + app.enable_api = False + app.api_rph = 0 + app.api_rpm = 0 + app.max_active_requests = None + app.created_by = account_id + app.maintainer = account_id + app.updated_by = account_id + self._session.add(app) + self._session.flush() + + app_model_config = AppModelConfig(app_id=app.id, created_by=account_id, updated_by=account_id) + self._session.add(app_model_config) + self._session.flush() + app.app_model_config_id = app_model_config.id + self._session.flush() + return app + def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str: """Create one console debug conversation for an Agent App editor.""" @@ -423,8 +476,31 @@ class AgentRosterService: self._session.flush() return conversation.id + @staticmethod + def runtime_backing_app_id(agent: Agent) -> str | None: + """Return the App id that backs Agent runtime chat/log/monitoring.""" + + return agent.backing_app_id or agent.app_id + + def _ensure_workflow_agent_backing_app(self, *, agent: Agent, account_id: str | None) -> str | None: + if agent.scope != AgentScope.WORKFLOW_ONLY or agent.backing_app_id: + return self.runtime_backing_app_id(agent) + backing_app = self.create_hidden_backing_app_for_workflow_agent( + tenant_id=agent.tenant_id, + account_id=account_id or agent.updated_by or agent.created_by, + name=agent.name, + description=agent.description, + icon_type=agent.icon_type, + icon=agent.icon, + icon_background=agent.icon_background, + ) + agent.backing_app_id = backing_app.id + self._session.flush() + return backing_app.id + def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: - if not agent.app_id: + backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id) + if not backing_app_id: raise AgentNotFoundError() mapping = self._session.scalar( @@ -438,7 +514,7 @@ class AgentRosterService: conversation_id = self._session.scalar( select(Conversation.id).where( Conversation.id == mapping.conversation_id, - Conversation.app_id == agent.app_id, + Conversation.app_id == backing_app_id, Conversation.from_source == ConversationFromSource.CONSOLE, Conversation.from_account_id == account_id, Conversation.is_deleted.is_(False), @@ -448,21 +524,22 @@ class AgentRosterService: return conversation_id mapping.conversation_id = self._create_agent_app_debug_conversation( - app_id=agent.app_id, + app_id=backing_app_id, account_id=account_id, ) + mapping.app_id = backing_app_id self._session.flush() return mapping.conversation_id conversation_id = self._create_agent_app_debug_conversation( - app_id=agent.app_id, + app_id=backing_app_id, account_id=account_id, ) self._session.add( AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, - app_id=agent.app_id, + app_id=backing_app_id, account_id=account_id, conversation_id=conversation_id, ) @@ -479,8 +556,6 @@ class AgentRosterService: select(Agent).where( Agent.tenant_id == tenant_id, Agent.id == agent_id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, Agent.status == AgentStatus.ACTIVE, ) ) @@ -501,16 +576,20 @@ class AgentRosterService: select(Agent).where( Agent.tenant_id == tenant_id, Agent.id == agent_id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, Agent.status == AgentStatus.ACTIVE, ) ) - if agent is None or not agent.app_id: + if agent is None: + raise AgentNotFoundError() + backing_app_id = self._ensure_workflow_agent_backing_app( + agent=agent, + account_id=agent.updated_by or agent.created_by, + ) + if not backing_app_id: raise AgentNotFoundError() conversation_id = self._create_agent_app_debug_conversation( - app_id=agent.app_id, + app_id=backing_app_id, account_id=account_id, ) mapping = self._session.scalar( @@ -525,13 +604,13 @@ class AgentRosterService: AgentDebugConversation( tenant_id=tenant_id, agent_id=agent_id, - app_id=agent.app_id, + app_id=backing_app_id, account_id=account_id, conversation_id=conversation_id, ) ) else: - mapping.app_id = agent.app_id + mapping.app_id = backing_app_id mapping.conversation_id = conversation_id self._session.flush() if commit: @@ -546,11 +625,7 @@ class AgentRosterService: conversation_ids_by_agent_id: dict[str, str] = {} changed = False for agent in agents: - if ( - agent.tenant_id != tenant_id - or agent.scope != AgentScope.ROSTER - or agent.source != AgentSource.AGENT_APP - ): + if agent.tenant_id != tenant_id or agent.status != AgentStatus.ACTIVE: continue conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( agent=agent, @@ -574,7 +649,7 @@ class AgentRosterService: Agent.status == AgentStatus.ACTIVE, ) ).all() - return {agent.app_id: agent for agent in agents if agent.app_id} + return {agent.app_id: agent for agent in agents if agent.app_id and agent.id} def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None: """Return the roster Agent that backs the given Agent App, if any.""" @@ -625,6 +700,59 @@ class AgentRosterService: raise AgentNotFoundError() return app + def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App: + """Resolve the App that backs an Agent runtime surface. + + Roster Agents use their public Agent App. Workflow-only Agents use a + hidden Agent App stored in ``backing_app_id`` so console chat/logs can + reuse the app runtime without exposing the resource in workspace app + lists. + """ + + agent = self._session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.id == agent_id, + Agent.status == AgentStatus.ACTIVE, + or_( + and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP), + and_( + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.source == AgentSource.WORKFLOW, + Agent.workflow_id.is_not(None), + Agent.workflow_node_id.is_not(None), + ), + ), + ) + .limit(1) + ) + if agent is None: + raise AgentNotFoundError() + should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id + backing_app_id = self._ensure_workflow_agent_backing_app( + agent=agent, + account_id=agent.updated_by or agent.created_by, + ) + if not backing_app_id: + raise AgentNotFoundError() + if should_commit_backing_app: + self._session.commit() + + app = self._session.scalar( + select(App) + .where( + App.tenant_id == tenant_id, + App.id == backing_app_id, + App.mode == AppMode.AGENT, + App.status == AppStatus.NORMAL, + ) + .limit(1) + ) + if app is None: + raise AgentNotFoundError() + return app + def duplicate_agent_app( self, *, @@ -692,10 +820,10 @@ class AgentRosterService: return target_app @staticmethod - def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None: + def _normalize_app_icon_type(icon_type: Any | None) -> str | None: if icon_type is None: return None - if isinstance(icon_type, IconType): + if isinstance(icon_type, IconType) or hasattr(icon_type, "value"): return icon_type.value return icon_type @@ -836,6 +964,7 @@ class AgentRosterService: def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: if agent.source == AgentSource.AGENT_APP: return { + AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, @@ -849,16 +978,37 @@ class AgentRosterService: } def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: - """Return whether the Agent's current active snapshot is a visible published version.""" + """Return whether the editable draft matches the active published snapshot.""" return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get( agent.id, False, ) def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: - """Return publish-state flags for the active config snapshots of the given Agents.""" + """Return whether each Agent's normal draft is aligned with its active published snapshot.""" + agents = [agent for agent in agents if agent.id] + if not agents: + return {} + published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents) - return {agent.id: agent.id in published_agent_ids for agent in agents} + drafts = self._session.scalars( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id.in_([agent.id for agent in agents]), + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), + ) + ).all() + drafts_by_agent_id = {draft.agent_id: draft for draft in drafts} + result: dict[str, bool] = {} + for agent in agents: + draft = drafts_by_agent_id.get(agent.id) + result[agent.id] = ( + agent.id in published_agent_ids + and bool(agent.active_config_snapshot_id) + and (draft is None or draft.base_snapshot_id == agent.active_config_snapshot_id) + ) + return result def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) @@ -957,26 +1107,37 @@ class AgentRosterService: raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) - if agent.active_config_snapshot_id == version.id: - return {"result": "success", "active_config_snapshot_id": version.id} - - previous_snapshot_id = agent.active_config_snapshot_id - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(version.config_snapshot) - agent.updated_by = account_id - self._session.add( - AgentConfigRevision( + draft = self._session.scalar( + select(AgentConfigDraft) + .where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), + ) + .limit(1) + ) + if draft is None: + draft = AgentConfigDraft( tenant_id=tenant_id, agent_id=agent_id, - previous_snapshot_id=previous_snapshot_id, - current_snapshot_id=version.id, - revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id), - operation=AgentConfigRevisionOperation.RESTORE_VERSION, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", created_by=account_id, ) - ) + self._session.add(draft) + draft.base_snapshot_id = version.id + draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict) + draft.updated_by = account_id + agent.updated_by = account_id self._session.commit() - return {"result": "success", "active_config_snapshot_id": version.id} + return { + "result": "success", + "active_config_snapshot_id": agent.active_config_snapshot_id or version.id, + "draft_config_id": draft.id, + "restored_version_id": version.id, + } def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent: stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id) diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index c4c25d48143..3974d306a14 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -1,15 +1,17 @@ -"""Validate + extract metadata from an uploaded Skill package (ENG-370). +"""Validate and normalize uploaded Skill packages for drive standardization. A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``, followed by markdown instructions). This service validates the archive (extension, -size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and -extracts a manifest consumed by drive standardization. +size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields), +normalizes retained member paths relative to the selected skill root, rebuilds +canonical archive bytes, and returns normalized metadata together with the +archive-root ``SKILL.md`` bytes. It does NOT execute or load the skill — the agent backend owns execution. It also does not persist anything into Agent Soul or bind anything to config versions; -``SkillStandardizeService`` consumes the manifest and commits the canonical drive -rows instead. +``SkillStandardizeService`` consumes the normalized package and commits the +canonical drive rows instead. """ from __future__ import annotations @@ -19,6 +21,7 @@ import io import posixpath import re import zipfile +import zlib import yaml from pydantic import BaseModel @@ -58,10 +61,69 @@ class SkillManifest(BaseModel): hash: str # sha256 of the archive bytes -class SkillPackageService: - """Validate Skill archives and extract their manifest.""" +class NormalizedSkillPackage(BaseModel): + """Canonical skill package bytes and metadata ready to store in agent drive.""" - def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest: + manifest: SkillManifest + archive_bytes: bytes + skill_md_bytes: bytes + strip_prefix: str | None + + +class SkillPackageService: + """Validate Skill archives and produce the normalized package stored in drive.""" + + def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage: + """Return the canonical drive package for an uploaded skill archive. + + The shallowest ``SKILL.md`` defines the skill root. When exactly one + depth-2 ``/SKILL.md`` exists, normalization strips that top-level + folder and silently discards all members outside it, including nested + foreign paths. When that unique depth-2 condition does not apply, files + outside the selected skill root still raise ``files_outside_skill_root``. + The returned manifest is normalized to archive-root ``SKILL.md`` and its + hash describes the rebuilt archive bytes. Member read/decompression + failures while consuming the archive are mapped to ``invalid_archive``. + """ + archive = self._open_archive(content=content, filename=filename) + with archive: + members = self._collect_file_members(archive) + member_paths = [safe_path for _, safe_path in members] + entry_path = self._find_skill_md(member_paths) + strip_prefix = self._skill_root_prefix(entry_path) + normalized_members = self._normalize_members( + members=members, + skill_root_prefix=strip_prefix, + ignore_outside_selected_root=self._can_strip_single_top_level_folder( + paths=member_paths, entry_path=entry_path + ), + ) + skill_md_member = normalized_members[_SKILL_MD_NAME] + self._validate_skill_md_size(skill_md_member) + skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member) + skill_md = self._decode_skill_md(skill_md_bytes) + normalized_archive_bytes = self._build_normalized_archive( + archive=archive, normalized_members=normalized_members + ) + normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values()) + + name, description = self._parse_skill_md(skill_md) + manifest = SkillManifest( + name=name, + description=description, + entry_path=_SKILL_MD_NAME, + files=sorted(normalized_members), + size=normalized_size, + hash=hashlib.sha256(normalized_archive_bytes).hexdigest(), + ) + return NormalizedSkillPackage( + manifest=manifest, + archive_bytes=normalized_archive_bytes, + skill_md_bytes=skill_md_bytes, + strip_prefix=strip_prefix, + ) + + def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile: self._check_extension(filename) if not content: raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400) @@ -69,52 +131,90 @@ class SkillPackageService: raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400) try: - archive = zipfile.ZipFile(io.BytesIO(content)) + return zipfile.ZipFile(io.BytesIO(content)) except zipfile.BadZipFile as exc: raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc - with archive: - infos = [info for info in archive.infolist() if not info.is_dir()] - if len(infos) > _MAX_ENTRIES: - raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400) + def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) > _MAX_ENTRIES: + raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400) - safe_paths: list[str] = [] - total_uncompressed = 0 - for info in infos: - safe_paths.append(self._safe_member_path(info.filename)) - total_uncompressed += max(info.file_size, 0) - if total_uncompressed > _MAX_UNCOMPRESSED_BYTES: - raise SkillPackageError( - "archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400 - ) - - entry_path = self._find_skill_md(safe_paths) - skill_md = self._read_skill_md(archive, entry_path) - - name, description = self._parse_skill_md(skill_md) - return SkillManifest( - name=name, - description=description, - entry_path=entry_path, - files=sorted(safe_paths), - size=total_uncompressed, - hash=hashlib.sha256(content).hexdigest(), - ) - - def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes: - """Read a single archive member's bytes (used by standardization, ENG-594).""" - try: - archive = zipfile.ZipFile(io.BytesIO(content)) - except zipfile.BadZipFile as exc: - raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc - with archive: - member = next( - (info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path), - None, + members: list[tuple[zipfile.ZipInfo, str]] = [] + total_uncompressed = 0 + for info in infos: + members.append((info, self._safe_member_path(info.filename))) + total_uncompressed += max(info.file_size, 0) + if total_uncompressed > _MAX_UNCOMPRESSED_BYTES: + raise SkillPackageError( + "archive_too_large", + "skill archive uncompressed size exceeds limit", + status_code=400, ) - if member is None: - raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400) - return archive.read(member) + return members + + @staticmethod + def _skill_root_prefix(entry_path: str) -> str | None: + skill_root = posixpath.dirname(entry_path) + if not skill_root: + return None + return f"{skill_root}/" + + def _normalize_members( + self, + *, + members: list[tuple[zipfile.ZipInfo, str]], + skill_root_prefix: str | None, + ignore_outside_selected_root: bool = False, + ) -> dict[str, zipfile.ZipInfo]: + normalized_members: dict[str, zipfile.ZipInfo] = {} + for info, safe_path in members: + if skill_root_prefix is not None: + if not safe_path.startswith(skill_root_prefix): + if ignore_outside_selected_root: + continue + raise SkillPackageError( + "files_outside_skill_root", + "skill archive contains files outside the selected skill root", + status_code=400, + ) + normalized_path = safe_path.removeprefix(skill_root_prefix) + else: + normalized_path = safe_path + + if ( + not normalized_path + or normalized_path in {".", ".."} + or normalized_path.startswith("/") + or "\\" in normalized_path + ): + raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400) + if normalized_path in normalized_members: + raise SkillPackageError( + "duplicate_member_path", + "skill archive contains duplicate normalized paths", + status_code=400, + ) + normalized_members[normalized_path] = info + + if _SKILL_MD_NAME not in normalized_members: + raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400) + return normalized_members + + def _build_normalized_archive( + self, + *, + archive: zipfile.ZipFile, + normalized_members: dict[str, zipfile.ZipInfo], + ) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as normalized_archive: + for normalized_path in sorted(normalized_members): + normalized_archive.writestr( + normalized_path, + self._read_member_bytes_from_archive(archive, member_info=normalized_members[normalized_path]), + ) + return output.getvalue() @staticmethod def _check_extension(filename: str) -> None: @@ -145,17 +245,26 @@ class SkillPackageService: return min(candidates, key=lambda p: (p.count("/"), len(p))) @staticmethod - def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str: - # Look the member up by its original name (normpath may differ from the stored name). - member = next( - (info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path), - None, - ) - if member is None: - raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400) - if member.file_size > _MAX_SKILL_MD_BYTES: + def _can_strip_single_top_level_folder(*, paths: list[str], entry_path: str) -> bool: + if entry_path.count("/") != 1: + return False + candidates = [path for path in paths if path.count("/") == 1 and posixpath.basename(path) == _SKILL_MD_NAME] + return len(candidates) == 1 and candidates[0] == entry_path + + @staticmethod + def _read_member_bytes_from_archive(archive: zipfile.ZipFile, *, member_info: zipfile.ZipInfo) -> bytes: + try: + return archive.read(member_info) + except (zipfile.BadZipFile, EOFError, OSError, RuntimeError, ValueError, zlib.error) as exc: + raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc + + @staticmethod + def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None: + if member_info.file_size > _MAX_SKILL_MD_BYTES: raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400) - raw = archive.read(member) + + @staticmethod + def _decode_skill_md(raw: bytes) -> str: try: return raw.decode("utf-8") except UnicodeDecodeError as exc: @@ -193,4 +302,4 @@ class SkillPackageService: return loaded if isinstance(loaded, dict) else {} -__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"] +__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"] diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index 7e64d6f0422..cc2ba4b9bdc 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -7,19 +7,14 @@ to the agent drive (Agent Files §5.4 / §4): * ``/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the complete skill contents. -Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit`` -with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned -payload is the slim drive-derived skill DTO the UI needs to work with the drive -catalog — ``name``, ``description``, ``path``, ``skill_md_key``, and -``archive_key`` — plus the extracted manifest for upload feedback. The console -``/skills/upload`` endpoints delegate to this service so "upload" now always means -drive-backed skill normalization rather than Agent Soul binding. +The archive's member list is stored in skill metadata and resolved lazily for +inspect/preview/runtime. Upload must not eagerly materialize every archive member +as a separate ToolFile; small archives with many files would otherwise perform +hundreds of storage writes and DB commits inside the request. """ from __future__ import annotations -import mimetypes -import posixpath import re from typing import Any @@ -38,7 +33,11 @@ def slugify_skill_name(name: str) -> str: class SkillStandardizeService: - """Validate + standardize a Skill package into a per-agent drive upload result.""" + """Persist a normalized skill package into drive-owned files for one agent. + + Instances are intentionally stateful: ``standardize()`` updates + ``last_committed_items`` with the drive commit result for the most recent call. + """ def __init__( self, @@ -50,6 +49,7 @@ class SkillStandardizeService: self._package = package_service or SkillPackageService() self._drive = drive_service or AgentDriveService() self._tool_files = tool_file_manager or ToolFileManager() + self.last_committed_items: list[dict[str, Any]] = [] def standardize( self, @@ -60,17 +60,23 @@ class SkillStandardizeService: user_id: str, agent_id: str, ) -> dict[str, Any]: - manifest = self._package.validate_and_extract(content=content, filename=filename) - skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path) + """Create two ToolFiles, commit two drive-owned keys, and return skill metadata. + + This writes ``/SKILL.md`` and ``/.DIFY-SKILL-FULL.zip``, + stores the drive commit rows in ``last_committed_items``, and returns the + console response shape ``{"skill": ..., "manifest": ...}``. + """ + package = self._package.validate_and_normalize(content=content, filename=filename) + manifest = package.manifest slug = slugify_skill_name(manifest.name) - # Drive-owned files: canonical SKILL.md, every inspectable archive file, - # and the full archive for future restore/export. + # Drive-owned files: canonical SKILL.md and the full archive. The + # archive member tree is preserved in metadata and resolved lazily. md_tool_file = self._tool_files.create_file_by_raw( user_id=user_id, tenant_id=tenant_id, conversation_id=None, - file_binary=skill_md_bytes, + file_binary=package.skill_md_bytes, mimetype="text/markdown", filename=_SKILL_MD_NAME, ) @@ -78,38 +84,14 @@ class SkillStandardizeService: user_id=user_id, tenant_id=tenant_id, conversation_id=None, - file_binary=content, + file_binary=package.archive_bytes, mimetype="application/zip", filename=_FULL_ARCHIVE_NAME, ) skill_md_key = f"{slug}/{_SKILL_MD_NAME}" archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}" - member_items: list[DriveCommitItem] = [] - for member_path in sorted(set(manifest.files)): - member_key = f"{slug}/{member_path}" - if member_key in {skill_md_key, archive_key}: - continue - - member_bytes = self._package.read_member_bytes(content=content, member_path=member_path) - mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream" - member_tool_file = self._tool_files.create_file_by_raw( - user_id=user_id, - tenant_id=tenant_id, - conversation_id=None, - file_binary=member_bytes, - mimetype=mimetype, - filename=posixpath.basename(member_path), - ) - member_items.append( - DriveCommitItem( - key=member_key, - file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id), - value_owned_by_drive=True, - ) - ) - - self._drive.commit( + committed_items = self._drive.commit( tenant_id=tenant_id, user_id=user_id, agent_id=agent_id, @@ -130,23 +112,17 @@ class SkillStandardizeService: file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id), value_owned_by_drive=True, ), - *member_items, ], ) - - drive_skill = next( - skill - for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id) - if skill["skill_md_key"] == skill_md_key - ) + self.last_committed_items = committed_items return { "skill": { - "name": drive_skill["name"], - "description": drive_skill["description"], - "path": drive_skill["path"], - "skill_md_key": drive_skill["skill_md_key"], - "archive_key": drive_skill["archive_key"], + "name": manifest.name, + "description": manifest.description, + "path": slug, + "skill_md_key": skill_md_key, + "archive_key": archive_key, }, "manifest": manifest.model_dump(), } diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index eb3766996bf..0210aeb6708 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -39,10 +39,10 @@ class WorkflowAgentPublishService: @classmethod def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]: - """Return draft graph with persisted Agent node job config projected into node data. + """Return draft graph with persisted Agent binding fields projected into node data. Workflow draft graph is the front-end's editing source of truth, while - runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This + runtime/publish reads WorkflowAgentNodeBinding. This response-only projection keeps reads aligned without writing binding details back into the stored graph JSON. """ @@ -64,6 +64,18 @@ class WorkflowAgentPublishService: node_data = agent_nodes.get(binding.node_id) if not isinstance(node_data, dict): continue + graph_binding = node_data.get(cls._AGENT_BINDING_KEY) + is_pending_inline_graph_binding = ( + isinstance(graph_binding, Mapping) + and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value + and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id")) + ) + if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + node_data[cls._AGENT_BINDING_KEY] = { + "binding_type": binding.binding_type.value, + "agent_id": binding.agent_id, + "current_snapshot_id": binding.current_snapshot_id, + } node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) if node_job.workflow_prompt is not None: node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt @@ -231,6 +243,10 @@ class WorkflowAgentPublishService: continue if not isinstance(binding_payload, Mapping): raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.") + if binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value and ( + not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id") + ): + continue cls._sync_agent_binding_for_node( session=session, draft_workflow=draft_workflow, diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index c20b3f570a3..d79aa1abe9c 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -19,10 +19,18 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane: from __future__ import annotations +import base64 +import hashlib +import hmac +import io import json import logging +import mimetypes +import os import re +import time import urllib.parse +import zipfile from typing import Any, Literal, TypedDict from urllib.parse import unquote @@ -31,6 +39,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import DataError, SQLAlchemyError from sqlalchemy.orm import Session +from configs import dify_config from core.app.file_access.controller import DatabaseFileAccessController from core.db.session_factory import session_factory from extensions.ext_storage import storage @@ -46,6 +55,7 @@ _MAX_KEY_LENGTH = 512 _DRIVE_REF_PREFIX = "agent-" _SKILL_MD_SUFFIX = "/SKILL.md" _SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" +_ARCHIVE_MEMBER_DOWNLOAD_PURPOSE = "agent-drive-archive-member" class AgentDriveError(Exception): @@ -365,6 +375,7 @@ class AgentDriveService: skill_md_key=skill_md_key, manifest_files=manifest_files, drive_keys=drive_keys, + archive_available=catalog["archive_key"] in drive_keys if catalog["archive_key"] else False, ) return { **catalog, @@ -598,6 +609,7 @@ class AgentDriveService: skill_md_key: str, manifest_files: list[str] | None, drive_keys: set[str], + archive_available: bool = False, ) -> tuple[list[AgentDriveSkillFileInfo], list[str]]: warnings: list[str] = [] if manifest_files: @@ -617,13 +629,14 @@ class AgentDriveService: if path == _SKILL_ARCHIVE_NAME: continue drive_key = f"{skill_path}/{path}" + available_in_drive = drive_key in drive_keys or (archive_available and path != _SKILL_ARCHIVE_NAME) files.append( { "path": path, "name": path.rsplit("/", 1)[-1], "type": "file", - "drive_key": drive_key if drive_key in drive_keys else None, - "available_in_drive": drive_key in drive_keys, + "drive_key": drive_key if available_in_drive else None, + "available_in_drive": available_in_drive, } ) if "SKILL.md" not in {file["path"] for file in files}: @@ -844,56 +857,209 @@ class AgentDriveService: return row def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str: - if row.file_kind == AgentDriveFileKind.TOOL_FILE: - tool_file = session.scalar( - select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id) - ) + return self._storage_key_for_ref( + session, + tenant_id=tenant_id, + file_kind=row.file_kind, + file_id=row.file_id, + ) + + def _storage_key_for_ref( + self, + session: Session, + *, + tenant_id: str, + file_kind: AgentDriveFileKind, + file_id: str, + ) -> str: + if file_kind == AgentDriveFileKind.TOOL_FILE: + tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id)) if tool_file is None: raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) return tool_file.file_key upload_file = session.scalar( - select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id) + select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) ) if upload_file is None: raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) return upload_file.key - def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]: - """Truncated text preview of one drive value (binary-safe, never 500s on size).""" - with session_factory.create_session() as session: - self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) - storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row) - size = row.size + def _archive_member_for_key( + self, + session: Session, + *, + tenant_id: str, + agent_id: str, + key: str, + ) -> tuple[AgentDriveFile, str]: + normalized_key = normalize_drive_key(key) + if "/" not in normalized_key: + raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) + skill_path, member_path = normalized_key.split("/", 1) + if member_path in {_SKILL_ARCHIVE_NAME, ""}: + raise AgentDriveError("drive_key_not_found", "no archive member for this key", status_code=404) - data = bytearray() - for chunk in storage.load_stream(storage_key): - data.extend(chunk) - if len(data) > self.PREVIEW_MAX_BYTES: - break - truncated = len(data) > self.PREVIEW_MAX_BYTES - sample = bytes(data[: self.PREVIEW_MAX_BYTES]) - # Same semantics as the sandbox read endpoint: NUL or undecodable -> binary. + skill_md_key = f"{skill_path}{_SKILL_MD_SUFFIX}" + skill_row = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == skill_md_key, + AgentDriveFile.is_skill.is_(True), + ) + ) + if skill_row is None: + raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) + metadata = self._parse_skill_metadata(skill_row.key, skill_row.skill_metadata) + manifest_files = {normalize_drive_key(path) for path in (metadata.manifest_files or [])} + if member_path not in manifest_files: + raise AgentDriveError("drive_key_not_found", "archive member is not part of this skill", status_code=404) + archive_row = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == self._skill_archive_key(skill_md_key), + ) + ) + if archive_row is None: + raise AgentDriveError("drive_key_not_found", "skill archive is missing", status_code=404) + return archive_row, member_path + + def _load_archive_member_bytes( + self, + *, + tenant_id: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + ) -> bytes: + member_path = normalize_drive_key(member_path) + with session_factory.create_session() as session: + storage_key = self._storage_key_for_ref( + session, + tenant_id=tenant_id, + file_kind=archive_file_kind, + file_id=archive_file_id, + ) + archive_bytes = b"".join(storage.load_stream(storage_key)) + try: + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + member = next( + ( + info + for info in archive.infolist() + if not info.is_dir() and normalize_drive_key(info.filename) == member_path + ), + None, + ) + if member is None: + raise AgentDriveError( + "drive_key_not_found", "archive member is missing from the skill archive", status_code=404 + ) + return archive.read(member) + except zipfile.BadZipFile as exc: + raise AgentDriveError("invalid_skill_archive", "skill archive is not a valid zip", status_code=500) from exc + + @classmethod + def _preview_bytes(cls, *, key: str, size: int | None, payload: bytes) -> dict[str, Any]: + truncated = len(payload) > cls.PREVIEW_MAX_BYTES + sample = payload[: cls.PREVIEW_MAX_BYTES] if b"\x00" in sample: - return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} try: text = sample.decode("utf-8") except UnicodeDecodeError: if truncated: - # A multi-byte char may sit on the cut point; retry without the tail. try: text = sample[:-3].decode("utf-8", errors="strict") except UnicodeDecodeError: - return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} else: - return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} - return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text} + return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None} + return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text} + + def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]: + """Truncated text preview of one drive value (binary-safe, never 500s on size).""" + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + try: + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row) + size = row.size + response_key = row.key + archive_ref: tuple[AgentDriveFile, str] | None = None + except AgentDriveError: + archive_ref = self._archive_member_for_key( + session, + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + ) + storage_key = None + size = None + response_key = normalize_drive_key(key) + + if archive_ref is not None: + archive_row, member_path = archive_ref + payload = self._load_archive_member_bytes( + tenant_id=tenant_id, + archive_file_kind=archive_row.file_kind, + archive_file_id=archive_row.file_id, + member_path=member_path, + ) + return self._preview_bytes(key=response_key, size=len(payload), payload=payload) + + data = bytearray() + assert storage_key is not None + for chunk in storage.load_stream(storage_key): + data.extend(chunk) + if len(data) > self.PREVIEW_MAX_BYTES: + break + return self._preview_bytes(key=response_key, size=size, payload=bytes(data)) + + def preview_archive_member_for_ref( + self, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + ) -> dict[str, Any]: + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + payload = self._load_archive_member_bytes( + tenant_id=tenant_id, + archive_file_kind=archive_file_kind, + archive_file_id=archive_file_id, + member_path=member_path, + ) + return self._preview_bytes(key=normalize_drive_key(key), size=len(payload), payload=payload) def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str: """External signed URL for a browser download of one drive value.""" with session_factory.create_session() as session: self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) - row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + try: + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + except AgentDriveError: + archive_row, member_path = self._archive_member_for_key( + session, + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + ) + return self.sign_archive_member_url( + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + archive_file_kind=archive_row.file_kind, + archive_file_id=archive_row.file_id, + member_path=member_path, + for_external=True, + as_attachment=True, + ) url = self._resolve_download_url( tenant_id=tenant_id, file_kind=row.file_kind, @@ -905,6 +1071,159 @@ class AgentDriveService: raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) return url + def download_url_archive_member_for_ref( + self, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + for_external: bool = True, + ) -> str: + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + return self.sign_archive_member_url( + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + archive_file_kind=archive_file_kind, + archive_file_id=archive_file_id, + member_path=member_path, + for_external=for_external, + as_attachment=True, + ) + + @staticmethod + def _secret_key() -> bytes: + return dify_config.SECRET_KEY.encode() + + @classmethod + def _archive_member_signature_payload( + cls, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + timestamp: str, + nonce: str, + ) -> str: + return "|".join( + [ + _ARCHIVE_MEMBER_DOWNLOAD_PURPOSE, + tenant_id, + agent_id, + normalize_drive_key(key), + archive_file_kind.value, + archive_file_id, + normalize_drive_key(member_path), + timestamp, + nonce, + ] + ) + + @classmethod + def _sign_archive_member_payload(cls, payload: str) -> str: + digest = hmac.new(cls._secret_key(), payload.encode(), hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode() + + @classmethod + def sign_archive_member_url( + cls, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + for_external: bool, + as_attachment: bool = False, + ) -> str: + base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL) + timestamp = str(int(time.time())) + nonce = os.urandom(16).hex() + payload = cls._archive_member_signature_payload( + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + archive_file_kind=archive_file_kind, + archive_file_id=archive_file_id, + member_path=member_path, + timestamp=timestamp, + nonce=nonce, + ) + query = urllib.parse.urlencode( + { + "tenant_id": tenant_id, + "agent_id": agent_id, + "key": normalize_drive_key(key), + "archive_file_kind": archive_file_kind.value, + "archive_file_id": archive_file_id, + "member_path": normalize_drive_key(member_path), + "timestamp": timestamp, + "nonce": nonce, + "sign": cls._sign_archive_member_payload(payload), + "as_attachment": str(as_attachment).lower(), + } + ) + return f"{base_url}/files/agent-drive/archive-member?{query}" + + @classmethod + def verify_archive_member_signature( + cls, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + timestamp: str, + nonce: str, + sign: str, + ) -> bool: + payload = cls._archive_member_signature_payload( + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + archive_file_kind=archive_file_kind, + archive_file_id=archive_file_id, + member_path=member_path, + timestamp=timestamp, + nonce=nonce, + ) + if sign != cls._sign_archive_member_payload(payload): + return False + current_time = int(time.time()) + return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT + + def load_archive_member_for_signed_request( + self, + *, + tenant_id: str, + agent_id: str, + key: str, + archive_file_kind: AgentDriveFileKind, + archive_file_id: str, + member_path: str, + ) -> tuple[bytes, str, str]: + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + payload = self._load_archive_member_bytes( + tenant_id=tenant_id, + archive_file_kind=archive_file_kind, + archive_file_id=archive_file_id, + member_path=member_path, + ) + mime_type = mimetypes.guess_type(member_path)[0] or "application/octet-stream" + filename = normalize_drive_key(key).rsplit("/", 1)[-1] + return payload, mime_type, filename + __all__ = [ "AgentDriveError", diff --git a/api/services/app_service.py b/api/services/app_service.py index 941855b8321..bd0e3fd08e0 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -96,6 +96,17 @@ class AppService: filters.append(App.mode == AppMode.AGENT_CHAT) elif params.mode == "agent": filters.append(App.mode == AppMode.AGENT) + filters.append( + sa.exists() + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == App.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + .correlate(App) + ) elif params.mode == "all": filters.append(App.mode != AppMode.AGENT) diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index a8634bceb09..3184455d0a4 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -31,6 +31,10 @@ class ComposerSoulLockPayload(BaseModel): unlocked_from_version_id: str | None = None +class WorkflowAgentComposerQuery(BaseModel): + snapshot_id: str | None = Field(default=None, max_length=255) + + class ComposerSavePayload(BaseModel): variant: ComposerVariant binding: ComposerBindingPayload | None = None diff --git a/api/services/entities/knowledge_retrieval_inner.py b/api/services/entities/knowledge_retrieval_inner.py index 86276b80177..aaf686da44c 100644 --- a/api/services/entities/knowledge_retrieval_inner.py +++ b/api/services/entities/knowledge_retrieval_inner.py @@ -173,14 +173,6 @@ class InnerKnowledgeRetrieveRequest(BaseModel): class InnerKnowledgeRetrieveUsage(ResponseModel): """Serialized LLM usage payload returned by dataset retrieval.""" - model_config = ConfigDict( - from_attributes=True, - extra="forbid", - populate_by_name=True, - serialize_by_alias=True, - protected_namespaces=(), - ) - prompt_tokens: int completion_tokens: int total_tokens: int 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 c91d0fd3e8a..d3f97072589 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 @@ -162,8 +162,15 @@ def test_request_builder_adds_knowledge_layer_when_configured(): run_input = _run_input() run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 4}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ], } ) @@ -174,7 +181,7 @@ def test_request_builder_adds_knowledge_layer_when_configured(): assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) - assert knowledge_config.dataset_ids == ["dataset-1"] + assert knowledge_config.sets[0].dataset_ids == ["dataset-1"] def test_request_builder_can_delete_on_exit_for_cleanup_paths(): @@ -332,7 +339,7 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "PROJECT_NAME" -def test_workflow_request_builder_binds_shell_to_drive_when_configured(): +def test_workflow_request_builder_binds_drive_to_shell_when_configured(): run_input = _run_input() run_input.include_shell = True run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1") @@ -341,11 +348,11 @@ def test_workflow_request_builder_binds_shell_to_drive_when_configured(): layers = {layer.name: layer for layer in request.composition.layers} layer_names = [layer.name for layer in request.composition.layers] - assert layers[DIFY_SHELL_LAYER_ID].deps == { - "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, - "drive": DIFY_DRIVE_LAYER_ID, - } - assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID) + assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) + assert shell_config.agent_stub_drive_ref == "agent-agent-1" + assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} + assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID) def test_agent_app_request_builder_omits_shell_layer_by_default(): @@ -367,7 +374,7 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "APP_ENV" -def test_agent_app_request_builder_binds_shell_to_drive_when_configured(): +def test_agent_app_request_builder_binds_drive_to_shell_when_configured(): run_input = _agent_app_input(include_shell=True) run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1") @@ -375,19 +382,26 @@ def test_agent_app_request_builder_binds_shell_to_drive_when_configured(): layers = {layer.name: layer for layer in request.composition.layers} layer_names = [layer.name for layer in request.composition.layers] - assert layers[DIFY_SHELL_LAYER_ID].deps == { - "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, - "drive": DIFY_DRIVE_LAYER_ID, - } - assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID) + assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) + assert shell_config.agent_stub_drive_ref == "agent-agent-1" + assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID} + assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID) def test_agent_app_request_builder_adds_knowledge_layer_when_configured(): run_input = _agent_app_input() run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( { - "dataset_ids": ["dataset-1", "dataset-2"], - "retrieval": {"mode": "multiple", "top_k": 2}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ], } ) @@ -398,7 +412,7 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured(): assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) - assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"] + assert knowledge_config.sets[0].dataset_ids == ["dataset-1", "dataset-2"] # ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ───── diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index c30386c9d65..403fb0e94a4 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -149,3 +149,55 @@ def test_generate_specs_is_idempotent(tmp_path): assert [path.name for path in first_paths] == [path.name for path in second_paths] for first_path, second_path in zip(first_paths, second_paths): assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8") + + +def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + console_path = next(path for path in written_paths if path.name == "console-openapi.json") + payload = json.loads(console_path.read_text(encoding="utf-8")) + schemas = payload["components"]["schemas"] + + assert "AgentKnowledgeSetConfig" in schemas + assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == ( + "#/components/schemas/AgentKnowledgeSetConfig" + ) + assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"] + + +def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync(): + api_dir = Path(__file__).resolve().parents[3] + repo_root = api_dir.parent + + markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8") + agent_types = ( + repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts" + ).read_text(encoding="utf-8") + apps_types = ( + repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts" + ).read_text(encoding="utf-8") + agent_zod = ( + repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts" + ).read_text(encoding="utf-8") + apps_zod = ( + repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts" + ).read_text(encoding="utf-8") + + assert "#### AgentKnowledgeSetConfig" in markdown + assert "#### AgentSoulKnowledgeConfig" in markdown + assert "#### AgentKnowledgeQueryMode" in markdown + + for content in (agent_types, apps_types): + assert "export type AgentKnowledgeSetConfig = {" in content + assert "export type AgentSoulKnowledgeConfig = {" in content + assert "AgentKnowledgeQueryMode" in content + assert "generated_query" in content + assert "user_query" in content + + for content in (agent_zod, apps_zod): + assert "export const zAgentKnowledgeSetConfig = z.object({" in content + assert "export const zAgentSoulKnowledgeConfig = z.object({" in content + assert "zAgentKnowledgeQueryMode = z.enum([" in content + assert "generated_query" in content + assert "user_query" in content 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 3d84f899379..371a3df7acc 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 @@ -28,11 +28,15 @@ from controllers.console.agent.roster import ( AgentAppApi, AgentAppCopyApi, AgentAppListApi, + AgentBuildDraftApi, + AgentBuildDraftApplyApi, + AgentBuildDraftCheckoutApi, AgentDebugConversationRefreshApi, AgentInviteOptionsApi, AgentLogMessagesApi, AgentLogsApi, AgentLogSourcesApi, + AgentPublishApi, AgentRosterVersionDetailApi, AgentRosterVersionRestoreApi, AgentRosterVersionsApi, @@ -95,7 +99,7 @@ def _agent_app_composer_response() -> dict: }, "active_config_snapshot": _version_response(), "agent_soul": {}, - "save_options": ["save_to_current_version", "save_as_new_version"], + "save_options": ["save_to_current_version"], } @@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//composer/candidates", "/agent//features", "/agent//copy", + "/agent//publish", + "/agent//build-draft/checkout", + "/agent//build-draft", + "/agent//build-draft/apply", "/agent//referencing-workflows", "/agent//drive/files", "/agent//sandbox/files", @@ -520,6 +528,129 @@ def test_agent_debug_conversation_refresh_uses_current_user( } +def test_agent_publish_and_build_draft_routes_call_composer_service( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + current_user = SimpleNamespace(id=account_id) + captured: dict[str, object] = {} + + def publish_agent_app_draft(**kwargs: object) -> dict[str, object]: + captured["publish"] = kwargs + return {"result": "success", "active_config_snapshot_id": "version-1"} + + def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["checkout"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["load"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["save"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["apply"] = kwargs + return {"result": "success", "draft": {"id": "draft-1"}} + + def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["discard"] = kwargs + return {"result": "success"} + + monkeypatch.setattr( + roster_controller.AgentComposerService, + "publish_agent_app_draft", + publish_agent_app_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "checkout_agent_app_build_draft", + checkout_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "load_agent_app_build_draft", + load_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "save_agent_app_build_draft", + save_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "apply_agent_app_build_draft", + apply_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "discard_agent_app_build_draft", + discard_agent_app_build_draft, + ) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/publish", + json={"version_note": "publish v1"}, + ): + published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id) + assert published["active_config_snapshot_id"] == "version-1" + assert captured["publish"] == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "version_note": "publish v1", + } + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout", + json={"force": True}, + ): + checked_out = unwrap(AgentBuildDraftCheckoutApi.post)( + AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id + ) + assert checked_out["draft"]["id"] == "build-draft-1" + assert captured["checkout"] == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "force": True, + } + + with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"): + loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert loaded["draft"]["id"] == "build-draft-1" + assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft", + json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}}, + ): + saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert saved["draft"]["id"] == "build-draft-1" + assert captured["save"]["tenant_id"] == "tenant-1" + assert captured["save"]["agent_id"] == agent_id + assert captured["save"]["account_id"] == account_id + assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply", + method="POST", + ): + applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id) + assert applied == {"result": "success", "draft": {"id": "draft-1"}} + assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft", + method="DELETE", + ): + discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert discarded == {"result": "success"} + assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -963,10 +1094,11 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"}, } + captured_load: dict[str, object] = {} monkeypatch.setattr( composer_controller.AgentComposerService, "load_workflow_composer", - lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]), + lambda **kwargs: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]), ) monkeypatch.setattr( composer_controller.AgentComposerService, @@ -993,8 +1125,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( }, ) - workflow_state = unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1") + with app.test_request_context("?snapshot_id=preview-version"): + workflow_state = unwrap(WorkflowAgentComposerApi.get)( + WorkflowAgentComposerApi(), "tenant-1", app_model, "node-1" + ) assert workflow_state["node_id"] == "node-1" + assert captured_load["snapshot_id"] == "preview-version" with app.test_request_context(json=payload): saved_state = unwrap(WorkflowAgentComposerApi.put)( WorkflowAgentComposerApi(), "tenant-1", account_id, app_model, "node-1" @@ -1092,13 +1228,11 @@ def test_agent_composer_routes_resolve_app_from_agent_id( "agent_soul": {"prompt": {"system_prompt": "x"}}, } - monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1")) - - def load_agent_app_composer(**kwargs: object) -> dict: + def load_agent_composer(**kwargs: object) -> dict: captured["load"] = kwargs return _agent_app_composer_response() - def save_agent_app_composer(**kwargs: object) -> dict: + def save_agent_composer(**kwargs: object) -> dict: captured["save"] = kwargs return _agent_app_composer_response() @@ -1112,13 +1246,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id( monkeypatch.setattr( composer_controller.AgentComposerService, - "load_agent_app_composer", - load_agent_app_composer, + "load_agent_composer", + load_agent_composer, ) monkeypatch.setattr( composer_controller.AgentComposerService, - "save_agent_app_composer", - save_agent_app_composer, + "save_agent_composer", + save_agent_composer, ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) monkeypatch.setattr( @@ -1133,13 +1267,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id( ) assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app" - assert cast(dict[str, object], captured["load"])["app_id"] == "app-1" + assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id with app.test_request_context(json=payload): assert ( unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app" ) - assert cast(dict[str, object], captured["save"])["app_id"] == "app-1" + assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == { "result": "success", "errors": [], @@ -1150,7 +1284,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id( candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id) assert candidates["variant"] == "agent_app" - assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1" + assert cast(dict[str, object], captured["candidates"])["agent_id"] == agent_id def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( @@ -1172,7 +1306,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( captured["stop"] = kwargs return {"result": "success"}, 200 - monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model) monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message) monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message) @@ -1375,7 +1509,7 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp captured["detail"] = kwargs return {"id": message_id} - monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(message_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model) monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages) monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback) monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions) diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index 3cda0a34332..9b0c530f4fd 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -119,7 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None: def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: service = _AgentAppService() monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service) - monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) + monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model()) monkeypatch.setattr( module, "query_params_from_request", @@ -151,7 +151,7 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) monkeypatch.setattr(module, "AgentAppSandboxService", FailingService) - monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) + monkeypatch.setattr(module, "resolve_agent_runtime_app_model", lambda *, tenant_id, agent_id: _app_model()) monkeypatch.setattr( module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") ) diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py index 81f6fcf36bf..c8b28ef29f0 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py @@ -64,7 +64,7 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload(): raw = _raw(AgentDriveListByAgentApi.get) with app.test_request_context("/?prefix=pdf-toolkit/"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.manifest.return_value = [ @@ -105,7 +105,7 @@ def test_skill_list_by_agent_calls_service(): raw = _raw(AgentDriveSkillListByAgentApi.get) with app.test_request_context("/"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.list_skills.return_value = [ @@ -177,7 +177,7 @@ def test_skill_inspect_by_agent_returns_strict_json_response(): } with app.test_request_context("/"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP), + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.inspect_skill.return_value = payload @@ -256,7 +256,7 @@ def test_preview_by_agent_passes_through_and_maps_errors(): raw = _raw(AgentDrivePreviewByAgentApi.get) with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.preview.return_value = { @@ -272,7 +272,7 @@ def test_preview_by_agent_passes_through_and_maps_errors(): with app.test_request_context("/?key=ghost/SKILL.md"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP), + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP), patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.preview.side_effect = AgentDriveError( @@ -296,7 +296,7 @@ def test_download_by_agent_returns_signed_url_json(): raw = _raw(AgentDriveDownloadByAgentApi.get) with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.download_url.return_value = "https://signed.example/zip" diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py index bafdc3b46ac..79a363e74ca 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -67,7 +67,7 @@ def test_upload_by_agent_resolves_app_and_standardizes_into_drive(): with _file_ctx(files={"file": b"zip-bytes"}): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.SkillStandardizeService") as svc, ): svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}} @@ -174,7 +174,7 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(): upload = SimpleNamespace(id="uf-1", name="sample.pdf") with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.db") as db_mock, patch(f"{_MOD}.AgentDriveService") as drive, @@ -252,7 +252,7 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(): raw = _raw(AgentDriveFilesByAgentApi.delete) with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}] @@ -316,7 +316,7 @@ def test_skill_delete_by_agent_uses_agent_route(): raw = _raw(AgentSkillByAgentApi.delete) with _json_ctx(method="DELETE", query_string="node_id=ignored"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.AgentDriveService") as drive, ): drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}] @@ -360,7 +360,7 @@ def test_infer_tools_by_agent_uses_agent_route(): raw = _raw(AgentSkillInferToolsByAgentApi.post) with _json_ctx(query_string="node_id=ignored"): with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app, patch(f"{_MOD}.SkillToolInferenceService") as svc, ): svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None} 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 b8cdf471ca3..7418ca2660a 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 @@ -69,7 +69,7 @@ class TestGenerateSuccess: def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self): assert ( AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1") - is None + == "snap-1" ) assert ( AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1") @@ -111,7 +111,12 @@ class TestGenerateSuccess: assert result == {"result": "ok"} thread_obj.start.assert_called_once() - generator._resolve_agent.assert_called_once_with(app_model) + generator._resolve_agent.assert_called_once_with( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=user, + ) def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") 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 4f920df0ec6..f5f6c12365f 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 @@ -46,6 +46,13 @@ class _FakeCredentialsProvider: return {"openai_api_key": "sk-test"} +@pytest.fixture(autouse=True) +def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False + ) + + class _NoToolsBuilder: def build(self, **kwargs): del kwargs 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 c0a226ad575..15f1bacb8f6 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 @@ -14,6 +14,7 @@ import pytest from core.app.apps.agent_app import app_generator as gen_mod from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError +from core.app.entities.app_invoke_entities import InvokeFrom _SOUL_DICT = { "model": { @@ -84,14 +85,24 @@ 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(app_model) # type: ignore[arg-type] + agent, snap, soul = AgentAppGenerator()._resolve_agent( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=SimpleNamespace(id="user-1"), + ) # type: ignore[arg-type] - assert agent is inner_agent - assert snap is snapshot + assert agent is bound_agent + assert snap == snapshot.id assert soul.model is not None def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [None]) app_model = SimpleNamespace(id="app-1", tenant_id="t1") with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"): - AgentAppGenerator()._resolve_agent(app_model) # type: ignore[arg-type] + AgentAppGenerator()._resolve_agent( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=SimpleNamespace(id="user-1"), + ) # type: ignore[arg-type] 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 4f292d90bb4..61bf3443cc8 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 @@ -85,6 +85,49 @@ class _NoToolsBuilder: del kwargs +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 _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}, + ], + ) + + +@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: dify_context = SimpleNamespace( tenant_id="tenant-1", @@ -128,7 +171,15 @@ class TestAgentAppRuntimeRequestBuilder: req = result.request assert req.purpose == "agent_app" names = [layer.name for layer in req.composition.layers] - assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"] + assert names == [ + "agent_soul_prompt", + "agent_app_user_prompt", + "execution_context", + "shell", + "drive", + "history", + "llm", + ] # plugin_id / provider normalized for plugin-daemon transport. llm = next(layer for layer in req.composition.layers if layer.name == "llm") assert llm.config.plugin_id == "langgenius/openai" @@ -169,12 +220,19 @@ class TestAgentAppRuntimeRequestBuilder: "model": "gpt-4o-mini", }, "knowledge": { - "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], - "query_config": { - "top_k": 3, - "score_threshold": 0.5, - "score_threshold_enabled": False, - }, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": { + "mode": "multiple", + "top_k": 3, + "score_threshold": None, + }, + } + ], }, } ) @@ -189,10 +247,12 @@ class TestAgentAppRuntimeRequestBuilder: assert knowledge.type == "dify.knowledge_base" assert knowledge.deps == {"execution_context": "execution_context"} dumped_config = knowledge.config.model_dump(mode="json", by_alias=True) - assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"] - assert dumped_config["retrieval"]["mode"] == "multiple" - assert dumped_config["retrieval"]["top_k"] == 3 - assert dumped_config["retrieval"]["score_threshold"] == 0.0 + knowledge_set = dumped_config["sets"][0] + assert [dataset["id"] for dataset in knowledge_set["datasets"]] == ["dataset-1", "dataset-2"] + assert knowledge_set["query"] == {"mode": "generated_query", "value": None} + assert knowledge_set["retrieval"]["mode"] == "multiple" + assert knowledge_set["retrieval"]["top_k"] == 3 + assert knowledge_set["retrieval"]["score_threshold"] == 0.0 def test_build_raises_when_model_missing(self): builder = AgentAppRuntimeRequestBuilder( @@ -242,9 +302,26 @@ class TestAgentAppRuntimeRequestBuilder: def _soul_with_model_and_skill() -> AgentSoulConfig: - soul = _soul_with_model() - soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]" - return soul + return AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "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.", + } + ] + }, + } + ) class TestAgentAppDriveLayer: @@ -252,28 +329,7 @@ class TestAgentAppDriveLayer: monkeypatch.setattr( "core.app.apps.agent_app.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: [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": None, - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "size": 1, - "mime_type": "text/markdown", - "hash": None, - "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} - ], - ) + _mock_drive_catalog(monkeypatch) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -283,27 +339,20 @@ class TestAgentAppDriveLayer: drive = next(layer for layer in result.request.composition.layers if layer.name == "drive") assert drive.type == "dify.drive" - assert drive.deps == {"execution_context": "execution_context"} + 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"] - # injected right after execution_context, mirroring the workflow surface + # shell enters first; drive uses that shell to materialize mentioned targets. names = [layer.name for layer in result.request.composition.layers] - assert names.index("drive") == names.index("execution_context") + 1 + assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1 + assert names.index("drive") == names.index(DIFY_SHELL_LAYER_ID) + 1 - def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch): + def test_drive_layer_injected_with_empty_catalog_and_drive_depends_on_shell(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) - 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: [], - ) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -314,12 +363,14 @@ class TestAgentAppDriveLayer: 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 layers[DIFY_SHELL_LAYER_ID].deps == { - "execution_context": "execution_context", - "drive": "drive", - } + 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} - def test_no_drive_layer_when_flag_disabled(self): + def test_no_drive_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] @@ -334,29 +385,7 @@ class TestAgentAppDriveLayer: monkeypatch.setattr( "core.app.apps.agent_app.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: [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": None, - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "size": 1, - "mime_type": "text/markdown", - "hash": None, - "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": "files/sample.pdf", "is_skill": False}, - ], - ) + _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§]." @@ -379,14 +408,7 @@ class TestAgentAppDriveLayer: monkeypatch.setattr( "core.app.apps.agent_app.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: [], - ) + _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§], " @@ -407,29 +429,7 @@ class TestAgentAppDriveLayer: monkeypatch.setattr( "core.app.apps.agent_app.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: [ - { - "path": "tender-analyzer", - "skill_md_key": "tender-analyzer/SKILL.md", - "archive_key": None, - "name": "Tender Analyzer", - "description": "Parses RFPs.", - "size": 1, - "mime_type": "text/markdown", - "hash": None, - "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": "files/sample.pdf", "is_skill": False}, - ], - ) + _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§]" @@ -452,14 +452,6 @@ class TestAgentAppDriveLayer: monkeypatch.setattr( "core.app.apps.agent_app.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: [], - ) soul = _soul_with_model() soul.prompt.system_prompt = ( "Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], " diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index b2a5ef3931e..1290e384369 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, patch +import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData @@ -50,6 +51,13 @@ class FakeCredentialsProvider: return {"api_key": "secret-key"} +@pytest.fixture(autouse=True) +def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False + ) + + def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File: return File( type=FileType.DOCUMENT, 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 ffa7ccdbca7..0f37068947a 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 @@ -36,6 +36,13 @@ class FakeCredentialsProvider: return {"api_key": "secret-key"} +@pytest.fixture(autouse=True) +def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False + ) + + class CapturingCredentialsProvider: def __init__(self) -> None: self.provider_name: str | None = None @@ -181,7 +188,8 @@ def test_builds_create_run_request_from_agent_soul_and_node_job(): assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"] assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string" assert DIFY_AGENT_HISTORY_LAYER_ID in layers - assert result.redacted_request["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]" + redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]} + assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]" def test_normalizes_langgenius_model_provider_for_agent_backend_transport(): @@ -262,7 +270,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"] assert output_schema["properties"]["confidence"]["type"] == "number" assert output_schema["required"] == ["report"] - assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2} + assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2} assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid" assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap" assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == [] @@ -512,12 +520,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): "model": "gpt-test", }, "knowledge": { - "datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}], - "query_config": { - "top_k": 6, - "score_threshold": 0.4, - "score_threshold_enabled": True, - }, + "sets": [ + { + "id": "support", + "name": "Support KB", + "description": "Support content", + "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": { + "mode": "multiple", + "top_k": 6, + "score_threshold": 0.4, + "reranking_model": {"provider": "cohere", "model": "rerank-v3"}, + "weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}}, + }, + "metadata_filtering": { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + {"name": "category", "comparison_operator": "contains", "value": "auth"} + ], + }, + }, + }, + { + "id": "release", + "name": "Release Notes", + "datasets": [{"id": "dataset-3"}], + "query": {"mode": "user_query", "value": "release notes"}, + "retrieval": { + "mode": "single", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0.2}, + }, + }, + "metadata_filtering": { + "mode": "automatic", + "model_config": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {}, + }, + }, + }, + ], }, } ), @@ -531,25 +582,73 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): knowledge_layer = layers["knowledge"] assert knowledge_layer["type"] == "dify.knowledge_base" assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} - assert knowledge_layer["config"] == { - "dataset_ids": ["dataset-1", "dataset-2"], - "retrieval": { - "mode": "multiple", - "top_k": 6, - "score_threshold": 0.4, - "reranking_mode": "reranking_model", - "reranking_enable": True, - "reranking_model": None, - "weights": None, - "model": None, + assert knowledge_layer["config"]["sets"] == [ + { + "id": "support", + "name": "Support KB", + "description": "Support content", + "datasets": [ + {"id": "dataset-1", "name": None, "description": None}, + {"id": "dataset-2", "name": None, "description": None}, + ], + "query": {"mode": "generated_query", "value": None}, + "retrieval": { + "mode": "multiple", + "top_k": 6, + "score_threshold": 0.4, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": {"provider": "cohere", "model": "rerank-v3"}, + "weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}}, + "model": None, + }, + "metadata_filtering": { + "mode": "manual", + "metadata_model_config": None, + "conditions": { + "logical_operator": "and", + "conditions": [{"name": "category", "comparison_operator": "contains", "value": "auth"}], + }, + }, }, - "metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None}, - "max_result_content_chars": 2000, - "max_observation_chars": 12000, - } + { + "id": "release", + "name": "Release Notes", + "description": None, + "datasets": [{"id": "dataset-3", "name": None, "description": None}], + "query": {"mode": "user_query", "value": "release notes"}, + "retrieval": { + "mode": "single", + "top_k": None, + "score_threshold": 0.0, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": None, + "weights": None, + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0.2}, + }, + }, + "metadata_filtering": { + "mode": "automatic", + "metadata_model_config": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {}, + }, + "conditions": None, + }, + }, + ] + assert knowledge_layer["config"]["max_result_content_chars"] == 2000 + assert knowledge_layer["config"]["max_observation_chars"] == 12000 -def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it(): +def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -565,8 +664,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits "model": "gpt-test", }, "knowledge": { - "datasets": [{"id": "dataset-1"}], - "query_config": {}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": { + "mode": "multiple", + "top_k": 4, + "score_threshold": None, + }, + } + ], }, } ), @@ -577,10 +687,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits dumped = result.request.model_dump(mode="json") knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge") - assert knowledge_layer["config"]["retrieval"]["top_k"] == 4 + assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0 -def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids(): +def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets(): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -595,9 +705,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids(): "model_provider": "openai", "model": "gpt-test", }, - "knowledge": { - "datasets": [{"id": " "}, {}], - }, + "knowledge": {"sets": []}, } ), ) @@ -931,10 +1039,9 @@ def test_workflow_run_request_contains_drive_layer_with_empty_catalog(monkeypatc "mentioned_skill_keys": [], "mentioned_file_keys": [], } - assert layers[DIFY_SHELL_LAYER_ID]["deps"] == { - "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, - "drive": "drive", - } + 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} def test_build_drive_layer_config_requires_agent_identity(): @@ -960,11 +1067,12 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch dumped = result.request.model_dump(mode="json") layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] assert "drive" in layer_names - # injected right after execution_context, before history/llm - assert layer_names.index("drive") == layer_names.index("execution_context") + 1 + # shell enters first; drive 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"] == {"execution_context": "execution_context"} + assert drive["deps"] == {"shell": DIFY_SHELL_LAYER_ID} assert drive["config"]["drive_ref"] == "agent-agent-1" assert drive["config"]["skills"] == [ { @@ -1031,7 +1139,10 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded assert "[§" not in soul_prompt.config.prefix -def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(): +def test_workflow_run_request_has_no_drive_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() @@ -1094,7 +1205,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu soul = AgentSoulConfig.model_validate( { "knowledge": { - "datasets": [{"id": "dataset-1", "name": "Product Docs"}], + "sets": [ + { + "id": "product", + "name": "Product Docs", + "datasets": [{"id": "dataset-1", "name": "Product Docs"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ], } } ) @@ -1106,13 +1225,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) -def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured(): +def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured(): from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest soul = AgentSoulConfig.model_validate( { "knowledge": { - "datasets": [{"id": " "}, {}], + "sets": [], } } ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index 440bd49e5c0..2254cd16d49 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot: ) +def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + knowledge={ + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": dataset_id}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, + ), + ) + + def _graph(edges: list[dict]) -> dict: return { "nodes": [ @@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref(): ) +def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets( + monkeypatch: pytest.MonkeyPatch, +): + dataset_id = "550e8400-e29b-41d4-a716-446655440000" + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = _snapshot_with_knowledge_dataset(dataset_id) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + captured = {} + + def fake_get_datasets_by_ids(ids, tenant_id): + captured["ids"] = ids + captured["tenant_id"] = tenant_id + return [], 0 + + import services.dataset_service as dataset_service_module + + monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) + + with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"} + + def test_publish_validation_accepts_tool_node_agentic_manual_mode(): session = Mock() diff --git a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py b/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py index cd97559725e..691a1c61cc5 100644 --- a/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py +++ b/api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py @@ -64,7 +64,7 @@ def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> No module.op = original_op -def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None: +def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None: engine = sa.create_engine("sqlite:///:memory:") _create_pre_upgrade_schema(engine) snapshot = { @@ -91,7 +91,7 @@ def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"), {"id": "snap-1"}, ).scalar_one() - assert "skills_files" not in json.loads(stored_snapshot) + assert json.loads(stored_snapshot) == snapshot def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None: diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py index 422a3218eaa..fb2e834e8ac 100644 --- a/api/tests/unit_tests/models/test_agent.py +++ b/api/tests/unit_tests/models/test_agent.py @@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries(): assert AgentStatus.ARCHIVED.value == "archived" assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version" assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version" + assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft" + assert AgentConfigDraftType.DRAFT.value == "draft" + assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build" assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent" assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent" @@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json(): assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1" +def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json(): + config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}}) + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + config_snapshot=config_snapshot, + ) + + config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot + assert isinstance(config_snapshot_column.type, JSONModelColumn) + assert config_snapshot_column.server_default is None + assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json") + assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt" + + def test_workflow_binding_stores_node_job_config_separately_from_agent_soul(): node_job_config = { "schema_version": 1, @@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults(): assert isinstance(column.type, LongText) assert column.server_default is None assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None + assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index 23988c2ec20..43323ca49b4 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -1,4 +1,5 @@ import pytest +from pydantic import ValidationError from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType from services.agent.composer_service import AgentComposerService @@ -26,6 +27,24 @@ def test_workflow_variant_rejects_agent_app_only_fields(): ) +def test_workflow_variant_accepts_agent_soul_files_section(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "agent_soul": { + "schema_version": 1, + "prompt": {"system_prompt": "jjjj"}, + "files": {"skills": [], "files": []}, + }, + } + ) + + assert payload.agent_soul is not None + assert payload.agent_soul.files.skills == [] + assert payload.agent_soul.files.files == [] + + def test_agent_app_variant_rejects_workflow_node_job(): with pytest.raises(ValueError): ComposerSavePayload.model_validate( @@ -131,14 +150,144 @@ def test_knowledge_query_mode_uses_stable_backend_enums(): config = AgentSoulConfig.model_validate( { "knowledge": { - "datasets": [{"dataset_id": "dataset-1"}], - "query_mode": "generated_query", - "query_config": {"generation_prompt": "Create a retrieval query."}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ], } } ) - assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY + assert config.knowledge.sets[0].query.mode == AgentKnowledgeQueryMode.GENERATED_QUERY + + +@pytest.mark.parametrize( + ("knowledge_payload", "match"), + [ + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + { + "id": "support", + "name": "Billing KB", + "datasets": [{"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + ] + }, + "knowledge set ids must be unique", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Shared KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + { + "id": "billing", + "name": "Shared KB", + "datasets": [{"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + ] + }, + "knowledge set names must be unique", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}, {"id": " dataset-1 "}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + ] + }, + "knowledge set dataset ids must be unique", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "user_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + ] + }, + "knowledge query.value is required for user_query mode", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "single"}, + }, + ] + }, + "knowledge retrieval.model is required for single mode", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "automatic"}, + }, + ] + }, + "metadata_filtering.model_config is required for automatic mode", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "manual"}, + }, + ] + }, + "metadata_filtering.conditions is required for manual mode", + ), + ], +) +def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str): + with pytest.raises(ValidationError, match=match): + AgentSoulConfig.model_validate({"knowledge": knowledge_payload}) def test_agent_soul_model_config_is_first_class_without_credentials(): 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 1bac183c39f..43cf19c224b 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -8,6 +8,8 @@ from sqlalchemy.exc import IntegrityError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDebugConversation, @@ -26,8 +28,8 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) -from models.enums import ConversationFromSource, ConversationStatus -from models.model import Conversation, IconType +from models.enums import AppStatus, ConversationFromSource, ConversationStatus +from models.model import App, AppMode, Conversation, IconType from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -37,6 +39,7 @@ from services.agent.errors import ( AgentNameConflictError, AgentNotFoundError, AgentVersionConflictError, + AgentVersionNotFoundError, InvalidComposerConfigError, ) from services.agent.roster_service import AgentRosterService @@ -156,6 +159,96 @@ def test_load_workflow_composer_serializes_existing_binding(monkeypatch: pytest. assert result == {"agent": "agent-1", "version": "version-1"} +def test_load_workflow_composer_uses_roster_preview_snapshot(monkeypatch: pytest.MonkeyPatch): + binding = SimpleNamespace( + agent_id="agent-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + current_snapshot_id="binding-version", + ) + agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER, active_config_snapshot_id="active-version") + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent) + monkeypatch.setattr( + AgentComposerService, + "_require_version", + lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]), + ) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: { + "binding_snapshot_id": kwargs["binding"].current_snapshot_id, + "version": kwargs["version"].id, + }, + ) + + result = AgentComposerService.load_workflow_composer( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + snapshot_id="preview-version", + ) + + assert result == {"binding_snapshot_id": "binding-version", "version": "preview-version"} + + +def test_load_workflow_composer_uses_inline_preview_snapshot(monkeypatch: pytest.MonkeyPatch): + binding = SimpleNamespace( + agent_id="inline-agent-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + current_snapshot_id="inline-version-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + ) + agent = SimpleNamespace( + id="inline-agent-1", + scope=AgentScope.WORKFLOW_ONLY, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="node-1", + active_config_snapshot_id="inline-version-1", + ) + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent) + monkeypatch.setattr( + AgentComposerService, + "_require_version", + lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]), + ) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: {"agent": kwargs["agent"].id, "version": kwargs["version"].id}, + ) + + result = AgentComposerService.load_workflow_composer( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + snapshot_id="inline-preview-version", + ) + + assert result == {"agent": "inline-agent-1", "version": "inline-preview-version"} + + +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) + + with pytest.raises(AgentVersionNotFoundError): + AgentComposerService.load_workflow_composer( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + snapshot_id="preview-version", + ) + + @pytest.mark.parametrize( ("strategy", "helper_name"), [ @@ -271,16 +364,16 @@ def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveSt def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession(scalar=[None]) - created_version = SimpleNamespace(id="version-1") + saved_draft = SimpleNamespace(id="draft-1", config_snapshot_dict={"prompt": {"system_prompt": "x"}}) monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) - monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version) - monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) + monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **kwargs: saved_draft) + monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, - "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "new_agent_name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}, } @@ -293,25 +386,66 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest. assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} assert fake_session.added[0].name == "Analyst" - assert fake_session.added[0].active_config_snapshot_id == "version-1" - assert fake_session.added[0].active_config_has_model is False + assert fake_session.added[0].active_config_snapshot_id is None assert fake_session.commits == 1 -def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.MonkeyPatch): +def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.MonkeyPatch): + agent = SimpleNamespace( + id="agent-1", + active_config_snapshot_id="version-1", + updated_by="account-1", + created_by="account-1", + app_id="app-1", + backing_app_id="app-1", + scope=AgentScope.ROSTER, + status=AgentStatus.ACTIVE, + ) + draft = SimpleNamespace(config_snapshot_dict={"prompt": {"system_prompt": "x"}}) + + monkeypatch.setattr(AgentComposerService, "_require_agent_app_agent", lambda **kwargs: agent) + monkeypatch.setattr(AgentComposerService, "_get_or_create_agent_draft", lambda **kwargs: draft) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: None) + monkeypatch.setattr(AgentComposerService, "_serialize_agent", lambda _agent: {"id": _agent.id}) + monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: None) + monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda _draft: {"id": "draft-1"}) + + result = AgentComposerService.load_agent_app_composer(tenant_id="tenant-1", app_id="app-1") + + assert result["save_options"] == [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value] + + +def test_save_agent_app_composer_rejects_version_save_strategy(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, + "agent_soul": {"prompt": {"system_prompt": "x"}}, + } + ) + + with pytest.raises(InvalidComposerConfigError, match="Use the publish endpoint"): + AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", + app_id="app-1", + account_id="account-1", + payload=payload, + ) + + +def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch): agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None) fake_session = FakeSession(scalar=[agent]) - updated = {} + saved = {} monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) - monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1")) monkeypatch.setattr( AgentComposerService, - "_update_current_version", - lambda **kwargs: updated.update(kwargs) or SimpleNamespace(id="version-2"), + "_save_agent_draft", + lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"), ) - monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) + monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, @@ -326,12 +460,118 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} - assert updated["operation"].value == "save_current_version" - assert agent.active_config_has_model is True + assert saved["draft_type"] == AgentConfigDraftType.DRAFT + assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json") assert fake_session._scalar == [] assert fake_session.commits == 1 +def test_publish_agent_app_draft_creates_published_snapshot(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", + ) + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=_agent_soul_with_model(), + ) + version = SimpleNamespace(id="version-2") + fake_session = FakeSession(scalar=[agent, draft]) + created: dict[str, object] = {} + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None) + monkeypatch.setattr( + AgentComposerService, + "_create_config_version", + lambda **kwargs: created.update(kwargs) or version, + ) + monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: {"id": _version.id}) + + result = AgentComposerService.publish_agent_app_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + version_note="ship it", + ) + + assert result["result"] == "success" + assert result["active_config_snapshot_id"] == "version-2" + assert result["draft"]["base_snapshot_id"] == "version-2" + assert created["operation"] == AgentConfigRevisionOperation.PUBLISH_DRAFT + assert created["previous_snapshot_id"] == "version-1" + assert agent.active_config_snapshot_id == "version-2" + assert agent.active_config_has_model is True + assert fake_session.commits == 1 + + +def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(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", + ) + 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=_agent_soul_with_model(), + ) + fake_session = FakeSession(scalar=[agent, normal_draft, None]) + + monkeypatch.setattr(composer_service.db, "session", fake_session) + + checked_out = AgentComposerService.checkout_agent_app_build_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + build_draft = fake_session.added[0] + assert checked_out["draft"]["id"] == build_draft.id + assert checked_out["draft"]["draft_type"] == AgentConfigDraftType.DEBUG_BUILD.value + assert checked_out["draft"]["account_id"] == "account-1" + assert checked_out["draft"]["base_snapshot_id"] == "version-1" + assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict + assert fake_session.commits == 1 + + fake_session = FakeSession(scalar=[agent, build_draft, normal_draft]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + + applied = AgentComposerService.apply_agent_app_build_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + assert applied["result"] == "success" + assert applied["draft"]["id"] == normal_draft.id + assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict + assert fake_session.deleted == [build_draft] + assert fake_session.commits == 1 + + def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch): bindings = [ SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"), @@ -345,14 +585,14 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatc raise ValueError("draft workflow not found") monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow) - monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None) + monkeypatch.setattr(AgentComposerService, "_load_agent_soul", lambda **kwargs: None) monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: []) workflow_candidates = AgentComposerService.get_workflow_candidates( tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1" ) agent_app_candidates = AgentComposerService.get_agent_app_candidates( - tenant_id="tenant-1", app_id="app-1", user_id="account-1" + tenant_id="tenant-1", agent_id="agent-1", user_id="account-1" ) impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1") @@ -376,13 +616,28 @@ def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch: pyt node_id="node-1", node_job_config='{"workflow_prompt":"do work"}', ) - agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE) + agent = Agent( + id="agent-1", + name="Analyst", + description="Clarifies tenders", + role="Tender Analyst", + icon_type="emoji", + icon="robot", + icon_background="#F5F3FF", + scope=AgentScope.ROSTER, + source=AgentSource.ROSTER, + status=AgentStatus.ACTIVE, + ) version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) state = AgentComposerService._serialize_workflow_state(binding=binding, agent=agent, version=version) assert state["soul_lock"]["locked"] is True + assert state["agent"]["role"] == "Tender Analyst" + assert state["agent"]["icon_type"] == "emoji" + assert state["agent"]["icon"] == "robot" + assert state["agent"]["icon_background"] == "#F5F3FF" assert "save_as_new_version" in state["save_options"] assert state["agent_soul"]["app_features"] == {} # Stage 4 §10.1 (D-3): binding with no declared_outputs → response surfaces @@ -404,7 +659,14 @@ def test_serialize_workflow_state_passes_user_declared_outputs_through_effective '{"workflow_prompt":"work","declared_outputs":[{"name":"summary","type":"string","required":true}]}' ), ) - agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE) + agent = Agent( + id="agent-1", + name="Analyst", + description="", + scope=AgentScope.ROSTER, + source=AgentSource.ROSTER, + status=AgentStatus.ACTIVE, + ) version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) @@ -1196,6 +1458,7 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) created_apps = [] + hidden_backing_apps = [] backing_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", @@ -1215,6 +1478,10 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes def __init__(self, session): self.session = session + def create_hidden_backing_app_for_workflow_agent(self, **kwargs): + hidden_backing_apps.append(kwargs) + return SimpleNamespace(id="hidden-app-1") + def get_app_backing_agent(self, *, tenant_id, app_id): assert tenant_id == "tenant-1" assert app_id == "app-agent-1" @@ -1253,6 +1520,8 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes assert workflow_agent.active_config_snapshot_id == "version-with-model" assert workflow_agent.active_config_has_model is True + assert workflow_agent.backing_app_id == "hidden-app-1" + assert hidden_backing_apps[0]["name"] == "Workflow Agent node-1" assert roster_agent.active_config_snapshot_id == "version-with-model" assert roster_agent.active_config_has_model is True assert roster_agent.source == AgentSource.AGENT_APP @@ -1481,7 +1750,9 @@ def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch): scalar=[2, 1, SimpleNamespace(id="workflow-1")], scalars=[ [agent, unconfigured_agent], + [], [agent], + [], [SimpleNamespace(agent_id="agent-1", node_id="node-1")], ], ) @@ -1568,7 +1839,15 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): status=AgentStatus.ACTIVE, active_config_snapshot_id=None, ) - service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]])) + published_draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=AgentSoulConfig(), + ) + service = AgentRosterService(FakeSession(scalars=[["agent-1"], [published_draft], ["agent-1"], [published_draft]])) flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent]) @@ -1580,6 +1859,62 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): ) == {"agent-2": False} +def test_active_config_is_published_skips_empty_agent_ids(): + empty_id_agent = Agent( + id="", + tenant_id="tenant-1", + name="Broken", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id=None, + ) + fake_session = FakeSession(scalars=[["should-not-be-read"]]) + + assert ( + AgentRosterService(fake_session).load_active_config_is_published_by_agent_id( + tenant_id="tenant-1", + agents=[empty_id_agent], + ) + == {} + ) + assert fake_session._scalars == [["should-not-be-read"]] + + +def test_load_app_backing_agents_skips_empty_agent_ids(): + valid_agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Valid", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="app-1", + status=AgentStatus.ACTIVE, + ) + empty_id_agent = Agent( + id="", + tenant_id="tenant-1", + name="Broken", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="app-2", + status=AgentStatus.ACTIVE, + ) + + result = AgentRosterService(FakeSession(scalars=[[valid_agent, empty_id_agent]])).load_app_backing_agents_by_app_id( + tenant_id="tenant-1", + app_ids=["app-1", "app-2"], + ) + + assert result == {"app-1": valid_agent} + + def test_published_references_include_app_display_fields_and_sort_by_updated_at(): recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC) stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) @@ -1765,6 +2100,42 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch assert loaded_versions["version-1"].agent_id == "agent-1" +def test_get_agent_runtime_app_model_creates_hidden_backing_app_for_existing_inline_agent(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + app_id="workflow-app-1", + workflow_id="workflow-1", + workflow_node_id="node-1", + name="Inline Agent", + description="desc", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + created_by="account-1", + updated_by="account-1", + ) + backing_app = App( + id="generated-1", + tenant_id="tenant-1", + name="Inline Agent", + mode=AppMode.AGENT, + status=AppStatus.NORMAL, + ) + session = FakeSession(scalar=[agent, backing_app]) + service = AgentRosterService(session) + + resolved_app = service.get_agent_runtime_app_model(tenant_id="tenant-1", agent_id="agent-1") + + assert resolved_app is backing_app + assert agent.backing_app_id == "generated-1" + assert session.commits == 1 + created_app = next(value for value in session.added if isinstance(value, App)) + assert created_app.enable_site is False + assert created_app.enable_api is False + + def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent = Agent( id="agent-1", @@ -1849,7 +2220,7 @@ def test_agent_app_debug_conversation_requires_app_binding(): ) -def test_load_or_create_agent_app_debug_conversations_filters_agent_apps(): +def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_agents(): valid_agent = Agent( id="agent-1", tenant_id="tenant-1", @@ -1888,10 +2259,11 @@ def test_load_or_create_agent_app_debug_conversations_filters_agent_apps(): account_id="account-1", ) - assert list(result) == ["agent-1"] + assert list(result) == ["agent-1", "agent-3"] assert result["agent-1"] + assert result["agent-3"] assert fake_session.commits == 1 - assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 1 + assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2 def test_agent_app_visible_versions_exclude_draft_saves(): @@ -1902,6 +2274,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): roster_operations = AgentRosterService._visible_version_operations(roster_agent) assert agent_app_operations == { + AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, @@ -1913,7 +2286,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch): - fake_session = FakeSession(scalar=["version-2", 6]) + fake_session = FakeSession(scalar=["version-2", None]) service = AgentRosterService(fake_session) agent = Agent( id="agent-1", @@ -1944,19 +2317,22 @@ def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pyte account_id="account-1", ) - assert restored == {"result": "success", "active_config_snapshot_id": "version-2"} - assert agent.active_config_snapshot_id == "version-2" - assert agent.active_config_has_model is True + assert restored == { + "result": "success", + "active_config_snapshot_id": "version-4", + "draft_config_id": fake_session.added[0].id, + "restored_version_id": "version-2", + } + assert agent.active_config_snapshot_id == "version-4" assert agent.updated_by == "account-1" assert fake_session.commits == 1 - revision = fake_session.added[0] - assert revision.tenant_id == "tenant-1" - assert revision.agent_id == "agent-1" - assert revision.previous_snapshot_id == "version-4" - assert revision.current_snapshot_id == "version-2" - assert revision.revision == 7 - assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION - assert revision.created_by == "account-1" + draft = fake_session.added[0] + assert draft.tenant_id == "tenant-1" + assert draft.agent_id == "agent-1" + assert draft.draft_type == AgentConfigDraftType.DRAFT + assert draft.base_snapshot_id == "version-2" + assert draft.config_snapshot_dict == _agent_soul_with_model().model_dump(mode="json") + assert draft.updated_by == "account-1" def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch): @@ -1998,6 +2374,18 @@ def test_app_list_all_excludes_agent_apps_by_default(): assert "apps.mode != :mode_1" in sql +def test_app_list_agent_mode_requires_visible_roster_backing_agent(): + filters = AppService._build_app_list_filters( + "account-1", "tenant-1", AppListParams(mode="agent"), FakeSession(scalar=None, scalars=None) + ) + sql = " ".join(str(filter_) for filter_ in filters) + + assert "EXISTS" in sql + assert "agents.app_id = apps.id" in sql + assert "agents.scope" in sql + assert "agents.source" in sql + + def test_validator_dict_helpers_wrap_validation_errors(): valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}}) valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"}) @@ -2843,6 +3231,11 @@ class TestWorkflowAgentDraftBindingSync: ) node_data = graph["nodes"][0]["data"] + assert node_data["agent_binding"] == { + "binding_type": "roster_agent", + "agent_id": "agent-1", + "current_snapshot_id": "snapshot-1", + } assert node_data["agent_task"] == "Summarize the upstream result." assert node_data["agent_declared_outputs"][0]["name"] == "summary" assert node_data["agent_declared_outputs"][0]["type"] == "string" @@ -2852,6 +3245,103 @@ class TestWorkflowAgentDraftBindingSync: assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city" assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"] + def test_projects_inline_binding_over_pending_inline_graph_response(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + }, + }, + } + ], + "edges": [], + } + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-snapshot-1", + ) + session = FakeSession(scalars=[[binding]]) + + graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=session, + draft_workflow=workflow, + ) + + assert graph["nodes"][0]["data"]["agent_binding"] == { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-snapshot-1", + } + assert workflow.graph_dict["nodes"][0]["data"]["agent_binding"] == { + "binding_type": "inline_agent", + } + + def test_keeps_pending_inline_graph_response_over_existing_roster_binding(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + }, + }, + } + ], + "edges": [], + } + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + ) + session = FakeSession(scalars=[[binding]]) + + graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=session, + draft_workflow=workflow, + ) + + assert graph["nodes"][0]["data"]["agent_binding"] == { + "binding_type": "inline_agent", + } + def test_creates_roster_binding_from_agent_node_graph(self): workflow = Workflow( id="workflow-1", @@ -2979,6 +3469,52 @@ class TestWorkflowAgentDraftBindingSync: workflow_prompt="Use the current node context.", ).model_dump(mode="json") + def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + }, + }, + } + ] + } + ), + ) + existing_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + ) + session = FakeSession(scalars=[[existing_binding]]) + + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + assert session.deleted == [] + assert session.added == [] + assert session.flushes == 1 + def test_rejects_inline_binding_for_agent_owned_by_another_node(self): workflow = Workflow( id="workflow-1", @@ -3058,7 +3594,7 @@ class TestWorkflowAgentDraftBindingSync: account_id="account-1", ) - def test_rejects_inline_binding_without_current_snapshot_id(self): + def test_treats_partial_inline_binding_as_pending_draft_state(self): workflow = Workflow( id="workflow-1", tenant_id="tenant-1", @@ -3083,12 +3619,17 @@ class TestWorkflowAgentDraftBindingSync: ), ) - with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"): - WorkflowAgentPublishService.sync_agent_bindings_for_draft( - session=FakeSession(scalars=[[]]), - draft_workflow=workflow, - account_id="account-1", - ) + session = FakeSession(scalars=[[]]) + + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + assert session.added == [] + assert session.deleted == [] + assert session.flushes == 1 def test_rejects_inline_binding_with_missing_snapshot(self): workflow = Workflow( @@ -3312,20 +3853,151 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch): return [], 0 import services.dataset_service as dataset_service_module + from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) valid = "550e8400-e29b-41d4-a716-446655440000" - rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid]) + rows = get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid]) assert rows == {} assert captured["ids"] == [valid] # all-malformed input never touches the DB captured.clear() - assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {} + assert get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {} assert captured == {} +@pytest.mark.parametrize( + ("variant", "save_call"), + [ + ( + ComposerVariant.AGENT_APP, + lambda payload: AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", + app_id="app-1", + account_id="account-1", + payload=payload, + ), + ), + ( + ComposerVariant.WORKFLOW, + lambda payload: AgentComposerService.save_workflow_composer( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + payload=payload, + ), + ), + ], +) +def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call): + captured = {"calls": 0} + + def fake_get_datasets_by_ids(ids, tenant_id): + captured["calls"] += 1 + captured["ids"] = ids + captured["tenant_id"] = tenant_id + return [], 0 + + import services.dataset_service as dataset_service_module + + monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) + + payload = ComposerSavePayload.model_validate( + { + "variant": variant.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "soul_lock": {"locked": False}, + "agent_soul": { + "knowledge": { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "not-a-uuid"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + } + }, + } + ) + + with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"): + save_call(payload) + + assert captured == {"calls": 0} + + +@pytest.mark.parametrize( + ("variant", "save_call"), + [ + ( + ComposerVariant.AGENT_APP, + lambda payload: AgentComposerService.save_agent_app_composer( + tenant_id="tenant-1", + app_id="app-1", + account_id="account-1", + payload=payload, + ), + ), + ( + ComposerVariant.WORKFLOW, + lambda payload: AgentComposerService.save_workflow_composer( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + payload=payload, + ), + ), + ], +) +def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets( + monkeypatch: pytest.MonkeyPatch, variant, save_call +): + captured = {} + missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000" + + def fake_get_datasets_by_ids(ids, tenant_id): + captured["ids"] = ids + captured["tenant_id"] = tenant_id + return [], 0 + + import services.dataset_service as dataset_service_module + + monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) + + payload = ComposerSavePayload.model_validate( + { + "variant": variant.value, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, + "soul_lock": {"locked": False}, + "agent_soul": { + "knowledge": { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": missing_dataset_id}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + } + }, + } + ) + + with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id): + save_call(payload) + + assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"} + + def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch): """The slash-menu Tools tab needs both selection granularities: a provider hosts many tools (like an MCP server), so candidates return one diff --git a/api/tests/unit_tests/services/agent/test_composer_candidates.py b/api/tests/unit_tests/services/agent/test_composer_candidates.py index 863ebafc994..5566d48c690 100644 --- a/api/tests/unit_tests/services/agent/test_composer_candidates.py +++ b/api/tests/unit_tests/services/agent/test_composer_candidates.py @@ -124,7 +124,18 @@ def _soul() -> AgentSoulConfig: {"id": "ct-2", "name": "disabled-one", "enabled": False}, ], }, - "knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]}, + "knowledge": { + "sets": [ + { + "id": "kb-1", + "name": "产品知识", + "description": "knowledge set", + "datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, "human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]}, } ) @@ -143,12 +154,16 @@ def test_soul_candidates_lists_configured_items_only(): assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"] # the stable mention id flows through so the frontend can mint [§cli_tool:§] assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"] - # enriched from DB; dangling dataset kept with missing flag (placeholder, 0522) - knowledge = {item["id"]: item for item in lists["knowledge_datasets"]} - assert knowledge["ds-1"]["name"] == "产品手册" - assert knowledge["ds-1"]["missing"] is False - assert knowledge["ds-gone"]["missing"] is True - assert knowledge["ds-gone"]["name"] == "已删" + # Knowledge mentions point at set ids; nested datasets are hydrated for context. + knowledge_set = lists["knowledge_sets"][0] + assert knowledge_set["id"] == "kb-1" + assert knowledge_set["name"] == "产品知识" + assert knowledge_set["missing_dataset_ids"] == ["ds-gone"] + datasets = {item["id"]: item for item in knowledge_set["datasets"]} + assert datasets["ds-1"]["name"] == "产品手册" + assert datasets["ds-1"]["missing"] is False + assert datasets["ds-gone"]["missing"] is True + assert datasets["ds-gone"]["name"] == "已删" assert lists["human_contacts"][0]["id"] == "c-1" assert lists["dify_tools"][0]["id"] == "tavily/tavily_search" diff --git a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py index ffbec86f4e6..f56ae3751e8 100644 --- a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py +++ b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py @@ -149,22 +149,32 @@ def test_dangling_knowledge_without_label_gets_fallback_name(): ] -def test_configured_but_deleted_dataset_surfaces_as_placeholder(): +def test_configured_but_deleted_knowledge_set_surfaces_as_placeholder(): payload = ComposerSavePayload.model_validate( { "variant": "agent_app", "agent_soul": { - "prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"}, - "knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]}, + "prompt": {"system_prompt": "see [§knowledge:kb-1:产品手册§]"}, + "knowledge": { + "sets": [ + { + "id": "kb-1", + "name": "产品手册", + "datasets": [{"id": "ds-1", "name": "产品手册"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, }, "save_strategy": "save_to_current_version", } ) - # configured + DB row exists -> clean - assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == [] - # configured but deleted in DB -> placeholder - assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [ - {"id": "ds-1", "placeholder_name": "产品手册"} + # configured + current Agent Soul row exists -> clean + assert _findings(payload, existing_knowledge_set_ids={"kb-1"})["knowledge_retrieval_placeholder"] == [] + # configured but removed from the current Agent Soul surface -> placeholder + assert _findings(payload, existing_knowledge_set_ids=set())["knowledge_retrieval_placeholder"] == [ + {"id": "kb-1", "placeholder_name": "产品手册"} ] 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 b8b908d432f..5bc614f4e49 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -107,7 +107,17 @@ def soul() -> AgentSoulConfig: ], "cli_tools": [{"id": "ct-1", "name": "ffmpeg"}], }, - "knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]}, + "knowledge": { + "sets": [ + { + "id": "kb-1", + "name": "产品手册", + "datasets": [{"id": "ds-1", "name": "产品手册"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, "human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]}, } ) @@ -117,7 +127,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig): resolver = build_soul_mention_resolver(soul) prompt = ( "Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], " - "ground in [§knowledge:ds-1§], ask [§human:c-1§]." + "ground in [§knowledge:kb-1§], ask [§human:c-1§]." ) expanded = expand_prompt_mentions(prompt, resolver) diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py index c88b72b3fcc..5c4e479e187 100644 --- a/api/tests/unit_tests/services/agent/test_skill_package_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -1,13 +1,16 @@ -"""Unit tests for the Skill package validator/extractor (ENG-370).""" +"""Unit tests for the Skill package validator/normalizer (ENG-370).""" from __future__ import annotations +import hashlib import io import zipfile +import zlib import pytest -from services.agent.skill_package_service import SkillPackageError, SkillPackageService +from services.agent import skill_package_service as skill_package_service_module +from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService _SKILL_MD = """--- name: PDF Toolkit @@ -28,12 +31,17 @@ def _zip(members: dict[str, bytes], *, compression: int = zipfile.ZIP_DEFLATED) return buffer.getvalue() -def _extract(members: dict[str, bytes], *, filename: str = "skill.zip"): - return SkillPackageService().validate_and_extract(content=_zip(members), filename=filename) +def _normalize(members: dict[str, bytes], *, filename: str = "skill.zip") -> NormalizedSkillPackage: + return SkillPackageService().validate_and_normalize(content=_zip(members), filename=filename) -def test_valid_skill_extracts_manifest(): - manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}) +def _archive_members(content: bytes) -> list[str]: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + return sorted(info.filename for info in archive.infolist() if not info.is_dir()) + + +def test_valid_skill_normalizes_manifest(): + manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest assert manifest.name == "PDF Toolkit" assert manifest.description == "Tools for working with PDF files." @@ -44,19 +52,101 @@ def test_valid_skill_extracts_manifest(): def test_name_falls_back_to_heading_without_frontmatter(): - manifest = _extract({"SKILL.md": b"# Heading Name\n\nbody"}) + manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest assert manifest.name == "Heading Name" assert manifest.description == "" -def test_nested_skill_md_is_found(): - manifest = _extract({"pdf-toolkit/SKILL.md": _SKILL_MD.encode()}) - assert manifest.entry_path == "pdf-toolkit/SKILL.md" - - -def test_shallowest_skill_md_preferred(): - manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()}) +def test_shallowest_skill_md_preferred_during_normalization(): + manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()}).manifest assert manifest.entry_path == "SKILL.md" + assert manifest.files == ["SKILL.md", "nested/SKILL.md"] + + +def test_validate_and_normalize_keeps_root_skill_unchanged(): + package = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}) + + assert package.manifest.entry_path == "SKILL.md" + assert package.manifest.files == ["SKILL.md", "scripts/run.py"] + assert package.skill_md_bytes == _SKILL_MD.encode() + assert package.strip_prefix is None + assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + assert len(package.manifest.hash) == 64 + + +def test_validate_and_normalize_strips_single_top_level_folder(): + package = _normalize( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('hi')\n", + } + ) + + assert package.manifest.entry_path == "SKILL.md" + assert package.manifest.files == ["SKILL.md", "scripts/run.py"] + assert package.skill_md_bytes == _SKILL_MD.encode() + assert package.strip_prefix == "pdf-toolkit/" + assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + + +def test_validate_and_normalize_strips_single_top_level_folder_ignoring_other_root_entries(): + package = _normalize( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('hi')\n", + "README.md": b"bundle notes\n", + } + ) + + assert package.manifest.entry_path == "SKILL.md" + assert package.manifest.files == ["SKILL.md", "scripts/run.py"] + assert package.skill_md_bytes == _SKILL_MD.encode() + assert package.strip_prefix == "pdf-toolkit/" + assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + + +def test_validate_and_normalize_strips_single_top_level_folder_dropping_nested_foreign_paths(): + package = _normalize( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('hi')\n", + "bundle/other.txt": b"x", + } + ) + + assert package.manifest.entry_path == "SKILL.md" + assert package.manifest.files == ["SKILL.md", "scripts/run.py"] + assert package.skill_md_bytes == _SKILL_MD.encode() + assert package.strip_prefix == "pdf-toolkit/" + assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + + +def test_validate_and_normalize_rejects_multiple_depth_2_skill_roots_with_sibling_skill_tree(): + with pytest.raises(SkillPackageError) as exc_info: + _normalize( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('hi')\n", + "other-tool/SKILL.md": _SKILL_MD.encode(), + } + ) + assert exc_info.value.code == "files_outside_skill_root" + + +def test_validate_and_normalize_strips_deeper_selected_skill_root(): + members = { + "bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "bundle/pdf-toolkit/scripts/run.py": b"print('hi')\n", + } + original_upload_bytes = _zip(members) + package = SkillPackageService().validate_and_normalize(content=original_upload_bytes, filename="skill.zip") + + assert package.manifest.entry_path == "SKILL.md" + assert package.manifest.files == ["SKILL.md", "scripts/run.py"] + assert package.strip_prefix == "bundle/pdf-toolkit/" + assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + assert package.manifest.hash == hashlib.sha256(package.archive_bytes).hexdigest() + assert package.manifest.hash != hashlib.sha256(original_upload_bytes).hexdigest() @pytest.mark.parametrize( @@ -71,53 +161,105 @@ def test_shallowest_skill_md_preferred(): ) def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str): with pytest.raises(SkillPackageError) as exc_info: - _extract(members, filename=filename) + _normalize(members, filename=filename) assert exc_info.value.code == code assert exc_info.value.status_code == 400 def test_non_zip_content_rejected(): with pytest.raises(SkillPackageError) as exc_info: - SkillPackageService().validate_and_extract(content=b"not a zip", filename="skill.zip") + SkillPackageService().validate_and_normalize(content=b"not a zip", filename="skill.zip") assert exc_info.value.code == "invalid_archive" def test_zip_slip_member_rejected(): payload = _zip({"../evil.txt": b"x", "SKILL.md": _SKILL_MD.encode()}) with pytest.raises(SkillPackageError) as exc_info: - SkillPackageService().validate_and_extract(content=payload, filename="skill.zip") + SkillPackageService().validate_and_normalize(content=payload, filename="skill.zip") assert exc_info.value.code == "unsafe_path" def test_empty_archive_rejected(): with pytest.raises(SkillPackageError) as exc_info: - SkillPackageService().validate_and_extract(content=b"", filename="skill.zip") + SkillPackageService().validate_and_normalize(content=b"", filename="skill.zip") assert exc_info.value.code == "empty_archive" +def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(skill_package_service_module, "_MAX_SKILL_MD_BYTES", 8) + + with pytest.raises(SkillPackageError) as exc_info: + _normalize({"SKILL.md": _SKILL_MD.encode()}) + assert exc_info.value.code == "skill_md_too_large" + + +def test_validate_and_normalize_rejects_too_many_entries(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(skill_package_service_module, "_MAX_ENTRIES", 1) + + with pytest.raises(SkillPackageError) as exc_info: + _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"}) + assert exc_info.value.code == "too_many_entries" + + +def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(skill_package_service_module, "_MAX_UNCOMPRESSED_BYTES", 32) + + with pytest.raises(SkillPackageError) as exc_info: + _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"x" * 33}) + assert exc_info.value.code == "archive_too_large" + + +def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(skill_package_service_module, "_MAX_ARCHIVE_BYTES", 8) + + with pytest.raises(SkillPackageError) as exc_info: + SkillPackageService().validate_and_normalize(content=b"x" * 9, filename="skill.zip") + assert exc_info.value.code == "archive_too_large" + + def test_bad_frontmatter_yaml_rejected(): bad = b"---\n: : : not yaml\n---\n# x\n" with pytest.raises(SkillPackageError) as exc_info: - _extract({"SKILL.md": bad}) + _normalize({"SKILL.md": bad}) assert exc_info.value.code == "invalid_frontmatter" def test_unterminated_frontmatter_falls_back_to_heading(): # leading '---' with no closing fence -> no frontmatter, use the heading - manifest = _extract({"SKILL.md": b"---\n# Heading Wins\nbody"}) + manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest assert manifest.name == "Heading Wins" -def test_read_member_bytes_roundtrip_and_errors(): - service = SkillPackageService() - payload = _zip({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"}) +def test_validate_and_normalize_rejects_files_outside_selected_skill_root(): + with pytest.raises(SkillPackageError) as exc_info: + _normalize({"bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), "README.md": b"x"}) + assert exc_info.value.code == "files_outside_skill_root" - assert service.read_member_bytes(content=payload, member_path="scripts/run.py") == b"print('x')\n" - with pytest.raises(SkillPackageError) as missing: - service.read_member_bytes(content=payload, member_path="nope.txt") - assert missing.value.code == "member_not_found" +def test_validate_and_normalize_rejects_duplicate_normalized_paths(): + with pytest.raises(SkillPackageError) as exc_info: + _normalize( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('x')\n", + "pdf-toolkit/scripts/./run.py": b"print('y')\n", + } + ) + assert exc_info.value.code == "duplicate_member_path" - with pytest.raises(SkillPackageError) as bad_zip: - service.read_member_bytes(content=b"not a zip", member_path="SKILL.md") - assert bad_zip.value.code == "invalid_archive" + +def test_validate_and_normalize_maps_member_decompression_failures_to_invalid_archive(monkeypatch: pytest.MonkeyPatch): + original_read = zipfile.ZipFile.read + + def corrupted_read(self: zipfile.ZipFile, member: str | zipfile.ZipInfo, *args: object, **kwargs: object) -> bytes: + filename = member.filename if isinstance(member, zipfile.ZipInfo) else member + if filename == "scripts/run.py": + raise zlib.error("invalid distance too far back") + return original_read(self, member, *args, **kwargs) + + monkeypatch.setattr(zipfile.ZipFile, "read", corrupted_read) + + with pytest.raises(SkillPackageError) as exc_info: + _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"}) + assert exc_info.value.code == "invalid_archive" + assert exc_info.value.message == "skill archive is not a valid zip" diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index fa47c9bd905..074ac59bb1c 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -32,30 +32,16 @@ def test_slugify_skill_name(): assert slugify_skill_name("") == "skill" -def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members(): - content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"}) +def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(): + content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"}) tool_files = MagicMock() tool_files.create_file_by_raw.side_effect = [ SimpleNamespace(id="md-tool-file"), SimpleNamespace(id="zip-tool-file"), - SimpleNamespace(id="script-tool-file"), ] drive = MagicMock() drive.commit.return_value = [] - drive.list_skills.return_value = [ - { - "path": "pdf-toolkit", - "skill_md_key": "pdf-toolkit/SKILL.md", - "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", - "name": "PDF Toolkit", - "description": "Work with PDFs.", - "size": len(_SKILL_MD), - "mime_type": "text/markdown", - "hash": None, - "created_at": None, - }, - ] service = SkillStandardizeService(tool_file_manager=tool_files, drive_service=drive) result = service.standardize( @@ -66,34 +52,35 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members() agent_id="agent-1", ) - # ToolFiles: SKILL.md, full archive, and each inspectable package member. - assert tool_files.create_file_by_raw.call_count == 3 - md_call, zip_call, script_call = tool_files.create_file_by_raw.call_args_list + # ToolFiles: SKILL.md and the full archive. Archive members stay lazy. + assert tool_files.create_file_by_raw.call_count == 2 + md_call, zip_call = tool_files.create_file_by_raw.call_args_list assert md_call.kwargs["mimetype"] == "text/markdown" assert md_call.kwargs["file_binary"] == _SKILL_MD assert zip_call.kwargs["mimetype"] == "application/zip" - assert zip_call.kwargs["file_binary"] == content - assert script_call.kwargs["mimetype"] in {"text/x-python", "text/plain", "application/octet-stream"} - assert script_call.kwargs["file_binary"] == b"print('x')\n" - assert script_call.kwargs["filename"] == "run.py" + assert zip_call.kwargs["file_binary"] != content + with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive: + assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [ + "SKILL.md", + "scripts/run.py", + ] - # Committed as drive-owned with the standardized keys. + # Committed as drive-owned with the standardized keys. Member paths are + # carried in metadata for inspect/preview/runtime lazy resolution. commit_kwargs = drive.commit.call_args.kwargs assert commit_kwargs["agent_id"] == "agent-1" items = commit_kwargs["items"] assert [item.key for item in items] == [ "pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip", - "pdf-toolkit/scripts/run.py", ] assert all(item.value_owned_by_drive for item in items) - assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"] + assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"] assert items[0].is_skill is True assert items[0].skill_metadata is not None assert items[0].skill_metadata.name == "PDF Toolkit" assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"] assert items[1].is_skill is False - assert items[2].is_skill is False # The returned upload response carries only the drive-derived fields the UI needs. skill = result["skill"] @@ -101,4 +88,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members() assert skill["name"] == "PDF Toolkit" assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip" assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md" + assert result["manifest"]["entry_path"] == "SKILL.md" assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"] + drive.list_skills.assert_not_called() + assert "_committed_items" not in result diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index e4a22b64a48..ad72e142522 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -7,6 +7,8 @@ exercised against the project's in-memory SQLite engine with seeded ToolFiles. from __future__ import annotations import datetime +import io +import zipfile from collections.abc import Generator from unittest.mock import patch @@ -103,6 +105,14 @@ def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt") -> str: return tool_file.id +def _zip_bytes(members: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + return buffer.getvalue() + + def _commit(key: str, tool_file_id: str, *, owned: bool = True): return AgentDriveService().commit( tenant_id=TENANT, @@ -769,7 +779,8 @@ def test_inspect_skill_returns_manifest_files_and_file_tree(): assert result["warnings"] == [] assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"] assert result["files"][0]["available_in_drive"] is True - assert result["files"][1]["available_in_drive"] is False + assert result["files"][1]["available_in_drive"] is True + assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md" assert result["file_tree"][0]["name"] == "references" assert result["file_tree"][1]["name"] == "scripts" assert result["file_tree"][2]["name"] == "SKILL.md" @@ -787,6 +798,48 @@ def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing(): assert [file["path"] for file in result["files"]] == ["SKILL.md"] +def test_preview_skill_archive_member_from_manifest_without_drive_row(): + _commit_skill(manifest_files=["SKILL.md", "references/guide.md"]) + archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"}) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([archive]) + result = AgentDriveService().preview( + tenant_id=TENANT, + agent_id=AGENT, + key="pdf-toolkit/references/guide.md", + ) + + assert result == { + "key": "pdf-toolkit/references/guide.md", + "size": len(b"Guide content\n"), + "truncated": False, + "binary": False, + "text": "Guide content\n", + } + + +def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row(): + _commit_skill(manifest_files=["SKILL.md", "references/guide.md"]) + + with patch.object( + AgentDriveService, + "sign_archive_member_url", + return_value="https://signed.example/member", + ) as sign: + url = AgentDriveService().download_url( + tenant_id=TENANT, + agent_id=AGENT, + key="pdf-toolkit/references/guide.md", + ) + + assert url == "https://signed.example/member" + kwargs = sign.call_args.kwargs + assert kwargs["key"] == "pdf-toolkit/references/guide.md" + assert kwargs["member_path"] == "references/guide.md" + assert kwargs["for_external"] is True + + def test_skill_metadata_rejects_non_canonical_rows(): tf = _seed_tool_file(name="not-skill.md") with pytest.raises(AgentDriveError) as exc_info: diff --git a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py index 7a8efe85f13..321b1a7c046 100644 --- a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py +++ b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py @@ -98,6 +98,8 @@ class TestInnerKnowledgeRetrievalService: "total_price": "0", "currency": "USD", "latency": 0, + "time_to_first_token": None, + "time_to_generate": None, } mock_rag_cls.return_value = rag diff --git a/api/uv.lock b/api/uv.lock index 38578c812ae..dfdbc7343c9 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1299,6 +1299,7 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, { name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" }, + { 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" }, diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index c6f1138e087..00cf16359d0 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -23,6 +23,7 @@ server = [ "graphon==0.5.2", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", + "logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0", "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", diff --git a/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py new file mode 100644 index 00000000000..cf2ec969ae2 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/_drive_materialization.py @@ -0,0 +1,169 @@ +"""Shared drive download materialization helpers. + +This module centralizes the safety-critical filesystem logic used by both the +sandbox-visible CLI and the runtime drive layer. It owns path resolution under +one local drive base, overwrite-via-temp-file semantics, payload size checks, +and safe extraction of downloaded skill archives so those invariants cannot +drift between the two call sites. +""" + +from __future__ import annotations + +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from typing import Final +from uuid import uuid4 +from zipfile import BadZipFile, ZipFile, ZipInfo + + +SKILL_ARCHIVE_FILENAME: Final[str] = ".DIFY-SKILL-FULL.zip" + + +@dataclass(frozen=True, slots=True) +class DriveDownloadPayload: + """One downloaded drive payload ready to materialize under a local base.""" + + key: str + payload: bytes + size: int | None = None + + +class DriveMaterializationValidationError(ValueError): + """Raised when one drive key or archive entry is structurally unsafe.""" + + +class DriveMaterializationTransferError(RuntimeError): + """Raised when one downloaded payload cannot be safely materialized.""" + + +def materialize_drive_downloads( + *, + base_path: Path, + downloads: list[DriveDownloadPayload], +) -> list[Path]: + """Write downloaded drive payloads under one local base and extract skills. + + The helper preserves caller-provided order in the returned list of paths. + Skill archives are extracted and deleted only after every payload has been + written successfully so partial extraction cannot outlive a later failure in + the same batch. The returned path for an archive is the path where it was + downloaded before successful extraction. + """ + + resolved_base_path = base_path.expanduser().resolve() + try: + _ = resolved_base_path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise DriveMaterializationTransferError(f"failed to prepare drive base {resolved_base_path}") from exc + + written_paths: list[Path] = [] + archive_paths: list[Path] = [] + for download in downloads: + if download.size is not None and len(download.payload) != download.size: + raise DriveMaterializationTransferError(f"downloaded drive file size mismatch for {download.key}") + destination = resolve_drive_destination(resolved_base_path, download.key) + try: + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") + _ = temp_path.write_bytes(download.payload) + _ = temp_path.replace(destination) + except OSError as exc: + raise DriveMaterializationTransferError(f"failed to materialize drive file {download.key}") from exc + written_paths.append(destination) + if destination.name == SKILL_ARCHIVE_FILENAME: + archive_paths.append(destination) + + for archive_path in sorted(archive_paths): + extract_skill_archive(archive_path) + _delete_extracted_archive(archive_path) + return written_paths + + +def resolve_drive_destination(base_path: Path, drive_key: str) -> Path: + """Resolve one drive key under a local base and reject path traversal.""" + + destination = (base_path / Path(drive_key)).resolve() + try: + destination.relative_to(base_path) + except ValueError as exc: + raise DriveMaterializationValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc + return destination + + +def extract_skill_archive(archive_path: Path) -> None: + """Safely extract one downloaded skill archive into its containing directory.""" + + target_dir = archive_path.parent.resolve() + try: + with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name: + staging_dir = Path(staging_dir_name).resolve() + with ZipFile(archive_path) as archive: + for zip_info in archive.infolist(): + destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename) + if _is_zip_symlink(zip_info): + raise DriveMaterializationValidationError( + f"skill archive contains unsupported symlink entry: {zip_info.filename}" + ) + if zip_info.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + continue + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(zip_info) as source_file: + temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") + _ = temp_path.write_bytes(source_file.read()) + _ = temp_path.replace(destination) + for staged_path in sorted(staging_dir.rglob("*")): + if staged_path.is_dir(): + continue + relative_path = staged_path.relative_to(staging_dir) + destination = (target_dir / relative_path).resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + _ = staged_path.replace(destination) + except DriveMaterializationValidationError: + raise + except (BadZipFile, OSError) as exc: + raise DriveMaterializationTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc + + +def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path: + normalized_name = entry_name.replace("\\", "/") + pure_path = PurePosixPath(normalized_name) + if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute(): + raise DriveMaterializationValidationError(f"skill archive contains unsafe absolute path: {entry_name}") + if any(part in {"", ".", ".."} for part in pure_path.parts): + raise DriveMaterializationValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}") + destination = (target_dir / Path(*pure_path.parts)).resolve() + try: + destination.relative_to(target_dir) + except ValueError as exc: + raise DriveMaterializationValidationError( + f"skill archive entry resolves outside the skill directory: {entry_name}" + ) from exc + return destination + + +def _is_zip_symlink(zip_info: ZipInfo) -> bool: + file_mode = zip_info.external_attr >> 16 + return stat.S_ISLNK(file_mode) + + +def _delete_extracted_archive(archive_path: Path) -> None: + try: + archive_path.unlink(missing_ok=True) + except OSError as exc: + raise DriveMaterializationTransferError( + f"failed to delete extracted skill archive: {archive_path.name}" + ) from exc + + +__all__ = [ + "DriveDownloadPayload", + "DriveMaterializationTransferError", + "DriveMaterializationValidationError", + "SKILL_ARCHIVE_FILENAME", + "extract_skill_archive", + "materialize_drive_downloads", + "resolve_drive_destination", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_drive.py b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py index d3d6241449b..69c9d5455bd 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_drive.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py @@ -10,14 +10,23 @@ ToolFile ids back into the drive. from __future__ import annotations -import stat from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass -from pathlib import Path, PurePosixPath +from pathlib import Path from tempfile import TemporaryDirectory -from uuid import uuid4 -from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile, ZipInfo +from typing import ClassVar, Literal +from zipfile import ZIP_DEFLATED, ZipFile +from pydantic import BaseModel, ConfigDict + +from dify_agent.agent_stub._drive_materialization import ( + DriveDownloadPayload, + DriveMaterializationTransferError, + DriveMaterializationValidationError, + SKILL_ARCHIVE_FILENAME, + materialize_drive_downloads, + resolve_drive_destination, +) 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 ( @@ -37,11 +46,11 @@ from dify_agent.agent_stub.protocol.agent_stub import ( ) _SKILL_MD_FILENAME = "SKILL.md" -_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip" _SKIP_DIR_NAMES = frozenset( {".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".venv", "node_modules"} ) -_SKIP_FILE_NAMES = frozenset({".DS_Store", _SKILL_ARCHIVE_FILENAME}) +_SKIP_FILE_NAMES = frozenset({".DS_Store", SKILL_ARCHIVE_FILENAME}) +DrivePushKind = Literal["file", "skill", "dir"] @dataclass(frozen=True, slots=True) @@ -52,18 +61,28 @@ class _DriveUploadItem: drive_key: str -def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentStubDriveManifestResponse: +class DrivePullResult(BaseModel): + """Structured JSON result for ``dify-agent drive pull --json``.""" + + class Item(BaseModel): + key: str + local_path: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + items: list[Item] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +def list_drive_manifest_from_environment(prefix: str) -> AgentStubDriveManifestResponse: """List drive items through the Agent Stub using the current environment. Args: prefix: Optional drive-key prefix forwarded to the manifest request. - json_output: When ``True``, return the validated manifest response model. - When ``False``, return one human-readable tab-separated line per item - containing size, mime type, hash, and key. Returns: - Either ``AgentStubDriveManifestResponse`` for JSON callers or a formatted - string for human-facing CLI output. + The validated manifest response model. Side effects: Calls the Agent Stub drive manifest control-plane endpoint with @@ -78,24 +97,24 @@ def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentSt prefix=prefix, include_download_url=False, ) - if json_output: - return response - return _format_manifest(response) + return response def pull_drive_from_environment( targets: list[str] | None = None, - drive_base: str = DEFAULT_AGENT_STUB_DRIVE_BASE, -) -> list[Path]: + local_base: str | None = None, +) -> DrivePullResult: """Pull drive files into one local drive base via signed download URLs. Args: targets: Optional drive-key targets or prefixes. An empty list preserves the historical whole-drive pull by using ``[""]``. - drive_base: Local base directory that receives downloaded drive files. + local_base: Local base directory that receives downloaded drive files. + When omitted, the historical Agent Stub drive base is used. Returns: - A list of written local paths under ``drive_base``. + A structured JSON-ready result with requested drive targets/prefixes + that matched at least one manifest item and their local paths. Observable behavior: Requests a manifest with ``include_download_url=True``, requires every @@ -107,10 +126,12 @@ def pull_drive_from_environment( ``.DIFY-SKILL-FULL.zip`` archives into their containing skill directory with the same path-safety checks. Archive extraction is staged under a temporary directory and only moved into place after the full - archive validates successfully. + archive validates successfully. Successfully extracted skill archives + are deleted from disk. - The return value remains the list of downloaded paths only; extracted - files are materialized on disk but are not added to the returned list. + Downloaded files and extracted files are materialized on disk but are + not enumerated in the returned item list; prefix pulls return the local + path corresponding to the requested prefix. Raises: AgentStubValidationError: if a manifest item omits ``download_url``, a @@ -124,48 +145,68 @@ def pull_drive_from_environment( environment = read_agent_stub_environment() manifest_targets = targets or [""] - with ThreadPoolExecutor(max_workers=min(len(manifest_targets), 4)) as executor: - responses = list( - executor.map( - lambda target: request_agent_stub_drive_manifest_sync( - url=environment.url, - auth_jwe=environment.auth_jwe, - prefix=target, - include_download_url=True, - ), - manifest_targets, - ) + + def _fetch_manifest(target: str) -> AgentStubDriveManifestResponse: + return request_agent_stub_drive_manifest_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + prefix=target, + include_download_url=True, ) - base_path = Path(drive_base).expanduser().resolve() - base_path.mkdir(parents=True, exist_ok=True) - written_paths: list[Path] = [] + + with ThreadPoolExecutor(max_workers=min(len(manifest_targets), 4)) as executor: + responses = list(executor.map(_fetch_manifest, manifest_targets)) + downloads: list[DriveDownloadPayload] = [] + resolved_base_path = Path(local_base or DEFAULT_AGENT_STUB_DRIVE_BASE).expanduser().resolve() + result_items: list[DrivePullResult.Item] = [] + seen_result_targets: set[str] = set() + for target, response in zip(manifest_targets, responses, strict=True): + if not response.items or target in seen_result_targets: + continue + seen_result_targets.add(target) + try: + local_path = resolve_drive_destination(resolved_base_path, target) + except DriveMaterializationValidationError as exc: + raise AgentStubValidationError(str(exc)) from exc + result_items.append(DrivePullResult.Item(key=target, local_path=str(local_path))) deduplicated_items = {item.key: item for response in responses for item in response.items} for item in [deduplicated_items[key] for key in sorted(deduplicated_items)]: download_url = item.download_url if not isinstance(download_url, str) or not download_url: raise AgentStubValidationError(f"drive manifest item is missing download_url: {item.key}") - destination = _resolve_drive_destination(base_path, item.key) + try: + _ = resolve_drive_destination(resolved_base_path, item.key) + except DriveMaterializationValidationError as exc: + raise AgentStubValidationError(str(exc)) from exc payload = download_file_bytes_from_signed_url_sync(download_url=download_url) - if item.size is not None and len(payload) != item.size: - raise AgentStubTransferError(f"downloaded drive file size mismatch for {item.key}") - destination.parent.mkdir(parents=True, exist_ok=True) - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - temp_path.write_bytes(payload) - temp_path.replace(destination) - written_paths.append(destination) - if destination.name == _SKILL_ARCHIVE_FILENAME: - _extract_skill_archive(destination) - return written_paths + downloads.append(DriveDownloadPayload(key=item.key, payload=payload, size=item.size)) + + try: + _ = materialize_drive_downloads( + base_path=resolved_base_path, + downloads=downloads, + ) + except DriveMaterializationValidationError as exc: + raise AgentStubValidationError(str(exc)) from exc + except DriveMaterializationTransferError as exc: + raise AgentStubTransferError(str(exc)) from exc + + return DrivePullResult(items=result_items) -def push_drive_from_environment(local_path: str, drive_path: str, recursive: bool) -> AgentStubDriveCommitResponse: +def push_drive_from_environment( + local_path: str, + drive_path: str, + *, + kind: DrivePushKind | None, +) -> AgentStubDriveCommitResponse: """Upload local files through the Agent Stub and commit them into the drive. Args: local_path: Source file or directory in the sandbox filesystem. drive_path: Destination drive key or drive-key prefix. - recursive: Select directory mode. ``False`` standardizes skill - directories, while ``True`` uploads raw directory contents. + kind: Optional public upload mode. Files infer file mode when omitted, + while directories require explicit ``skill`` or ``dir`` selection. Returns: The validated drive commit response returned by the Agent Stub. @@ -173,25 +214,40 @@ def push_drive_from_environment(local_path: str, drive_path: str, recursive: boo Mode split: * If ``local_path`` is a file, upload that file and commit exactly one ``tool_file`` binding to ``drive_path``. - * If ``local_path`` is a directory and ``recursive`` is ``False``, + * If ``local_path`` is a directory and ``kind`` is ``"skill"``, require ``SKILL.md`` and standardize the upload into ``/SKILL.md`` plus ``/.DIFY-SKILL-FULL.zip``. - * If ``local_path`` is a directory and ``recursive`` is ``True``, upload + * If ``local_path`` is a directory and ``kind`` is ``"dir"``, upload each regular file under ``drive_path/`` without skill standardization. Observable safety behavior: - Rejects missing local paths, rejects recursive directory pushes with no - regular files, and rejects symlinked or escaping paths while preparing - directory uploads or skill archives. + Rejects missing local paths, rejects directory pushes without an + explicit mode, rejects raw directory pushes with no regular files, and + rejects symlinked or escaping paths, including symlinked top-level + ``local_path`` roots, while preparing directory uploads or skill + archives. """ - source_path = Path(local_path).expanduser().resolve() + source_path = Path(local_path).expanduser() + if kind not in {None, "file", "skill", "dir"}: + raise AgentStubValidationError(f"invalid drive push kind: {kind}") + if source_path.is_symlink(): + raise AgentStubValidationError(f"drive push does not support symlinked local paths: {source_path}") + source_path = source_path.resolve() if source_path.is_file(): + if kind == "skill": + raise AgentStubValidationError("--kind skill requires a directory containing SKILL.md") + if kind == "dir": + raise AgentStubValidationError("--kind dir requires a directory") return _commit_uploaded_items([_prepare_uploaded_file(source_path, drive_path)]) if not source_path.is_dir(): raise AgentStubValidationError(f"local path not found: {source_path}") - if recursive: + if kind is None: + raise AgentStubValidationError("directory drive push requires --kind skill or --kind dir") + if kind == "file": + raise AgentStubValidationError("--kind file requires a file") + if kind == "dir": upload_items = [ _prepare_uploaded_file(path, _join_drive_key(drive_path, relative_path)) for path, relative_path in _iter_regular_files(source_path) @@ -205,14 +261,14 @@ def push_drive_from_environment(local_path: str, drive_path: str, recursive: boo def _push_skill_directory(source_path: Path, drive_path: str) -> AgentStubDriveCommitResponse: skill_md_path = source_path / _SKILL_MD_FILENAME if not skill_md_path.is_file(): - raise AgentStubValidationError(f"non-recursive drive push requires {_SKILL_MD_FILENAME}: {source_path}") + raise AgentStubValidationError("--kind skill requires a directory containing SKILL.md") with TemporaryDirectory() as temp_dir: - archive_path = Path(temp_dir) / _SKILL_ARCHIVE_FILENAME + archive_path = Path(temp_dir) / SKILL_ARCHIVE_FILENAME _build_skill_archive(source_path, archive_path) return _commit_uploaded_items( [ _prepare_uploaded_file(skill_md_path.resolve(), _join_drive_key(drive_path, _SKILL_MD_FILENAME)), - _prepare_uploaded_file(archive_path, _join_drive_key(drive_path, _SKILL_ARCHIVE_FILENAME)), + _prepare_uploaded_file(archive_path, _join_drive_key(drive_path, SKILL_ARCHIVE_FILENAME)), ] ) @@ -239,7 +295,7 @@ def _commit_uploaded_items(items: list[_DriveUploadItem]) -> AgentStubDriveCommi ) -def _format_manifest(response: AgentStubDriveManifestResponse) -> str: +def format_drive_manifest(response: AgentStubDriveManifestResponse) -> str: return "\n".join(_format_manifest_item(item) for item in response.items) @@ -250,15 +306,6 @@ def _format_manifest_item(item: AgentStubDriveItem) -> str: return f"{size}\t{mime_type}\t{item_hash}\t{item.key}" -def _resolve_drive_destination(base_path: Path, drive_key: str) -> Path: - destination = (base_path / Path(drive_key)).resolve() - try: - destination.relative_to(base_path) - except ValueError as exc: - raise AgentStubValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc - return destination - - def _iter_regular_files(root_path: Path) -> list[tuple[Path, str]]: """Return all regular files under one directory, rejecting unsafe symlinks.""" @@ -305,82 +352,6 @@ def _build_skill_archive(source_path: Path, archive_path: Path) -> None: archive.write(file_path, arcname=relative_path) -def _extract_skill_archive(archive_path: Path) -> None: - """Safely extract one downloaded skill archive into its containing directory. - - Extraction is staged under a temporary directory created inside the target - skill directory. Every entry is validated and materialized into staging - first, and only after the full archive succeeds are staged files moved into - their final locations under the skill directory. Existing files at those - final locations are overwritten in place by the extracted archive content. - - Error mapping is intentionally stable for CLI callers: unsafe archive entry - names raise ``AgentStubValidationError``, while malformed archives and zip - parsing / archive I/O failures are translated into ``AgentStubTransferError``. - """ - - target_dir = archive_path.parent.resolve() - try: - with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name: - staging_dir = Path(staging_dir_name).resolve() - with ZipFile(archive_path) as archive: - for zip_info in archive.infolist(): - destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename) - if _is_zip_symlink(zip_info): - raise AgentStubValidationError( - f"skill archive contains unsupported symlink entry: {zip_info.filename}" - ) - if zip_info.is_dir(): - destination.mkdir(parents=True, exist_ok=True) - continue - destination.parent.mkdir(parents=True, exist_ok=True) - with archive.open(zip_info) as source_file: - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - temp_path.write_bytes(source_file.read()) - temp_path.replace(destination) - for staged_path in sorted(staging_dir.rglob("*")): - if staged_path.is_dir(): - continue - relative_path = staged_path.relative_to(staging_dir) - destination = (target_dir / relative_path).resolve() - destination.parent.mkdir(parents=True, exist_ok=True) - staged_path.replace(destination) - except AgentStubValidationError: - raise - except (BadZipFile, OSError) as exc: - raise AgentStubTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc - - -def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path: - """Resolve one zip entry path under a target skill directory. - - Zip metadata may contain POSIX or backslash-separated names, so entry names - are normalized to forward slashes before validation. The resolved entry must - not be absolute, empty, ``.`` / ``..`` based, or otherwise escape the target - skill directory after resolution. - """ - - normalized_name = entry_name.replace("\\", "/") - pure_path = PurePosixPath(normalized_name) - if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute(): - raise AgentStubValidationError(f"skill archive contains unsafe absolute path: {entry_name}") - if any(part in {"", ".", ".."} for part in pure_path.parts): - raise AgentStubValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}") - destination = (target_dir / Path(*pure_path.parts)).resolve() - try: - destination.relative_to(target_dir) - except ValueError as exc: - raise AgentStubValidationError( - f"skill archive entry resolves outside the skill directory: {entry_name}" - ) from exc - return destination - - -def _is_zip_symlink(zip_info: ZipInfo) -> bool: - file_mode = zip_info.external_attr >> 16 - return stat.S_ISLNK(file_mode) - - def _join_drive_key(base_key: str, child_key: str) -> str: stripped_base = base_key.rstrip("/") stripped_child = child_key.lstrip("/") @@ -388,7 +359,10 @@ def _join_drive_key(base_key: str, child_key: str) -> str: __all__ = [ - "list_drive_from_environment", + "DrivePullResult", + "DrivePushKind", + "format_drive_manifest", + "list_drive_manifest_from_environment", "pull_drive_from_environment", "push_drive_from_environment", ] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py index dccc6d36c44..9c3c017c51f 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_files.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -5,7 +5,7 @@ from __future__ import annotations import mimetypes from dataclasses import dataclass from pathlib import Path -from typing import Literal, cast +from typing import ClassVar, Literal, cast from pydantic import BaseModel, ConfigDict, ValidationError @@ -26,7 +26,7 @@ class UploadedToolFileMapping(BaseModel): transfer_method: Literal["tool_file"] = "tool_file" reference: str - model_config = ConfigDict(extra="forbid") + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @dataclass(frozen=True, slots=True) @@ -76,12 +76,14 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile environment = read_agent_stub_environment() filename = source_path.name mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" - upload_request = request_agent_stub_file_upload_sync( + upload_request: object = request_agent_stub_file_upload_sync( url=environment.url, auth_jwe=environment.auth_jwe, filename=filename, mimetype=mime_type, ) + if not hasattr(upload_request, "upload_url") or not isinstance(upload_request.upload_url, str): + raise AgentStubTransferError("signed file upload response is missing upload_url") with source_path.open("rb") as file_obj: payload = upload_file_to_signed_url_sync( upload_url=upload_request.upload_url, @@ -94,19 +96,65 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile def download_file_from_environment( *, - transfer_method: str, - reference_or_url: str, - directory: str | None = None, + transfer_method: str | None = None, + reference_or_url: str | None = None, + mapping: str | None = None, + local_dir: str | None = None, ) -> DownloadedFileResult: - """Download one workflow file mapping into the sandbox filesystem.""" + """Download one workflow file mapping into the sandbox filesystem. + Callers may provide either the public positional pair + ``TRANSFER_METHOD REFERENCE_OR_URL`` or one JSON ``--mapping`` payload. + The helper normalizes both forms into ``AgentStubFileMapping`` before + requesting a signed download URL from the Agent Stub. + """ + + file_mapping = _build_download_mapping( + transfer_method=transfer_method, + reference_or_url=reference_or_url, + mapping=mapping, + ) environment = read_agent_stub_environment() + + download_request: object = request_agent_stub_file_download_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + file=file_mapping, + ) + if not hasattr(download_request, "filename") or not isinstance(download_request.filename, str): + raise AgentStubTransferError("signed file download response is missing filename") + if not hasattr(download_request, "download_url") or not isinstance(download_request.download_url, str): + raise AgentStubTransferError("signed file download response is missing download_url") + target_dir = Path(local_dir).expanduser().resolve() if local_dir else Path.cwd() + target_dir.mkdir(parents=True, exist_ok=True) + destination = _deduplicate_destination_path(target_dir / _sanitize_download_filename(download_request.filename)) + _ = destination.write_bytes(download_file_bytes_from_signed_url_sync(download_url=download_request.download_url)) + return DownloadedFileResult(path=destination) + + +def _build_download_mapping( + *, + transfer_method: str | None, + reference_or_url: str | None, + mapping: str | None, +) -> AgentStubFileMapping: + if mapping is not None: + if transfer_method is not None or reference_or_url is not None: + raise AgentStubValidationError("--mapping cannot be combined with TRANSFER_METHOD or REFERENCE_OR_URL") + try: + return AgentStubFileMapping.model_validate_json(mapping) + except ValidationError as exc: + raise AgentStubValidationError("invalid file download mapping") from exc + + if transfer_method is None or reference_or_url is None: + raise AgentStubValidationError("file download requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL") + normalized_transfer_method = cast( Literal["local_file", "tool_file", "datasource_file", "remote_url"], transfer_method, ) try: - file_mapping = AgentStubFileMapping( + return AgentStubFileMapping( transfer_method=normalized_transfer_method, url=reference_or_url if normalized_transfer_method == "remote_url" else None, reference=reference_or_url if normalized_transfer_method != "remote_url" else None, @@ -114,17 +162,6 @@ def download_file_from_environment( except ValidationError as exc: raise AgentStubValidationError("invalid file download arguments") from exc - download_request = request_agent_stub_file_download_sync( - url=environment.url, - auth_jwe=environment.auth_jwe, - file=file_mapping, - ) - target_dir = Path(directory).expanduser().resolve() if directory else Path.cwd() - target_dir.mkdir(parents=True, exist_ok=True) - destination = _deduplicate_destination_path(target_dir / _sanitize_download_filename(download_request.filename)) - destination.write_bytes(download_file_bytes_from_signed_url_sync(download_url=download_request.download_url)) - return DownloadedFileResult(path=destination) - def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> UploadedToolFileResource: reference = payload.get("reference") 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 c5c722d7e03..3d34b96d62e 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -11,13 +11,16 @@ does not pull in FastAPI, Redis, shellctl, or JWE runtime dependencies. from __future__ import annotations import sys +from typing import cast 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._drive import ( - list_drive_from_environment, + DrivePushKind, + format_drive_manifest, + list_drive_manifest_from_environment, pull_drive_from_environment, push_drive_from_environment, ) @@ -60,21 +63,23 @@ def upload(path: str = typer.Argument(..., metavar="PATH")) -> None: @file_app.command("download") def download( - transfer_method: str = typer.Argument(..., metavar="TRANSFER_METHOD"), - reference_or_url: str = typer.Argument(..., metavar="REFERENCE_OR_URL"), - directory: str | None = typer.Argument(default=None, metavar="DIR"), + transfer_method: str | None = typer.Argument(None, metavar="TRANSFER_METHOD"), + reference_or_url: str | None = typer.Argument(None, metavar="REFERENCE_OR_URL"), + mapping: str | None = typer.Option(None, "--mapping", help="Download one file from a mapping JSON object."), + local_dir: str | None = typer.Option(None, "--to", help="Local directory for the downloaded file."), ) -> None: """Download one workflow file mapping into the local sandbox directory.""" _run_file_download( transfer_method=transfer_method, reference_or_url=reference_or_url, - directory=directory, + mapping=mapping, + local_dir=local_dir, ) @drive_app.command("list") def drive_list( - path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"), + path_prefix: str = typer.Argument("", metavar="REMOTE_PREFIX"), json_output: bool = typer.Option(False, "--json", help="Emit the drive manifest as JSON."), ) -> None: """List drive files visible to the current sandbox execution.""" @@ -83,32 +88,39 @@ def drive_list( @drive_app.command("pull") def drive_pull( - targets: list[str] = typer.Argument(None, metavar="TARGET"), - drive_base: str | None = typer.Option( + targets: list[str] = typer.Argument(None, metavar="REMOTE"), + local_base: str | None = typer.Option( None, - "--drive-base", + "--to", help=( f"Local base directory for pulled drive files. Defaults to ${AGENT_STUB_DRIVE_BASE_ENV_VAR} " f"or {DEFAULT_AGENT_STUB_DRIVE_BASE}." ), ), + json_output: bool = typer.Option(False, "--json", help="Emit the pull result as JSON."), ) -> None: """Pull one or more drive keys/prefixes into one local directory tree. Passing no ``TARGET`` preserves the historical whole-drive behavior by pulling from the empty prefix. """ - _run_drive_pull(targets=targets, drive_base=drive_base) + _run_drive_pull(targets=targets or None, local_base=local_base, json_output=json_output) @drive_app.command("push") def drive_push( local_path: str = typer.Argument(..., metavar="LOCAL_PATH"), - drive_path: str = typer.Argument(..., metavar="DRIVE_PATH"), - recursive: bool = typer.Option(False, "-r", "--recursive", help="Recursively upload directory contents."), + drive_path: str = typer.Argument(..., metavar="REMOTE_PATH"), + kind: str | None = typer.Option(None, "--kind", help="Directory upload kind: skill or dir."), + json_output: bool = typer.Option( + False, + "--json", + help="Accepted for consistency; drive push output is already emitted as JSON.", + ), ) -> None: """Upload one local file or directory into the agent drive.""" - _run_drive_push(local_path=local_path, drive_path=drive_path, recursive=recursive) + del json_output + _run_drive_push(local_path=local_path, drive_path=drive_path, kind=kind) def main(argv: list[str] | None = None) -> None: @@ -190,12 +202,19 @@ def _run_file_upload(*, path: str) -> None: typer.echo(response.model_dump_json()) -def _run_file_download(*, transfer_method: str, reference_or_url: str, directory: str | None) -> None: +def _run_file_download( + *, + transfer_method: str | None, + reference_or_url: str | None, + mapping: str | None, + local_dir: str | None, +) -> None: try: response = download_file_from_environment( transfer_method=transfer_method, reference_or_url=reference_or_url, - directory=directory, + mapping=mapping, + local_dir=local_dir, ) except MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) @@ -208,7 +227,7 @@ def _run_file_download(*, transfer_method: str, reference_or_url: str, directory def _run_drive_list(*, path_prefix: str, json_output: bool) -> None: try: - response = list_drive_from_environment(prefix=path_prefix, json_output=json_output) + response = list_drive_manifest_from_environment(prefix=path_prefix) except MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc @@ -216,29 +235,34 @@ def _run_drive_list(*, path_prefix: str, json_output: bool) -> None: typer.echo(str(exc), err=True) raise SystemExit(1) from exc if json_output: - if isinstance(response, str): - raise RuntimeError("drive list JSON output expected a manifest response") typer.echo(response.model_dump_json()) return - typer.echo(response) + typer.echo(format_drive_manifest(response)) -def _run_drive_pull(*, targets: list[str] | None, drive_base: str | None) -> None: +def _run_drive_pull(*, targets: list[str] | None, local_base: str | None, json_output: bool) -> None: try: - response = pull_drive_from_environment(targets=targets, drive_base=drive_base or read_agent_stub_drive_base()) + response = pull_drive_from_environment(targets=targets, local_base=local_base or read_agent_stub_drive_base()) 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 - for path in response: - typer.echo(str(path)) + if json_output: + typer.echo(response.model_dump_json()) + return + for item in response.items: + typer.echo(item.local_path) -def _run_drive_push(*, local_path: str, drive_path: str, recursive: bool) -> None: +def _run_drive_push(*, local_path: str, drive_path: str, kind: str | None) -> None: try: - response = push_drive_from_environment(local_path=local_path, drive_path=drive_path, recursive=recursive) + response = push_drive_from_environment( + local_path=local_path, + drive_path=drive_path, + kind=cast(DrivePushKind | None, kind), + ) except MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc 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 a6a0d4793fe..c6d0333cd5c 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 @@ -267,7 +267,11 @@ class AgentStubDriveCommitRequest(BaseModel): class AgentStubDriveItem(BaseModel): - """One manifest or commit item returned by the Agent Stub drive API.""" + """One manifest or commit item returned by the Agent Stub drive API. + + Known stable fields stay typed, while extra response metadata from the Dify + API is preserved for forward compatibility. + """ key: str size: int | None = None @@ -282,7 +286,7 @@ class AgentStubDriveItem(BaseModel): is_skill: bool | None = None skill_metadata: str | None = None - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") class AgentStubDriveManifestResponse(BaseModel): diff --git a/dify-agent/src/dify_agent/layers/drive/configs.py b/dify-agent/src/dify_agent/layers/drive/configs.py index f74a24298fd..20fd514baf2 100644 --- a/dify-agent/src/dify_agent/layers/drive/configs.py +++ b/dify-agent/src/dify_agent/layers/drive/configs.py @@ -6,7 +6,9 @@ only: skills are declared as metadata, not content, and plain files are listed only when the prompt explicitly mentions their drive keys. The API backend catalogs and writes this config; the Agent backend consumes it -(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files). +by running sandbox-visible ``dify-agent drive pull`` commands through the shell +layer so materialized files live in the same filesystem that model shell jobs +use. """ from typing import Final diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py index e38704d2b9d..e6941b50cbe 100644 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -1,32 +1,49 @@ -"""Runtime Dify drive layer with eager pull for prompt-mentioned targets. +"""Runtime Dify drive layer with shell-backed eager pulls. The API backend sends the full drive skill catalog plus the ordered drive keys mentioned in the prompt. When the layer enters a run context it eagerly pulls -those mentioned skills/files from the Dify inner drive bridge, materializes them -under the fixed Agent Stub drive base for ``drive_ref``, and contributes a -concise prompt block describing what was loaded and what other skills remain -available for lazy pull. +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. """ from __future__ import annotations -import asyncio +import shlex from dataclasses import dataclass, field -from pathlib import Path, PurePosixPath -from tempfile import TemporaryDirectory -from typing import Any, ClassVar, cast -from uuid import uuid4 -from zipfile import BadZipFile, ZipFile, ZipInfo +from pathlib import Path +from typing import ClassVar -import httpx from typing_extensions import Self, override -from agenton.layers import EmptyRuntimeState, Layer, LayerDeps, PlainLayer +from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer 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 -_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip" -_DOWNLOAD_CONCURRENCY = 4 +_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":"..."}`.""" class DifyDriveLayerError(RuntimeError): @@ -34,53 +51,34 @@ class DifyDriveLayerError(RuntimeError): class DifyDriveDeps(LayerDeps): - execution_context: Layer[Any, Any, Any, Any, Any, Any] # pyright: ignore[reportUninitializedInstanceVariable] - - -@dataclass(frozen=True, slots=True) -class _DriveManifestItem: - key: str - download_url: str - size: int | None = None + shell: DifyShellLayer # pyright: ignore[reportUninitializedInstanceVariable] @dataclass(slots=True) class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]): - """Drive runtime layer that eagerly materializes prompt-mentioned drive targets.""" + """Drive runtime layer that materializes prompt-mentioned targets via shell.""" type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID config: DifyDriveLayerConfig - inner_api_url: str - inner_api_key: str _loaded_skill_bodies: dict[str, str] = field(default_factory=dict) _pulled_file_paths: dict[str, str] = field(default_factory=dict) @classmethod @override def from_config(cls, config: DifyDriveLayerConfig) -> Self: - del config - raise TypeError("DifyDriveLayer requires server-side Dify API settings and must use a provider factory.") - - @classmethod - def from_config_with_settings( - cls, - config: DifyDriveLayerConfig, - *, - inner_api_url: str, - inner_api_key: str, - ) -> Self: - return cls( - config=DifyDriveLayerConfig.model_validate(config), - inner_api_url=inner_api_url.rstrip("/"), - inner_api_key=inner_api_key, - ) + return cls(config=DifyDriveLayerConfig.model_validate(config)) @property @override def prefix_prompts(self) -> list[str]: return [self.build_prompt_context()] + @property + @override + def suffix_prompts(self) -> list[str]: + return [self.build_suffix_prompt()] + @override async def on_context_create(self) -> None: await self._pull_mentioned_targets() @@ -100,7 +98,11 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None) if skill is None: continue - loaded_skill_sections.append(f"Path: {skill.path}\nName: {skill.name}\nSKILL.md:\n{body}") + pulled_skill_path = self._pulled_file_paths.get(skill_key) + if pulled_skill_path is None: + continue + local_path = Path(pulled_skill_path).parent + loaded_skill_sections.append(f"Path: {skill.path}\nLocal path: {local_path}\nSKILL.md:\n{body}") if loaded_skill_sections: sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections)) @@ -112,217 +114,138 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim if mentioned_files: sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files)) + if not sections: + return "" + return "\n\n".join(sections) + + def build_suffix_prompt(self) -> str: + sections: list[str] = [] + mentioned_skill_keys = set(self.config.mentioned_skill_keys) other_skills = [ f"- {skill.path}: {skill.name} — {skill.description}" for skill in self.config.skills - if skill.skill_md_key not in set(self.config.mentioned_skill_keys) + if skill.skill_md_key not in mentioned_skill_keys ] if other_skills: - sections.append("Other available skills:\n" + "\n".join(other_skills)) - - if not sections: - return "" - sections.append( - "Additional drive skills/files can be pulled lazily later with the Agent Stub drive commands if needed." - ) + 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) return "\n\n".join(sections) async def _pull_mentioned_targets(self) -> None: self._loaded_skill_bodies = {} self._pulled_file_paths = {} - targets: list[tuple[str, bool]] = [ - (self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys - ] + [(file_key, True) for file_key in self.config.mentioned_file_keys] + targets = self._mentioned_pull_targets() if not targets: return - tenant_id = self._require_tenant_id() - manifest_items = await self._fetch_manifest_items(tenant_id=tenant_id, targets=targets) - written_paths = await self._download_items(manifest_items) + script = self._build_shell_pull_script(targets=targets) + result = await self.deps.shell.run_remote_script(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}" + ) + 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 + + def _build_shell_pull_script(self, *, targets: list[tuple[str, bool]]) -> str: + pull_targets = list(dict.fromkeys(prefix for prefix, _exact in targets)) + base_path = agent_stub_drive_base_for_ref(self.config.drive_ref) + lines = [ + "set -eu", + f"base={shlex.quote(base_path)}", + "dify-agent drive pull " + " ".join(shlex.quote(target) for target in pull_targets) + ' --to "$base"', + ] + for skill_key in self.config.mentioned_skill_keys: + skill_path = self._shell_local_path(skill_key) + lines.extend( + [ + f"test -f {shlex.quote(skill_path)}", + f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(skill_key)} {shlex.quote(skill_path)}", + f"printf '__DIFY_DRIVE_SKILL_BEGIN__\\t%s\\n' {shlex.quote(skill_key)}", + f"cat {shlex.quote(skill_path)}", + f"printf '\\n__DIFY_DRIVE_SKILL_END__\\t%s\\n' {shlex.quote(skill_key)}", + ] + ) + for file_key in self.config.mentioned_file_keys: + file_path = self._shell_local_path(file_key) + lines.extend( + [ + f"test -e {shlex.quote(file_path)}", + f"printf '\\n__DIFY_DRIVE_MENTIONED_PATH__\\t%s\\t%s\\n' {shlex.quote(file_key)} {shlex.quote(file_path)}", + ] + ) + return "\n".join(lines) + + def _parse_shell_pull_output(self, output: str) -> tuple[dict[str, str], dict[str, str]]: + written_paths: dict[str, str] = {} + skill_bodies: dict[str, str] = {} + current_skill_key: str | None = None + current_skill_body: list[str] = [] + + for line in output.splitlines(keepends=True): + stripped_line = line.rstrip("\n") + if current_skill_key is not None: + if stripped_line == f"__DIFY_DRIVE_SKILL_END__\t{current_skill_key}": + skill_bodies[current_skill_key] = "".join(current_skill_body) + current_skill_key = None + current_skill_body = [] + continue + current_skill_body.append(line) + continue + + if stripped_line.startswith("__DIFY_DRIVE_MENTIONED_PATH__\t"): + parts = stripped_line.split("\t", 2) + if len(parts) != 3: + raise DifyDriveLayerError("drive mentioned pull emitted an invalid path marker") + _marker, key, path = parts + written_paths[key] = path + continue + if stripped_line.startswith("__DIFY_DRIVE_SKILL_BEGIN__\t"): + current_skill_key = stripped_line.split("\t", 1)[1] + current_skill_body = [] + + if current_skill_key is not None: + raise DifyDriveLayerError(f"drive mentioned pull omitted SKILL.md end marker for {current_skill_key}") + return written_paths, skill_bodies + + def _record_pulled_paths(self, written_paths: dict[str, str]) -> None: self._pulled_file_paths = written_paths for file_key in self.config.mentioned_file_keys: if file_key not in written_paths: raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}") for skill_key in self.config.mentioned_skill_keys: - skill_path = written_paths.get(skill_key) - if skill_path is None: + if skill_key not in written_paths: raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}") - try: - self._loaded_skill_bodies[skill_key] = Path(skill_path).read_text(encoding="utf-8") - except (OSError, UnicodeError) as exc: - raise DifyDriveLayerError(f"failed to load pulled SKILL.md for mentioned skill {skill_key}") from exc - async def _fetch_manifest_items( - self, - *, - tenant_id: str, - targets: list[tuple[str, bool]], - ) -> list[_DriveManifestItem]: - semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY) + def _mentioned_pull_targets(self) -> list[tuple[str, bool]]: + return [(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys] + [ + (file_key, True) for file_key in self.config.mentioned_file_keys + ] - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client: - - async def fetch_one(target: tuple[str, bool]) -> list[_DriveManifestItem]: - prefix, exact = target - try: - async with semaphore: - response = await client.get( - f"{self.inner_api_url}/inner/api/drive/{self.config.drive_ref}/manifest", - params={ - "tenant_id": tenant_id, - "prefix": prefix, - "include_download_url": "true", - }, - headers={"X-Inner-Api-Key": self.inner_api_key}, - ) - except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc: - raise DifyDriveLayerError(f"drive manifest request failed for {prefix}") from exc - if response.is_error: - raise DifyDriveLayerError(f"drive manifest request failed for {prefix}: {response.status_code}") - try: - payload = response.json() - except ValueError as exc: - raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}") from exc - items = payload.get("items") if isinstance(payload, dict) else None - if not isinstance(items, list): - raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}") - manifest_items: list[_DriveManifestItem] = [] - for item in items: - if not isinstance(item, dict): - continue - key = item.get("key") - download_url = item.get("download_url") - if not isinstance(key, str) or not isinstance(download_url, str) or not download_url: - raise DifyDriveLayerError(f"drive manifest item is missing download_url for {prefix}") - if exact and key != prefix: - continue - manifest_items.append(_DriveManifestItem(key=key, download_url=download_url, size=item.get("size"))) - return manifest_items - - grouped_items = await asyncio.gather(*(fetch_one(target) for target in targets)) - - deduplicated: dict[str, _DriveManifestItem] = {} - for items in grouped_items: - for item in items: - deduplicated.setdefault(item.key, item) - return [deduplicated[key] for key in sorted(deduplicated)] - - async def _download_items(self, items: list[_DriveManifestItem]) -> dict[str, str]: - base_path = Path(agent_stub_drive_base_for_ref(self.config.drive_ref)) - try: - base_path.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise DifyDriveLayerError(f"failed to prepare drive base {base_path}") from exc - semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY) - archive_paths: list[Path] = [] - canonical_skill_dirs = {item.key.rsplit("/", 1)[0] for item in items if item.key.endswith("/SKILL.md")} - - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client: - - async def download_one(item: _DriveManifestItem) -> tuple[str, str]: - try: - async with semaphore: - response = await client.get(item.download_url) - except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc: - raise DifyDriveLayerError(f"drive download failed for {item.key}") from exc - if response.is_error: - raise DifyDriveLayerError(f"drive download failed for {item.key}: {response.status_code}") - payload = response.content - if item.size is not None and len(payload) != item.size: - raise DifyDriveLayerError(f"downloaded drive file size mismatch for {item.key}") - try: - destination = _resolve_drive_destination(base_path, item.key) - destination.parent.mkdir(parents=True, exist_ok=True) - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - temp_path.write_bytes(payload) - temp_path.replace(destination) - except OSError as exc: - raise DifyDriveLayerError(f"failed to materialize drive file {item.key}") from exc - if destination.name == _SKILL_ARCHIVE_FILENAME: - archive_paths.append(destination) - return item.key, str(destination) - - pairs = await asyncio.gather(*(download_one(item) for item in items)) - for archive_path in sorted(archive_paths): - archive_skill_dir = archive_path.parent.relative_to(base_path).as_posix() - skip_entry_names = {"SKILL.md"} if archive_skill_dir in canonical_skill_dirs else set() - _extract_skill_archive(archive_path, skip_entry_names=skip_entry_names) - return {key: path for key, path in pairs} - - def _require_tenant_id(self) -> str: - execution_context = self.deps.execution_context.config - tenant_id = getattr(execution_context, "tenant_id", None) - if not isinstance(tenant_id, str) or not tenant_id.strip(): - raise DifyDriveLayerError("DifyDriveLayer requires execution_context.tenant_id") - return cast(str, tenant_id).strip() + def _shell_local_path(self, drive_key: str) -> str: + return f"{agent_stub_drive_base_for_ref(self.config.drive_ref).rstrip('/')}/{drive_key.lstrip('/')}" @staticmethod def _skill_prefix(skill_key: str) -> str: return f"{skill_key.rsplit('/', 1)[0]}/" -def _resolve_drive_destination(base_path: Path, drive_key: str) -> Path: - destination = (base_path / Path(drive_key)).resolve() - try: - destination.relative_to(base_path) - except ValueError as exc: - raise DifyDriveLayerError(f"drive key resolves outside the drive base: {drive_key}") from exc - return destination - - -def _extract_skill_archive(archive_path: Path, *, skip_entry_names: set[str]) -> None: - target_dir = archive_path.parent.resolve() - try: - with TemporaryDirectory(dir=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(): - if zip_info.filename.replace("\\", "/").rstrip("/") in skip_entry_names: - continue - destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename) - if _is_zip_symlink(zip_info): - raise DifyDriveLayerError( - f"skill archive contains unsupported symlink entry: {zip_info.filename}" - ) - if zip_info.is_dir(): - destination.mkdir(parents=True, exist_ok=True) - continue - destination.parent.mkdir(parents=True, exist_ok=True) - with archive.open(zip_info) as source_file: - temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") - temp_path.write_bytes(source_file.read()) - temp_path.replace(destination) - for staged_path in sorted(staging_dir.rglob("*")): - if staged_path.is_dir(): - continue - relative_path = staged_path.relative_to(staging_dir) - destination = (target_dir / relative_path).resolve() - destination.parent.mkdir(parents=True, exist_ok=True) - staged_path.replace(destination) - except DifyDriveLayerError: - raise - except (BadZipFile, OSError) as exc: - raise DifyDriveLayerError(f"downloaded skill archive is invalid: {archive_path.name}") from exc - - -def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path: - normalized_name = entry_name.replace("\\", "/") - pure_path = PurePosixPath(normalized_name) - if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute(): - raise DifyDriveLayerError(f"skill archive contains unsafe absolute path: {entry_name}") - if any(part in {"", ".", ".."} for part in pure_path.parts): - raise DifyDriveLayerError(f"skill archive contains unsafe path traversal entry: {entry_name}") - destination = (target_dir / Path(*pure_path.parts)).resolve() - try: - destination.relative_to(target_dir) - except ValueError as exc: - raise DifyDriveLayerError(f"skill archive entry resolves outside the skill directory: {entry_name}") from exc - return destination - - -def _is_zip_symlink(zip_info: ZipInfo) -> bool: - file_mode = zip_info.external_attr >> 16 - return (file_mode & 0o170000) == 0o120000 - - __all__ = ["DifyDriveLayer", "DifyDriveLayerError"] diff --git a/dify-agent/src/dify_agent/layers/knowledge/__init__.py b/dify-agent/src/dify_agent/layers/knowledge/__init__.py index 569512d8004..86a9405bce3 100644 --- a/dify-agent/src/dify_agent/layers/knowledge/__init__.py +++ b/dify-agent/src/dify_agent/layers/knowledge/__init__.py @@ -7,21 +7,31 @@ root stays import-safe for callers that only need to construct run requests. from dify_agent.layers.knowledge.configs import ( DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig, + DifyKnowledgeDatasetConfig, + DifyKnowledgeEagerResult, DifyKnowledgeMetadataCondition, DifyKnowledgeMetadataConditions, DifyKnowledgeMetadataFilteringConfig, DifyKnowledgeModelConfig, + DifyKnowledgeQueryConfig, DifyKnowledgeRerankingModelConfig, DifyKnowledgeRetrievalConfig, + DifyKnowledgeRuntimeState, + DifyKnowledgeSetConfig, ) __all__ = [ "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeDatasetConfig", + "DifyKnowledgeEagerResult", "DifyKnowledgeMetadataCondition", "DifyKnowledgeMetadataConditions", "DifyKnowledgeMetadataFilteringConfig", "DifyKnowledgeModelConfig", + "DifyKnowledgeQueryConfig", "DifyKnowledgeRerankingModelConfig", "DifyKnowledgeRetrievalConfig", + "DifyKnowledgeRuntimeState", + "DifyKnowledgeSetConfig", ] diff --git a/dify-agent/src/dify_agent/layers/knowledge/configs.py b/dify-agent/src/dify_agent/layers/knowledge/configs.py index 9ada075d1cc..b7b71ab9c42 100644 --- a/dify-agent/src/dify_agent/layers/knowledge/configs.py +++ b/dify-agent/src/dify_agent/layers/knowledge/configs.py @@ -1,12 +1,11 @@ """Client-safe DTOs for the Dify knowledge-base Agenton layer. -The public layer config exposes only static retrieval controls: dataset ids, -retrieval strategy, metadata filtering, and observation-size limits. The agent -model itself should only ever see a single ``query`` tool argument; tenant/ -app/user context comes from the execution-context layer and the actual -retrieval is delegated to the Dify API inner endpoint. Tool naming is not -caller-configurable: the runtime always exposes the same stable knowledge-base -search tool. +The public layer config carries one or more named knowledge sets. Each set owns +its dataset ids plus query, retrieval, and metadata-filtering policy. Generated- +query sets are exposed through one stable model-visible search tool whose +schema lets the model pick ``set_name`` and ``query``; user-query sets are +retrieved eagerly when the layer enters a run and their formatted observations +are kept only in JSON-safe ``runtime_state`` for session snapshots. """ from __future__ import annotations @@ -61,6 +60,44 @@ class DifyKnowledgeRerankingModelConfig(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class DifyKnowledgeDatasetConfig(BaseModel): + """One dataset selected by a knowledge set. + + Only ``id`` is used for retrieval. ``name`` and ``description`` are retained + because callers already have them and they are useful in runtime/debug + snapshots without changing the inner retrieval request contract. + """ + + id: str + name: str | None = None + description: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("id") + @classmethod + def validate_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("dataset id must not be blank") + return normalized + + +class DifyKnowledgeQueryConfig(BaseModel): + """Query policy for one knowledge set.""" + + mode: Literal["user_query", "generated_query"] + value: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> DifyKnowledgeQueryConfig: + if self.mode == "user_query" and not (self.value or "").strip(): + raise ValueError("query.value is required for user_query mode") + return self + + class DifyKnowledgeRetrievalConfig(BaseModel): """Static retrieval controls mirrored into the inner API request.""" @@ -151,38 +188,90 @@ class DifyKnowledgeMetadataFilteringConfig(BaseModel): return payload -class DifyKnowledgeBaseLayerConfig(LayerConfig): - """Public config for one model-visible knowledge search tool. +class DifyKnowledgeSetConfig(BaseModel): + """One independently searchable or eagerly-preloaded knowledge set.""" - The model only gets to choose whether to call the tool and what ``query`` - to send. Dataset ids, retrieval settings, metadata filtering, and caller - context remain config/runtime concerns outside the model-visible tool - schema. The tool name and description are fixed by the layer runtime and do - not appear in the public config DTO. - """ - - dataset_ids: list[str] + id: str + name: str + description: str | None = None + datasets: list[DifyKnowledgeDatasetConfig] + query: DifyKnowledgeQueryConfig retrieval: DifyKnowledgeRetrievalConfig metadata_filtering: DifyKnowledgeMetadataFilteringConfig = Field( default_factory=DifyKnowledgeMetadataFilteringConfig ) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("id", "name") + @classmethod + def validate_non_blank_identity(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("knowledge set id and name must not be blank") + return normalized + + @model_validator(mode="after") + def validate_dataset_ids(self) -> DifyKnowledgeSetConfig: + if not self.datasets: + raise ValueError("knowledge set requires at least one dataset") + dataset_ids = [dataset.id for dataset in self.datasets] + if len(dataset_ids) != len(set(dataset_ids)): + raise ValueError("knowledge set dataset ids must be unique") + return self + + @property + def dataset_ids(self) -> list[str]: + """Return the selected dataset ids for the inner retrieval request.""" + return [dataset.id for dataset in self.datasets] + + +class DifyKnowledgeEagerResult(BaseModel): + """JSON-safe eager user-query result stored in layer runtime state.""" + + set_id: str + set_name: str + query: str + observation: str + status: Literal["success", "empty", "temporarily_unavailable"] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRuntimeState(BaseModel): + """Serializable eager-retrieval state stored in Agenton session snapshots.""" + + eager_config_fingerprint: str | None = None + eager_results: list[DifyKnowledgeEagerResult] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True) + + +class DifyKnowledgeBaseLayerConfig(LayerConfig): + """Public config for one knowledge-base layer. + + The model-visible surface stays fixed to ``knowledge_base_search``. Set + names are the only model-visible selection labels; dataset ids, retrieval + controls, metadata filtering, and caller identity remain config/runtime + concerns outside the tool schema. + """ + + sets: list[DifyKnowledgeSetConfig] max_result_content_chars: int = Field(default=2000, ge=1) max_observation_chars: int = Field(default=12000, ge=1) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - @field_validator("dataset_ids") - @classmethod - def validate_dataset_ids(cls, value: list[str]) -> list[str]: - if not value: - raise ValueError("dataset_ids must contain at least one item") - normalized_ids = [item.strip() for item in value] - if any(not item for item in normalized_ids): - raise ValueError("dataset_ids must not contain blank items") - return normalized_ids - @model_validator(mode="after") - def validate_observation_limits(self) -> DifyKnowledgeBaseLayerConfig: + def validate_sets_and_observation_limits(self) -> DifyKnowledgeBaseLayerConfig: + if not self.sets: + raise ValueError("sets must contain at least one knowledge set") + set_ids = [knowledge_set.id for knowledge_set in self.sets] + if len(set_ids) != len(set(set_ids)): + raise ValueError("knowledge set ids must be unique") + normalized_names = [knowledge_set.name.strip().lower() for knowledge_set in self.sets] + if len(normalized_names) != len(set(normalized_names)): + raise ValueError("knowledge set names must be unique") if self.max_observation_chars < self.max_result_content_chars: raise ValueError("max_observation_chars must be greater than or equal to max_result_content_chars") return self @@ -191,10 +280,15 @@ class DifyKnowledgeBaseLayerConfig(LayerConfig): __all__ = [ "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeDatasetConfig", + "DifyKnowledgeEagerResult", "DifyKnowledgeMetadataCondition", "DifyKnowledgeMetadataConditions", "DifyKnowledgeMetadataFilteringConfig", "DifyKnowledgeModelConfig", + "DifyKnowledgeQueryConfig", "DifyKnowledgeRerankingModelConfig", "DifyKnowledgeRetrievalConfig", + "DifyKnowledgeRuntimeState", + "DifyKnowledgeSetConfig", ] diff --git a/dify-agent/src/dify_agent/layers/knowledge/layer.py b/dify-agent/src/dify_agent/layers/knowledge/layer.py index 02c9f07dd56..df07dc3cd36 100644 --- a/dify-agent/src/dify_agent/layers/knowledge/layer.py +++ b/dify-agent/src/dify_agent/layers/knowledge/layer.py @@ -1,17 +1,18 @@ -"""Dify knowledge-base layer exposing one model-visible search tool. +"""Dify knowledge-base layer exposing set-aware retrieval. The layer depends on ``DifyExecutionContextLayer`` for tenant/app/user/invoke -identity, keeps retrieval controls in config only, and borrows a lifespan-owned -HTTP client for each tool invocation. It never owns live clients or stores -retrieved source content in layer state. Tool identity is intentionally fixed at -runtime: callers cannot rename the knowledge tool or override its description -through public layer config because the model-visible surface must stay stable -across API-side Agent Soul mappings. +identity. Generated-query sets become one stable model-visible +``knowledge_base_search(set_name, query)`` tool, while user-query sets are +retrieved eagerly during context entry and exposed as additional user prompt +content. Eager observations are persisted only as JSON-safe runtime state so +Agenton session snapshots can resume without repeating unchanged retrievals. """ from __future__ import annotations from dataclasses import dataclass +import hashlib +import json import logging from typing import ClassVar, cast @@ -27,7 +28,13 @@ from dify_agent.layers.knowledge.client import ( DifyKnowledgeBaseClientError, DifyKnowledgeRetrieveResponse, ) -from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.configs import ( + DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + DifyKnowledgeBaseLayerConfig, + DifyKnowledgeEagerResult, + DifyKnowledgeRuntimeState, + DifyKnowledgeSetConfig, +) logger = logging.getLogger(__name__) @@ -35,23 +42,14 @@ logger = logging.getLogger(__name__) # public DTO cannot grow a parallel naming contract that diverges from the # runtime knowledge-search surface. _KNOWLEDGE_BASE_TOOL_NAME = "knowledge_base_search" -_KNOWLEDGE_BASE_TOOL_DESCRIPTION = "Search configured knowledge bases for information relevant to the query." +_KNOWLEDGE_BASE_TOOL_DESCRIPTION = ( + "Search a configured knowledge set. Pick one configured set_name and provide a focused search query." +) BLANK_QUERY_OBSERVATION = "knowledge base search requires a non-empty query" NO_RESULTS_OBSERVATION = "No relevant knowledge base results were found." TEMPORARY_UNAVAILABLE_OBSERVATION = ( "Knowledge base search is temporarily unavailable. Please continue without it if possible." ) -QUERY_TOOL_SCHEMA = { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query for the configured knowledge bases.", - } - }, - "required": ["query"], - "additionalProperties": False, -} class DifyKnowledgeBaseDeps(LayerDeps): @@ -61,8 +59,10 @@ class DifyKnowledgeBaseDeps(LayerDeps): @dataclass(slots=True) -class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig]): - """Layer that resolves one config-scoped knowledge search tool.""" +class DifyKnowledgeBaseLayer( + PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig, DifyKnowledgeRuntimeState] +): + """Layer that resolves set-scoped knowledge tools and eager user prompts.""" type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID @@ -95,7 +95,7 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase ) async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: - """Build one Pydantic AI tool that exposes only ``query`` to the model. + """Build the unified generated-query Pydantic AI tool, when needed. Knowledge tools depend on execution-context identity that is optional for other run types but mandatory here: ``tenant_id``, ``user_id``, @@ -103,11 +103,15 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase any HTTP request is attempted. Tool execution then follows a strict observation policy: + - unknown ``set_name`` returns a local validation observation; - blank ``query`` returns a local validation observation; - retryable client failures (timeouts, connection failures, HTTP ``429``/``502``) become a temporary-unavailable observation; - non-retryable client failures are raised so the run fails fast. """ + generated_sets = self._generated_query_sets() + if not generated_sets: + return [] if http_client.is_closed: raise RuntimeError("DifyKnowledgeBaseLayer.get_tools() requires an open shared HTTP client.") @@ -118,54 +122,28 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase api_key=self.inner_api_key, http_client=http_client, ) + set_by_name = {knowledge_set.name: knowledge_set for knowledge_set in generated_sets} - async def knowledge_base_search(_ctx: RunContext[object], query: str) -> str: + async def knowledge_base_search(_ctx: RunContext[object], set_name: str, query: str) -> str: + knowledge_set = set_by_name.get(set_name) + if knowledge_set is None: + return f"unknown knowledge set: {set_name}" normalized_query = query.strip() if not normalized_query: return BLANK_QUERY_OBSERVATION - try: - response = await client.retrieve( - tenant_id=caller["tenant_id"], - user_id=caller["user_id"], - app_id=caller["app_id"], - user_from=caller["user_from"], - invoke_from=caller["invoke_from"], - dataset_ids=list(self.config.dataset_ids), - query=normalized_query, - retrieval=self.config.retrieval, - metadata_filtering=self.config.metadata_filtering, - ) - except DifyKnowledgeBaseClientError as exc: - if exc.retryable: - logger.warning( - "knowledge base search temporarily unavailable", - extra={ - "tenant_id": caller["tenant_id"], - "app_id": caller["app_id"], - "invoke_from": caller["invoke_from"], - "error_code": exc.error_code, - "status_code": exc.status_code, - }, - ) - return TEMPORARY_UNAVAILABLE_OBSERVATION - logger.error( - "knowledge base search failed", - extra={ - "tenant_id": caller["tenant_id"], - "app_id": caller["app_id"], - "invoke_from": caller["invoke_from"], - "error_code": exc.error_code, - "status_code": exc.status_code, - }, - ) - raise - return _format_observation(response, self.config) + return await self._retrieve_for_set( + client=client, + caller=caller, + knowledge_set=knowledge_set, + query=normalized_query, + retryable_observation=True, + ) 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=QUERY_TOOL_SCHEMA, + parameters_json_schema=_tool_schema(generated_sets), strict=tool_def.strict, sequential=tool_def.sequential, metadata=tool_def.metadata, @@ -181,11 +159,177 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase knowledge_base_search, takes_ctx=True, name=_KNOWLEDGE_BASE_TOOL_NAME, - description=_KNOWLEDGE_BASE_TOOL_DESCRIPTION, + description=_tool_description(generated_sets), prepare=prepare_tool_definition, ) ] + @property + @override + def user_prompts(self) -> list[str]: + """Expose eager user-query results as an additional user prompt.""" + if not self.runtime_state.eager_results: + return [] + + sections: list[str] = [] + for result in self.runtime_state.eager_results: + sections.append( + "\n".join( + [ + f"Set: {result.set_name}", + f"Query: {result.query}", + "Results:", + result.observation, + ] + ) + ) + return ["Knowledge retrieval results:\n\n" + "\n\n".join(sections)] + + @override + async def on_context_create(self) -> None: + await self._refresh_eager_results_if_needed() + + @override + async def on_context_resume(self) -> None: + await self._refresh_eager_results_if_needed() + + def _generated_query_sets(self) -> list[DifyKnowledgeSetConfig]: + return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "generated_query"] + + def _user_query_sets(self) -> list[DifyKnowledgeSetConfig]: + return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "user_query"] + + async def _refresh_eager_results_if_needed(self) -> None: + user_query_sets = self._user_query_sets() + if not user_query_sets: + self.runtime_state.eager_config_fingerprint = None + self.runtime_state.eager_results = [] + return + + fingerprint = _eager_config_fingerprint(user_query_sets) + if self.runtime_state.eager_config_fingerprint == fingerprint: + return + + caller = _build_caller_context(self.deps.execution_context.config) + async with httpx.AsyncClient() as http_client: + client = DifyKnowledgeBaseClient( + base_url=self.inner_api_url, + api_key=self.inner_api_key, + http_client=http_client, + ) + eager_results: list[DifyKnowledgeEagerResult] = [] + for knowledge_set in user_query_sets: + query = (knowledge_set.query.value or "").strip() + try: + response = await client.retrieve( + tenant_id=caller["tenant_id"], + user_id=caller["user_id"], + app_id=caller["app_id"], + user_from=caller["user_from"], + invoke_from=caller["invoke_from"], + dataset_ids=knowledge_set.dataset_ids, + query=query, + retrieval=knowledge_set.retrieval, + metadata_filtering=knowledge_set.metadata_filtering, + ) + except DifyKnowledgeBaseClientError as exc: + if exc.retryable: + logger.warning( + "eager knowledge retrieval temporarily unavailable", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "knowledge_set_id": knowledge_set.id, + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + eager_results.append( + DifyKnowledgeEagerResult( + set_id=knowledge_set.id, + set_name=knowledge_set.name, + query=query, + observation=TEMPORARY_UNAVAILABLE_OBSERVATION, + status="temporarily_unavailable", + ) + ) + continue + logger.error( + "eager knowledge retrieval failed", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "knowledge_set_id": knowledge_set.id, + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + raise + + eager_results.append( + DifyKnowledgeEagerResult( + set_id=knowledge_set.id, + set_name=knowledge_set.name, + query=query, + observation=_format_observation(response, self.config, include_heading=False), + status="success" if response.results else "empty", + ) + ) + + self.runtime_state.eager_results = eager_results + self.runtime_state.eager_config_fingerprint = fingerprint + + async def _retrieve_for_set( + self, + *, + client: DifyKnowledgeBaseClient, + caller: dict[str, str], + knowledge_set: DifyKnowledgeSetConfig, + query: str, + retryable_observation: bool, + ) -> str: + try: + response = await client.retrieve( + tenant_id=caller["tenant_id"], + user_id=caller["user_id"], + app_id=caller["app_id"], + user_from=caller["user_from"], + invoke_from=caller["invoke_from"], + dataset_ids=knowledge_set.dataset_ids, + query=query, + retrieval=knowledge_set.retrieval, + metadata_filtering=knowledge_set.metadata_filtering, + ) + except DifyKnowledgeBaseClientError as exc: + if exc.retryable and retryable_observation: + logger.warning( + "knowledge base search temporarily unavailable", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "knowledge_set_id": knowledge_set.id, + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + return TEMPORARY_UNAVAILABLE_OBSERVATION + logger.error( + "knowledge base search failed", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "knowledge_set_id": knowledge_set.id, + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + raise + return _format_observation(response, self.config) + def _build_caller_context(execution_context: object) -> dict[str, str]: """Extract the inner-API caller identity from execution-context config. @@ -232,7 +376,56 @@ def _build_caller_context(execution_context: object) -> dict[str, str]: } -def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKnowledgeBaseLayerConfig) -> str: +def _tool_schema(generated_sets: list[DifyKnowledgeSetConfig]) -> dict[str, object]: + return { + "type": "object", + "properties": { + "set_name": { + "type": "string", + "enum": [knowledge_set.name for knowledge_set in generated_sets], + "description": "Knowledge set to search.", + }, + "query": { + "type": "string", + "description": "Search query for the selected knowledge set.", + }, + }, + "required": ["set_name", "query"], + "additionalProperties": False, + } + + +def _tool_description(generated_sets: list[DifyKnowledgeSetConfig]) -> str: + set_descriptions = [] + for knowledge_set in generated_sets: + if knowledge_set.description: + set_descriptions.append(f"{knowledge_set.name}: {knowledge_set.description}") + else: + set_descriptions.append(knowledge_set.name) + return f"{_KNOWLEDGE_BASE_TOOL_DESCRIPTION} Configured sets: {', '.join(set_descriptions)}." + + +def _eager_config_fingerprint(user_query_sets: list[DifyKnowledgeSetConfig]) -> str: + payload = [ + { + "id": knowledge_set.id, + "query": knowledge_set.query.model_dump(mode="json"), + "dataset_ids": knowledge_set.dataset_ids, + "retrieval": knowledge_set.retrieval.model_dump(mode="json"), + "metadata_filtering": knowledge_set.metadata_filtering.model_dump(mode="json", by_alias=True), + } + for knowledge_set in user_query_sets + ] + serialized = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _format_observation( + response: DifyKnowledgeRetrieveResponse, + config: DifyKnowledgeBaseLayerConfig, + *, + include_heading: bool = True, +) -> str: """Render inner-API retrieval results into the model-visible tool response. The formatting contract is intentionally simple and stable for the model: @@ -248,7 +441,7 @@ def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKno if not response.results: return NO_RESULTS_OBSERVATION - lines = ["Knowledge base search results:"] + lines = ["Knowledge base search results:"] if include_heading else [] for index, result in enumerate(response.results, start=1): metadata = result.metadata title = result.title or metadata.document_name or "Untitled" @@ -280,6 +473,5 @@ __all__ = [ "DifyKnowledgeBaseDeps", "DifyKnowledgeBaseLayer", "NO_RESULTS_OBSERVATION", - "QUERY_TOOL_SCHEMA", "TEMPORARY_UNAVAILABLE_OBSERVATION", ] diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py index fafab6a3bd9..f538ffa2636 100644 --- a/dify-agent/src/dify_agent/layers/shell/configs.py +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -3,7 +3,8 @@ Server-only shellctl connection settings are injected by the runtime provider factory. Public config carries product-level Agent Soul settings that must affect the sandbox workspace itself: CLI tool bootstrap commands, normal environment -variables, secret environment variable names, and sandbox-provider metadata. +variables, secret environment variable names, sandbox-provider metadata, and the +Agent Stub drive ref used by shell-visible drive commands. """ import re @@ -80,6 +81,8 @@ class DifyShellLayerConfig(LayerConfig): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + # Optional because shell can be used without a drive layer. + agent_stub_drive_ref: str | None = Field(default=None, max_length=1024) cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list) env: list[DifyShellEnvVarConfig] = Field(default_factory=list) secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list) diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index a8f46d628a6..17ef504f6bf 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -51,7 +51,6 @@ from typing_extensions import Self, override from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env -from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig @@ -173,11 +172,11 @@ type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObserv class DifyShellLayerDeps(LayerDeps): """Optional direct-layer dependencies used by the shell runtime layer. - The drive dependency supplies the drive ref for injected - Agent Stub CLI commands; the execution context supplies the token principal. + 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. """ - drive: DifyDriveLayer | None # pyright: ignore[reportUninitializedInstanceVariable] execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable] @@ -768,10 +767,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC """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 - drive_layer = self.deps.drive return build_shell_agent_stub_env( agent_stub_api_base_url=self.agent_stub_api_base_url, - agent_stub_drive_ref=drive_layer.config.drive_ref if drive_layer is not None else None, + agent_stub_drive_ref=self.config.agent_stub_drive_ref, execution_context=execution_context, token_factory=self.agent_stub_token_factory, session_id=self.runtime_state.session_id, diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 0cfab33ad0d..610733cff98 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -38,7 +38,6 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.layers.ask_human.layer import DifyAskHumanLayer from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer -from dify_agent.layers.drive import DifyDriveLayerConfig from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer @@ -90,14 +89,7 @@ def create_default_layer_providers( LayerProvider.from_layer_type(PydanticAIHistoryLayer), LayerProvider.from_layer_type(DifyOutputLayer), LayerProvider.from_layer_type(DifyAskHumanLayer), - LayerProvider.from_factory( - layer_type=DifyDriveLayer, - create=lambda config: DifyDriveLayer.from_config_with_settings( - DifyDriveLayerConfig.model_validate(config), - inner_api_url=inner_api_url, - inner_api_key=inner_api_key, - ), - ), + LayerProvider.from_layer_type(DifyDriveLayer), LayerProvider.from_factory( layer_type=DifyExecutionContextLayer, create=lambda config: DifyExecutionContextLayer.from_config_with_settings( diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index cc3760da393..50f27660e77 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -10,7 +10,9 @@ stay state-only: they borrow the lifespan-owned clients through the runner and receive shell-layer server settings through provider construction rather than reading environment variables themselves. The standard server always mounts the HTTP Agent Stub router and additionally starts the optional grpclib Agent Stub -server when ``DIFY_AGENT_STUB_API_BASE_URL`` uses ``grpc://``. +server when ``DIFY_AGENT_STUB_API_BASE_URL`` uses ``grpc://``. Process-level +Logfire instrumentation is configured at app construction time and only exports +remotely when Logfire's default environment configuration provides a token. """ from collections.abc import AsyncGenerator @@ -25,6 +27,7 @@ from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_serv from dify_agent.agent_stub.server.router import create_agent_stub_router from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.run_scheduler import RunScheduler +from dify_agent.server.observability import configure_server_observability from dify_agent.server.routes.runs import create_runs_router from dify_agent.server.routes.sandbox_files import create_sandbox_files_router from dify_agent.server.sandbox_files import SandboxFileService @@ -94,6 +97,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: await redis.aclose() app = FastAPI(title="Dify Agent Run Server", version="0.1.0", lifespan=lifespan) + configure_server_observability(app) def get_store() -> RedisRunStore: return state["store"] # pyright: ignore[reportReturnType] diff --git a/dify-agent/src/dify_agent/server/observability.py b/dify-agent/src/dify_agent/server/observability.py new file mode 100644 index 00000000000..7893dfe4769 --- /dev/null +++ b/dify-agent/src/dify_agent/server/observability.py @@ -0,0 +1,47 @@ +"""Process-level Logfire setup for the Dify Agent run server. + +The run server performs observability setup at the FastAPI app boundary rather +than inside agent runtime code. Global instrumentations cover shared HTTPX, +Redis, and Pydantic AI clients once per process; the FastAPI instrumentation is +applied per app instance because tests and embedded callers can build multiple +apps in one Python process. Remote export remains token-gated through Logfire's +``if-token-present`` mode and Logfire's default environment-variable handling, +so development without a token only writes Logfire's console output locally. +""" + +from __future__ import annotations + +import logfire +from fastapi import FastAPI + +_global_instrumentation_ready = False + + +def configure_server_observability(app: FastAPI) -> None: + """Configure Logfire and instrument the server's framework/client boundaries. + + Instrumentation calls intentionally use Logfire's defaults instead of + re-exposing capture options through Dify settings. The only Dify-owned + policy here is that remote export stays token-gated while local console + output still works without a token. + """ + global _global_instrumentation_ready + + logfire.configure( + send_to_logfire="if-token-present", + inspect_arguments=False, + ) + + if not _global_instrumentation_ready: + logfire.instrument_httpx() + logfire.instrument_redis() + logfire.instrument_pydantic_ai() + _global_instrumentation_ready = True + + if getattr(app.state, "dify_agent_logfire_instrumented", False): + return + logfire.instrument_fastapi(app) + app.state.dify_agent_logfire_instrumented = True + + +__all__ = ["configure_server_observability"] diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py index 6e45e14a034..61ffcec9e3a 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py @@ -8,7 +8,9 @@ from zipfile import ZipFile, ZipInfo import pytest from dify_agent.agent_stub.cli._drive import ( - list_drive_from_environment, + DrivePullResult, + format_drive_manifest, + list_drive_manifest_from_environment, pull_drive_from_environment, push_drive_from_environment, ) @@ -22,7 +24,7 @@ from dify_agent.agent_stub.protocol.agent_stub import ( ) -def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_drive_manifest_from_environment_returns_manifest_model(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") captured: dict[str, object] = {} @@ -47,7 +49,7 @@ def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: py fake_manifest, ) - result = list_drive_from_environment(prefix="skills/", json_output=True) + result = list_drive_manifest_from_environment(prefix="skills/") assert isinstance(result, AgentStubDriveManifestResponse) assert result.items[0].key == "skills/example/SKILL.md" @@ -55,7 +57,7 @@ def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: py assert captured["include_download_url"] is False -def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_format_drive_manifest_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") captured: dict[str, object] = {} @@ -88,7 +90,7 @@ def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch: fake_manifest, ) - result = list_drive_from_environment(prefix="skills/", json_output=False) + result = format_drive_manifest(list_drive_manifest_from_environment(prefix="skills/")) assert result == ("12\ttext/markdown\t-\tskills/example/SKILL.md\n-\t-\tsha256:abc\tskills/example/helper.py") assert captured["prefix"] == "skills/" @@ -128,10 +130,10 @@ def test_pull_drive_from_environment_writes_files_under_drive_base( lambda **_kwargs: b"hello world", ) - results = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path)) + result = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path)) - assert results == [tmp_path / "skills" / "example" / "SKILL.md"] - assert results[0].read_bytes() == b"hello world" + assert result.model_dump() == {"items": [{"key": "skills/", "local_path": str(tmp_path / "skills")}]} + assert (tmp_path / "skills" / "example" / "SKILL.md").read_bytes() == b"hello world" assert captured["prefix"] == "skills/" assert captured["include_download_url"] is True @@ -169,11 +171,11 @@ def test_pull_drive_from_environment_auto_extracts_skill_archive( lambda **_kwargs: archive_bytes, ) - results = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path)) + result = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path)) archive_path = tmp_path / "skills" / "foo" / ".DIFY-SKILL-FULL.zip" - assert results == [archive_path] - assert archive_path.read_bytes() == archive_bytes + assert result.model_dump() == {"items": [{"key": "skills/foo", "local_path": str(tmp_path / "skills" / "foo")}]} + assert not archive_path.exists() assert (tmp_path / "skills" / "foo" / "SKILL.md").read_text(encoding="utf-8") == "# Example\n" assert (tmp_path / "skills" / "foo" / "nested" / "helper.py").read_text(encoding="utf-8") == "print('x')\n" @@ -202,7 +204,7 @@ def test_pull_drive_from_environment_rejects_traversal_keys( ) with pytest.raises(AgentStubValidationError, match="outside the drive base"): - _ = pull_drive_from_environment(targets=[""], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=[""], local_base=str(tmp_path)) def test_pull_drive_from_environment_rejects_skill_archive_path_traversal( @@ -239,7 +241,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_path_traversal( ) with pytest.raises(AgentStubValidationError, match="path traversal"): - _ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path)) assert not (tmp_path / "skills" / "foo" / "SKILL.md").exists() @@ -276,7 +278,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry( ) with pytest.raises(AgentStubValidationError, match="absolute path"): - _ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path)) def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry( @@ -314,7 +316,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry( ) with pytest.raises(AgentStubValidationError, match="symlink entry"): - _ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path)) def test_pull_drive_from_environment_rejects_invalid_skill_archive( @@ -347,7 +349,7 @@ def test_pull_drive_from_environment_rejects_invalid_skill_archive( ) with pytest.raises(AgentStubTransferError, match="downloaded skill archive is invalid"): - _ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/foo"], local_base=str(tmp_path)) def test_pull_drive_from_environment_rejects_missing_download_url( @@ -373,7 +375,7 @@ def test_pull_drive_from_environment_rejects_missing_download_url( ) with pytest.raises(AgentStubValidationError, match="missing download_url"): - _ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path)) def test_pull_drive_from_environment_rejects_size_mismatch( @@ -404,7 +406,7 @@ def test_pull_drive_from_environment_rejects_size_mismatch( ) with pytest.raises(AgentStubTransferError, match="size mismatch"): - _ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path)) + _ = pull_drive_from_environment(targets=["skills/"], local_base=str(tmp_path)) def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_overlaps( @@ -463,11 +465,16 @@ def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_ ), ) - results = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], drive_base=str(tmp_path)) + result = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], local_base=str(tmp_path)) - assert captured_prefixes == ["skills/foo", "files/a.txt"] - assert results == [tmp_path / "files" / "a.txt", tmp_path / "skills" / "foo" / "SKILL.md"] - assert downloaded_urls == ["https://files.example.com/a-txt", "https://files.example.com/skill-md"] + assert set(captured_prefixes) == {"skills/foo", "files/a.txt"} + assert len(captured_prefixes) == 2 + assert {(item.key, item.local_path) for item in result.items} == { + ("files/a.txt", str(tmp_path / "files" / "a.txt")), + ("skills/foo", str(tmp_path / "skills" / "foo")), + } + assert set(downloaded_urls) == {"https://files.example.com/a-txt", "https://files.example.com/skill-md"} + assert len(downloaded_urls) == 2 def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull( @@ -482,10 +489,44 @@ def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull( lambda **kwargs: captured_prefixes.append(kwargs["prefix"]) or AgentStubDriveManifestResponse(items=[]), ) - assert pull_drive_from_environment(drive_base=str(tmp_path)) == [] + assert pull_drive_from_environment(local_base=str(tmp_path)).model_dump() == {"items": []} assert captured_prefixes == [""] +def test_pull_drive_from_environment_returns_json_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="files/a.txt", + size=1, + hash=None, + mime_type="text/plain", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/a-txt", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: b"a", + ) + + result = pull_drive_from_environment(targets=["files/a.txt"], local_base=str(tmp_path)) + + assert isinstance(result, DrivePullResult) + assert result.model_dump() == {"items": [{"key": "files/a.txt", "local_path": str(tmp_path / "files" / "a.txt")}]} + assert (tmp_path / "files" / "a.txt").read_bytes() == b"a" + + def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: source = tmp_path / "report.pdf" source.write_bytes(b"report") @@ -518,21 +559,46 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon monkeypatch.setattr("dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", fake_commit) - response = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", recursive=False) + response = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind=None) assert response.items[0].key == "files/report.pdf" request = captured["request"] assert isinstance(request, AgentStubDriveCommitRequest) - assert request.items[0].model_dump(mode="json") == { - "key": "files/report.pdf", - "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, - "value_owned_by_drive": True, - "is_skill": False, - "skill_metadata": None, - } + assert request.items[0].key == "files/report.pdf" + assert request.items[0].file_ref is not None + assert request.items[0].file_ref.kind == "tool_file" + assert request.items[0].file_ref.id == "tool-file-1" -def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directory( +def test_push_drive_from_environment_rejects_file_with_kind_skill( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report") + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="--kind skill requires a directory containing SKILL.md"): + _ = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind="skill") + + +def test_push_drive_from_environment_rejects_symlinked_file_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report") + symlink_path = tmp_path / "report-link.pdf" + symlink_path.symlink_to(source) + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="symlink"): + _ = push_drive_from_environment(local_path=str(symlink_path), drive_path="files/report.pdf", kind=None) + + +def test_push_drive_from_environment_requires_kind_for_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -541,11 +607,24 @@ def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directo monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") - with pytest.raises(AgentStubValidationError, match="SKILL.md"): - _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + with pytest.raises(AgentStubValidationError, match="requires --kind skill or --kind dir"): + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind=None) -def test_push_drive_from_environment_standardizes_non_recursive_skill_directory( +def test_push_drive_from_environment_kind_skill_requires_skill_md( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="requires a directory containing SKILL.md"): + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill") + + +def test_push_drive_from_environment_kind_skill_standardizes_skill_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -584,7 +663,7 @@ def test_push_drive_from_environment_standardizes_non_recursive_skill_directory( ), ) - response = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + response = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill") assert set(uploaded_paths) == {"SKILL.md", ".DIFY-SKILL-FULL.zip"} assert {item.key for item in response.items} == { @@ -593,7 +672,7 @@ def test_push_drive_from_environment_standardizes_non_recursive_skill_directory( } -def test_push_drive_from_environment_non_recursive_archive_excludes_transient_entries( +def test_push_drive_from_environment_kind_skill_archive_excludes_transient_entries( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -641,7 +720,7 @@ def test_push_drive_from_environment_non_recursive_archive_excludes_transient_en ), ) - _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill") assert {"SKILL.md", "helper.py"}.issubset(archive_entries) assert ".git/config" not in archive_entries @@ -649,7 +728,7 @@ def test_push_drive_from_environment_non_recursive_archive_excludes_transient_en assert ".DIFY-SKILL-FULL.zip" not in archive_entries -def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_entries( +def test_push_drive_from_environment_kind_skill_rejects_symlinked_archive_entries( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -663,10 +742,52 @@ def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_ent monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") with pytest.raises(AgentStubValidationError, match="symlink"): - _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="skill") -def test_push_drive_from_environment_rejects_symlinked_recursive_files( +def test_push_drive_from_environment_kind_dir_requires_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report") + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="--kind dir requires a directory"): + _ = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", kind="dir") + + +def test_push_drive_from_environment_kind_file_requires_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="--kind file requires a file"): + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", kind="file") + + +def test_push_drive_from_environment_rejects_symlinked_directory_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source_dir = tmp_path / "skill" + source_dir.mkdir() + (source_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8") + symlink_path = tmp_path / "skill-link" + symlink_path.symlink_to(source_dir, target_is_directory=True) + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="symlink"): + _ = push_drive_from_environment(local_path=str(symlink_path), drive_path="skills/example", kind="skill") + + +def test_push_drive_from_environment_kind_dir_rejects_symlinked_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -679,10 +800,10 @@ def test_push_drive_from_environment_rejects_symlinked_recursive_files( monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") with pytest.raises(AgentStubValidationError, match="symlink"): - _ = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True) + _ = push_drive_from_environment(local_path=str(root), drive_path="skills/example", kind="dir") -def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packaging_skips( +def test_push_drive_from_environment_kind_dir_keeps_user_files_that_skill_packaging_skips( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -723,7 +844,7 @@ def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packa ), ) - response = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True) + response = push_drive_from_environment(local_path=str(root), drive_path="skills/example", kind="dir") assert set(uploaded_paths) == {".DIFY-SKILL-FULL.zip", "node_modules/module.js"} assert {item.key for item in response.items} == { diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py index f2de6e8edc5..c2f2d44afb4 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py @@ -11,7 +11,7 @@ from dify_agent.agent_stub.cli._files import ( upload_file_from_environment, upload_tool_file_resource_from_environment, ) -from dify_agent.agent_stub.client._errors import AgentStubTransferError +from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError def _reference(record_id: str) -> str: @@ -120,7 +120,7 @@ def test_download_file_from_environment_saves_bytes_and_renames_on_collision( result = download_file_from_environment( transfer_method="tool_file", reference_or_url=_reference("tool-file-1"), - directory=str(target_dir), + local_dir=str(target_dir), ) assert result.path.name == "report (1).pdf" @@ -157,7 +157,7 @@ def test_download_file_from_environment_sanitizes_server_filename( result = download_file_from_environment( transfer_method="tool_file", reference_or_url=_reference("tool-file-1"), - directory=str(target_dir), + local_dir=str(target_dir), ) assert result.path.parent == target_dir @@ -187,6 +187,62 @@ def test_upload_file_from_environment_rejects_non_canonical_reference( _ = upload_file_from_environment(path=str(source)) +def test_download_file_from_environment_supports_mapping_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target_dir = tmp_path / "inputs" + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + captured: dict[str, object] = {} + + def fake_request_download(**kwargs): + captured["file"] = kwargs["file"] + return type( + "Response", + (), + { + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 12, + "download_url": "https://files.example.com/download", + }, + )() + + monkeypatch.setattr("dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", fake_request_download) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: b"downloaded", + ) + + result = download_file_from_environment( + mapping=json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}), + local_dir=str(target_dir), + ) + + assert captured["file"].model_dump() == { + "transfer_method": "tool_file", + "reference": _reference("tool-file-1"), + "url": None, + } + assert result.path == target_dir / "report.pdf" + assert result.path.read_bytes() == b"downloaded" + + +def test_download_file_from_environment_requires_mapping_or_positional_pair() -> None: + with pytest.raises(AgentStubValidationError, match="requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL"): + _ = download_file_from_environment() + + +def test_download_file_from_environment_rejects_mapping_mixed_with_positionals() -> None: + with pytest.raises(AgentStubValidationError, match="cannot be combined"): + _ = download_file_from_environment( + transfer_method="tool_file", + reference_or_url=_reference("tool-file-1"), + mapping=json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}), + ) + + def test_upload_tool_file_resource_from_environment_rejects_missing_id( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 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 d66b801778c..8bdb8b2106d 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 @@ -6,6 +6,7 @@ from pathlib import Path 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 ( AgentStubDriveCommitResponse, @@ -194,20 +195,81 @@ def test_cli_file_download_prints_saved_path( ) with pytest.raises(SystemExit) as exc_info: - main(["file", "download", "tool_file", _reference("tool-file-1"), "/tmp"]) + main(["file", "download", "tool_file", _reference("tool-file-1"), "--to", "/tmp"]) captured = capsys.readouterr() assert exc_info.value.code == 0 assert captured.out.strip() == "/tmp/report.pdf" +def test_cli_file_download_supports_mapping_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_download_file_from_environment(**kwargs): + captured_kwargs.update(kwargs) + return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})() + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment + ) + + with pytest.raises(SystemExit) as exc_info: + main( + [ + "file", + "download", + "--mapping", + json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}), + "--to", + "/tmp/inputs", + ] + ) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert captured_kwargs == { + "transfer_method": None, + "reference_or_url": None, + "mapping": json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}), + "local_dir": "/tmp/inputs", + } + assert captured.out.strip() == "/tmp/inputs/report.pdf" + + +def test_cli_file_download_rejects_legacy_positional_directory( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + called = False + + def fake_download_file_from_environment(**_kwargs): + nonlocal called + called = True + return type("Response", (), {"path": Path("/tmp/report.pdf")})() + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.download_file_from_environment", fake_download_file_from_environment + ) + + with pytest.raises(SystemExit) as exc_info: + main(["file", "download", "tool_file", _reference("tool-file-1"), "/tmp"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert called is False + assert "/tmp" in captured.err + + def test_cli_drive_list_prints_manifest_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.list_drive_from_environment", - lambda *, prefix, json_output: AgentStubDriveManifestResponse( + "dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment", + lambda *, prefix: AgentStubDriveManifestResponse( items=[ AgentStubDriveItem( key=prefix + "example/SKILL.md", @@ -234,8 +296,19 @@ def test_cli_drive_list_prints_human_readable_listing( capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setattr( - "dify_agent.agent_stub.cli.main.list_drive_from_environment", - lambda *, prefix, json_output: f"12\ttext/markdown\t-\t{prefix}example/SKILL.md", + "dify_agent.agent_stub.cli.main.list_drive_manifest_from_environment", + lambda *, prefix: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key=f"{prefix}example/SKILL.md", + size=12, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + ) + ] + ), ) with pytest.raises(SystemExit) as exc_info: @@ -252,14 +325,20 @@ def test_cli_drive_pull_prints_downloaded_paths( ) -> None: monkeypatch.setattr( "dify_agent.agent_stub.cli.main.pull_drive_from_environment", - lambda *, targets, drive_base: [ - Path(drive_base) / targets[0] / "SKILL.md", - Path(drive_base) / targets[0] / "helper.py", - ], + lambda *, targets, local_base: DrivePullResult( + items=[ + DrivePullResult.Item( + key=f"{targets[0]}/SKILL.md", local_path=str(Path(local_base) / targets[0] / "SKILL.md") + ), + DrivePullResult.Item( + key=f"{targets[0]}/helper.py", local_path=str(Path(local_base) / targets[0] / "helper.py") + ), + ] + ), ) with pytest.raises(SystemExit) as exc_info: - main(["drive", "pull", "skills/example", "--drive-base", "/tmp/drive"]) + main(["drive", "pull", "skills/example", "--to", "/tmp/drive"]) captured = capsys.readouterr() assert exc_info.value.code == 0 @@ -269,16 +348,49 @@ def test_cli_drive_pull_prints_downloaded_paths( ] +def test_cli_drive_pull_prints_json_result( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.pull_drive_from_environment", + lambda *, targets, local_base: DrivePullResult( + items=[ + DrivePullResult.Item(key="files/a.txt", local_path=f"{local_base}/files/a.txt"), + DrivePullResult.Item(key="skills/foo/SKILL.md", local_path=f"{local_base}/skills/foo/SKILL.md"), + ] + ), + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "pull", "files/a.txt", "--to", "/tmp/drive", "--json"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out) == { + "items": [ + {"key": "files/a.txt", "local_path": "/tmp/drive/files/a.txt"}, + {"key": "skills/foo/SKILL.md", "local_path": "/tmp/drive/skills/foo/SKILL.md"}, + ] + } + + def test_cli_drive_pull_forwards_multiple_targets( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: captured_kwargs: dict[str, object] = {} - def fake_pull_drive_from_environment(*, targets, drive_base): + def fake_pull_drive_from_environment(*, targets, local_base): captured_kwargs["targets"] = targets - captured_kwargs["drive_base"] = drive_base - return [Path(drive_base) / "skills" / "foo" / "SKILL.md"] + captured_kwargs["local_base"] = local_base + return DrivePullResult( + items=[ + DrivePullResult.Item( + key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md") + ) + ] + ) monkeypatch.setattr( "dify_agent.agent_stub.cli.main.pull_drive_from_environment", @@ -286,11 +398,11 @@ def test_cli_drive_pull_forwards_multiple_targets( ) with pytest.raises(SystemExit) as exc_info: - main(["drive", "pull", "skills/foo", "files/a.txt", "--drive-base", "/tmp/drive"]) + main(["drive", "pull", "skills/foo", "files/a.txt", "--to", "/tmp/drive"]) captured = capsys.readouterr() assert exc_info.value.code == 0 - assert captured_kwargs == {"targets": ["skills/foo", "files/a.txt"], "drive_base": "/tmp/drive"} + assert captured_kwargs == {"targets": ["skills/foo", "files/a.txt"], "local_base": "/tmp/drive"} assert captured.out.strip() == "/tmp/drive/skills/foo/SKILL.md" @@ -301,10 +413,16 @@ def test_cli_drive_pull_uses_environment_drive_base_default( monkeypatch.setenv("DIFY_AGENT_STUB_DRIVE_BASE", "/env/drive") captured_kwargs: dict[str, object] = {} - def fake_pull_drive_from_environment(*, targets, drive_base): + def fake_pull_drive_from_environment(*, targets, local_base): captured_kwargs["targets"] = targets - captured_kwargs["drive_base"] = drive_base - return [Path(drive_base) / "skills" / "foo" / "SKILL.md"] + captured_kwargs["local_base"] = local_base + return DrivePullResult( + items=[ + DrivePullResult.Item( + key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md") + ) + ] + ) monkeypatch.setattr( "dify_agent.agent_stub.cli.main.pull_drive_from_environment", @@ -316,7 +434,7 @@ def test_cli_drive_pull_uses_environment_drive_base_default( captured = capsys.readouterr() assert exc_info.value.code == 0 - assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/env/drive"} + assert captured_kwargs == {"targets": ["skills/foo"], "local_base": "/env/drive"} assert captured.out.strip() == "/env/drive/skills/foo/SKILL.md" @@ -327,10 +445,16 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing( monkeypatch.delenv("DIFY_AGENT_STUB_DRIVE_BASE", raising=False) captured_kwargs: dict[str, object] = {} - def fake_pull_drive_from_environment(*, targets, drive_base): + def fake_pull_drive_from_environment(*, targets, local_base): captured_kwargs["targets"] = targets - captured_kwargs["drive_base"] = drive_base - return [Path(drive_base) / "skills" / "foo" / "SKILL.md"] + captured_kwargs["local_base"] = local_base + return DrivePullResult( + items=[ + DrivePullResult.Item( + key="skills/foo/SKILL.md", local_path=str(Path(local_base) / "skills" / "foo" / "SKILL.md") + ) + ] + ) monkeypatch.setattr( "dify_agent.agent_stub.cli.main.pull_drive_from_environment", @@ -342,17 +466,44 @@ def test_cli_drive_pull_keeps_historical_drive_base_when_env_is_missing( captured = capsys.readouterr() assert exc_info.value.code == 0 - assert captured_kwargs == {"targets": ["skills/foo"], "drive_base": "/mnt/drive"} + assert captured_kwargs == {"targets": ["skills/foo"], "local_base": "/mnt/drive"} assert captured.out.strip() == "/mnt/drive/skills/foo/SKILL.md" +def test_cli_drive_pull_without_targets_pulls_whole_visible_drive( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_pull_drive_from_environment(*, targets, local_base): + captured_kwargs["targets"] = targets + captured_kwargs["local_base"] = local_base + return DrivePullResult( + items=[DrivePullResult.Item(key="files/a.txt", local_path=str(Path(local_base) / "files" / "a.txt"))] + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.pull_drive_from_environment", + fake_pull_drive_from_environment, + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "pull", "--to", "/tmp/drive"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert captured_kwargs == {"targets": None, "local_base": "/tmp/drive"} + assert captured.out.strip() == "/tmp/drive/files/a.txt" + + def test_cli_drive_push_prints_commit_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setattr( "dify_agent.agent_stub.cli.main.push_drive_from_environment", - lambda *, local_path, drive_path, recursive: AgentStubDriveCommitResponse( + lambda *, local_path, drive_path, kind: AgentStubDriveCommitResponse( items=[ AgentStubDriveItem( key=drive_path, @@ -361,7 +512,7 @@ def test_cli_drive_push_prints_commit_json( mime_type="text/markdown", file_kind="tool_file", file_id=Path(local_path).name, - value_owned_by_drive=recursive is False, + value_owned_by_drive=kind != "dir", ) ] ), @@ -373,3 +524,87 @@ def test_cli_drive_push_prints_commit_json( captured = capsys.readouterr() assert exc_info.value.code == 0 assert json.loads(captured.out)["items"][0]["key"] == "skills/example/SKILL.md" + + +def test_cli_drive_push_forwards_kind( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_push_drive_from_environment(*, local_path, drive_path, kind): + captured_kwargs["local_path"] = local_path + captured_kwargs["drive_path"] = drive_path + captured_kwargs["kind"] = kind + return AgentStubDriveCommitResponse(items=[]) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.push_drive_from_environment", + fake_push_drive_from_environment, + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "push", "/tmp/skill", "skills/example", "--kind", "skill"]) + + capsys.readouterr() + assert exc_info.value.code == 0 + assert captured_kwargs == { + "local_path": "/tmp/skill", + "drive_path": "skills/example", + "kind": "skill", + } + + +def test_cli_drive_push_accepts_json_flag( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_push_drive_from_environment(*, local_path, drive_path, kind): + captured_kwargs["local_path"] = local_path + captured_kwargs["drive_path"] = drive_path + captured_kwargs["kind"] = kind + return AgentStubDriveCommitResponse(items=[]) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.push_drive_from_environment", + fake_push_drive_from_environment, + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "push", "/tmp/report.md", "files/report.md", "--json"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out) == {"items": []} + assert captured_kwargs == { + "local_path": "/tmp/report.md", + "drive_path": "files/report.md", + "kind": None, + } + + +def test_cli_drive_push_rejects_recursive_option( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + called = False + + def fake_push_drive_from_environment(*, local_path, drive_path, kind): + nonlocal called + called = True + return AgentStubDriveCommitResponse(items=[]) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.push_drive_from_environment", + fake_push_drive_from_environment, + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "push", "/tmp/dir", "files/dir", "--recursive"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert called is False + assert "--recursive" in captured.err diff --git a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py index dcca5144607..7722ff940bd 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py @@ -196,6 +196,7 @@ def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None: "items": [ { "key": "skills/example/SKILL.md", + "name": "SKILL.md", "size": 12, "hash": "sha256:abc", "mime_type": "text/markdown", @@ -220,6 +221,7 @@ def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None: http_client.close() assert response.items[0].key == "skills/example/SKILL.md" + assert response.items[0].model_extra == {"name": "SKILL.md"} def test_request_agent_stub_drive_commit_sync_posts_commit_request() -> None: diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py index f4bc75291fc..0c16cf8bccf 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py @@ -11,6 +11,7 @@ from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubDriveCommitItem, AgentStubDriveCommitRequest, AgentStubDriveFileRef, + AgentStubDriveManifestResponse, AgentStubFileMapping, agent_stub_connections_url, agent_stub_drive_base_for_ref, @@ -159,6 +160,7 @@ def test_agent_stub_drive_commit_request_validates_file_refs() -> None: ] ) + assert request.items[0].file_ref is not None assert request.items[0].file_ref.kind == "tool_file" with pytest.raises(ValidationError, match="tool_file"): @@ -168,6 +170,15 @@ def test_agent_stub_drive_commit_request_validates_file_refs() -> None: assert item_without_file_ref.file_ref is None +def test_agent_stub_drive_manifest_response_preserves_extra_item_fields() -> None: + response = AgentStubDriveManifestResponse.model_validate( + {"items": [{"key": "skills/example/SKILL.md", "name": "SKILL.md"}]} + ) + + assert response.items[0].model_extra == {"name": "SKILL.md"} + assert response.items[0].model_dump(mode="json")["name"] == "SKILL.md" + + @pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"]) def test_agent_stub_file_mapping_rejects_non_remote_with_url( transfer_method: Literal["tool_file", "local_file", "datasource_file"], diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py index 7de8faa8d58..11e56f62d67 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 @@ -57,6 +57,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifes "items": [ { "key": "skills/example/SKILL.md", + "name": "SKILL.md", "size": 12, "hash": "sha256:abc", "mime_type": "text/markdown", @@ -82,6 +83,7 @@ def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifes include_download_url=True, ) assert response.items[0].download_url == "https://files.example.com/download" + assert response.items[0].model_extra == {"name": "SKILL.md"} asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py index 96d781caa72..05ddad3543c 100644 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py @@ -48,10 +48,8 @@ def test_layer_config_rejects_unknown_fields() -> None: def test_drive_layer_is_registered_and_constructible_from_config() -> None: - layer = DifyDriveLayer.from_config_with_settings( + layer = DifyDriveLayer.from_config( DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]), - inner_api_url="https://api.example.com", - inner_api_key="secret", ) assert isinstance(layer, DifyDriveLayer) 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 c95526f046d..f9abc8e718b 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,27 +2,30 @@ from __future__ import annotations -from pathlib import Path +from typing import cast + import pytest -from agenton.layers import EmptyRuntimeState, LayerConfig, NoLayerDeps, PlainLayer from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig -from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _DriveManifestItem +from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError +from dify_agent.layers.shell import DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult, ShellctlClientFactory -class _FakeExecutionContextConfig(LayerConfig): - tenant_id: str +def _unused_client_factory(_entrypoint: str): + raise AssertionError("shellctl client should not be used by these drive-layer tests") -class _FakeExecutionContextLayer(PlainLayer[NoLayerDeps, _FakeExecutionContextConfig, EmptyRuntimeState]): - type_id = None - - def __init__(self, tenant_id: str) -> None: - self.config = _FakeExecutionContextConfig(tenant_id=tenant_id) +def _shell_layer() -> DifyShellLayer: + return DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=cast(ShellctlClientFactory, _unused_client_factory), + ) -def _build_layer(tmp_path: Path) -> DifyDriveLayer: - layer = DifyDriveLayer.from_config_with_settings( +def _build_layer() -> DifyDriveLayer: + layer = DifyDriveLayer.from_config( DifyDriveLayerConfig( drive_ref="agent-1", skills=[ @@ -43,158 +46,210 @@ def _build_layer(tmp_path: Path) -> DifyDriveLayer: ], mentioned_skill_keys=["tender-analyzer/SKILL.md"], mentioned_file_keys=["files/report.pdf"], - ), - inner_api_url="https://api.example.com", - inner_api_key="secret", + ) ) - layer.bind_deps({"execution_context": _FakeExecutionContextLayer("tenant-1")}) + layer.bind_deps({"shell": _shell_layer()}) return layer +def _remote_result( + output: str, + *, + exit_code: int | None = 0, + truncated: bool = False, +) -> RemoteCommandResult: + return RemoteCommandResult( + job_id="remote-drive-pull", + status="exited", + done=True, + exit_code=exit_code, + output=output, + offset=len(output), + truncated=truncated, + output_path="/tmp/output.log", + ) + + +def _pulled_output() -> str: + return ( + "/mnt/drive/agent-1/tender-analyzer\n" + "/mnt/drive/agent-1/files/report.pdf\n" + "__DIFY_DRIVE_MENTIONED_PATH__\ttender-analyzer/SKILL.md\t/mnt/drive/agent-1/tender-analyzer/SKILL.md\n" + "__DIFY_DRIVE_SKILL_BEGIN__\ttender-analyzer/SKILL.md\n" + "# Tender Analyzer\n" + "Use carefully.\n" + "__DIFY_DRIVE_SKILL_END__\ttender-analyzer/SKILL.md\n" + "__DIFY_DRIVE_MENTIONED_PATH__\tfiles/report.pdf\t/mnt/drive/agent-1/files/report.pdf\n" + ) + + +def 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 + + @pytest.mark.anyio -async def test_on_context_create_loads_mentioned_targets_into_prompt( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path +async def test_on_context_create_pulls_mentioned_targets_through_shell( + monkeypatch: pytest.MonkeyPatch, ) -> None: - layer = _build_layer(tmp_path) + layer = _build_layer() + captured: dict[str, object] = {} - async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]: - assert tenant_id == "tenant-1" - assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)] - return [ - _DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"), - _DriveManifestItem(key="files/report.pdf", download_url="https://files/report"), - ] + async def fake_run_remote_script( + self: DifyShellLayer, + script: str, + *, + timeout: float = 10.0, + inject_agent_stub_env: bool = False, + ) -> RemoteCommandResult: + del self, timeout + captured["script"] = script + captured["inject_agent_stub_env"] = inject_agent_stub_env + return _remote_result(_pulled_output()) - async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]: - assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"} - skill_path = tmp_path / "tender-analyzer" / "SKILL.md" - skill_path.parent.mkdir(parents=True, exist_ok=True) - skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8") - file_path = tmp_path / "files" / "report.pdf" - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_bytes(b"pdf") - return { - "tender-analyzer/SKILL.md": str(skill_path), - "files/report.pdf": str(file_path), - } - - monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items) - monkeypatch.setattr(layer, "_download_items", _download_items) + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) await layer.on_context_create() + 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 f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt - assert "Other available skills" in prompt - assert "other-skill: Other Skill — Fallback catalog entry." 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_loads_mentioned_targets_into_prompt( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path +async def test_on_context_resume_repulls_mentioned_targets_through_shell( + monkeypatch: pytest.MonkeyPatch, ) -> None: - layer = _build_layer(tmp_path) + layer = _build_layer() + calls = 0 - async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]: - assert tenant_id == "tenant-1" - assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)] - return [ - _DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"), - _DriveManifestItem(key="files/report.pdf", download_url="https://files/report"), - ] + 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 + nonlocal calls + calls += 1 + assert inject_agent_stub_env is True + return _remote_result(_pulled_output()) - async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]: - assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"} - skill_path = tmp_path / "tender-analyzer" / "SKILL.md" - skill_path.parent.mkdir(parents=True, exist_ok=True) - skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8") - file_path = tmp_path / "files" / "report.pdf" - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_bytes(b"pdf") - return { - "tender-analyzer/SKILL.md": str(skill_path), - "files/report.pdf": str(file_path), - } - - monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items) - monkeypatch.setattr(layer, "_download_items", _download_items) + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) await layer.on_context_resume() - prompt = layer.build_prompt_context() - assert "Loaded mentioned skills" in prompt - assert "# Tender Analyzer\nUse carefully." in prompt - assert f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt - assert "Other available skills" in prompt - assert "other-skill: Other Skill — Fallback catalog entry." in prompt + 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, - tmp_path: Path, ) -> None: - layer = _build_layer(tmp_path) + layer = _build_layer() - async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]: - del tenant_id, targets - return [_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md")] + 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) - async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]: - del items - skill_path = tmp_path / "tender-analyzer" / "SKILL.md" - skill_path.parent.mkdir(parents=True, exist_ok=True) - skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8") - return {"tender-analyzer/SKILL.md": str(skill_path)} - - monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items) - monkeypatch.setattr(layer, "_download_items", _download_items) + 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_resume_raises_when_mentioned_targets_are_missing( +async def test_on_context_create_raises_when_shell_pull_fails( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - layer = _build_layer(tmp_path) + layer = _build_layer() - async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]: - del tenant_id, targets - return [] + 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 + return _remote_result("permission denied\n", exit_code=1) - async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]: - assert items == [] - return {} + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) - monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items) - monkeypatch.setattr(layer, "_download_items", _download_items) - - with pytest.raises(DifyDriveLayerError, match="missing pulled file"): - await layer.on_context_resume() + with pytest.raises(DifyDriveLayerError, match="drive mentioned pull failed in shell"): + await layer.on_context_create() @pytest.mark.anyio -async def test_on_context_create_raises_when_manifest_is_empty_for_mentioned_targets( +async def test_on_context_create_raises_when_shell_output_is_truncated( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - layer = _build_layer(tmp_path) + layer = _build_layer() - async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]: - del tenant_id, targets - return [] + 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 + return _remote_result(_pulled_output(), truncated=True) - async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]: - assert items == [] - return {} + monkeypatch.setattr(DifyShellLayer, "run_remote_script", fake_run_remote_script) - monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items) - monkeypatch.setattr(layer, "_download_items", _download_items) - - with pytest.raises(DifyDriveLayerError, match="missing pulled file"): + with pytest.raises(DifyDriveLayerError, match="output was truncated"): await layer.on_context_create() + + +def test_parse_shell_pull_output_rejects_unclosed_skill_marker() -> None: + layer = _build_layer() + + 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") diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py index f28939e329b..dbe8fddcbec 100644 --- a/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py @@ -6,46 +6,142 @@ from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig def _valid_config() -> dict[str, object]: return { - "dataset_ids": ["dataset-1"], - "retrieval": { - "mode": "multiple", - "top_k": 4, - }, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": { + "mode": "multiple", + "top_k": 4, + }, + } + ], } def test_knowledge_base_config_accepts_valid_multiple_mode() -> None: config = DifyKnowledgeBaseLayerConfig.model_validate(_valid_config()) - assert config.dataset_ids == ["dataset-1"] - assert config.retrieval.top_k == 4 - assert config.metadata_filtering.mode == "disabled" + assert config.sets[0].dataset_ids == ["dataset-1"] + assert config.sets[0].retrieval.top_k == 4 + assert config.sets[0].metadata_filtering.mode == "disabled" @pytest.mark.parametrize( "payload, expected_message", [ - ({"dataset_ids": [], "retrieval": {"mode": "multiple", "top_k": 4}}, "dataset_ids"), + ({"sets": []}, "sets"), ({"tool_name": "knowledge_base_search", **_valid_config()}, "Extra inputs are not permitted"), ({"tool_description": "Search knowledge", **_valid_config()}, "Extra inputs are not permitted"), - ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "multiple"}}, "top_k"), - ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "single"}}, "retrieval.model"), ( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 4}, - "metadata_filtering": {"mode": "automatic"}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": ""}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, + "dataset id", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "user_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ] + }, + "query.value", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple"}, + } + ] + }, + "top_k", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "single"}, + } + ] + }, + "retrieval.model", + ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "automatic"}, + } + ], }, "metadata_filtering.model_config", ), ( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 4}, - "metadata_filtering": {"mode": "manual"}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "manual"}, + } + ], }, "metadata_filtering.conditions", ), + ( + { + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + { + "id": "docs", + "name": "support kb", + "datasets": [{"id": "dataset-2"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + }, + ] + }, + "names must be unique", + ), ], ) def test_knowledge_base_config_rejects_invalid_inputs(payload: dict[str, object], expected_message: str) -> None: @@ -57,8 +153,7 @@ def test_knowledge_base_config_rejects_observation_limit_smaller_than_result_lim with pytest.raises(ValidationError, match="max_observation_chars"): _ = DifyKnowledgeBaseLayerConfig.model_validate( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 4}, + **_valid_config(), "max_result_content_chars": 50, "max_observation_chars": 20, } diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py index 28fadcb903b..ed6c798b409 100644 --- a/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py @@ -8,7 +8,11 @@ from pydantic_ai import Tool from agenton.compositor import Compositor, LayerNode, LayerProvider from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer -from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError +from dify_agent.layers.knowledge.client import ( + DifyKnowledgeBaseClient, + DifyKnowledgeBaseClientError, + DifyKnowledgeRetrieveResponse, +) from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig from dify_agent.layers.knowledge.layer import ( BLANK_QUERY_OBSERVATION, @@ -32,10 +36,23 @@ def _execution_context_config(**overrides: object) -> DifyExecutionContextLayerC def _knowledge_config(**overrides: object) -> DifyKnowledgeBaseLayerConfig: - payload: dict[str, object] = { - "dataset_ids": ["dataset-1"], + set_payload: dict[str, object] = { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, "retrieval": {"mode": "multiple", "top_k": 4}, } + for key in ("id", "name", "description", "datasets", "query", "retrieval", "metadata_filtering"): + if key in overrides: + set_payload[key] = overrides.pop(key) + if "dataset_ids" in overrides: + dataset_ids = overrides.pop("dataset_ids") + assert isinstance(dataset_ids, list) + set_payload["datasets"] = [{"id": dataset_id} for dataset_id in dataset_ids] + payload: dict[str, object] = { + "sets": [set_payload], + } payload.update(overrides) return DifyKnowledgeBaseLayerConfig.model_validate(payload) @@ -62,7 +79,7 @@ def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]: ) -def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None: +def test_knowledge_layer_exposes_one_set_scoped_tool_definition() -> None: async def scenario() -> None: compositor = Compositor( [ @@ -82,20 +99,23 @@ def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None: tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType] assert isinstance(tool, Tool) assert tool.name == "knowledge_base_search" - assert tool.description == "Search configured knowledge bases for information relevant to the query." + assert "Pick one configured set_name" in tool.description assert tool_def is not None - assert ( - tool_def.description == "Search configured knowledge bases for information relevant to the query." - ) + assert "Pick one configured set_name" in tool_def.description assert tool_def.parameters_json_schema == { "type": "object", "properties": { + "set_name": { + "type": "string", + "enum": ["Support KB"], + "description": "Knowledge set to search.", + }, "query": { "type": "string", - "description": "Search query for the configured knowledge bases.", - } + "description": "Search query for the selected knowledge set.", + }, }, - "required": ["query"], + "required": ["set_name", "query"], "additionalProperties": False, } @@ -119,12 +139,105 @@ def test_knowledge_layer_rejects_blank_query_locally() -> None: ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": " "}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": " "}, None + ) assert result == BLANK_QUERY_OBSERVATION asyncio.run(scenario()) +def test_knowledge_layer_exposes_no_tool_when_all_sets_are_user_query(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_retrieve(self: DifyKnowledgeBaseClient, **_kwargs: object) -> DifyKnowledgeRetrieveResponse: + del self + return DifyKnowledgeRetrieveResponse.model_validate({"results": [], "usage": {}}) + + monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + assert await knowledge_layer.get_tools(http_client=http_client) == [] + + asyncio.run(scenario()) + + +def test_knowledge_layer_fetches_user_query_sets_on_context_entry(monkeypatch: pytest.MonkeyPatch) -> None: + seen_requests: list[dict[str, object]] = [] + + async def fake_retrieve(self: DifyKnowledgeBaseClient, **kwargs: object) -> DifyKnowledgeRetrieveResponse: + del self + seen_requests.append(kwargs) + return DifyKnowledgeRetrieveResponse.model_validate( + { + "results": [ + { + "metadata": { + "_source": "knowledge", + "dataset_name": "Docs", + "document_name": "Release.md", + "score": 0.8, + }, + "title": "Release", + "files": [], + "content": "Version notes", + "summary": None, + } + ], + "usage": {}, + } + ) + + monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + assert len(seen_requests) == 1 + assert seen_requests[0]["query"] == "release notes" + assert seen_requests[0]["dataset_ids"] == ["dataset-1"] + assert knowledge_layer.runtime_state.eager_config_fingerprint + assert knowledge_layer.runtime_state.eager_results[0].status == "success" + assert knowledge_layer.user_prompts == [ + "Knowledge retrieval results:\n\n" + "Set: Support KB\n" + "Query: release notes\n" + "Results:\n" + "1. Title: Release\n" + " Dataset: Docs\n" + " Document: Release.md\n" + " Score: 0.8\n" + " Content: Version notes" + ] + await knowledge_layer.on_context_resume() + assert len(seen_requests) == 1 + + asyncio.run(scenario()) + + @pytest.mark.parametrize( ("field_name", "field_value"), [ @@ -199,7 +312,9 @@ def test_knowledge_layer_formats_results_and_truncates_observation() -> None: ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert result.startswith("Knowledge base search results:\n1. Title: Guide") assert "Dataset: Docs" in result assert "Document: Guide.md" in result @@ -229,7 +344,9 @@ def test_knowledge_layer_returns_no_results_observation() -> None: ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert result == NO_RESULTS_OBSERVATION asyncio.run(scenario()) @@ -256,7 +373,9 @@ def test_knowledge_layer_converts_retryable_failures_into_observation() -> None: ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert result == TEMPORARY_UNAVAILABLE_OBSERVATION asyncio.run(scenario()) @@ -289,7 +408,9 @@ def test_knowledge_layer_converts_retryable_transport_failures_into_observation( ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert result == TEMPORARY_UNAVAILABLE_OBSERVATION asyncio.run(scenario()) @@ -317,7 +438,9 @@ def test_knowledge_layer_raises_non_retryable_client_errors() -> None: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: - await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert exc_info.value.status_code == 403 asyncio.run(scenario()) @@ -343,7 +466,9 @@ def test_knowledge_layer_raises_for_malformed_success_responses() -> None: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: - await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert exc_info.value.error_code == "invalid_response" assert exc_info.value.retryable is False @@ -411,7 +536,9 @@ def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api( ) as run: knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) tool = (await knowledge_layer.get_tools(http_client=http_client))[0] - result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( # pyright: ignore[reportArgumentType] + {"set_name": "Support KB", "query": "reset"}, None + ) assert result == NO_RESULTS_OBSERVATION asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py index c15b15ee284..ec429f94339 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -29,6 +29,7 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None: config = DifyShellLayerConfig() assert config.model_dump() == { + "agent_stub_drive_ref": None, "cli_tools": [], "env": [], "secret_refs": [], @@ -51,6 +52,7 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: ], env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")], secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")], + agent_stub_drive_ref="agent-1", sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), ) @@ -59,6 +61,7 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: assert config.cli_tools[0].secret_refs[0].ref == "credential-2" assert config.env[0].name == "PROJECT_NAME" assert config.secret_refs[0].ref == "credential-1" + assert config.agent_stub_drive_ref == "agent-1" assert config.sandbox is not None assert config.sandbox.config == {"cpu": 2} 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 c7d2599b63c..c3a5c2b941e 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 @@ -14,8 +14,6 @@ from dify_agent.agent_stub.server.shell_agent_stub_env import ( AGENT_STUB_DRIVE_BASE_ENV_VAR, AGENT_STUB_API_BASE_URL_ENV_VAR, ) -from dify_agent.layers.drive import DifyDriveLayerConfig -from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell import ( @@ -238,14 +236,6 @@ def _execution_context_layer() -> DifyExecutionContextLayer: ) -def _drive_layer() -> DifyDriveLayer: - return DifyDriveLayer.from_config_with_settings( - DifyDriveLayerConfig(drive_ref="agent-1"), - inner_api_url="https://api.example.com", - inner_api_key="secret", - ) - - def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]: return LayerProvider.from_factory( layer_type=DifyShellLayer, @@ -666,7 +656,7 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> client = FakeShellctlClient(run_handler=run_handler) layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(), + DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), shellctl_entrypoint="http://shellctl", shellctl_client_factory=lambda _entrypoint: client, agent_stub_api_base_url="https://agent.example.com/agent-stub", @@ -674,7 +664,7 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> f"token-for:{execution_context.tenant_id}:{session_id}" ), ) - layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer()) + layer.deps = layer.deps_type(execution_context=_execution_context_layer()) tools = {tool.name: tool for tool in layer.tools} async def scenario() -> None: @@ -793,7 +783,7 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() client = FakeShellctlClient(run_handler=run_handler) layer = DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(), + DifyShellLayerConfig(agent_stub_drive_ref="agent-1"), shellctl_entrypoint="http://shellctl", shellctl_client_factory=lambda _entrypoint: client, agent_stub_api_base_url="https://agent.example.com/agent-stub", @@ -801,7 +791,7 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() f"token-for:{execution_context.tenant_id}:{session_id}" ), ) - layer.deps = layer.deps_type(drive=_drive_layer(), execution_context=_execution_context_layer()) + layer.deps = layer.deps_type(execution_context=_execution_context_layer()) async def scenario() -> None: async with layer.resource_context(): 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 f5ddeb72367..4a64fe9090d 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -995,7 +995,7 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] async def fake_get_tools(self: DifyKnowledgeBaseLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: - assert self.config.dataset_ids == ["dataset-1"] + assert self.config.sets[0].dataset_ids == ["dataset-1"] assert http_client.headers.get("X-Test-Client") == "dify-api" return [Tool(knowledge_tool, name="knowledge_base_search")] @@ -1055,8 +1055,15 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest deps={"execution_context": "execution_context"}, config=DifyKnowledgeBaseLayerConfig.model_validate( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 4}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ], } ), ), 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 8e40bd683b9..ea0bc3b2977 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -231,8 +231,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt knowledge_layer = knowledge_provider.create_layer( DifyKnowledgeBaseLayerConfig.model_validate( { - "dataset_ids": ["dataset-1"], - "retrieval": {"mode": "multiple", "top_k": 2}, + "sets": [ + { + "id": "support", + "name": "Support KB", + "datasets": [{"id": "dataset-1"}], + "query": {"mode": "generated_query"}, + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ], } ) ) diff --git a/dify-agent/tests/local/dify_agent/server/test_observability.py b/dify-agent/tests/local/dify_agent/server/test_observability.py new file mode 100644 index 00000000000..534298415ba --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_observability.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import ClassVar + +from fastapi import FastAPI + +import dify_agent.server.observability as observability +from dify_agent.server.observability import configure_server_observability + + +class FakeLogfireModule: + configure_calls: ClassVar[list[dict[str, object]]] = [] + fastapi_calls: ClassVar[list[dict[str, object]]] = [] + httpx_calls: ClassVar[list[dict[str, object]]] = [] + redis_calls: ClassVar[list[dict[str, object]]] = [] + pydantic_ai_calls: ClassVar[list[dict[str, object]]] = [] + + @classmethod + def reset(cls) -> None: + cls.configure_calls.clear() + cls.fastapi_calls.clear() + cls.httpx_calls.clear() + cls.redis_calls.clear() + cls.pydantic_ai_calls.clear() + + @classmethod + def configure(cls, **kwargs: object) -> None: + cls.configure_calls.append(kwargs) + + @classmethod + def instrument_fastapi(cls, app: FastAPI, **kwargs: object) -> None: + cls.fastapi_calls.append({"app": app, **kwargs}) + + @classmethod + def instrument_httpx(cls, **kwargs: object) -> None: + cls.httpx_calls.append(kwargs) + + @classmethod + def instrument_redis(cls, **kwargs: object) -> None: + cls.redis_calls.append(kwargs) + + @classmethod + def instrument_pydantic_ai(cls, **kwargs: object) -> None: + cls.pydantic_ai_calls.append(kwargs) + + +def test_configure_server_observability_keeps_remote_export_token_gated_by_logfire_env(monkeypatch) -> None: + FakeLogfireModule.reset() + monkeypatch.setattr(observability, "logfire", FakeLogfireModule) + monkeypatch.setattr(observability, "_global_instrumentation_ready", False) + app = FastAPI() + + configure_server_observability(app) + + assert FakeLogfireModule.configure_calls == [ + { + "send_to_logfire": "if-token-present", + "inspect_arguments": False, + } + ] + + +def test_configure_server_observability_instruments_server_boundaries_once(monkeypatch) -> None: + FakeLogfireModule.reset() + monkeypatch.setattr(observability, "logfire", FakeLogfireModule) + monkeypatch.setattr(observability, "_global_instrumentation_ready", False) + first_app = FastAPI() + second_app = FastAPI() + + configure_server_observability(first_app) + configure_server_observability(second_app) + + assert FakeLogfireModule.httpx_calls == [{}] + assert FakeLogfireModule.redis_calls == [{}] + assert FakeLogfireModule.pydantic_ai_calls == [{}] + assert FakeLogfireModule.fastapi_calls == [ + {"app": first_app}, + {"app": second_app}, + ] + assert first_app.state.dify_agent_logfire_instrumented is True + assert second_app.state.dify_agent_logfire_instrumented is True diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 104f12031f0..c24941fae7f 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -115,7 +115,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", - "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig']", + "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", ], diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index b2e6b24dc38..84ee53ba2dd 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -19,6 +19,7 @@ SERVER_RUNTIME_DEPENDENCIES = { "graphon==0.5.2", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", + "logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0", "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", diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index bc20927b44e..d9a151cb209 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -78,6 +78,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -601,6 +610,7 @@ server = [ { name = "graphon" }, { name = "jsonschema" }, { name = "jwcrypto" }, + { name = "logfire", extra = ["fastapi", "httpx", "redis"] }, { name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] }, { name = "pydantic-settings" }, { name = "redis" }, @@ -633,6 +643,7 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, { name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" }, + { 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" }, @@ -699,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "fastapi" version = "0.136.0" @@ -806,6 +826,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "graphon" version = "0.5.2" @@ -1367,6 +1399,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, ] +[[package]] +name = "logfire" +version = "4.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/f2/34b8ebbd6bbd82c71055d6b881b24d8ada79a0e6692d3dd8cca5e86fadb3/logfire-4.37.0.tar.gz", hash = "sha256:7ee0cb64b59c356a41a1701fb84597037f8db1fa15df7a3715ef363e5a1de06a", size = 1212176, upload-time = "2026-06-12T20:47:06.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/08/1805d2f26955671115aae555d78cc4c72a6fe733f332d44d69756bc1737b/logfire-4.37.0-py3-none-any.whl", hash = "sha256:a20823e6dbb3204614a3ea5e79c91df42405c5112393ec9d8e34ef45b60d315f", size = 378930, upload-time = "2026-06-12T20:47:03.674Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "opentelemetry-instrumentation-fastapi" }, +] +httpx = [ + { name = "opentelemetry-instrumentation-httpx" }, +] +redis = [ + { name = "opentelemetry-instrumentation-redis" }, +] + [[package]] name = "logfire-api" version = "4.32.1" @@ -2007,6 +2068,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/db/851fa88db7441da82d50bd80f2de5ee55213782e25dc858e04d0c9961d60/opentelemetry_instrumentation_asgi-0.60b1.tar.gz", hash = "sha256:16bfbe595cd24cda309a957456d0fc2523f41bc7b076d1f2d7e98a1ad9876d6f", size = 26107, upload-time = "2025-12-11T13:36:47.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/76/1fb94367cef64420d2171157a6b9509582873bd09a6afe08a78a8d1f59d9/opentelemetry_instrumentation_asgi-0.60b1-py3-none-any.whl", hash = "sha256:d48def2dbed10294c99cfcf41ebbd0c414d390a11773a41f472d20000fcddc25", size = 16933, upload-time = "2025-12-11T13:35:40.462Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/e7/e7e5e50218cf488377209d85666b182fa2d4928bf52389411ceeee1b2b60/opentelemetry_instrumentation_fastapi-0.60b1.tar.gz", hash = "sha256:de608955f7ff8eecf35d056578346a5365015fd7d8623df9b1f08d1c74769c01", size = 24958, upload-time = "2025-12-11T13:36:59.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/cc/6e808328ba54662e50babdcab21138eae4250bc0fddf67d55526a615a2ca/opentelemetry_instrumentation_fastapi-0.60b1-py3-none-any.whl", hash = "sha256:af94b7a239ad1085fc3a820ecf069f67f579d7faf4c085aaa7bd9b64eafc8eaf", size = 13478, upload-time = "2025-12-11T13:36:00.811Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-redis" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/1e/225364fab4db793f6f5024ed9f3dd53774fd7c7c21fa242460234dcdf8d9/opentelemetry_instrumentation_redis-0.60b1.tar.gz", hash = "sha256:ecafa8f81c88917b59f0d842fb3d157f3a8edc71fb4b85bebca3bc19432ce7b8", size = 14774, upload-time = "2025-12-11T13:37:11.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/bd/d55d3b34fd49df08d9d9fa3701dff0051b216e2c7e9adaaa4ff6aa1de8d7/opentelemetry_instrumentation_redis-0.60b1-py3-none-any.whl", hash = "sha256:33bef0ff9af6f2d88de90c1cd7e25675c10a16d4f9ee5ae7592b28bb08b78139", size = 15502, upload-time = "2025-12-11T13:36:21.481Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, +] + [[package]] name = "orjson" version = "3.11.8" diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e00a1c2a1a3..e844f2f03a4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -878,19 +878,6 @@ "count": 1 } }, - "web/app/components/app/overview/customize/index.tsx": { - "jsx-a11y/anchor-has-content": { - "count": 3 - } - }, - "web/app/components/app/overview/settings/index.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "no-restricted-imports": { "count": 1 @@ -1203,14 +1190,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat/chat-input-area/index.tsx": { - "jsx-a11y/no-autofocus": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/chat/chat/check-input-forms-hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -1241,14 +1220,6 @@ "count": 17 } }, - "web/app/components/base/chat/chat/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - }, - "ts/no-non-null-asserted-optional-chain": { - "count": 1 - } - }, "web/app/components/base/chat/chat/log/index.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4554,9 +4525,6 @@ "web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts": { "no-restricted-imports": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 } }, "web/app/components/workflow-app/hooks/use-workflow-init.ts": { @@ -6283,9 +6251,6 @@ "web/app/components/workflow/operator/add-block.tsx": { "no-restricted-imports": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 } }, "web/app/components/workflow/operator/hooks.ts": { diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index 5e4a692f244..f8f9ccaa785 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -6,6 +6,8 @@ import * as z from 'zod' import { zDeleteAgentByAgentIdApiKeysByApiKeyIdPath, zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse, + zDeleteAgentByAgentIdBuildDraftPath, + zDeleteAgentByAgentIdBuildDraftResponse, zDeleteAgentByAgentIdFilesPath, zDeleteAgentByAgentIdFilesQuery, zDeleteAgentByAgentIdFilesResponse, @@ -17,6 +19,8 @@ import { zGetAgentByAgentIdApiAccessResponse, zGetAgentByAgentIdApiKeysPath, zGetAgentByAgentIdApiKeysResponse, + zGetAgentByAgentIdBuildDraftPath, + zGetAgentByAgentIdBuildDraftResponse, zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath, zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse, zGetAgentByAgentIdChatMessagesPath, @@ -76,6 +80,11 @@ import { zPostAgentByAgentIdApiEnableResponse, zPostAgentByAgentIdApiKeysPath, zPostAgentByAgentIdApiKeysResponse, + zPostAgentByAgentIdBuildDraftApplyPath, + zPostAgentByAgentIdBuildDraftApplyResponse, + zPostAgentByAgentIdBuildDraftCheckoutBody, + zPostAgentByAgentIdBuildDraftCheckoutPath, + zPostAgentByAgentIdBuildDraftCheckoutResponse, zPostAgentByAgentIdChatMessagesByTaskIdStopPath, zPostAgentByAgentIdChatMessagesByTaskIdStopResponse, zPostAgentByAgentIdComposerValidateBody, @@ -95,6 +104,9 @@ import { zPostAgentByAgentIdFilesBody, zPostAgentByAgentIdFilesPath, zPostAgentByAgentIdFilesResponse, + zPostAgentByAgentIdPublishBody, + zPostAgentByAgentIdPublishPath, + zPostAgentByAgentIdPublishResponse, zPostAgentByAgentIdSandboxFilesUploadBody, zPostAgentByAgentIdSandboxFilesUploadPath, zPostAgentByAgentIdSandboxFilesUploadResponse, @@ -107,6 +119,9 @@ import { zPostAgentByAgentIdVersionsByVersionIdRestoreResponse, zPostAgentResponse, zPutAgentByAgentIdBody, + zPutAgentByAgentIdBuildDraftBody, + zPutAgentByAgentIdBuildDraftPath, + zPutAgentByAgentIdBuildDraftResponse, zPutAgentByAgentIdComposerBody, zPutAgentByAgentIdComposerPath, zPutAgentByAgentIdComposerResponse, @@ -206,10 +221,88 @@ export const apiKeys = { byApiKeyId, } +export const post3 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdBuildDraftApply', + path: '/agent/{agent_id}/build-draft/apply', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdBuildDraftApplyPath })) + .output(zPostAgentByAgentIdBuildDraftApplyResponse) + +export const apply = { + post: post3, +} + +export const post4 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdBuildDraftCheckout', + path: '/agent/{agent_id}/build-draft/checkout', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdBuildDraftCheckoutBody, + params: zPostAgentByAgentIdBuildDraftCheckoutPath, + }), + ) + .output(zPostAgentByAgentIdBuildDraftCheckoutResponse) + +export const checkout = { + post: post4, +} + +export const delete2 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdBuildDraft', + path: '/agent/{agent_id}/build-draft', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdBuildDraftPath })) + .output(zDeleteAgentByAgentIdBuildDraftResponse) + +export const get4 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdBuildDraft', + path: '/agent/{agent_id}/build-draft', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdBuildDraftPath })) + .output(zGetAgentByAgentIdBuildDraftResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAgentByAgentIdBuildDraft', + path: '/agent/{agent_id}/build-draft', + tags: ['console'], + }) + .input( + z.object({ body: zPutAgentByAgentIdBuildDraftBody, params: zPutAgentByAgentIdBuildDraftPath }), + ) + .output(zPutAgentByAgentIdBuildDraftResponse) + +export const buildDraft = { + delete: delete2, + get: get4, + put, + apply, + checkout, +} + /** * Get suggested questions for an Agent App message */ -export const get4 = oc +export const get5 = oc .route({ description: 'Get suggested questions for an Agent App message', inputStructure: 'detailed', @@ -222,7 +315,7 @@ export const get4 = oc .output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get4, + get: get5, } export const byMessageId = { @@ -232,7 +325,7 @@ export const byMessageId = { /** * Stop a running Agent App chat message generation */ -export const post3 = oc +export const post5 = oc .route({ description: 'Stop a running Agent App chat message generation', inputStructure: 'detailed', @@ -245,7 +338,7 @@ export const post3 = oc .output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post3, + post: post5, } export const byTaskId = { @@ -255,7 +348,7 @@ export const byTaskId = { /** * Get Agent App chat messages for a conversation with pagination */ -export const get5 = oc +export const get6 = oc .route({ description: 'Get Agent App chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -273,12 +366,12 @@ export const get5 = oc .output(zGetAgentByAgentIdChatMessagesResponse) export const chatMessages = { - get: get5, + get: get6, byMessageId, byTaskId, } -export const get6 = oc +export const get7 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -290,10 +383,10 @@ export const get6 = oc .output(zGetAgentByAgentIdComposerCandidatesResponse) export const candidates = { - get: get6, + get: get7, } -export const post4 = oc +export const post6 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -310,10 +403,10 @@ export const post4 = oc .output(zPostAgentByAgentIdComposerValidateResponse) export const validate = { - post: post4, + post: post6, } -export const get7 = oc +export const get8 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -324,7 +417,7 @@ export const get7 = oc .input(z.object({ params: zGetAgentByAgentIdComposerPath })) .output(zGetAgentByAgentIdComposerResponse) -export const put = oc +export const put2 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -336,13 +429,13 @@ export const put = oc .output(zPutAgentByAgentIdComposerResponse) export const composer = { - get: get7, - put, + get: get8, + put: put2, candidates, validate, } -export const post5 = oc +export const post7 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -355,10 +448,10 @@ export const post5 = oc .output(zPostAgentByAgentIdCopyResponse) export const copy = { - post: post5, + post: post7, } -export const post6 = oc +export const post8 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -370,7 +463,7 @@ export const post6 = oc .output(zPostAgentByAgentIdDebugConversationRefreshResponse) export const refresh = { - post: post6, + post: post8, } export const debugConversation = { @@ -380,7 +473,7 @@ export const debugConversation = { /** * Time-limited external signed URL for one Agent App drive value */ -export const get8 = oc +export const get9 = oc .route({ description: 'Time-limited external signed URL for one Agent App drive value', inputStructure: 'detailed', @@ -398,13 +491,13 @@ export const get8 = oc .output(zGetAgentByAgentIdDriveFilesDownloadResponse) export const download = { - get: get8, + get: get9, } /** * Truncated text preview of one Agent App drive value */ -export const get9 = oc +export const get10 = oc .route({ description: 'Truncated text preview of one Agent App drive value', inputStructure: 'detailed', @@ -422,13 +515,13 @@ export const get9 = oc .output(zGetAgentByAgentIdDriveFilesPreviewResponse) export const preview = { - get: get9, + get: get10, } /** * List agent drive entries for an Agent App */ -export const get10 = oc +export const get11 = oc .route({ description: 'List agent drive entries for an Agent App', inputStructure: 'detailed', @@ -446,7 +539,7 @@ export const get10 = oc .output(zGetAgentByAgentIdDriveFilesResponse) export const files = { - get: get10, + get: get11, download, preview, } @@ -454,7 +547,7 @@ export const files = { /** * Inspect one drive-backed skill for slash-menu hover/detail UI */ -export const get11 = oc +export const get12 = oc .route({ description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', inputStructure: 'detailed', @@ -467,7 +560,7 @@ export const get11 = oc .output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse) export const inspect = { - get: get11, + get: get12, } export const bySkillPath = { @@ -477,7 +570,7 @@ export const bySkillPath = { /** * List drive-backed skills for an Agent App */ -export const get12 = oc +export const get13 = oc .route({ description: 'List drive-backed skills for an Agent App', inputStructure: 'detailed', @@ -490,7 +583,7 @@ export const get12 = oc .output(zGetAgentByAgentIdDriveSkillsResponse) export const skills = { - get: get12, + get: get13, bySkillPath, } @@ -502,7 +595,7 @@ export const drive = { /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ -export const post7 = oc +export const post9 = oc .route({ description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', inputStructure: 'detailed', @@ -517,13 +610,13 @@ export const post7 = oc .output(zPostAgentByAgentIdFeaturesResponse) export const features = { - post: post7, + post: post9, } /** * Create or update Agent App message feedback */ -export const post8 = oc +export const post10 = oc .route({ description: 'Create or update Agent App message feedback', inputStructure: 'detailed', @@ -538,13 +631,13 @@ export const post8 = oc .output(zPostAgentByAgentIdFeedbacksResponse) export const feedbacks = { - post: post8, + post: post10, } /** * Delete one Agent App drive file by key */ -export const delete2 = oc +export const delete3 = oc .route({ description: 'Delete one Agent App drive file by key', inputStructure: 'detailed', @@ -561,7 +654,7 @@ export const delete2 = oc /** * Commit an uploaded file into the Agent App drive under files/ */ -export const post9 = oc +export const post11 = oc .route({ description: 'Commit an uploaded file into the Agent App drive under files/', inputStructure: 'detailed', @@ -575,11 +668,11 @@ export const post9 = oc .output(zPostAgentByAgentIdFilesResponse) export const files2 = { - delete: delete2, - post: post9, + delete: delete3, + post: post11, } -export const get13 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -591,10 +684,10 @@ export const get13 = oc .output(zGetAgentByAgentIdLogSourcesResponse) export const logSources = { - get: get13, + get: get14, } -export const get14 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -611,14 +704,14 @@ export const get14 = oc .output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse) export const messages = { - get: get14, + get: get15, } export const byConversationId = { messages, } -export const get15 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -632,14 +725,14 @@ export const get15 = oc .output(zGetAgentByAgentIdLogsResponse) export const logs = { - get: get15, + get: get16, byConversationId, } /** * Get Agent App message details by ID */ -export const get16 = oc +export const get17 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -652,17 +745,32 @@ export const get16 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get16, + get: get17, } export const messages2 = { byMessageId: byMessageId2, } +export const post12 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdPublish', + path: '/agent/{agent_id}/publish', + tags: ['console'], + }) + .input(z.object({ body: zPostAgentByAgentIdPublishBody, params: zPostAgentByAgentIdPublishPath })) + .output(zPostAgentByAgentIdPublishResponse) + +export const publish = { + post: post12, +} + /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get17 = oc +export const get18 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -675,13 +783,13 @@ export const get17 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get17, + get: get18, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get18 = oc +export const get19 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -699,13 +807,13 @@ export const get18 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get18, + get: get19, } /** * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const post10 = oc +export const post13 = oc .route({ description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -723,13 +831,13 @@ export const post10 = oc .output(zPostAgentByAgentIdSandboxFilesUploadResponse) export const upload = { - post: post10, + post: post13, } /** * List a directory in an Agent App conversation sandbox */ -export const get19 = oc +export const get20 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -747,7 +855,7 @@ export const get19 = oc .output(zGetAgentByAgentIdSandboxFilesResponse) export const files3 = { - get: get19, + get: get20, read, upload, } @@ -759,7 +867,7 @@ export const sandbox = { /** * Upload + standardize a Skill into an Agent App drive */ -export const post11 = oc +export const post14 = oc .route({ description: 'Upload + standardize a Skill into an Agent App drive', inputStructure: 'detailed', @@ -778,13 +886,13 @@ export const post11 = oc .output(zPostAgentByAgentIdSkillsUploadResponse) export const upload2 = { - post: post11, + post: post14, } /** * Infer CLI tool + ENV suggestions from a standardized Agent App skill */ -export const post12 = oc +export const post15 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', inputStructure: 'detailed', @@ -797,13 +905,13 @@ export const post12 = oc .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) export const inferTools = { - post: post12, + post: post15, } /** * Delete a standardized skill from an Agent App drive */ -export const delete3 = oc +export const delete4 = oc .route({ description: 'Delete a standardized skill from an Agent App drive', inputStructure: 'detailed', @@ -816,7 +924,7 @@ export const delete3 = oc .output(zDeleteAgentByAgentIdSkillsBySlugResponse) export const bySlug = { - delete: delete3, + delete: delete4, inferTools, } @@ -825,7 +933,7 @@ export const skills2 = { bySlug, } -export const get20 = oc +export const get21 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -842,14 +950,14 @@ export const get20 = oc .output(zGetAgentByAgentIdStatisticsSummaryResponse) export const summary = { - get: get20, + get: get21, } export const statistics = { summary, } -export const post13 = oc +export const post16 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -861,10 +969,10 @@ export const post13 = oc .output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse) export const restore = { - post: post13, + post: post16, } -export const get21 = oc +export const get22 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -876,11 +984,11 @@ export const get21 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get21, + get: get22, restore, } -export const get22 = oc +export const get23 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -892,11 +1000,11 @@ export const get22 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get22, + get: get23, byVersionId, } -export const delete4 = oc +export const delete5 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -908,7 +1016,7 @@ export const delete4 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get23 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -919,7 +1027,7 @@ export const get23 = oc .input(z.object({ params: zGetAgentByAgentIdPath })) .output(zGetAgentByAgentIdResponse) -export const put2 = oc +export const put3 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -931,12 +1039,13 @@ export const put2 = oc .output(zPutAgentByAgentIdResponse) export const byAgentId = { - delete: delete4, - get: get23, - put: put2, + delete: delete5, + get: get24, + put: put3, apiAccess, apiEnable, apiKeys, + buildDraft, chatMessages, composer, copy, @@ -948,6 +1057,7 @@ export const byAgentId = { logSources, logs, messages: messages2, + publish, referencingWorkflows, sandbox, skills: skills2, @@ -955,7 +1065,7 @@ export const byAgentId = { versions, } -export const get24 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -966,7 +1076,7 @@ export const get24 = oc .input(z.object({ query: zGetAgentQuery.optional() })) .output(zGetAgentResponse) -export const post14 = oc +export const post17 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -979,8 +1089,8 @@ export const post14 = oc .output(zPostAgentResponse) export const agent = { - get: get24, - post: post14, + get: get25, + post: post17, 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 43119c4f1f4..975d2215f63 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -26,6 +26,7 @@ export type AgentAppDetailWithSite = { active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null + backing_app_id?: string | null bound_agent_id?: string | null created_at?: number | null created_by?: string | null @@ -34,6 +35,7 @@ export type AgentAppDetailWithSite = { description?: string | null enable_api: boolean enable_site: boolean + hidden_app_backed?: boolean icon?: string | null icon_background?: string | null icon_type?: string | null @@ -46,7 +48,7 @@ export type AgentAppDetailWithSite = { name: string permission_keys?: Array role?: string | null - site?: Site | null + site?: AppDetailSiteResponse | null tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -107,27 +109,18 @@ export type ApiKeyItem = { type: string } -export type MessageInfiniteScrollPaginationResponse = { - data: Array - has_more: boolean - limit: number -} - -export type SuggestedQuestionsResponse = { - data: Array -} - -export type SimpleResultResponse = { +export type AgentSimpleResultResponse = { result: string } -export type AgentAppComposerResponse = { - active_config_snapshot: AgentConfigSnapshotSummaryResponse - agent: AgentComposerAgentResponse - agent_soul: AgentSoulConfig - save_options: Array - validation?: ComposerValidationFindingsResponse | null - variant: 'agent_app' +export type AgentBuildDraftResponse = { + agent_soul: { + [key: string]: unknown + } + draft: { + [key: string]: unknown + } + variant: string } export type ComposerSavePayload = { @@ -148,6 +141,45 @@ export type ComposerSavePayload = { version_note?: string | null } +export type AgentBuildDraftApplyResponse = { + draft: { + [key: string]: unknown + } + result: string +} + +export type AgentBuildDraftCheckoutPayload = { + force?: boolean +} + +export type MessageInfiniteScrollPaginationResponse = { + data: Array + has_more: boolean + limit: number +} + +export type SuggestedQuestionsResponse = { + data: Array +} + +export type SimpleResultResponse = { + result: string +} + +export type AgentAppComposerResponse = { + active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null + agent: AgentComposerAgentResponse + agent_soul: AgentSoulConfig + app_id?: string | null + backing_app_id?: string | null + chat_endpoint?: string | null + draft?: AgentConfigDraftSummaryResponse | null + hidden_app_backed?: boolean + save_options: Array + validation?: ComposerValidationFindingsResponse | null + variant: 'agent_app' +} + export type AgentComposerCandidatesResponse = { allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse allowed_soul_candidates?: AgentComposerSoulCandidatesResponse @@ -294,6 +326,21 @@ export type MessageDetailResponse = { workflow_run_id?: string | null } +export type AgentPublishPayload = { + version_note?: string | null +} + +export type AgentPublishResponse = { + active_config_snapshot?: { + [key: string]: unknown + } | null + active_config_snapshot_id: string + draft?: { + [key: string]: unknown + } | null + result: string +} + export type AgentReferencingWorkflowsResponse = { data?: Array } @@ -359,6 +406,8 @@ export type AgentConfigSnapshotDetailResponse = { export type AgentConfigSnapshotRestoreResponse = { active_config_snapshot_id: string + draft_config_id?: string | null + restored_version_id?: string | null result: 'success' } @@ -367,6 +416,7 @@ export type AgentAppPartial = { active_config_is_published?: boolean app_id?: string | null author_name?: string | null + backing_app_id?: string | null bound_agent_id?: string | null create_user_name?: string | null created_at?: number | null @@ -374,6 +424,7 @@ export type AgentAppPartial = { debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null + hidden_app_backed?: boolean icon?: string | null icon_background?: string | null icon_type?: string | null @@ -413,21 +464,31 @@ export type ModelConfig = { provider: string } -export type Site = { +export type AppDetailSiteResponse = { + access_token?: string | null + app_base_url?: string | null chat_color_theme?: string | null - chat_color_theme_inverted: boolean + chat_color_theme_inverted?: boolean | null + code?: string | null copyright?: string | null + created_at?: number | null + created_by?: string | null custom_disclaimer?: string | null - default_language: string + customize_domain?: string | null + customize_token_strategy?: string | null + default_language?: string | null description?: string | null icon?: string | null icon_background?: string | null - icon_type?: string | null + icon_type?: string | IconType | null readonly icon_url: string | null privacy_policy?: string | null - show_workflow_steps: boolean - title: string - use_icon_as_answer_icon: boolean + prompt_public?: boolean | null + show_workflow_steps?: boolean | null + title?: string | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null } export type Tag = { @@ -463,10 +524,12 @@ export type AgentInviteOptionResponse = { app_id?: string | null archived_at?: number | null archived_by?: string | null + backing_app_id?: string | null created_at?: number | null created_by?: string | null description: string existing_node_ids?: Array + hidden_app_backed?: boolean icon?: string | null icon_background?: string | null icon_type?: AgentIconType | null @@ -487,31 +550,11 @@ export type AgentInviteOptionResponse = { workflow_node_id?: string | null } -export type AgentConfigSnapshotSummaryResponse = { - agent_id?: string | null - created_at?: number | null - created_by?: string | null - display_version?: number | null - id: string - snapshot_version?: number | null - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentComposerAgentResponse = { - active_config_snapshot_id?: string | null - description: string - id: string - name: string - scope: AgentScope - status: AgentStatus -} - export type AgentSoulConfig = { app_features?: AgentSoulAppFeaturesConfig app_variables?: Array env?: AgentSoulEnvConfig + files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig knowledge?: AgentSoulKnowledgeConfig memory?: AgentSoulMemoryConfig @@ -523,18 +566,6 @@ export type AgentSoulConfig = { tools?: AgentSoulToolsConfig } -export type ComposerSaveStrategy - = | 'node_job_only' - | 'save_as_new_agent' - | 'save_as_new_version' - | 'save_to_current_version' - | 'save_to_roster' - -export type ComposerValidationFindingsResponse = { - knowledge_retrieval_placeholder?: Array - warnings?: Array -} - export type ComposerBindingPayload = { agent_id?: string | null binding_type: 'inline_agent' | 'roster_agent' @@ -553,6 +584,13 @@ export type WorkflowNodeJobConfig = { workflow_prompt?: string } +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + export type ComposerSoulLockPayload = { locked?: boolean unlocked_from_version_id?: string | null @@ -560,6 +598,52 @@ export type ComposerSoulLockPayload = { export type ComposerVariant = 'agent_app' | 'workflow' +export type AgentConfigSnapshotSummaryResponse = { + agent_id?: string | null + created_at?: number | null + created_by?: string | null + display_version?: number | null + id: string + snapshot_version?: number | null + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentComposerAgentResponse = { + active_config_snapshot_id?: string | null + app_id?: string | null + backing_app_id?: string | null + description: string + hidden_app_backed?: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + name: string + role?: string | null + scope: AgentScope + source?: AgentSource | null + status: AgentStatus +} + +export type AgentConfigDraftSummaryResponse = { + account_id?: string | null + agent_id: string + base_snapshot_id?: string | null + created_at?: number | null + created_by?: string | null + draft_type: AgentConfigDraftType + id: string + updated_at?: number | null + updated_by?: string | null +} + +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + export type AgentComposerNodeJobCandidatesResponse = { declare_output_types?: Array human_contacts?: Array @@ -570,7 +654,7 @@ export type AgentComposerSoulCandidatesResponse = { cli_tools?: Array dify_tools?: Array human_contacts?: Array - knowledge_datasets?: Array + knowledge_sets?: Array } export type ComposerCandidateCapabilities = { @@ -925,15 +1009,18 @@ export type AgentSoulEnvConfig = { variables?: Array } +export type AgentSoulFilesConfig = { + files?: Array + skills?: Array +} + export type AgentSoulHumanConfig = { contacts?: Array tools?: Array } export type AgentSoulKnowledgeConfig = { - datasets?: Array - query_config?: AgentKnowledgeQueryConfig - query_mode?: AgentKnowledgeQueryMode | null + sets?: Array } export type AgentSoulMemoryConfig = { @@ -1030,6 +1117,8 @@ export type WorkflowPreviousNodeOutputRef = { [key: string]: unknown } +export type AgentConfigDraftType = 'debug_build' | 'draft' + export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' export type AgentCliToolConfig = { @@ -1074,11 +1163,12 @@ export type AgentComposerDifyToolCandidateResponse = { tools_count?: number | null } -export type AgentKnowledgeDatasetConfig = { +export type AgentComposerKnowledgeSetCandidateResponse = { + datasets?: Array description?: string | null - id?: string | null - name?: string | null - [key: string]: unknown + id: string + missing_dataset_ids?: Array + name: string } export type AgentModerationProviderConfig = { @@ -1173,6 +1263,7 @@ export type AgentUserSatisfactionRateStatisticResponse = { export type AgentConfigRevisionOperation = | 'create_version' + | 'publish_draft' | 'restore_version' | 'save_current_version' | 'save_new_agent' @@ -1226,6 +1317,35 @@ export type AgentEnvVariableConfig = { [key: string]: unknown } +export type AgentFileRefConfig = { + drive_key?: string | null + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + export type AgentHumanToolConfig = { description?: string | null enabled?: boolean @@ -1233,16 +1353,16 @@ export type AgentHumanToolConfig = { [key: string]: unknown } -export type AgentKnowledgeQueryConfig = { - query?: string | null - score_threshold?: number | null - score_threshold_enabled?: boolean | null - top_k?: number | null - [key: string]: unknown +export type AgentKnowledgeSetConfig = { + datasets: Array + description?: string | null + id: string + metadata_filtering?: AgentKnowledgeMetadataFilteringConfig + name: string + query: AgentKnowledgeQueryConfig + retrieval: AgentKnowledgeRetrievalConfig } -export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' - export type AgentMemoryArtifactConfig = { id?: string | null name?: string | null @@ -1343,21 +1463,6 @@ export type DeclaredOutputFileConfig = { mime_types?: Array } -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - export type AgentCliToolAuthorizationStatus = | 'allowed' | 'authorized' @@ -1381,6 +1486,13 @@ export type AgentPermissionConfig = { export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' +export type AgentComposerKnowledgeDatasetCandidateResponse = { + description?: string | null + id?: string | null + missing?: boolean + name?: string | null +} + export type AgentModerationIoConfig = { enabled?: boolean preset_response?: string | null @@ -1409,6 +1521,34 @@ export type FormInputConfig export type JsonValue2 = unknown +export type AgentKnowledgeDatasetConfig = { + description?: string | null + id?: string | null + name?: string | null +} + +export type AgentKnowledgeMetadataFilteringConfig = { + conditions?: AgentKnowledgeMetadataConditions | null + mode?: 'automatic' | 'disabled' | 'manual' + model_config?: AgentKnowledgeModelConfig | null +} + +export type AgentKnowledgeQueryConfig = { + mode: AgentKnowledgeQueryMode + value?: string | null +} + +export type AgentKnowledgeRetrievalConfig = { + mode: 'multiple' | 'single' + model?: AgentKnowledgeModelConfig | null + reranking_enable?: boolean + reranking_mode?: string + reranking_model?: AgentKnowledgeRerankingModelConfig | null + score_threshold?: number | null + top_k?: number | null + weights?: AgentKnowledgeWeightedScoreConfig | null +} + export type AgentModelResponseFormatConfig = { type?: string | null [key: string]: unknown @@ -1459,6 +1599,38 @@ export type FileListInputConfig = { type?: 'file-list' } +export type AgentKnowledgeMetadataConditions = { + conditions?: Array + logical_operator?: 'and' | 'or' +} + +export type AgentKnowledgeModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: string + name: string + provider: string +} + +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type AgentKnowledgeRerankingModelConfig = { + model: string + provider: string +} + +export type AgentKnowledgeWeightedScoreConfig = { + keyword_setting?: { + [key: string]: unknown + } | null + vector_setting?: { + [key: string]: unknown + } | null + weight_type?: string | null + [key: string]: unknown +} + export type StringSource = { selector?: Array type: ValueSourceType @@ -1475,6 +1647,30 @@ export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' +export type AgentKnowledgeMetadataCondition = { + comparison_operator: + | '<' + | '=' + | '>' + | 'after' + | 'before' + | 'contains' + | 'empty' + | 'end with' + | 'in' + | 'is' + | 'is not' + | 'not contains' + | 'not empty' + | 'not in' + | 'start with' + | '≠' + | '≤' + | '≥' + name: string + value?: string | Array | number | null +} + export type ValueSourceType = 'constant' | 'variable' export type AgentAppPaginationWritable = { @@ -1490,6 +1686,7 @@ export type AgentAppDetailWithSiteWritable = { active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null + backing_app_id?: string | null bound_agent_id?: string | null created_at?: number | null created_by?: string | null @@ -1498,6 +1695,7 @@ export type AgentAppDetailWithSiteWritable = { description?: string | null enable_api: boolean enable_site: boolean + hidden_app_backed?: boolean icon?: string | null icon_background?: string | null icon_type?: string | null @@ -1509,7 +1707,7 @@ export type AgentAppDetailWithSiteWritable = { name: string permission_keys?: Array role?: string | null - site?: SiteWritable | null + site?: AppDetailSiteResponseWritable | null tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -1523,6 +1721,7 @@ export type AgentAppPartialWritable = { active_config_is_published?: boolean app_id?: string | null author_name?: string | null + backing_app_id?: string | null bound_agent_id?: string | null create_user_name?: string | null created_at?: number | null @@ -1530,6 +1729,7 @@ export type AgentAppPartialWritable = { debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null + hidden_app_backed?: boolean icon?: string | null icon_background?: string | null icon_type?: string | null @@ -1551,20 +1751,30 @@ export type AgentAppPartialWritable = { workflow?: WorkflowPartial | null } -export type SiteWritable = { +export type AppDetailSiteResponseWritable = { + access_token?: string | null + app_base_url?: string | null chat_color_theme?: string | null - chat_color_theme_inverted: boolean + chat_color_theme_inverted?: boolean | null + code?: string | null copyright?: string | null + created_at?: number | null + created_by?: string | null custom_disclaimer?: string | null - default_language: string + customize_domain?: string | null + customize_token_strategy?: string | null + default_language?: string | null description?: string | null icon?: string | null icon_background?: string | null - icon_type?: string | null + icon_type?: string | IconType | null privacy_policy?: string | null - show_workflow_steps: boolean - title: string - use_icon_as_answer_icon: boolean + prompt_public?: boolean | null + show_workflow_steps?: boolean | null + title?: string | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null } export type GetAgentData = { @@ -1778,6 +1988,86 @@ export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = { export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse = DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses] +export type DeleteAgentByAgentIdBuildDraftData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-draft' +} + +export type DeleteAgentByAgentIdBuildDraftResponses = { + 200: AgentSimpleResultResponse +} + +export type DeleteAgentByAgentIdBuildDraftResponse + = DeleteAgentByAgentIdBuildDraftResponses[keyof DeleteAgentByAgentIdBuildDraftResponses] + +export type GetAgentByAgentIdBuildDraftData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-draft' +} + +export type GetAgentByAgentIdBuildDraftResponses = { + 200: AgentBuildDraftResponse +} + +export type GetAgentByAgentIdBuildDraftResponse + = GetAgentByAgentIdBuildDraftResponses[keyof GetAgentByAgentIdBuildDraftResponses] + +export type PutAgentByAgentIdBuildDraftData = { + body: ComposerSavePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-draft' +} + +export type PutAgentByAgentIdBuildDraftResponses = { + 200: AgentBuildDraftResponse +} + +export type PutAgentByAgentIdBuildDraftResponse + = PutAgentByAgentIdBuildDraftResponses[keyof PutAgentByAgentIdBuildDraftResponses] + +export type PostAgentByAgentIdBuildDraftApplyData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-draft/apply' +} + +export type PostAgentByAgentIdBuildDraftApplyResponses = { + 200: AgentBuildDraftApplyResponse +} + +export type PostAgentByAgentIdBuildDraftApplyResponse + = PostAgentByAgentIdBuildDraftApplyResponses[keyof PostAgentByAgentIdBuildDraftApplyResponses] + +export type PostAgentByAgentIdBuildDraftCheckoutData = { + body: AgentBuildDraftCheckoutPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/build-draft/checkout' +} + +export type PostAgentByAgentIdBuildDraftCheckoutResponses = { + 200: AgentBuildDraftResponse +} + +export type PostAgentByAgentIdBuildDraftCheckoutResponse + = PostAgentByAgentIdBuildDraftCheckoutResponses[keyof PostAgentByAgentIdBuildDraftCheckoutResponses] + export type GetAgentByAgentIdChatMessagesData = { body?: never path: { @@ -2201,6 +2491,26 @@ export type GetAgentByAgentIdMessagesByMessageIdResponses = { export type GetAgentByAgentIdMessagesByMessageIdResponse = GetAgentByAgentIdMessagesByMessageIdResponses[keyof GetAgentByAgentIdMessagesByMessageIdResponses] +export type PostAgentByAgentIdPublishData = { + body: AgentPublishPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/publish' +} + +export type PostAgentByAgentIdPublishErrors = { + 403: unknown +} + +export type PostAgentByAgentIdPublishResponses = { + 200: AgentPublishResponse +} + +export type PostAgentByAgentIdPublishResponse + = PostAgentByAgentIdPublishResponses[keyof PostAgentByAgentIdPublishResponses] + export type GetAgentByAgentIdReferencingWorkflowsData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d7f5681ffc4..0208a1ce361 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -47,6 +47,37 @@ export const zApiKeyList = z.object({ data: z.array(zApiKeyItem), }) +/** + * AgentSimpleResultResponse + */ +export const zAgentSimpleResultResponse = z.object({ + result: z.string(), +}) + +/** + * AgentBuildDraftResponse + */ +export const zAgentBuildDraftResponse = z.object({ + agent_soul: z.record(z.string(), z.unknown()), + draft: z.record(z.string(), z.unknown()), + variant: z.string(), +}) + +/** + * AgentBuildDraftApplyResponse + */ +export const zAgentBuildDraftApplyResponse = z.object({ + draft: z.record(z.string(), z.unknown()), + result: z.string(), +}) + +/** + * AgentBuildDraftCheckoutPayload + */ +export const zAgentBuildDraftCheckoutPayload = z.object({ + force: z.boolean().optional().default(false), +}) + /** * SuggestedQuestionsResponse */ @@ -110,6 +141,23 @@ export const zAgentDriveFilePayload = z.object({ upload_file_id: z.string(), }) +/** + * AgentPublishPayload + */ +export const zAgentPublishPayload = z.object({ + version_note: z.string().nullish(), +}) + +/** + * AgentPublishResponse + */ +export const zAgentPublishResponse = z.object({ + active_config_snapshot: z.record(z.string(), z.unknown()).nullish(), + active_config_snapshot_id: z.string(), + draft: z.record(z.string(), z.unknown()).nullish(), + result: z.string(), +}) + /** * SandboxReadResponse */ @@ -134,6 +182,8 @@ export const zAgentSandboxUploadPayload = z.object({ */ export const zAgentConfigSnapshotRestoreResponse = z.object({ active_config_snapshot_id: z.string(), + draft_config_id: z.string().nullish(), + restored_version_id: z.string().nullish(), result: z.literal('success'), }) @@ -190,23 +240,33 @@ export const zDeletedTool = z.object({ }) /** - * Site + * AppDetailSiteResponse */ -export const zSite = z.object({ +export const zAppDetailSiteResponse = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string().nullish(), chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean(), + chat_color_theme_inverted: z.boolean().nullish(), + code: z.string().nullish(), copyright: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), custom_disclaimer: z.string().nullish(), - default_language: z.string(), + customize_domain: z.string().nullish(), + customize_token_strategy: z.string().nullish(), + default_language: z.string().nullish(), description: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: z.string().nullish(), + icon_type: z.union([z.string(), zIconType]).nullish(), icon_url: z.string().nullable(), privacy_policy: z.string().nullish(), - show_workflow_steps: z.boolean(), - title: z.string(), - use_icon_as_answer_icon: z.boolean(), + prompt_public: z.boolean().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), }) /** @@ -240,6 +300,46 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + /** * AgentConfigSnapshotSummaryResponse */ @@ -262,46 +362,6 @@ export const zAgentConfigSnapshotListResponse = z.object({ data: z.array(zAgentConfigSnapshotSummaryResponse), }) -/** - * ComposerSaveStrategy - */ -export const zComposerSaveStrategy = z.enum([ - 'node_job_only', - 'save_as_new_agent', - 'save_as_new_version', - 'save_to_current_version', - 'save_to_roster', -]) - -/** - * ComposerBindingPayload - */ -export const zComposerBindingPayload = z.object({ - agent_id: z.string().nullish(), - binding_type: z.enum(['inline_agent', 'roster_agent']), - current_snapshot_id: z.string().nullish(), -}) - -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - -/** - * ComposerSoulLockPayload - */ -export const zComposerSoulLockPayload = z.object({ - locked: z.boolean().optional().default(true), - unlocked_from_version_id: z.string().nullish(), -}) - -/** - * ComposerVariant - */ -export const zComposerVariant = z.enum(['agent_app', 'workflow']) - /** * ComposerCandidateCapabilities */ @@ -733,6 +793,7 @@ export const zAgentAppPartial = z.object({ active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), author_name: z.string().nullish(), + backing_app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), create_user_name: z.string().nullish(), created_at: z.int().nullish(), @@ -740,6 +801,7 @@ export const zAgentAppPartial = z.object({ debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), + hidden_app_backed: z.boolean().optional().default(false), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), @@ -798,6 +860,7 @@ export const zAgentAppDetailWithSite = z.object({ active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -806,6 +869,7 @@ export const zAgentAppDetailWithSite = z.object({ description: z.string().nullish(), enable_api: z.boolean(), enable_site: z.boolean(), + hidden_app_backed: z.boolean().optional().default(false), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), @@ -818,7 +882,7 @@ export const zAgentAppDetailWithSite = z.object({ name: z.string(), permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), - site: zSite.nullish(), + site: zAppDetailSiteResponse.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -885,10 +949,12 @@ export const zAgentInviteOptionResponse = z.object({ app_id: z.string().nullish(), archived_at: z.int().nullish(), archived_by: z.string().nullish(), + backing_app_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), description: z.string(), existing_node_ids: z.array(z.string()).optional(), + hidden_app_backed: z.boolean().optional().default(false), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: zAgentIconType.nullish(), @@ -925,10 +991,18 @@ export const zAgentInviteOptionsResponse = z.object({ */ export const zAgentComposerAgentResponse = z.object({ active_config_snapshot_id: z.string().nullish(), + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), description: z.string(), + hidden_app_backed: z.boolean().optional().default(false), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), id: z.string(), name: z.string(), + role: z.string().nullish(), scope: zAgentScope, + source: zAgentSource.nullish(), status: zAgentStatus, }) @@ -987,6 +1061,28 @@ export const zWorkflowPreviousNodeOutputRef = z.object({ .nullish(), }) +/** + * AgentConfigDraftType + * + * Editable Agent Soul draft workspace type. + */ +export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) + +/** + * AgentConfigDraftSummaryResponse + */ +export const zAgentConfigDraftSummaryResponse = z.object({ + account_id: z.string().nullish(), + agent_id: z.string(), + base_snapshot_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + draft_type: zAgentConfigDraftType, + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + /** * DeclaredOutputType */ @@ -1022,15 +1118,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({ tools_count: z.int().nullish(), }) -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - /** * SimpleAccount */ @@ -1204,6 +1291,7 @@ export const zAgentStatisticSummaryEnvelopeResponse = z.object({ */ export const zAgentConfigRevisionOperation = z.enum([ 'create_version', + 'publish_draft', 'restore_version', 'save_current_version', 'save_new_agent', @@ -1262,6 +1350,55 @@ export const zAgentEnvVariableConfig = z.object({ variable: z.string().max(255).nullish(), }) +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + +/** + * AgentSkillRefConfig + */ +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSoulFilesConfig + */ +export const zAgentSoulFilesConfig = z.object({ + files: z.array(zAgentFileRefConfig).optional(), + skills: z.array(zAgentSkillRefConfig).optional(), +}) + /** * AgentHumanToolConfig */ @@ -1279,30 +1416,6 @@ export const zAgentSoulHumanConfig = z.object({ tools: z.array(zAgentHumanToolConfig).optional(), }) -/** - * AgentKnowledgeQueryConfig - */ -export const zAgentKnowledgeQueryConfig = z.object({ - query: z.string().nullish(), - score_threshold: z.number().gte(0).lte(1).nullish(), - score_threshold_enabled: z.boolean().nullish(), - top_k: z.int().gte(1).nullish(), -}) - -/** - * AgentKnowledgeQueryMode - */ -export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) - -/** - * AgentSoulKnowledgeConfig - */ -export const zAgentSoulKnowledgeConfig = z.object({ - datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), - query_config: zAgentKnowledgeQueryConfig.optional(), - query_mode: zAgentKnowledgeQueryMode.nullish(), -}) - /** * AgentMemoryArtifactConfig */ @@ -1394,31 +1507,6 @@ export const zDeclaredOutputFileConfig = z.object({ mime_types: z.array(z.string()).optional(), }) -/** - * AgentFileRefConfig - */ -export const zAgentFileRefConfig = z.object({ - drive_key: z.string().max(512).nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * WorkflowNodeJobMetadata - */ -export const zWorkflowNodeJobMetadata = z.object({ - agent_soul: z.record(z.string(), z.unknown()).nullish(), - file_refs: z.array(zAgentFileRefConfig).nullish(), -}) - /** * AgentCliToolAuthorizationStatus * @@ -1521,6 +1609,27 @@ export const zAgentCliToolConfig = z.object({ tool_name: z.string().max(255).nullish(), }) +/** + * AgentComposerKnowledgeDatasetCandidateResponse + */ +export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + missing: z.boolean().optional().default(false), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerKnowledgeSetCandidateResponse + */ +export const zAgentComposerKnowledgeSetCandidateResponse = z.object({ + datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(), + description: z.string().nullish(), + id: z.string(), + missing_dataset_ids: z.array(z.string()).optional(), + name: z.string(), +}) + /** * AgentComposerSoulCandidatesResponse */ @@ -1528,7 +1637,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({ cli_tools: z.array(zAgentCliToolConfig).optional(), dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), human_contacts: z.array(zAgentHumanContactConfig).optional(), - knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(), }) /** @@ -1583,6 +1692,15 @@ export const zHumanInputFormSubmissionData = z.object({ submitted_data: z.record(z.string(), zJsonValue2).nullish(), }) +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + /** * AgentModelResponseFormatConfig */ @@ -1733,53 +1851,6 @@ export const zAgentSoulToolsConfig = z.object({ dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), }) -/** - * AgentSoulConfig - */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.nullish(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - tools: zAgentSoulToolsConfig.optional(), -}) - -/** - * AgentAppComposerResponse - */ -export const zAgentAppComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse, - agent: zAgentComposerAgentResponse, - agent_soul: zAgentSoulConfig, - save_options: z.array(zComposerSaveStrategy), - validation: zComposerValidationFindingsResponse.nullish(), - variant: z.literal('agent_app'), -}) - -/** - * AgentConfigSnapshotDetailResponse - */ -export const zAgentConfigSnapshotDetailResponse = z.object({ - agent_id: z.string().nullish(), - config_snapshot: zAgentSoulConfig, - created_at: z.int().nullish(), - created_by: z.string().nullish(), - display_version: z.int().nullish(), - id: z.string(), - revisions: z.array(zAgentConfigRevisionResponse).optional(), - snapshot_version: z.int().nullish(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - /** * OutputErrorStrategy * @@ -1869,27 +1940,6 @@ export const zWorkflowNodeJobConfig = z.object({ workflow_prompt: z.string().optional().default(''), }) -/** - * ComposerSavePayload - */ -export const zComposerSavePayload = z.object({ - agent_soul: zAgentSoulConfig.nullish(), - binding: zComposerBindingPayload.nullish(), - client_revision_id: z.string().nullish(), - description: z.string().nullish(), - icon: z.string().max(255).nullish(), - icon_background: z.string().max(255).nullish(), - icon_type: zAgentIconType.nullish(), - idempotency_key: z.string().nullish(), - new_agent_name: z.string().min(1).max(255).nullish(), - node_job: zWorkflowNodeJobConfig.nullish(), - role: z.string().max(255).nullish(), - save_strategy: zComposerSaveStrategy, - soul_lock: zComposerSoulLockPayload.optional(), - variant: zComposerVariant, - version_note: z.string().nullish(), -}) - /** * ButtonStyle * @@ -1908,6 +1958,73 @@ export const zUserActionConfig = z.object({ title: z.string().max(100), }) +/** + * AgentKnowledgeModelConfig + */ +export const zAgentKnowledgeModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: z.string().min(1).max(64), + name: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentKnowledgeQueryMode + */ +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentKnowledgeQueryConfig + * + * Per-set query policy for Agent v2 knowledge retrieval. + * + * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + * set owns its own query policy, so ``user_query`` must carry an explicit + * ``value`` while ``generated_query`` leaves that value empty. + */ +export const zAgentKnowledgeQueryConfig = z.object({ + mode: zAgentKnowledgeQueryMode, + value: z.string().nullish(), +}) + +/** + * AgentKnowledgeRerankingModelConfig + */ +export const zAgentKnowledgeRerankingModelConfig = z.object({ + model: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentKnowledgeWeightedScoreConfig + */ +export const zAgentKnowledgeWeightedScoreConfig = z.object({ + keyword_setting: z.record(z.string(), z.unknown()).nullish(), + vector_setting: z.record(z.string(), z.unknown()).nullish(), + weight_type: z.string().max(64).nullish(), +}) + +/** + * AgentKnowledgeRetrievalConfig + * + * Per-set retrieval policy for Agent v2 knowledge retrieval. + * + * Retrieval settings now live on each knowledge set instead of one shared + * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + * ``single`` retrieval with a required model config. + */ +export const zAgentKnowledgeRetrievalConfig = z.object({ + mode: z.enum(['multiple', 'single']), + model: zAgentKnowledgeModelConfig.nullish(), + reranking_enable: z.boolean().optional().default(true), + reranking_mode: z.string().optional().default('reranking_model'), + reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(), + score_threshold: z.number().gte(0).lte(1).nullish(), + top_k: z.int().gte(1).nullish(), + weights: zAgentKnowledgeWeightedScoreConfig.nullish(), +}) + /** * FileType */ @@ -1946,6 +2063,166 @@ export const zFileListInputConfig = z.object({ type: z.literal('file-list').optional().default('file-list'), }) +/** + * AgentKnowledgeMetadataCondition + */ +export const zAgentKnowledgeMetadataCondition = z.object({ + comparison_operator: z.enum([ + '<', + '=', + '>', + 'after', + 'before', + 'contains', + 'empty', + 'end with', + 'in', + 'is', + 'is not', + 'not contains', + 'not empty', + 'not in', + 'start with', + '≠', + '≤', + '≥', + ]), + name: z.string().min(1).max(255), + value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(), +}) + +/** + * AgentKnowledgeMetadataConditions + */ +export const zAgentKnowledgeMetadataConditions = z.object({ + conditions: z.array(zAgentKnowledgeMetadataCondition).optional(), + logical_operator: z.enum(['and', 'or']).optional().default('and'), +}) + +/** + * AgentKnowledgeMetadataFilteringConfig + * + * Per-set metadata filtering policy. + * + * The Python attribute uses ``metadata_model_config`` for clarity because the + * model belongs to metadata filtering specifically, while the external API and + * generated schema keep the historical ``model_config`` field name via alias. + */ +export const zAgentKnowledgeMetadataFilteringConfig = z.object({ + conditions: zAgentKnowledgeMetadataConditions.nullish(), + mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'), + model_config: zAgentKnowledgeModelConfig.nullish(), +}) + +/** + * AgentKnowledgeSetConfig + * + * One explicit knowledge set in Agent v2. + * + * ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + * its datasets plus query, retrieval, and metadata policies. An individual + * set must contain at least one dataset id even though the overall knowledge + * section may be empty, which is how callers express "no knowledge layer". + */ +export const zAgentKnowledgeSetConfig = z.object({ + datasets: z.array(zAgentKnowledgeDatasetConfig), + description: z.string().nullish(), + id: z.string().min(1).max(255), + metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(), + name: z.string().min(1).max(255), + query: zAgentKnowledgeQueryConfig, + retrieval: zAgentKnowledgeRetrievalConfig, +}) + +/** + * AgentSoulKnowledgeConfig + * + * Top-level Agent v2 knowledge config. + * + * Agent v2 models knowledge as explicit sets instead of one flat + * ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + * list means no knowledge layer should be emitted at runtime, while set-name + * uniqueness stays case-insensitive because runtime selection addresses sets + * by name. + */ +export const zAgentSoulKnowledgeConfig = z.object({ + sets: z.array(zAgentKnowledgeSetConfig).optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + files: zAgentSoulFilesConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + +/** + * AgentAppComposerResponse + */ +export const zAgentAppComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + agent: zAgentComposerAgentResponse, + agent_soul: zAgentSoulConfig, + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), + chat_endpoint: z.string().nullish(), + draft: zAgentConfigDraftSummaryResponse.nullish(), + hidden_app_backed: z.boolean().optional().default(false), + save_options: z.array(zComposerSaveStrategy), + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('agent_app'), +}) + +/** + * AgentConfigSnapshotDetailResponse + */ +export const zAgentConfigSnapshotDetailResponse = z.object({ + agent_id: z.string().nullish(), + config_snapshot: zAgentSoulConfig, + created_at: z.int().nullish(), + created_by: z.string().nullish(), + display_version: z.int().nullish(), + id: z.string(), + revisions: z.array(zAgentConfigRevisionResponse).optional(), + snapshot_version: z.int().nullish(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + /** * ValueSourceType * @@ -2075,6 +2352,7 @@ export const zAgentAppPartialWritable = z.object({ active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), author_name: z.string().nullish(), + backing_app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), create_user_name: z.string().nullish(), created_at: z.int().nullish(), @@ -2082,6 +2360,7 @@ export const zAgentAppPartialWritable = z.object({ debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), + hidden_app_backed: z.boolean().optional().default(false), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), @@ -2115,22 +2394,32 @@ export const zAgentAppPaginationWritable = z.object({ }) /** - * Site + * AppDetailSiteResponse */ -export const zSiteWritable = z.object({ +export const zAppDetailSiteResponseWritable = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string().nullish(), chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean(), + chat_color_theme_inverted: z.boolean().nullish(), + code: z.string().nullish(), copyright: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), custom_disclaimer: z.string().nullish(), - default_language: z.string(), + customize_domain: z.string().nullish(), + customize_token_strategy: z.string().nullish(), + default_language: z.string().nullish(), description: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: z.string().nullish(), + icon_type: z.union([z.string(), zIconType]).nullish(), privacy_policy: z.string().nullish(), - show_workflow_steps: z.boolean(), - title: z.string(), - use_icon_as_answer_icon: z.boolean(), + prompt_public: z.boolean().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), }) /** @@ -2141,6 +2430,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -2149,6 +2439,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ description: z.string().nullish(), enable_api: z.boolean(), enable_site: z.boolean(), + hidden_app_backed: z.boolean().optional().default(false), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), @@ -2160,7 +2451,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ name: z.string(), permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), - site: zSiteWritable.nullish(), + site: zAppDetailSiteResponseWritable.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -2296,6 +2587,55 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({ */ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() +export const zDeleteAgentByAgentIdBuildDraftPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent build draft discarded + */ +export const zDeleteAgentByAgentIdBuildDraftResponse = zAgentSimpleResultResponse + +export const zGetAgentByAgentIdBuildDraftPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent build draft + */ +export const zGetAgentByAgentIdBuildDraftResponse = zAgentBuildDraftResponse + +export const zPutAgentByAgentIdBuildDraftBody = zComposerSavePayload + +export const zPutAgentByAgentIdBuildDraftPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent build draft saved + */ +export const zPutAgentByAgentIdBuildDraftResponse = zAgentBuildDraftResponse + +export const zPostAgentByAgentIdBuildDraftApplyPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent build draft applied + */ +export const zPostAgentByAgentIdBuildDraftApplyResponse = zAgentBuildDraftApplyResponse + +export const zPostAgentByAgentIdBuildDraftCheckoutBody = zAgentBuildDraftCheckoutPayload + +export const zPostAgentByAgentIdBuildDraftCheckoutPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent build draft checked out + */ +export const zPostAgentByAgentIdBuildDraftCheckoutResponse = zAgentBuildDraftResponse + export const zGetAgentByAgentIdChatMessagesPath = z.object({ agent_id: z.uuid(), }) @@ -2564,6 +2904,17 @@ export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ */ export const zGetAgentByAgentIdMessagesByMessageIdResponse = zMessageDetailResponse +export const zPostAgentByAgentIdPublishBody = zAgentPublishPayload + +export const zPostAgentByAgentIdPublishPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent draft published + */ +export const zPostAgentByAgentIdPublishResponse = zAgentPublishResponse + export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ agent_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index ea72df28458..59783646462 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -185,6 +185,7 @@ import { zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerQuery, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath, zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse, @@ -3569,7 +3570,12 @@ export const get62 = oc path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer', tags: ['console'], }) - .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath })) + .input( + z.object({ + params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + query: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerQuery.optional(), + }), + ) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const put4 = oc diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 9e79518f3cd..d396351b2b8 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -43,7 +43,7 @@ export type AppDetailWithSite = { model_config?: ModelConfig | null name: string permission_keys?: Array - site?: Site | null + site?: AppDetailSiteResponse | null tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -970,8 +970,11 @@ export type WorkflowAgentComposerResponse = { agent?: AgentComposerAgentResponse | null agent_soul: AgentSoulConfig app_id?: string | null + backing_app_id?: string | null binding?: AgentComposerBindingResponse | null + chat_endpoint?: string | null effective_declared_outputs?: Array + hidden_app_backed?: boolean impact_summary?: AgentComposerImpactResponse | null node_id?: string | null node_job: WorkflowNodeJobConfig @@ -1231,21 +1234,31 @@ export type ModelConfig = { provider: string } -export type Site = { +export type AppDetailSiteResponse = { + access_token?: string | null + app_base_url?: string | null chat_color_theme?: string | null - chat_color_theme_inverted: boolean + chat_color_theme_inverted?: boolean | null + code?: string | null copyright?: string | null + created_at?: number | null + created_by?: string | null custom_disclaimer?: string | null - default_language: string + customize_domain?: string | null + customize_token_strategy?: string | null + default_language?: string | null description?: string | null icon?: string | null icon_background?: string | null - icon_type?: string | null + icon_type?: string | IconType | null readonly icon_url: string | null privacy_policy?: string | null - show_workflow_steps: boolean - title: string - use_icon_as_answer_icon: boolean + prompt_public?: boolean | null + show_workflow_steps?: boolean | null + title?: string | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null } export type Tag = { @@ -1787,10 +1800,18 @@ export type AgentConfigSnapshotSummaryResponse = { export type AgentComposerAgentResponse = { active_config_snapshot_id?: string | null + app_id?: string | null + backing_app_id?: string | null description: string + hidden_app_backed?: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null id: string name: string + role?: string | null scope: AgentScope + source?: AgentSource | null status: AgentStatus } @@ -1798,6 +1819,7 @@ export type AgentSoulConfig = { app_features?: AgentSoulAppFeaturesConfig app_variables?: Array env?: AgentSoulEnvConfig + files?: AgentSoulFilesConfig human?: AgentSoulHumanConfig knowledge?: AgentSoulKnowledgeConfig memory?: AgentSoulMemoryConfig @@ -1903,7 +1925,7 @@ export type AgentComposerSoulCandidatesResponse = { cli_tools?: Array dify_tools?: Array human_contacts?: Array - knowledge_datasets?: Array + knowledge_sets?: Array } export type ComposerCandidateCapabilities = { @@ -2106,6 +2128,8 @@ export type WorkflowRunForArchivedLogResponse = { export type AgentScope = 'roster' | 'workflow_only' +export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' + export type AgentStatus = 'active' | 'archived' export type AgentSoulAppFeaturesConfig = { @@ -2131,15 +2155,18 @@ export type AgentSoulEnvConfig = { variables?: Array } +export type AgentSoulFilesConfig = { + files?: Array + skills?: Array +} + export type AgentSoulHumanConfig = { contacts?: Array tools?: Array } export type AgentSoulKnowledgeConfig = { - datasets?: Array - query_config?: AgentKnowledgeQueryConfig - query_mode?: AgentKnowledgeQueryMode | null + sets?: Array } export type AgentSoulMemoryConfig = { @@ -2291,11 +2318,12 @@ export type AgentComposerDifyToolCandidateResponse = { tools_count?: number | null } -export type AgentKnowledgeDatasetConfig = { +export type AgentComposerKnowledgeSetCandidateResponse = { + datasets?: Array description?: string | null - id?: string | null - name?: string | null - [key: string]: unknown + id: string + missing_dataset_ids?: Array + name: string } export type CheckResultView = { @@ -2399,6 +2427,35 @@ export type AgentEnvVariableConfig = { [key: string]: unknown } +export type AgentFileRefConfig = { + drive_key?: string | null + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + export type AgentHumanToolConfig = { description?: string | null enabled?: boolean @@ -2406,16 +2463,16 @@ export type AgentHumanToolConfig = { [key: string]: unknown } -export type AgentKnowledgeQueryConfig = { - query?: string | null - score_threshold?: number | null - score_threshold_enabled?: boolean | null - top_k?: number | null - [key: string]: unknown +export type AgentKnowledgeSetConfig = { + datasets: Array + description?: string | null + id: string + metadata_filtering?: AgentKnowledgeMetadataFilteringConfig + name: string + query: AgentKnowledgeQueryConfig + retrieval: AgentKnowledgeRetrievalConfig } -export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' - export type AgentMemoryArtifactConfig = { id?: string | null name?: string | null @@ -2473,21 +2530,6 @@ export type AgentSoulDifyToolConfig = { tool_name?: string | null } -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' export type DeclaredOutputRetryConfig = { @@ -2519,6 +2561,13 @@ export type AgentPermissionConfig = { export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' +export type AgentComposerKnowledgeDatasetCandidateResponse = { + description?: string | null + id?: string | null + missing?: boolean + name?: string | null +} + export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' export type ParagraphInputConfig = { @@ -2558,6 +2607,34 @@ export type AgentModerationProviderConfig = { [key: string]: unknown } +export type AgentKnowledgeDatasetConfig = { + description?: string | null + id?: string | null + name?: string | null +} + +export type AgentKnowledgeMetadataFilteringConfig = { + conditions?: AgentKnowledgeMetadataConditions | null + mode?: 'automatic' | 'disabled' | 'manual' + model_config?: AgentKnowledgeModelConfig | null +} + +export type AgentKnowledgeQueryConfig = { + mode: AgentKnowledgeQueryMode + value?: string | null +} + +export type AgentKnowledgeRetrievalConfig = { + mode: 'multiple' | 'single' + model?: AgentKnowledgeModelConfig | null + reranking_enable?: boolean + reranking_mode?: string + reranking_model?: AgentKnowledgeRerankingModelConfig | null + score_threshold?: number | null + top_k?: number | null + weights?: AgentKnowledgeWeightedScoreConfig | null +} + export type AgentModelResponseFormatConfig = { type?: string | null [key: string]: unknown @@ -2591,8 +2668,64 @@ export type AgentModerationIoConfig = { [key: string]: unknown } +export type AgentKnowledgeMetadataConditions = { + conditions?: Array + logical_operator?: 'and' | 'or' +} + +export type AgentKnowledgeModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: string + name: string + provider: string +} + +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type AgentKnowledgeRerankingModelConfig = { + model: string + provider: string +} + +export type AgentKnowledgeWeightedScoreConfig = { + keyword_setting?: { + [key: string]: unknown + } | null + vector_setting?: { + [key: string]: unknown + } | null + weight_type?: string | null + [key: string]: unknown +} + export type ValueSourceType = 'constant' | 'variable' +export type AgentKnowledgeMetadataCondition = { + comparison_operator: + | '<' + | '=' + | '>' + | 'after' + | 'before' + | 'contains' + | 'empty' + | 'end with' + | 'in' + | 'is' + | 'is not' + | 'not contains' + | 'not empty' + | 'not in' + | 'start with' + | '≠' + | '≤' + | '≥' + name: string + value?: string | Array | number | null +} + export type AppPaginationWritable = { data: Array has_more: boolean @@ -2622,7 +2755,7 @@ export type AppDetailWithSiteWritable = { model_config?: ModelConfig | null name: string permission_keys?: Array - site?: SiteWritable | null + site?: AppDetailSiteResponseWritable | null tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -2682,20 +2815,30 @@ export type AppPartialWritable = { workflow?: WorkflowPartial | null } -export type SiteWritable = { +export type AppDetailSiteResponseWritable = { + access_token?: string | null + app_base_url?: string | null chat_color_theme?: string | null - chat_color_theme_inverted: boolean + chat_color_theme_inverted?: boolean | null + code?: string | null copyright?: string | null + created_at?: number | null + created_by?: string | null custom_disclaimer?: string | null - default_language: string + customize_domain?: string | null + customize_token_strategy?: string | null + default_language?: string | null description?: string | null icon?: string | null icon_background?: string | null - icon_type?: string | null + icon_type?: string | IconType | null privacy_policy?: string | null - show_workflow_steps: boolean - title: string - use_icon_as_answer_icon: boolean + prompt_public?: boolean | null + show_workflow_steps?: boolean | null + title?: string | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null } export type WorkflowCommentBasicWritable = { @@ -5383,7 +5526,9 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = { app_id: string node_id: string } - query?: never + query?: { + snapshot_id?: string + } url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 9b86fda0a62..ea9b9a6dc50 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -845,23 +845,33 @@ export const zDeletedTool = z.object({ }) /** - * Site + * AppDetailSiteResponse */ -export const zSite = z.object({ +export const zAppDetailSiteResponse = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string().nullish(), chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean(), + chat_color_theme_inverted: z.boolean().nullish(), + code: z.string().nullish(), copyright: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), custom_disclaimer: z.string().nullish(), - default_language: z.string(), + customize_domain: z.string().nullish(), + customize_token_strategy: z.string().nullish(), + default_language: z.string().nullish(), description: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: z.string().nullish(), + icon_type: z.union([z.string(), zIconType]).nullish(), icon_url: z.string().nullable(), privacy_policy: z.string().nullish(), - show_workflow_steps: z.boolean(), - title: z.string(), - use_icon_as_answer_icon: z.boolean(), + prompt_public: z.boolean().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), }) /** @@ -2098,7 +2108,7 @@ export const zAppDetailWithSite = z.object({ model_config: zModelConfig.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), - site: zSite.nullish(), + site: zAppDetailSiteResponse.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -2486,6 +2496,13 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({ */ export const zAgentScope = z.enum(['roster', 'workflow_only']) +/** + * AgentSource + * + * Origin that created or imported the Agent. + */ +export const zAgentSource = z.enum(['agent_app', 'imported', 'roster', 'system', 'workflow']) + /** * AgentStatus * @@ -2498,10 +2515,18 @@ export const zAgentStatus = z.enum(['active', 'archived']) */ export const zAgentComposerAgentResponse = z.object({ active_config_snapshot_id: z.string().nullish(), + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), description: z.string(), + hidden_app_backed: z.boolean().optional().default(false), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), id: z.string(), name: z.string(), + role: z.string().nullish(), scope: zAgentScope, + source: zAgentSource.nullish(), status: zAgentStatus, }) @@ -2645,15 +2670,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({ tools_count: z.int().nullish(), }) -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - /** * CheckResultView * @@ -2766,6 +2782,55 @@ export const zAgentEnvVariableConfig = z.object({ variable: z.string().max(255).nullish(), }) +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + +/** + * AgentSkillRefConfig + */ +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSoulFilesConfig + */ +export const zAgentSoulFilesConfig = z.object({ + files: z.array(zAgentFileRefConfig).optional(), + skills: z.array(zAgentSkillRefConfig).optional(), +}) + /** * AgentHumanToolConfig */ @@ -2783,30 +2848,6 @@ export const zAgentSoulHumanConfig = z.object({ tools: z.array(zAgentHumanToolConfig).optional(), }) -/** - * AgentKnowledgeQueryConfig - */ -export const zAgentKnowledgeQueryConfig = z.object({ - query: z.string().nullish(), - score_threshold: z.number().gte(0).lte(1).nullish(), - score_threshold_enabled: z.boolean().nullish(), - top_k: z.int().gte(1).nullish(), -}) - -/** - * AgentKnowledgeQueryMode - */ -export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) - -/** - * AgentSoulKnowledgeConfig - */ -export const zAgentSoulKnowledgeConfig = z.object({ - datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), - query_config: zAgentKnowledgeQueryConfig.optional(), - query_mode: zAgentKnowledgeQueryMode.nullish(), -}) - /** * AgentMemoryArtifactConfig */ @@ -2855,31 +2896,6 @@ export const zAgentSoulSandboxConfig = z.object({ provider: z.string().nullish(), }) -/** - * AgentFileRefConfig - */ -export const zAgentFileRefConfig = z.object({ - drive_key: z.string().max(512).nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * WorkflowNodeJobMetadata - */ -export const zWorkflowNodeJobMetadata = z.object({ - agent_soul: z.record(z.string(), z.unknown()).nullish(), - file_refs: z.array(zAgentFileRefConfig).nullish(), -}) - /** * OutputErrorStrategy * @@ -3018,6 +3034,27 @@ export const zAgentCliToolConfig = z.object({ tool_name: z.string().max(255).nullish(), }) +/** + * AgentComposerKnowledgeDatasetCandidateResponse + */ +export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + missing: z.boolean().optional().default(false), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerKnowledgeSetCandidateResponse + */ +export const zAgentComposerKnowledgeSetCandidateResponse = z.object({ + datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(), + description: z.string().nullish(), + id: z.string(), + missing_dataset_ids: z.array(z.string()).optional(), + name: z.string(), +}) + /** * AgentComposerSoulCandidatesResponse */ @@ -3025,7 +3062,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({ cli_tools: z.array(zAgentCliToolConfig).optional(), dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), human_contacts: z.array(zAgentHumanContactConfig).optional(), - knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(), }) /** @@ -3057,6 +3094,15 @@ export const zUserActionConfig = z.object({ title: z.string().max(100), }) +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + /** * AgentModelResponseFormatConfig */ @@ -3308,62 +3354,70 @@ export const zAgentSoulAppFeaturesConfig = z.object({ }) /** - * AgentSoulConfig + * AgentKnowledgeModelConfig */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.nullish(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - tools: zAgentSoulToolsConfig.optional(), +export const zAgentKnowledgeModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: z.string().min(1).max(64), + name: z.string().min(1).max(255), + provider: z.string().min(1).max(255), }) /** - * WorkflowAgentComposerResponse + * AgentKnowledgeQueryMode */ -export const zWorkflowAgentComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - agent: zAgentComposerAgentResponse.nullish(), - agent_soul: zAgentSoulConfig, - app_id: z.string().nullish(), - binding: zAgentComposerBindingResponse.nullish(), - effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), - impact_summary: zAgentComposerImpactResponse.nullish(), - node_id: z.string().nullish(), - node_job: zWorkflowNodeJobConfig, - save_options: z.array(zComposerSaveStrategy), - soul_lock: zAgentComposerSoulLockResponse, - validation: zComposerValidationFindingsResponse.nullish(), - variant: z.literal('workflow'), - workflow_id: z.string().nullish(), +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentKnowledgeQueryConfig + * + * Per-set query policy for Agent v2 knowledge retrieval. + * + * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + * set owns its own query policy, so ``user_query`` must carry an explicit + * ``value`` while ``generated_query`` leaves that value empty. + */ +export const zAgentKnowledgeQueryConfig = z.object({ + mode: zAgentKnowledgeQueryMode, + value: z.string().nullish(), }) /** - * ComposerSavePayload + * AgentKnowledgeRerankingModelConfig */ -export const zComposerSavePayload = z.object({ - agent_soul: zAgentSoulConfig.nullish(), - binding: zComposerBindingPayload.nullish(), - client_revision_id: z.string().nullish(), - description: z.string().nullish(), - icon: z.string().max(255).nullish(), - icon_background: z.string().max(255).nullish(), - icon_type: zAgentIconType.nullish(), - idempotency_key: z.string().nullish(), - new_agent_name: z.string().min(1).max(255).nullish(), - node_job: zWorkflowNodeJobConfig.nullish(), - role: z.string().max(255).nullish(), - save_strategy: zComposerSaveStrategy, - soul_lock: zComposerSoulLockPayload.optional(), - variant: zComposerVariant, - version_note: z.string().nullish(), +export const zAgentKnowledgeRerankingModelConfig = z.object({ + model: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentKnowledgeWeightedScoreConfig + */ +export const zAgentKnowledgeWeightedScoreConfig = z.object({ + keyword_setting: z.record(z.string(), z.unknown()).nullish(), + vector_setting: z.record(z.string(), z.unknown()).nullish(), + weight_type: z.string().max(64).nullish(), +}) + +/** + * AgentKnowledgeRetrievalConfig + * + * Per-set retrieval policy for Agent v2 knowledge retrieval. + * + * Retrieval settings now live on each knowledge set instead of one shared + * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + * ``single`` retrieval with a required model config. + */ +export const zAgentKnowledgeRetrievalConfig = z.object({ + mode: z.enum(['multiple', 'single']), + model: zAgentKnowledgeModelConfig.nullish(), + reranking_enable: z.boolean().optional().default(true), + reranking_mode: z.string().optional().default('reranking_model'), + reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(), + score_threshold: z.number().gte(0).lte(1).nullish(), + top_k: z.int().gte(1).nullish(), + weights: zAgentKnowledgeWeightedScoreConfig.nullish(), }) /** @@ -3487,6 +3541,155 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ limit: z.int(), }) +/** + * AgentKnowledgeMetadataCondition + */ +export const zAgentKnowledgeMetadataCondition = z.object({ + comparison_operator: z.enum([ + '<', + '=', + '>', + 'after', + 'before', + 'contains', + 'empty', + 'end with', + 'in', + 'is', + 'is not', + 'not contains', + 'not empty', + 'not in', + 'start with', + '≠', + '≤', + '≥', + ]), + name: z.string().min(1).max(255), + value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(), +}) + +/** + * AgentKnowledgeMetadataConditions + */ +export const zAgentKnowledgeMetadataConditions = z.object({ + conditions: z.array(zAgentKnowledgeMetadataCondition).optional(), + logical_operator: z.enum(['and', 'or']).optional().default('and'), +}) + +/** + * AgentKnowledgeMetadataFilteringConfig + * + * Per-set metadata filtering policy. + * + * The Python attribute uses ``metadata_model_config`` for clarity because the + * model belongs to metadata filtering specifically, while the external API and + * generated schema keep the historical ``model_config`` field name via alias. + */ +export const zAgentKnowledgeMetadataFilteringConfig = z.object({ + conditions: zAgentKnowledgeMetadataConditions.nullish(), + mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'), + model_config: zAgentKnowledgeModelConfig.nullish(), +}) + +/** + * AgentKnowledgeSetConfig + * + * One explicit knowledge set in Agent v2. + * + * ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + * its datasets plus query, retrieval, and metadata policies. An individual + * set must contain at least one dataset id even though the overall knowledge + * section may be empty, which is how callers express "no knowledge layer". + */ +export const zAgentKnowledgeSetConfig = z.object({ + datasets: z.array(zAgentKnowledgeDatasetConfig), + description: z.string().nullish(), + id: z.string().min(1).max(255), + metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(), + name: z.string().min(1).max(255), + query: zAgentKnowledgeQueryConfig, + retrieval: zAgentKnowledgeRetrievalConfig, +}) + +/** + * AgentSoulKnowledgeConfig + * + * Top-level Agent v2 knowledge config. + * + * Agent v2 models knowledge as explicit sets instead of one flat + * ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + * list means no knowledge layer should be emitted at runtime, while set-name + * uniqueness stays case-insensitive because runtime selection addresses sets + * by name. + */ +export const zAgentSoulKnowledgeConfig = z.object({ + sets: z.array(zAgentKnowledgeSetConfig).optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + files: zAgentSoulFilesConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * WorkflowAgentComposerResponse + */ +export const zWorkflowAgentComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + agent: zAgentComposerAgentResponse.nullish(), + agent_soul: zAgentSoulConfig, + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), + binding: zAgentComposerBindingResponse.nullish(), + chat_endpoint: z.string().nullish(), + effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), + hidden_app_backed: z.boolean().optional().default(false), + impact_summary: zAgentComposerImpactResponse.nullish(), + node_id: z.string().nullish(), + node_job: zWorkflowNodeJobConfig, + save_options: z.array(zComposerSaveStrategy), + soul_lock: zAgentComposerSoulLockResponse, + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('workflow'), + workflow_id: z.string().nullish(), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + /** * GeneratedAppResponse */ @@ -3535,22 +3738,32 @@ export const zAppPaginationWritable = z.object({ }) /** - * Site + * AppDetailSiteResponse */ -export const zSiteWritable = z.object({ +export const zAppDetailSiteResponseWritable = z.object({ + access_token: z.string().nullish(), + app_base_url: z.string().nullish(), chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean(), + chat_color_theme_inverted: z.boolean().nullish(), + code: z.string().nullish(), copyright: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), custom_disclaimer: z.string().nullish(), - default_language: z.string(), + customize_domain: z.string().nullish(), + customize_token_strategy: z.string().nullish(), + default_language: z.string().nullish(), description: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: z.string().nullish(), + icon_type: z.union([z.string(), zIconType]).nullish(), privacy_policy: z.string().nullish(), - show_workflow_steps: z.boolean(), - title: z.string(), - use_icon_as_answer_icon: z.boolean(), + prompt_public: z.boolean().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), }) /** @@ -3577,7 +3790,7 @@ export const zAppDetailWithSiteWritable = z.object({ model_config: zModelConfig.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), - site: zSiteWritable.nullish(), + site: zAppDetailSiteResponseWritable.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -5333,6 +5546,10 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj node_id: z.string(), }) +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerQuery = z.object({ + snapshot_id: z.string().max(255).optional(), +}) + /** * Workflow agent composer state */ diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index f9a5eb01edc..c50f6316dce 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -31,6 +31,7 @@ export type AudioTranscriptResponse = { export type ChatMessagePayload = { conversation_id?: string | null + draft_type?: 'debug_build' | 'draft' files?: Array | null inputs: { [key: string]: unknown diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index a4556058506..2e397c25fb2 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -43,6 +43,7 @@ export const zAudioTranscriptResponse = z.object({ */ export const zChatMessagePayload = z.object({ conversation_id: z.string().nullish(), + draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'), files: z.array(z.unknown()).nullish(), inputs: z.record(z.string(), z.unknown()), model_config: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/iconify-collections/assets/vender/agent-v2/configure-active.svg b/packages/iconify-collections/assets/vender/agent-v2/configure-active.svg new file mode 100644 index 00000000000..9ad5ce5db2d --- /dev/null +++ b/packages/iconify-collections/assets/vender/agent-v2/configure-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/agent-v2/configure-build.svg b/packages/iconify-collections/assets/vender/agent-v2/configure-build.svg new file mode 100644 index 00000000000..fa32db16d8d --- /dev/null +++ b/packages/iconify-collections/assets/vender/agent-v2/configure-build.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/agent-v2/configure-preview.svg b/packages/iconify-collections/assets/vender/agent-v2/configure-preview.svg new file mode 100644 index 00000000000..7973118a153 --- /dev/null +++ b/packages/iconify-collections/assets/vender/agent-v2/configure-preview.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/agent-v2/configure.svg b/packages/iconify-collections/assets/vender/agent-v2/configure.svg new file mode 100644 index 00000000000..c8da02e46f3 --- /dev/null +++ b/packages/iconify-collections/assets/vender/agent-v2/configure.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 0f83cf3b3e8..bba5a879707 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1781515983, + "lastModified": 1782215796, "icons": { "agent-building-blocks": { "body": "" diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index 048c5af7553..f1aa8be7d73 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -1,14 +1,32 @@ { "prefix": "custom-vender", - "lastModified": 1781515983, + "lastModified": 1782365697, "icons": { "agent-v2-access-point": { "body": "", "width": 15, "height": 15 }, + "agent-v2-configure": { + "body": "", + "width": 14 + }, + "agent-v2-configure-active": { + "body": "", + "width": 14 + }, + "agent-v2-configure-build": { + "body": "", + "width": 16 + }, + "agent-v2-configure-preview": { + "body": "", + "width": 15, + "height": 15 + }, "agent-v2-end-user-auth": { - "body": "" + "body": "", + "width": 16 }, "agent-v2-plan": { "body": "", @@ -17,8 +35,8 @@ }, "agent-v2-prompt-insert": { "body": "", - "width": 14, - "height": 14 + "height": 14, + "width": 14 }, "agent-v2-robot-3": { "body": "", diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 608b5020f04..57770b38ddc 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 326, + "total": 330, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", @@ -14,11 +14,11 @@ }, "samples": [ "agent-v2-access-point", - "agent-v2-end-user-auth", - "agent-v2-plan", - "agent-v2-prompt-insert", - "agent-v2-robot-3", - "features-citations" + "agent-v2-configure", + "agent-v2-configure-active", + "agent-v2-configure-build", + "agent-v2-configure-preview", + "agent-v2-end-user-auth" ], "palette": false } diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts new file mode 100644 index 00000000000..f6f6abe160c --- /dev/null +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { canEmbedPath } from '@/proxy' + +describe('proxy frame options', () => { + it('should allow embedded share routes', () => { + expect(canEmbedPath('/chatbot/token')).toBe(true) + expect(canEmbedPath('/workflow/token')).toBe(true) + expect(canEmbedPath('/completion/token')).toBe(true) + expect(canEmbedPath('/webapp-signin')).toBe(true) + expect(canEmbedPath('/agent/token')).toBe(true) + }) + + it('should deny non-embedded console routes by default', () => { + expect(canEmbedPath('/agents')).toBe(false) + expect(canEmbedPath('/agent-settings')).toBe(false) + expect(canEmbedPath('/agentic')).toBe(false) + expect(canEmbedPath('/roster/agent/agent-1/access')).toBe(false) + expect(canEmbedPath('/apps')).toBe(false) + }) +}) diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index ab7ec49acca..1f4e17cfec0 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -47,10 +47,12 @@ const NavLink = ({ const NavIcon = isActive ? iconMap.selected : iconMap.normal const isCollapsed = mode !== 'expand' + const borderClassName = 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px]' const linkClassName = cn( + borderClassName, isActive - ? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' - : 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', + ? 'border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' + : 'border-transparent system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center rounded-lg pr-1 pl-3', 'rounded-lg', ) @@ -68,7 +70,9 @@ const NavLink = ({ type="button" disabled className={cn( + borderClassName, 'cursor-not-allowed rounded-lg system-sm-medium text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover', + 'border-transparent', isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center pr-1 pl-3', )} title={mode === 'collapse' ? name : ''} diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index 11c2dd54bb1..694a9aec7ba 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -285,6 +285,18 @@ describe('app-card-utils', () => { expect(snippet).not.toContain('isDev: true') }) + it('should generate an agent embedded script route when requested', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'agent-token', + webAppRoute: 'agent', + primaryColor: '#1C64F2', + inputValues: {}, + }) + + expect(snippet).toContain('routeSegment: \'agent\'') + }) + it('should compress and encode base64 using CompressionStream when available', async () => { const result = await compressAndEncodeBase64('hello') expect(typeof result).toBe('string') diff --git a/web/app/components/app/overview/app-card-utils.ts b/web/app/components/app/overview/app-card-utils.ts index 9475da4300a..acca7e162a0 100644 --- a/web/app/components/app/overview/app-card-utils.ts +++ b/web/app/components/app/overview/app-card-utils.ts @@ -11,6 +11,7 @@ type OverviewCardType = 'api' | 'webapp' export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop' export type WorkflowLaunchInputValue = string | boolean +export type EmbeddedWebAppRoute = 'chatbot' | 'agent' export type WorkflowHiddenStartVariable = Pick< InputVar, 'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable' @@ -156,12 +157,14 @@ ${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\ export const getEmbeddedScriptSnippet = ({ url, token, + webAppRoute = 'chatbot', primaryColor, isTestEnv, inputValues, }: { url: string token: string + webAppRoute?: EmbeddedWebAppRoute primaryColor: string isTestEnv?: boolean inputValues: Record @@ -174,6 +177,9 @@ export const getEmbeddedScriptSnippet = ({ : ''}${IS_CE_EDITION ? `, baseUrl: '${url}${basePath}'` + : ''}${webAppRoute !== 'chatbot' + ? `, + routeSegment: '${webAppRoute}'` : ''}, inputs: ${getScriptInputsContent(inputValues)}, systemVariables: { diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 3affa339bf3..98b67db112c 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -13,7 +13,8 @@ type IShareLinkProps = { onClose: () => void api_base_url: string appId: string - mode: AppModeEnum + mode?: AppModeEnum + sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator' } const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -38,10 +39,12 @@ const CustomizeModal: FC = ({ appId, api_base_url, mode, + sourceCodeRepository, }) => { const { t } = useTranslation() const docLink = useDocLink() const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT + const repository = sourceCodeRepository ?? (isChatApp ? 'webapp-conversation' : 'webapp-text-generator') const apiDocLink = docLink('/use-dify/publish/developing-with-apis') return ( @@ -67,7 +70,7 @@ const CustomizeModal: FC = ({
{t(`${prefixCustomize}.way1.step1`, { ns: 'appOverview' })}
{t(`${prefixCustomize}.way1.step1Tip`, { ns: 'appOverview' })}
- @@ -78,7 +81,7 @@ const CustomizeModal: FC = ({
{t(`${prefixCustomize}.way1.step2`, { ns: 'appOverview' })}
{t(`${prefixCustomize}.way1.step2Tip`, { ns: 'appOverview' })}
- @@ -114,7 +117,7 @@ const CustomizeModal: FC = ({

{t(`${prefixCustomize}.way2.name`, { ns: 'appOverview' })}

{hiddenInputsCollapsed - ? - : } + ? + : } {!hiddenInputsCollapsed && (
@@ -307,7 +311,7 @@ const EmbeddedContent = ({ ) } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => { +const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, webAppRoute = 'chatbot', hiddenInputs, className }: Props) => { const { t } = useTranslation() return ( @@ -327,10 +331,11 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
{isShow && ( )} diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index b69298268a2..c45a6c2047a 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -231,15 +231,15 @@ describe('SettingsModal', () => { expect(mockOnClose).toHaveBeenCalled() }) - it('should collapse the expanded settings section immediately when closing', () => { + it('should keep one show-more trigger while toggling the advanced section', () => { renderSettingsModal() + expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1) + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + + expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1) expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() - - fireEvent.click(screen.getByText('common.operation.cancel')) - - expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument() }) it('should reset local form state when the controlled dialog reopens', () => { @@ -276,6 +276,7 @@ describe('SettingsModal', () => { ) expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).not.toBeInTheDocument() }) it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => { diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 703e88dcee0..3a55011a43e 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -1,12 +1,15 @@ 'use client' import type { FC } from 'react' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' -import type { AppDetailResponse } from '@/models/app' -import type { AppIconType, AppSSO, Language } from '@/types/app' +import type { AppIconType, Language, SiteConfig } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { FieldControl, FieldDescription, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { Form } from '@langgenius/dify-ui/form' import { Input } from '@langgenius/dify-ui/input' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -29,13 +32,38 @@ import { AppModeEnum } from '@/types/app' type ISettingsModalProps = { isChat: boolean - appInfo: AppDetailResponse & Partial + appInfo: SettingsAppInfo isShow: boolean defaultValue?: string onClose: () => void onSave?: (params: ConfigParams) => Promise } +type SettingsSiteInfo = Pick< + SiteConfig, + | 'title' + | 'description' + | 'default_language' + | 'chat_color_theme' + | 'chat_color_theme_inverted' + | 'copyright' + | 'privacy_policy' + | 'custom_disclaimer' + | 'icon_type' + | 'icon' + | 'icon_background' + | 'icon_url' + | 'show_workflow_steps' + | 'use_icon_as_answer_icon' +> + +export type SettingsAppInfo = { + id: string + mode: AppModeEnum + enable_sso?: boolean + site: SettingsSiteInfo +} + export type ConfigParams = { title: string description: string @@ -124,7 +152,6 @@ const SettingsModal: FC = ({ onClose, onSave, }) => { - const [isShowMore, setIsShowMore] = useState(false) const { default_language } = appInfo.site const nextInputInfo = createInputInfo(appInfo) const nextAppIcon = createAppIcon(appInfo) @@ -164,17 +191,11 @@ const SettingsModal: FC = ({ setInputInfo(nextInputInfo) setLanguage(default_language) setAppIcon(nextAppIcon) - setIsShowMore(false) setPreviousSettingsResetKey(settingsResetKey) } } - const onHide = () => { - onClose() - setIsShowMore(false) - } - - const onClickSave = async () => { + const handleFormSubmit = async () => { if (!inputInfo.title) { toast.error(t('newApp.nameNotEmpty', { ns: 'app' })) return @@ -231,7 +252,7 @@ const SettingsModal: FC = ({ } await onSave?.(params) setSaveLoading(false) - onHide() + onClose() } const onChange = (field: string) => { @@ -252,8 +273,8 @@ const SettingsModal: FC = ({ return ( <> - !open && onHide()}> - + !open && onClose()} disablePointerDismissal> + {/* header */}
@@ -264,224 +285,227 @@ const SettingsModal: FC = ({ {t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}
- {/* form body */} -
- {/* name & icon */} -
-
-
{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}
- + {/* form body */} + + {/* name & icon */} +
+ + {t(`${prefixSettings}.webName`, { ns: 'appOverview' })} + setInputInfo(item => ({ ...item, title: value }))} + placeholder={t('appNamePlaceholder', { ns: 'app' }) || ''} + /> + + { setShowAppIconPicker(true) }} + className="mt-2 cursor-pointer" + iconType={appIcon.type} + icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} + background={appIcon.type === 'image' ? undefined : appIcon.background} + imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} />
- { setShowAppIconPicker(true) }} - className="mt-2 cursor-pointer" - iconType={appIcon.type} - icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} - background={appIcon.type === 'image' ? undefined : appIcon.background} - imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} - /> -
- {/* description */} -
-
{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
-