From f86bfb2a31acb8c5398dc6beca04ac7326319b28 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 15 Jul 2026 10:02:14 +0800 Subject: [PATCH] feat(agent): support new Agent DSL import and export (#38849) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/constants/dsl_version.py | 2 +- api/controllers/console/agent/composer.py | 201 +++ api/controllers/console/app/app.py | 3 +- .../console/snippets/snippet_workflow.py | 5 + api/controllers/console/workspace/snippets.py | 41 +- api/core/app/apps/agent_app/app_generator.py | 9 +- api/models/agent.py | 9 + api/models/model.py | 4 +- api/openapi/markdown/console-openapi.md | 145 +- api/openapi/markdown/openapi-openapi.md | 14 +- api/services/agent/composer_service.py | 7 +- api/services/agent/dsl_entities.py | 151 ++ api/services/agent/dsl_service.py | 711 +++++++++ api/services/agent/roster_service.py | 41 +- .../agent/workflow_publish_service.py | 211 ++- api/services/app_dsl_service.py | 85 +- api/services/app_service.py | 6 +- api/services/entities/dsl_entities.py | 10 + api/services/snippet_dsl_service.py | 56 +- api/services/snippet_service.py | 93 +- api/services/workflow_service.py | 44 +- .../services/test_app_dsl_service.py | 60 + .../console/workspace/test_snippets.py | 25 +- .../app/apps/agent_app/test_resolve_agent.py | 51 +- .../services/agent/test_agent_dsl_service.py | 729 +++++++++ .../services/agent/test_agent_services.py | 19 +- .../agent/test_workflow_publish_service.py | 281 ++++ .../services/test_snippet_dsl_service.py | 26 +- .../services/test_snippet_service.py | 52 +- .../generated/api/console/agent/types.gen.ts | 1 + .../generated/api/console/agent/zod.gen.ts | 1 + .../generated/api/console/apps/types.gen.ts | 11 + .../generated/api/console/apps/zod.gen.ts | 16 +- .../api/console/snippets/orpc.gen.ts | 218 ++- .../api/console/snippets/types.gen.ts | 933 ++++++++++++ .../generated/api/console/snippets/zod.gen.ts | 1301 +++++++++++++++++ .../api/console/workspaces/types.gen.ts | 10 + .../api/console/workspaces/zod.gen.ts | 13 + .../generated/api/openapi/types.gen.ts | 10 + .../generated/api/openapi/zod.gen.ts | 15 +- 40 files changed, 5477 insertions(+), 143 deletions(-) create mode 100644 api/services/agent/dsl_entities.py create mode 100644 api/services/agent/dsl_service.py create mode 100644 api/tests/unit_tests/services/agent/test_agent_dsl_service.py create mode 100644 api/tests/unit_tests/services/agent/test_workflow_publish_service.py diff --git a/api/constants/dsl_version.py b/api/constants/dsl_version.py index b0fbe0075c7..a2f13a9f23b 100644 --- a/api/constants/dsl_version.py +++ b/api/constants/dsl_version.py @@ -1 +1 @@ -CURRENT_APP_DSL_VERSION = "0.6.0" +CURRENT_APP_DSL_VERSION = "0.7.0" diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index f5d71990ade..b5be73f2596 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -2,6 +2,7 @@ from uuid import UUID from flask import request from flask_restx import Resource +from werkzeug.exceptions import NotFound from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -34,6 +35,7 @@ from services.entities.agent_entities import ( WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload, ) +from services.snippet_service import SnippetService register_schema_models( console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload @@ -237,6 +239,205 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): ) +def _require_snippet_app_id(*, tenant_id: str, snippet_id: UUID) -> str: + snippet = SnippetService(session=db.session()).get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=tenant_id, + ) + if snippet is None: + raise NotFound("Snippet not found") + return snippet.id + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer") +class SnippetAgentComposerApi(Resource): + @console_ns.response(200, "Snippet agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]) + @console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery)) + @setup_required + @login_required + @account_initialization_required + @with_current_user_id + @with_current_tenant_id + def get(self, tenant_id: str, account_id: str, snippet_id: UUID, 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=_require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id), + node_id=node_id, + account_id=account_id, + snapshot_id=query.snapshot_id, + session=db.session(), + ), + ) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response(200, "Snippet agent composer saved", console_ns.models[WorkflowAgentComposerResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) + @with_current_user_id + @with_current_tenant_id + def put(self, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.save_workflow_composer( + tenant_id=tenant_id, + app_id=_require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id), + node_id=node_id, + account_id=account_id, + payload=payload, + session=db.session(), + ), + ) + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer/copy-from-roster") +class SnippetAgentComposerCopyFromRosterApi(Resource): + @console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__]) + @console_ns.response( + 200, "Roster agent copied into snippet", console_ns.models[WorkflowAgentComposerResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) + @with_current_user_id + @with_current_tenant_id + def post(self, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str): + payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id=tenant_id, + app_id=_require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id), + node_id=node_id, + account_id=account_id, + source_agent_id=payload.source_agent_id, + source_snapshot_id=payload.source_snapshot_id, + idempotency_key=payload.idempotency_key, + session=db.session(), + ), + ) + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer/validate") +class SnippetAgentComposerValidateApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response( + 200, "Snippet agent composer validation", console_ns.models[AgentComposerValidateResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, tenant_id: str, snippet_id: UUID, node_id: str): + app_id = _require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_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, + agent_id=AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=tenant_id, + app_id=app_id, + node_id=node_id, + session=db.session(), + ), + session=db.session(), + ) + return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer/candidates") +class SnippetAgentComposerCandidatesApi(Resource): + @console_ns.response( + 200, "Snippet agent composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user_id + @with_current_tenant_id + def get(self, tenant_id: str, current_user_id: str, snippet_id: UUID, node_id: str): + return dump_response( + AgentComposerCandidatesResponse, + AgentComposerService.get_workflow_candidates( + tenant_id=tenant_id, + app_id=_require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id), + node_id=node_id, + user_id=current_user_id, + session=db.session(), + ), + ) + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer/impact") +class SnippetAgentComposerImpactApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response(200, "Snippet agent composer impact", console_ns.models[AgentComposerImpactResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, tenant_id: str, snippet_id: UUID, node_id: str): + _require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id) + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None + if not current_snapshot_id: + return dump_response( + AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []} + ) + return dump_response( + AgentComposerImpactResponse, + AgentComposerService.calculate_impact( + tenant_id=tenant_id, + current_snapshot_id=current_snapshot_id, + session=db.session(), + ), + ) + + +@console_ns.route("/snippets//workflows/draft/nodes//agent-composer/save-to-roster") +class SnippetAgentComposerSaveToRosterApi(Resource): + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response( + 200, "Snippet agent saved to roster", console_ns.models[WorkflowAgentComposerResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) + @with_current_user_id + @with_current_tenant_id + def post(self, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.save_workflow_composer( + tenant_id=tenant_id, + app_id=_require_snippet_app_id(tenant_id=tenant_id, snippet_id=snippet_id), + node_id=node_id, + account_id=account_id, + payload=payload, + session=db.session(), + ), + ) + + @console_ns.route("/agent//composer") class AgentComposerApi(Resource): @console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__]) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 65e9357d9ee..07820e0a31b 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -55,7 +55,7 @@ from services.app_dsl_service import AppDslService from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService -from services.entities.dsl_entities import ImportMode, ImportStatus +from services.entities.dsl_entities import DslImportWarning, ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( DataSource, InfoList, @@ -461,6 +461,7 @@ class AppImportResponse(ResponseModel): current_dsl_version: str imported_dsl_version: str = "" error: str = "" + warnings: list[DslImportWarning] = Field(default_factory=list) def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None: diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index f9404e55cb0..558d50930c5 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -56,6 +56,7 @@ from libs.helper import TimestampField from libs.login import current_account_with_tenant, login_required from models import Account from models.snippet import CustomizedSnippet +from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.snippet_generate_service import SnippetGenerateService from services.snippet_service import SnippetService @@ -177,6 +178,10 @@ class SnippetDraftWorkflowApi(Resource): workflow.conversation_variables = [] response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=db.session(), + draft_workflow=workflow, + ) response["input_fields"] = snippet.input_fields_list return response diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index 75ba9b64fd9..faf56f8e715 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -14,6 +14,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns +from controllers.console.app.wraps import with_session from controllers.console.snippets.payloads import ( CreateSnippetPayload, IncludeSecretQuery, @@ -39,6 +40,7 @@ from libs.helper import dump_response from libs.login import login_required from models import Account from models.snippet import SnippetType +from services.entities.dsl_entities import DslImportWarning from services.snippet_dsl_service import ImportStatus, SnippetDslService from services.snippet_service import SnippetService @@ -52,6 +54,7 @@ class SnippetImportResponse(ResponseModel): current_dsl_version: str imported_dsl_version: str error: str + warnings: list[DslImportWarning] class SnippetDependencyCheckResponse(ResponseModel): @@ -256,8 +259,9 @@ class CustomizedSnippetDetailApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) + @with_current_user @with_current_tenant_id - def delete(self, current_tenant_id: str, snippet_id: str): + def delete(self, current_tenant_id: str, current_user: Account, snippet_id: str): """Delete customized snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( @@ -273,6 +277,7 @@ class CustomizedSnippetDetailApi(Resource): SnippetService.delete_snippet( session=session, snippet=snippet, + account_id=current_user.id, ) session.commit() @@ -343,22 +348,21 @@ class CustomizedSnippetImportApi(Resource): RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False ) @with_current_user - def post(self, current_user: Account): + @with_session + def post(self, session: Session, current_user: Account): """Import snippet from DSL.""" payload = SnippetImportPayload.model_validate(console_ns.payload or {}) - with Session(db.engine) as session: - import_service = SnippetDslService(session) - result = import_service.import_snippet( - account=current_user, - import_mode=payload.mode, - yaml_content=payload.yaml_content, - yaml_url=payload.yaml_url, - snippet_id=payload.snippet_id, - name=payload.name, - description=payload.description, - ) - session.commit() + import_service = SnippetDslService(session) + result = import_service.import_snippet( + account=current_user, + import_mode=payload.mode, + yaml_content=payload.yaml_content, + yaml_url=payload.yaml_url, + snippet_id=payload.snippet_id, + name=payload.name, + description=payload.description, + ) # Return appropriate status code based on result status = result.status @@ -384,12 +388,11 @@ class CustomizedSnippetImportConfirmApi(Resource): RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False ) @with_current_user - def post(self, current_user: Account, import_id: str): + @with_session + def post(self, session: Session, current_user: Account, import_id: str): """Confirm a pending snippet import.""" - with Session(db.engine) as session: - import_service = SnippetDslService(session) - result = import_service.confirm_import(import_id=import_id, account=current_user) - session.commit() + import_service = SnippetDslService(session) + result = import_service.confirm_import(import_id=import_id, account=current_user) if result.status == ImportStatus.FAILED: return result.model_dump(mode="json"), 400 diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 4c10004fc7e..da802680c08 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -51,6 +51,7 @@ from core.workflow.file_reference import build_file_reference, is_canonical_file from extensions.ext_database import db from models import Account, App, EndUser, Message from models.agent import ( + APP_BACKED_AGENT_SOURCES, Agent, AgentConfigDraft, AgentConfigDraftType, @@ -580,7 +581,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): and_( Agent.app_id == app_model.id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), ), Agent.backing_app_id == app_model.id, ), @@ -590,6 +591,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") + if ( + agent.source == AgentSource.IMPORTED + and not agent.active_config_is_published + and invoke_from != InvokeFrom.DEBUGGER + ): + raise AgentAppNotPublishedError("Agent has not been published") if invoke_from == InvokeFrom.DEBUGGER: draft = self._resolve_debug_draft( tenant_id=app_model.tenant_id, diff --git a/api/models/agent.py b/api/models/agent.py index f91a05266ee..467d7a4b753 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -50,6 +50,13 @@ class AgentSource(StrEnum): SYSTEM = "system" +# Source records provenance. Product capability is determined by scope and +# backing ownership, so imported resources participate in both supported Agent +# surfaces instead of being filtered out by their origin. +APP_BACKED_AGENT_SOURCES = (AgentSource.AGENT_APP, AgentSource.IMPORTED) +WORKFLOW_ONLY_AGENT_SOURCES = (AgentSource.WORKFLOW, AgentSource.IMPORTED) + + class AgentIconType(StrEnum): """Supported icon storage formats for Agent roster entries.""" @@ -87,6 +94,8 @@ class AgentConfigRevisionOperation(StrEnum): RESTORE_VERSION = "restore_version" # Publishes the editable Agent Soul draft as a new immutable version. PUBLISH_DRAFT = "publish_draft" + # Seeds a new Agent from a portable DSL package. + IMPORT_PACKAGE = "import_package" class AgentConfigDraftType(StrEnum): diff --git a/api/models/model.py b/api/models/model.py index ac26f0cd80b..2d7c6e0c053 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -483,7 +483,7 @@ class App(Base): """ if self.mode != AppMode.AGENT: return None - from .agent import Agent, AgentScope, AgentSource, AgentStatus + from .agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus agent = db.session.scalar( select(Agent).where( @@ -492,7 +492,7 @@ class App(Base): sa.and_( Agent.app_id == self.id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), ), Agent.backing_app_id == self.id, ), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f033d7808b3..76993e0cbec 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -8989,6 +8989,135 @@ Returns an SSE event stream with loop progress and results. | 200 | Loop node run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| | 404 | Snippet or draft workflow not found | | +### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snapshot_id | query | | No | string | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent composer state | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [PUT] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent composer saved | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| + +### [POST] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowComposerCopyFromRosterPayload](#workflowcomposercopyfromrosterpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Roster agent copied into snippet | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [POST] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/impact +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent composer impact | **application/json**: [AgentComposerImpactResponse](#agentcomposerimpactresponse)
| + +### [POST] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent saved to roster | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [POST] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet agent composer validation | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| + ### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run **Get the last run result for a specific node in snippet draft workflow** @@ -15281,6 +15410,7 @@ This class is used to store the schema information of an api based tool. | id | string | | Yes | | imported_dsl_version | string | | No | | status | [ImportStatus](#importstatus) | | Yes | +| warnings | [ [DslImportWarning](#dslimportwarning) ] | | No | #### AppListQuery @@ -17305,6 +17435,17 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | node_id | string | Node ID | Yes | +#### DslImportWarning + +Portable DSL reference that could not be restored in the target workspace. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| details | object | | No | +| message | string | | Yes | +| path | string | | Yes | + #### EducationActivatePayload | Name | Type | Description | Required | @@ -18276,12 +18417,13 @@ How Dify forwards the end-user's identity to an MCP server. | ---- | ---- | ----------- | -------- | | app_id | string | | No | | app_mode | string | | No | -| current_dsl_version | string,
**Default:** 0.6.0 | | No | +| current_dsl_version | string,
**Default:** 0.7.0 | | No | | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | | permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | +| warnings | [ [DslImportWarning](#dslimportwarning) ] | | No | #### ImportStatus @@ -21426,6 +21568,7 @@ Payload for importing snippet from DSL. | imported_dsl_version | string | | Yes | | snippet_id | string | | Yes | | status | [ImportStatus](#importstatus) | | Yes | +| warnings | [ [DslImportWarning](#dslimportwarning) ] | | Yes | #### SnippetIterationNodeRunPayload diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 91a921ee31d..e67b1f87d10 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -712,6 +712,17 @@ mode is a closed enum of listable app types. | token_id | string | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | +#### DslImportWarning + +Portable DSL reference that could not be restored in the target workspace. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| details | object | | No | +| message | string | | Yes | +| path | string | | Yes | + #### ErrorBody Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the @@ -809,12 +820,13 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | ---- | ---- | ----------- | -------- | | app_id | string | | No | | app_mode | string | | No | -| current_dsl_version | string,
**Default:** 0.6.0 | | No | +| current_dsl_version | string,
**Default:** 0.7.0 | | No | | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | | permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | +| warnings | [ [DslImportWarning](#dslimportwarning) ] | | No | #### ImportStatus diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 3f4c50a794d..bd73fca171e 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -11,6 +11,7 @@ from extensions.ext_database import db from libs.helper import to_timestamp from models import Account from models.agent import ( + APP_BACKED_AGENT_SOURCES, Agent, AgentConfigDraft, AgentConfigDraftType, @@ -548,7 +549,7 @@ class AgentComposerService: ) if not active_version: return False - if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision( + if agent.source in APP_BACKED_AGENT_SOURCES and not cls._has_publish_visible_revision( tenant_id=tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id, @@ -589,7 +590,7 @@ class AgentComposerService: session: Session, ) -> dict[str, Any]: agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id, session=session) - if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP: + if agent.scope != AgentScope.ROSTER or agent.source not in APP_BACKED_AGENT_SOURCES: raise AgentNotFoundError() draft = cls._get_or_create_agent_draft( tenant_id=tenant_id, @@ -1755,7 +1756,7 @@ class AgentComposerService: Agent.tenant_id == tenant_id, Agent.app_id == app_id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.status == AgentStatus.ACTIVE, ) .order_by(Agent.created_at.desc()) diff --git a/api/services/agent/dsl_entities.py b/api/services/agent/dsl_entities.py new file mode 100644 index 00000000000..3c00b2a7f5a --- /dev/null +++ b/api/services/agent/dsl_entities.py @@ -0,0 +1,151 @@ +"""Portable Agent package DTOs and workspace-sensitive value filtering.""" + +from __future__ import annotations + +import hashlib +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from models.agent import Agent +from models.agent_config_entities import AgentSoulConfig + +AGENT_PACKAGE_SCHEMA_VERSION = 1 +AGENT_PACKAGE_REF_KEY = "package_ref" +AGENT_NODE_JOB_DSL_KEY = "agent_job" + + +class AgentPackageMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str = "" + role: str = "" + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + + +class AgentPackageOmittedAsset(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["skill", "file"] + name: str + size: int | None = None + hash: str | None = None + mime_type: str | None = None + + +class AgentPackage(BaseModel): + """One portable Agent Soul with display metadata and omitted asset hints.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] = 1 + metadata: AgentPackageMetadata + soul: AgentSoulConfig + omitted_assets: list[AgentPackageOmittedAsset] = Field(default_factory=list) + + +def portable_ref(prefix: str, value: str) -> str: + digest = hashlib.sha256(value.encode()).hexdigest()[:16] + return f"{prefix}-{digest}" + + +def _strip_sensitive_values(value: Any) -> Any: + """Remove nested credential, secret, and uploaded-file locators.""" + + if isinstance(value, list): + return [_strip_sensitive_values(item) for item in value] + if not isinstance(value, dict): + return value + + result: dict[str, Any] = {} + for key, item in value.items(): + normalized_key = key.lower() + is_sensitive = ( + "credential" in normalized_key + or "secret" in normalized_key + or "password" in normalized_key + or normalized_key in {"api_key", "token", "access_token", "refresh_token"} + or normalized_key.endswith("file_id") + or normalized_key == "upload_file_id" + ) + result[key] = None if is_sensitive else _strip_sensitive_values(item) + return result + + +def make_portable_agent_package(agent: Agent, agent_soul: AgentSoulConfig) -> AgentPackage: + """Return a package safe to place in YAML or the system clipboard.""" + + soul_data = agent_soul.model_dump(mode="json") + omitted_assets = [ + AgentPackageOmittedAsset( + kind="skill", + name=item.name, + size=item.size, + hash=item.hash, + mime_type=item.mime_type, + ) + for item in agent_soul.config_skills + ] + omitted_assets.extend( + AgentPackageOmittedAsset( + kind="file", + name=item.name, + size=item.size, + hash=item.hash, + mime_type=item.mime_type, + ) + for item in agent_soul.config_files + ) + soul_data["config_skills"] = [] + soul_data["config_files"] = [] + + if soul_data.get("model"): + soul_data["model"]["credential_ref"] = None + + for tool in soul_data.get("tools", {}).get("dify_tools", []): + tool["credential_type"] = "unauthorized" + tool["credential_ref"] = None + tool["runtime_parameters"] = _strip_sensitive_values(tool.get("runtime_parameters", {})) + + for tool in soul_data.get("tools", {}).get("cli_tools", []): + env = tool.get("env") or {} + env["secret_refs"] = [ + { + key: secret_ref.get(key) + for key in ("name", "key", "env_name", "variable", "type", "provider") + if secret_ref.get(key) is not None + } + for secret_ref in env.get("secret_refs", []) + ] + tool["env"] = env + + soul_data.setdefault("env", {})["secret_refs"] = [ + { + key: secret_ref.get(key) + for key in ("name", "key", "env_name", "variable", "type", "provider") + if secret_ref.get(key) is not None + } + for secret_ref in soul_data.get("env", {}).get("secret_refs", []) + ] + + for contact in soul_data.get("human", {}).get("contacts", []): + for key in ("id", "contact_id", "human_id", "tenant_id"): + contact[key] = None + + portable_soul = AgentSoulConfig.model_validate(soul_data) + icon_type = agent.icon_type.value if agent.icon_type is not None else None + return AgentPackage( + metadata=AgentPackageMetadata( + name=agent.name, + description=agent.description or "", + role=agent.role or "", + icon_type=icon_type, + icon=agent.icon, + icon_background=agent.icon_background, + ), + soul=portable_soul, + omitted_assets=omitted_assets, + ) diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py new file mode 100644 index 00000000000..62192d9f6c9 --- /dev/null +++ b/api/services/agent/dsl_service.py @@ -0,0 +1,711 @@ +"""Portable Agent package serialization and materialization. + +Agent runtime configuration is split across immutable Soul snapshots and +workflow-node bindings, while App and Snippet DSLs must be independent of the +source workspace's database identifiers. This module owns that translation. +It deliberately excludes drive payloads and stored credentials from portable +packages; same-workspace copies may use the separate server-side clone path. +""" + +from __future__ import annotations + +import copy +import json +import logging +from collections.abc import Mapping +from typing import Any, cast + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import event, func, select +from sqlalchemy.orm import Session + +from constants.model_template import default_app_templates +from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator +from events.app_event import app_was_created +from graphon.enums import BuiltinNodeTypes +from models import Account +from models.agent import ( + APP_BACKED_AGENT_SOURCES, + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentIconType, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from models.model import App, AppMode, AppModelConfig, IconType +from models.workflow import Workflow +from services.agent.agent_soul_state import agent_soul_has_model +from services.agent.dsl_entities import ( + AGENT_NODE_JOB_DSL_KEY, + AGENT_PACKAGE_REF_KEY, + AgentPackage, + AgentPackageMetadata, + make_portable_agent_package, + portable_ref, +) +from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows +from services.agent.roster_service import AgentRosterService +from services.entities.dsl_entities import DslImportWarning +from services.plugin.dependencies_analysis import DependenciesAnalysisService + +logger = logging.getLogger(__name__) + + +class AgentPackageImportResult(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + agent: Agent + snapshot: AgentConfigSnapshot + warnings: list[DslImportWarning] = Field(default_factory=list) + + +class AgentDslService: + """Coordinates portable Agent packages with persisted Agent resources.""" + + session: Session + + def __init__(self, session: Session) -> None: + self.session = session + + def export_agent_app(self, *, app: App) -> tuple[str, dict[str, AgentPackage]]: + """Export the editable shared Agent draft, falling back to the active snapshot.""" + + agent = self.session.scalar( + select(Agent) + .where( + Agent.tenant_id == app.tenant_id, + Agent.app_id == app.id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None: + raise ValueError("Agent App has no active backing Agent.") + + draft = self.session.scalar( + select(AgentConfigDraft) + .where( + AgentConfigDraft.tenant_id == app.tenant_id, + AgentConfigDraft.agent_id == agent.id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.draft_owner_key == "", + ) + .limit(1) + ) + if draft is not None: + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + else: + snapshot = self._require_snapshot( + tenant_id=app.tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ) + soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + + package_ref = "agent_1" + return package_ref, {package_ref: make_portable_agent_package(agent, soul)} + + def export_workflow_packages( + self, *, workflow: Workflow, graph: Mapping[str, Any] + ) -> tuple[dict[str, Any], dict[str, AgentPackage]]: + """Replace persisted Agent binding ids with portable package references.""" + + portable_graph = copy.deepcopy(dict(graph)) + agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(portable_graph)) + if not agent_nodes: + return portable_graph, {} + + bindings = self.session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id, + WorkflowAgentNodeBinding.workflow_id == workflow.id, + WorkflowAgentNodeBinding.workflow_version == workflow.version, + WorkflowAgentNodeBinding.node_id.in_(list(agent_nodes)), + ) + ).all() + bindings_by_node = {binding.node_id: binding for binding in bindings} + packages: dict[str, AgentPackage] = {} + package_refs_by_source: dict[tuple[str, str], str] = {} + + for node_id, raw_node_data in agent_nodes.items(): + node_data = cast(dict[str, Any], raw_node_data) + binding = bindings_by_node.get(node_id) + if binding is None or not binding.agent_id or not binding.current_snapshot_id: + raise ValueError(f"Workflow Agent node {node_id} has no complete persisted binding.") + agent = self._require_agent(tenant_id=workflow.tenant_id, agent_id=binding.agent_id) + snapshot = self._require_snapshot( + tenant_id=workflow.tenant_id, + agent_id=agent.id, + snapshot_id=binding.current_snapshot_id, + ) + source_key = (agent.id, snapshot.id) + package_ref = package_refs_by_source.get(source_key) + if package_ref is None: + package_ref = f"agent_{len(packages) + 1}" + package_refs_by_source[source_key] = package_ref + packages[package_ref] = make_portable_agent_package( + agent, + AgentSoulConfig.model_validate(snapshot.config_snapshot_dict), + ) + node_data["agent_binding"] = { + "binding_type": binding.binding_type.value, + AGENT_PACKAGE_REF_KEY: package_ref, + } + node_data[AGENT_NODE_JOB_DSL_KEY] = WorkflowNodeJobConfig.model_validate( + binding.node_job_config_dict + ).model_dump(mode="json") + + return portable_graph, packages + + @staticmethod + def graph_without_package_bindings(graph: Mapping[str, Any]) -> dict[str, Any]: + """Return a graph that can be created before package ids are materialized.""" + + result = copy.deepcopy(dict(graph)) + for _node_id, raw_node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(result): + node_data = cast(dict[str, Any], raw_node_data) + binding = node_data.get("agent_binding") + if isinstance(binding, Mapping) and binding.get(AGENT_PACKAGE_REF_KEY): + node_data.pop("agent_binding", None) + node_data.pop(AGENT_NODE_JOB_DSL_KEY, None) + return result + + def import_agent_app_package( + self, + *, + app: App, + account: Account, + package: AgentPackage, + ) -> AgentPackageImportResult: + """Create the imported backing Agent and its editable unpublished draft.""" + + soul, warnings = self._resolve_package_soul( + tenant_id=app.tenant_id, + package=package, + package_path="agent", + ) + if app.app_model_config is None: + model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id) + self.session.add(model_config) + self.session.flush() + app.app_model_config_id = model_config.id + + metadata = package.metadata + agent = AgentRosterService(self.session).create_backing_agent_for_app( + tenant_id=app.tenant_id, + account_id=account.id, + app_id=app.id, + name=self._unique_roster_name(tenant_id=app.tenant_id, requested=app.name or metadata.name), + description=app.description or metadata.description, + role=metadata.role, + icon_type=self._agent_icon_type(metadata.icon_type), + icon=metadata.icon, + icon_background=metadata.icon_background, + source=AgentSource.IMPORTED, + initial_soul=soul, + revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, + ) + snapshot = self._require_snapshot( + tenant_id=app.tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ) + self.session.add( + AgentConfigDraft( + tenant_id=app.tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=soul, + created_by=account.id, + updated_by=account.id, + ) + ) + agent.active_config_is_published = False + app.name = app.name or metadata.name + if not app.description: + app.description = metadata.description + self.session.flush() + return AgentPackageImportResult(agent=agent, snapshot=snapshot, warnings=warnings) + + def import_workflow_packages( + self, + *, + workflow: Workflow, + portable_graph: Mapping[str, Any], + raw_packages: Mapping[str, Any], + account: Account, + ) -> tuple[dict[str, Any], list[DslImportWarning]]: + """Materialize packages and bindings for a Workflow or Snippet draft.""" + + graph = copy.deepcopy(dict(portable_graph)) + packages = {key: AgentPackage.model_validate(value) for key, value in raw_packages.items()} + previous_bindings = self.session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == workflow.id, + WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT, + ) + ).all() + for binding in previous_bindings: + self.session.delete(binding) + self.session.flush() + imported_roster: dict[str, AgentPackageImportResult] = {} + warnings: list[DslImportWarning] = [] + + for node_id, raw_node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph): + node_data = cast(dict[str, Any], raw_node_data) + raw_binding = node_data.get("agent_binding") + if not isinstance(raw_binding, Mapping): + continue + package_ref = raw_binding.get(AGENT_PACKAGE_REF_KEY) + if not isinstance(package_ref, str): + continue + package = packages.get(package_ref) + if package is None: + raise ValueError(f"Workflow Agent node {node_id} references unknown package {package_ref!r}.") + + try: + binding_type = WorkflowAgentBindingType(str(raw_binding.get("binding_type"))) + except ValueError as exc: + raise ValueError(f"Workflow Agent node {node_id} has an invalid binding type.") from exc + + if binding_type == WorkflowAgentBindingType.ROSTER_AGENT: + imported = imported_roster.get(package_ref) + if imported is None: + imported = self._create_imported_roster_agent_app( + tenant_id=workflow.tenant_id, + account=account, + package=package, + package_path=f"agent_packages.{package_ref}", + ) + imported_roster[package_ref] = imported + else: + imported = self._create_imported_inline_agent( + workflow=workflow, + node_id=node_id, + account=account, + package=package, + package_path=f"agent_packages.{package_ref}", + ) + + node_job = WorkflowNodeJobConfig.model_validate(node_data.get(AGENT_NODE_JOB_DSL_KEY) or {}) + self.session.add( + WorkflowAgentNodeBinding( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + workflow_version=workflow.version, + node_id=node_id, + binding_type=binding_type, + agent_id=imported.agent.id, + current_snapshot_id=imported.snapshot.id, + node_job_config=node_job, + created_by=account.id, + updated_by=account.id, + ) + ) + node_data["agent_binding"] = { + "binding_type": binding_type.value, + "agent_id": imported.agent.id, + "current_snapshot_id": imported.snapshot.id, + } + node_data.pop(AGENT_NODE_JOB_DSL_KEY, None) + warnings.extend(imported.warnings) + + workflow.graph = json.dumps(graph) + self.session.flush() + return graph, warnings + + def clone_inline_binding_for_node( + self, + *, + workflow: Workflow, + node_id: str, + source_agent: Agent, + source_snapshot: AgentConfigSnapshot, + node_job: WorkflowNodeJobConfig, + account_id: str, + ) -> tuple[Agent, AgentConfigSnapshot]: + """Clone a same-workspace Inline Agent for a pasted target node.""" + + soul = AgentSoulConfig.model_validate(source_snapshot.config_snapshot_dict) + metadata = AgentPackageMetadata( + name=source_agent.name, + description=source_agent.description, + role=source_agent.role, + icon_type=source_agent.icon_type.value if source_agent.icon_type else None, + icon=source_agent.icon, + icon_background=source_agent.icon_background, + ) + agent, snapshot = self._create_workflow_only_agent( + workflow=workflow, + node_id=node_id, + account_id=account_id, + metadata=metadata, + soul=soul, + source=AgentSource.WORKFLOW, + operation=AgentConfigRevisionOperation.CREATE_VERSION, + ) + from services.agent.composer_service import AgentComposerService + + AgentComposerService._copy_agent_drive_rows( + tenant_id=workflow.tenant_id, + source_agent_id=source_agent.id, + target_agent_id=agent.id, + account_id=account_id, + agent_soul=soul, + node_job=node_job, + session=self.session, + ) + return agent, snapshot + + def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]: + dependencies: list[str] = [] + for package in packages.values(): + soul = package.soul + if soul.model is not None: + dependencies.append( + DependenciesAnalysisService.analyze_model_provider_dependency(soul.model.model_provider) + ) + for tool in soul.tools.dify_tools: + provider_id = tool.provider_id or ( + f"{tool.plugin_id}/{tool.provider}" if tool.plugin_id and tool.provider else None + ) + if provider_id: + dependencies.append(DependenciesAnalysisService.analyze_tool_dependency(provider_id)) + for knowledge_set in soul.knowledge.sets: + retrieval = knowledge_set.retrieval + if retrieval.model is not None: + dependencies.append( + DependenciesAnalysisService.analyze_model_provider_dependency(retrieval.model.provider) + ) + if retrieval.reranking_model is not None: + dependencies.append( + DependenciesAnalysisService.analyze_model_provider_dependency( + retrieval.reranking_model.provider + ) + ) + return dependencies + + def _create_imported_roster_agent_app( + self, + *, + tenant_id: str, + account: Account, + package: AgentPackage, + package_path: str, + ) -> AgentPackageImportResult: + metadata = package.metadata + app_template = dict(default_app_templates[AppMode.AGENT]["app"]) + app = App(**app_template) + app.name = metadata.name + app.description = metadata.description + app.mode = AppMode.AGENT + app.icon_type = self._app_icon_type(metadata.icon_type) + app.icon = metadata.icon + app.icon_background = metadata.icon_background + app.tenant_id = tenant_id + app.enable_site = True + app.enable_api = True + app.created_by = account.id + app.maintainer = account.id + app.updated_by = account.id + self.session.add(app) + self.session.flush() + app_was_created.send(app, account=account) + self._configure_visible_agent_app_after_commit( + tenant_id=tenant_id, + app_id=app.id, + account_id=account.id, + ) + result = self.import_agent_app_package(app=app, account=account, package=package) + result.warnings = [ + warning.model_copy(update={"path": f"{package_path}.{warning.path}"}) for warning in result.warnings + ] + return result + + def _create_imported_inline_agent( + self, + *, + workflow: Workflow, + node_id: str, + account: Account, + package: AgentPackage, + package_path: str, + ) -> AgentPackageImportResult: + soul, warnings = self._resolve_package_soul( + tenant_id=workflow.tenant_id, + package=package, + package_path=package_path, + ) + agent, snapshot = self._create_workflow_only_agent( + workflow=workflow, + node_id=node_id, + account_id=account.id, + metadata=package.metadata, + soul=soul, + source=AgentSource.IMPORTED, + operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, + ) + return AgentPackageImportResult(agent=agent, snapshot=snapshot, warnings=warnings) + + def _create_workflow_only_agent( + self, + *, + workflow: Workflow, + node_id: str, + account_id: str, + metadata: AgentPackageMetadata, + soul: AgentSoulConfig, + source: AgentSource, + operation: AgentConfigRevisionOperation, + ) -> tuple[Agent, AgentConfigSnapshot]: + backing_app = AgentRosterService(self.session).create_hidden_backing_app_for_workflow_agent( + tenant_id=workflow.tenant_id, + account_id=account_id, + name=metadata.name, + description=metadata.description, + icon_type=metadata.icon_type, + icon=metadata.icon, + icon_background=metadata.icon_background, + ) + agent = Agent( + tenant_id=workflow.tenant_id, + name=metadata.name, + description=metadata.description, + role=metadata.role, + icon_type=self._agent_icon_type(metadata.icon_type), + icon=metadata.icon, + icon_background=metadata.icon_background, + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=source, + app_id=workflow.app_id, + backing_app_id=backing_app.id, + workflow_id=workflow.id, + workflow_node_id=node_id, + status=AgentStatus.ACTIVE, + created_by=account_id, + updated_by=account_id, + ) + self.session.add(agent) + self.session.flush() + snapshot = self._create_snapshot( + tenant_id=workflow.tenant_id, + agent=agent, + account_id=account_id, + soul=soul, + operation=operation, + ) + agent.active_config_snapshot_id = snapshot.id + agent.active_config_has_model = agent_soul_has_model(soul) + agent.active_config_is_published = True + self.session.flush() + return agent, snapshot + + def _resolve_package_soul( + self, + *, + tenant_id: str, + package: AgentPackage, + package_path: str, + ) -> tuple[AgentSoulConfig, list[DslImportWarning]]: + soul_data = package.soul.model_dump(mode="json") + dataset_ids = [ + dataset["id"] + for knowledge_set in soul_data.get("knowledge", {}).get("sets", []) + for dataset in knowledge_set.get("datasets", []) + if dataset.get("id") + ] + existing = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids) + warnings = [ + DslImportWarning( + code=f"agent_{asset.kind}_omitted", + path=f"{package_path}.omitted_assets", + message=f"Agent {asset.kind} {asset.name!r} was not included in the portable package.", + details={"kind": asset.kind, "name": asset.name}, + ) + for asset in package.omitted_assets + ] + for tool_index, tool in enumerate(package.soul.tools.dify_tools): + tool_label = tool.tool_name or tool.provider or tool.provider_id + warnings.append( + DslImportWarning( + code="agent_tool_authorization_required", + path=f"{package_path}.soul.tools.dify_tools.{tool_index}", + message=f"Agent tool {tool_label!r} requires authorization.", + details={ + "provider": tool.provider or tool.provider_id, + "tool_name": tool.tool_name, + }, + ) + ) + secret_refs = list(package.soul.env.secret_refs) + for cli_tool in package.soul.tools.cli_tools: + secret_refs.extend(cli_tool.env.secret_refs) + for secret_ref in secret_refs: + secret_name = secret_ref.name or secret_ref.env_name or secret_ref.key + warnings.append( + DslImportWarning( + code="agent_secret_required", + path=f"{package_path}.soul.env.secret_refs", + message=f"Agent secret {secret_name!r} must be configured.", + details={"name": secret_name}, + ) + ) + for contact_index, contact in enumerate(package.soul.human.contacts): + warnings.append( + DslImportWarning( + code="agent_human_contact_unresolved", + path=f"{package_path}.soul.human.contacts.{contact_index}", + message=f"Human contact {contact.name or contact.email or 'contact'!r} must be reselected.", + details={"name": contact.name, "email": contact.email}, + ) + ) + for set_index, knowledge_set in enumerate(soul_data.get("knowledge", {}).get("sets", [])): + for dataset_index, dataset in enumerate(knowledge_set.get("datasets", [])): + dataset_id = dataset.get("id") + if dataset_id in existing: + continue + dataset_name = dataset.get("name") or "Knowledge" + dataset["id"] = portable_ref("missing-dataset", f"{dataset_id}:{dataset_name}") + warnings.append( + DslImportWarning( + code="agent_knowledge_unresolved", + path=(f"{package_path}.soul.knowledge.sets.{set_index}.datasets.{dataset_index}"), + message=f"Knowledge dataset {dataset_name!r} is unavailable in the target workspace.", + details={"name": dataset_name}, + ) + ) + return AgentSoulConfig.model_validate(soul_data), warnings + + def _create_snapshot( + self, + *, + tenant_id: str, + agent: Agent, + account_id: str, + soul: AgentSoulConfig, + operation: AgentConfigRevisionOperation, + ) -> AgentConfigSnapshot: + next_version = ( + self.session.scalar( + select(func.max(AgentConfigSnapshot.version)).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + ) + ) + or 0 + ) + 1 + snapshot = AgentConfigSnapshot( + tenant_id=tenant_id, + agent_id=agent.id, + version=next_version, + config_snapshot=soul, + created_by=account_id, + ) + self.session.add(snapshot) + self.session.flush() + revision = AgentConfigRevision( + tenant_id=tenant_id, + agent_id=agent.id, + current_snapshot_id=snapshot.id, + revision=1, + operation=operation, + created_by=account_id, + ) + self.session.add(revision) + self.session.flush() + return snapshot + + def _unique_roster_name(self, *, tenant_id: str, requested: str) -> str: + candidates = [requested] + for index in range(1, 100): + suffix = " import" if index == 1 else f" import {index}" + candidates.append(f"{requested[: 255 - len(suffix)]}{suffix}") + existing = set( + self.session.scalars( + select(Agent.name).where( + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.name.in_(candidates), + ) + ).all() + ) + return next(candidate for candidate in candidates if candidate not in existing) + + def _configure_visible_agent_app_after_commit(self, *, tenant_id: str, app_id: str, account_id: str) -> None: + """Apply external RBAC and web-app visibility only after the DB transaction commits.""" + + def configure(_session: Session) -> None: + try: + from services.enterprise import rbac_service as enterprise_rbac_service + from services.enterprise.enterprise_service import EnterpriseService + from services.feature_service import FeatureService + + enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( + tenant_id, + account_id, + enterprise_rbac_service.RBACResourceType.APP, + app_id, + ) + if FeatureService.get_system_features().webapp_auth.enabled: + EnterpriseService.WebAppAuth.update_app_access_mode(app_id, "private") + except Exception: + logger.exception("Failed to configure imported Agent App %s after commit", app_id) + + event.listen(self.session, "after_commit", configure, once=True) + + def _require_agent(self, *, tenant_id: str, agent_id: str) -> Agent: + agent = self.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + if agent is None: + raise ValueError("Agent package source Agent is unavailable.") + return agent + + def _require_snapshot(self, *, tenant_id: str, agent_id: str, snapshot_id: str | None) -> AgentConfigSnapshot: + if not snapshot_id: + raise ValueError("Agent package source snapshot is unavailable.") + snapshot = self.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise ValueError("Agent package source snapshot is unavailable.") + return snapshot + + @staticmethod + def _agent_icon_type(value: str | None) -> AgentIconType | None: + return AgentIconType(value) if value else None + + @staticmethod + def _app_icon_type(value: str | None) -> IconType: + return IconType(value) if value else IconType.EMOJI + + +def is_agent_v2_graph(graph: Mapping[str, Any]) -> bool: + return any( + node.get("data", {}).get("type") == BuiltinNodeTypes.AGENT and node.get("data", {}).get("version") == "2" + for node in graph.get("nodes", []) + if isinstance(node, Mapping) + ) diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 7758b81e59a..2d15fd1b9a2 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -11,6 +11,8 @@ 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 ( + APP_BACKED_AGENT_SOURCES, + WORKFLOW_ONLY_AGENT_SOURCES, Agent, AgentConfigDraft, AgentConfigDraftType, @@ -355,6 +357,9 @@ class AgentRosterService: icon_type: Any = None, icon: str | None = None, icon_background: str | None = None, + source: AgentSource = AgentSource.AGENT_APP, + initial_soul: AgentSoulConfig | None = None, + revision_operation: AgentConfigRevisionOperation = AgentConfigRevisionOperation.CREATE_VERSION, ) -> Agent: """Create the roster Agent that backs an Agent App, linked via ``app_id``. @@ -362,8 +367,10 @@ class AgentRosterService: (``AppService.create_app``) owns the surrounding transaction so the App row and its backing Agent are persisted atomically. A default (empty) Agent Soul is seeded; the user configures model/prompt/tools afterward in - the Composer. + the Composer. Importers may provide a portable Soul and provenance while + retaining the same one-App-to-one-Agent transaction boundary. """ + soul = initial_soul or AgentSoulConfig() agent = Agent( tenant_id=tenant_id, name=name, @@ -374,7 +381,7 @@ class AgentRosterService: icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, - source=AgentSource.AGENT_APP, + source=source, status=AgentStatus.ACTIVE, app_id=app_id, backing_app_id=app_id, @@ -392,7 +399,7 @@ class AgentRosterService: tenant_id=tenant_id, agent_id=agent.id, version=1, - config_snapshot=AgentSoulConfig(), + config_snapshot=soul, created_by=account_id, ) self._session.add(version) @@ -403,12 +410,12 @@ class AgentRosterService: agent_id=agent.id, current_snapshot_id=version.id, revision=1, - operation=AgentConfigRevisionOperation.CREATE_VERSION, + operation=revision_operation, created_by=account_id, ) self._session.add(revision) agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig()) + agent.active_config_has_model = agent_soul_has_model(soul) agent.active_config_is_published = False self._session.flush() self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) @@ -780,7 +787,7 @@ class AgentRosterService: Agent.tenant_id == tenant_id, Agent.app_id.in_(app_ids), Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.status == AgentStatus.ACTIVE, ) ).all() @@ -793,7 +800,7 @@ class AgentRosterService: Agent.tenant_id == tenant_id, Agent.app_id == app_id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.status == AgentStatus.ACTIVE, ) ) @@ -824,7 +831,7 @@ class AgentRosterService: Agent.tenant_id == tenant_id, Agent.id == agent_id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.app_id.is_not(None), Agent.status == AgentStatus.ACTIVE, ) @@ -863,10 +870,10 @@ class AgentRosterService: Agent.id == agent_id, Agent.status == AgentStatus.ACTIVE, or_( - and_(Agent.scope == AgentScope.ROSTER, Agent.source == AgentSource.AGENT_APP), + and_(Agent.scope == AgentScope.ROSTER, Agent.source.in_(APP_BACKED_AGENT_SOURCES)), and_( Agent.scope == AgentScope.WORKFLOW_ONLY, - Agent.source == AgentSource.WORKFLOW, + Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES), Agent.workflow_id.is_not(None), Agent.workflow_node_id.is_not(None), ), @@ -1111,20 +1118,28 @@ class AgentRosterService: @staticmethod def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: - if agent.source == AgentSource.AGENT_APP: - return { + if agent.source == AgentSource.AGENT_APP or ( + agent.source == AgentSource.IMPORTED and agent.scope == AgentScope.ROSTER and agent.app_id + ): + operations = { AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } - return { + if agent.source == AgentSource.IMPORTED: + operations.add(AgentConfigRevisionOperation.IMPORT_PACKAGE) + return operations + operations = { AgentConfigRevisionOperation.CREATE_VERSION, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_NEW_AGENT, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } + if agent.source == AgentSource.IMPORTED: + operations.add(AgentConfigRevisionOperation.IMPORT_PACKAGE) + return operations def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: """Return whether the normal shared draft has been published into the active snapshot.""" diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 2c69d9135df..0b4c6b59cbd 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -37,6 +37,18 @@ from services.entities.agent_entities import ( ) +class _InlineAgentUnavailableError(ValueError): + pass + + +class _InlineAgentOwnershipError(ValueError): + pass + + +class _InlineAgentSnapshotError(ValueError): + pass + + class WorkflowAgentPublishService: """Validate and freeze Workflow Agent v2 bindings during workflow publish.""" @@ -281,6 +293,11 @@ class WorkflowAgentPublishService: if not isinstance(agent_id, str) or not agent_id: raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.") + node_job_config = cls._node_job_config_from_node_data( + existing_binding=existing_binding, + node_data=node_data, + ) + if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value: agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding( session=session, @@ -294,22 +311,39 @@ class WorkflowAgentPublishService: if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id: raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.") current_snapshot_id = raw_current_snapshot_id - agent = cls._resolve_inline_agent_graph_binding( - session=session, - draft_workflow=draft_workflow, - node_id=node_id, - agent_id=agent_id, - current_snapshot_id=current_snapshot_id, - ) + try: + agent = cls._resolve_inline_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=agent_id, + current_snapshot_id=current_snapshot_id, + ) + except (_InlineAgentUnavailableError, _InlineAgentOwnershipError): + existing_agent = cls._resolve_existing_inline_binding_agent( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + existing_binding=existing_binding, + ) + if existing_agent is not None and existing_binding is not None: + agent = existing_agent + current_snapshot_id = existing_binding.current_snapshot_id or current_snapshot_id + else: + agent, current_snapshot_id = cls._clone_inline_graph_binding_for_node( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + source_agent_id=agent_id, + source_snapshot_id=current_snapshot_id, + node_job=node_job_config, + account_id=account_id, + ) resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT else: raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.") binding = existing_binding - node_job_config = cls._node_job_config_from_node_data( - existing_binding=existing_binding, - node_data=node_data, - ) if binding is None: binding = WorkflowAgentNodeBinding( tenant_id=draft_workflow.tenant_id, @@ -329,6 +363,81 @@ class WorkflowAgentPublishService: binding.current_snapshot_id = current_snapshot_id binding.updated_by = account_id + @classmethod + def _resolve_existing_inline_binding_agent( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + existing_binding: WorkflowAgentNodeBinding | None, + ) -> Agent | None: + if ( + existing_binding is None + or existing_binding.binding_type != WorkflowAgentBindingType.INLINE_AGENT + or not existing_binding.agent_id + or not existing_binding.current_snapshot_id + ): + return None + try: + return cls._resolve_inline_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=existing_binding.agent_id, + current_snapshot_id=existing_binding.current_snapshot_id, + ) + except ValueError: + return None + + @classmethod + def _clone_inline_graph_binding_for_node( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + source_agent_id: str, + source_snapshot_id: str, + node_job: WorkflowNodeJobConfig, + account_id: str, + ) -> tuple[Agent, str]: + source_agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == draft_workflow.tenant_id, + Agent.id == source_agent_id, + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if source_agent is None: + raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.") + source_snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id, + AgentConfigSnapshot.agent_id == source_agent.id, + AgentConfigSnapshot.id == source_snapshot_id, + ) + .limit(1) + ) + if source_snapshot is None: + raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.") + + from services.agent.dsl_service import AgentDslService + + agent, snapshot = AgentDslService(session).clone_inline_binding_for_node( + workflow=draft_workflow, + node_id=node_id, + source_agent=source_agent, + source_snapshot=source_snapshot, + node_job=node_job, + account_id=account_id, + ) + return agent, snapshot.id + @classmethod def _resolve_roster_agent_graph_binding( cls, @@ -380,14 +489,16 @@ class WorkflowAgentPublishService: .limit(1) ) if agent is None: - raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.") + raise _InlineAgentUnavailableError(f"Workflow Agent node {node_id} references an unavailable inline agent.") if ( agent.scope != AgentScope.WORKFLOW_ONLY or agent.app_id != draft_workflow.app_id or agent.workflow_id != draft_workflow.id or agent.workflow_node_id != node_id ): - raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.") + raise _InlineAgentOwnershipError( + f"Workflow Agent node {node_id} inline_agent binding does not belong to this node." + ) snapshot = session.scalar( select(AgentConfigSnapshot) @@ -399,7 +510,9 @@ class WorkflowAgentPublishService: .limit(1) ) if snapshot is None or snapshot.agent_id != agent.id: - raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.") + raise _InlineAgentSnapshotError( + f"Workflow Agent node {node_id} references a missing inline agent config snapshot." + ) return agent @classmethod @@ -494,3 +607,73 @@ class WorkflowAgentPublishService: updated_by=binding.updated_by, ) session.add(copied) + + @classmethod + def restore_agent_node_bindings_to_draft( + cls, + *, + session: Session, + source_workflow: Workflow, + draft_workflow: Workflow, + account_id: str, + ) -> None: + """Replace draft bindings with the frozen bindings of a published workflow.""" + + existing = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION, + ) + ).all() + for binding in existing: + session.delete(binding) + + source_bindings = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == source_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == source_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == source_workflow.id, + WorkflowAgentNodeBinding.workflow_version == source_workflow.version, + ) + ).all() + for source in source_bindings: + agent_id = source.agent_id + snapshot_id = source.current_snapshot_id + if source.binding_type == WorkflowAgentBindingType.INLINE_AGENT and agent_id and snapshot_id: + try: + cls._resolve_inline_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=source.node_id, + agent_id=agent_id, + current_snapshot_id=snapshot_id, + ) + except ValueError: + agent, snapshot_id = cls._clone_inline_graph_binding_for_node( + session=session, + draft_workflow=draft_workflow, + node_id=source.node_id, + source_agent_id=agent_id, + source_snapshot_id=snapshot_id, + node_job=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict), + account_id=account_id, + ) + agent_id = agent.id + session.add( + WorkflowAgentNodeBinding( + tenant_id=draft_workflow.tenant_id, + app_id=draft_workflow.app_id, + workflow_id=draft_workflow.id, + workflow_version=cls._DRAFT_WORKFLOW_VERSION, + node_id=source.node_id, + binding_type=source.binding_type, + agent_id=agent_id, + current_snapshot_id=snapshot_id, + node_job_config=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict), + created_by=account_id, + updated_by=account_id, + ) + ) + session.flush() diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index e8c12586856..189d34b3f41 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -39,9 +39,11 @@ from libs.datetime_utils import naive_utc_now from models import Account, App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType from models.workflow import Workflow +from services.agent.dsl_service import AgentDslService, AgentPackage +from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_version import check_version_compatibility -from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus +from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus from services.errors.app import WorkflowNotFoundError from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.workflow_draft_variable_service import WorkflowDraftVariableService @@ -64,6 +66,7 @@ class Import(BaseModel): current_dsl_version: str = CURRENT_DSL_VERSION imported_dsl_version: str = "" error: str = "" + warnings: list[DslImportWarning] = Field(default_factory=list) class PendingData(BaseModel): @@ -83,8 +86,11 @@ class CheckDependenciesPendingData(BaseModel): class AppDslService: + _warnings: list[DslImportWarning] + def __init__(self, session: Session): self._session = session + self._warnings = [] def import_app( self, @@ -102,6 +108,7 @@ class AppDslService: import_app_id: str | None = None, ) -> Import: """Import an app from YAML content or URL.""" + self._warnings = [] import_id = str(uuid.uuid4()) # Validate import mode @@ -277,12 +284,14 @@ class AppDslService: draft_var_srv = WorkflowDraftVariableService(session=self._session) draft_var_srv.delete_app_workflow_variables(app_id=app.id) + result_status = self._status_with_warnings(status) return Import( id=import_id, - status=status, + status=result_status, app_id=app.id, app_mode=app.mode, imported_dsl_version=imported_version, + warnings=self._warnings, ) except yaml.YAMLError as e: @@ -304,6 +313,7 @@ class AppDslService: """ Confirm an import that requires confirmation """ + self._warnings = [] redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" pending_data = redis_client.get(redis_key) @@ -346,11 +356,12 @@ class AppDslService: return Import( id=import_id, - status=ImportStatus.COMPLETED, + status=self._status_with_warnings(ImportStatus.COMPLETED), app_id=app.id, app_mode=app.mode, current_dsl_version=CURRENT_DSL_VERSION, imported_dsl_version=data.get("version", "0.1.0"), + warnings=self._warnings, ) except Exception as e: @@ -492,16 +503,34 @@ class AppDslService: ) ) ] - workflow_service.sync_draft_workflow( + raw_agent_packages = data.get("agent_packages") or {} + if not isinstance(raw_agent_packages, Mapping): + raise ValueError("agent_packages must be a mapping") + graph_for_sync = AgentDslService.graph_without_package_bindings(graph) if raw_agent_packages else graph + draft_workflow = workflow_service.sync_draft_workflow( app_model=app, - graph=workflow_data.get("graph", {}), + graph=graph_for_sync, features=workflow_data.get("features", {}), unique_hash=unique_hash, account=account, environment_variables=environment_variables, conversation_variables=conversation_variables, session=self._session, + commit=not raw_agent_packages, + sync_agent_bindings=not raw_agent_packages, ) + if raw_agent_packages: + _, warnings = AgentDslService(self._session).import_workflow_packages( + workflow=draft_workflow, + portable_graph=graph, + raw_packages=raw_agent_packages, + account=account, + ) + self._warnings.extend(warnings) + WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( + session=self._session, + draft_workflow=draft_workflow, + ) case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION: # Initialize model config model_config = data.get("model_config") @@ -517,6 +546,23 @@ class AppDslService: self._session.add(app_model_config) app_model_config_was_updated.send(app, app_model_config=app_model_config) + case AppMode.AGENT: + if app.app_model_config is not None: + raise ValueError("Agent DSL import only supports creating a new Agent App") + agent_data = data.get("agent") + raw_agent_packages = data.get("agent_packages") + if not isinstance(agent_data, Mapping) or not isinstance(raw_agent_packages, Mapping): + raise ValueError("Missing Agent package data") + package_ref = agent_data.get("package_ref") + if not isinstance(package_ref, str) or package_ref not in raw_agent_packages: + raise ValueError("Agent package_ref is missing or invalid") + package = AgentPackage.model_validate(raw_agent_packages[package_ref]) + imported = AgentDslService(self._session).import_agent_app_package( + app=app, + account=account, + package=package, + ) + self._warnings.extend(imported.warnings) case _: raise ValueError("Invalid app mode") return app @@ -538,7 +584,7 @@ class AppDslService: """ app_mode = AppMode.value_of(app_model.mode) - export_data = { + export_data: dict[str, Any] = { "version": CURRENT_DSL_VERSION, "kind": "app", "app": { @@ -562,6 +608,18 @@ class AppDslService: workflow_id=workflow_id, session=session, ) + elif app_mode == AppMode.AGENT: + package_ref, packages = AgentDslService(session).export_agent_app(app=app_model) + export_data["agent"] = {"package_ref": package_ref} + export_data["agent_packages"] = {key: package.model_dump(mode="json") for key, package in packages.items()} + dependencies = AgentDslService(session).extract_package_dependencies(packages) + export_data["dependencies"] = [ + jsonable_encoder(item.model_dump()) + for item in DependenciesAnalysisService.generate_dependencies( + tenant_id=app_model.tenant_id, + dependencies=dependencies, + ) + ] else: cls._append_model_config_export_data(export_data, app_model) @@ -588,6 +646,11 @@ class AppDslService: raise WorkflowNotFoundError("Missing draft workflow configuration, please check.") workflow_dict = workflow.to_dict(include_secret=include_secret) + graph, agent_packages = AgentDslService(session).export_workflow_packages( + workflow=workflow, + graph=workflow_dict.get("graph", {}), + ) + workflow_dict["graph"] = graph # TODO: refactor: we need a better way to filter workspace related data from nodes for node in workflow_dict.get("graph", {}).get("nodes", []): node_data = node.get("data", {}) @@ -620,6 +683,11 @@ class AppDslService: export_data["workflow"] = workflow_dict dependencies = cls._extract_dependencies_from_workflow(workflow) + dependencies.extend(AgentDslService(session).extract_package_dependencies(agent_packages)) + if agent_packages: + export_data["agent_packages"] = { + key: package.model_dump(mode="json") for key, package in agent_packages.items() + } export_data["dependencies"] = [ jsonable_encoder(d.model_dump()) for d in DependenciesAnalysisService.generate_dependencies( @@ -627,6 +695,11 @@ class AppDslService: ) ] + def _status_with_warnings(self, status: ImportStatus) -> ImportStatus: + if status == ImportStatus.COMPLETED and self._warnings: + return ImportStatus.COMPLETED_WITH_WARNINGS + return status + @classmethod def _append_model_config_export_data(cls, export_data: dict[str, Any], app_model: App): """ diff --git a/api/services/app_service.py b/api/services/app_service.py index 139513e87ee..dcca5393f6f 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -25,7 +25,7 @@ from libs.datetime_utils import naive_utc_now from libs.login import current_user from libs.pagination import PaginatedResult, paginate_query from models import Account, AppStar -from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus +from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentIconType, AgentScope, AgentStatus from models.model import App, AppMode, AppModelConfig, IconType, Site from models.tools import ApiToolProvider from services.agent.errors import AgentNameConflictError @@ -102,7 +102,7 @@ class AppService: Agent.tenant_id == tenant_id, Agent.app_id == App.id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.status == AgentStatus.ACTIVE, ) .correlate(App) @@ -529,7 +529,7 @@ class AppService: Agent.tenant_id == app.tenant_id, Agent.app_id == app.id, Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), Agent.status == AgentStatus.ACTIVE, ) ) diff --git a/api/services/entities/dsl_entities.py b/api/services/entities/dsl_entities.py index 05baa51fbd3..0d5f49dc4cc 100644 --- a/api/services/entities/dsl_entities.py +++ b/api/services/entities/dsl_entities.py @@ -1,4 +1,5 @@ from enum import StrEnum +from typing import Any from pydantic import BaseModel, Field @@ -17,5 +18,14 @@ class ImportStatus(StrEnum): FAILED = "failed" +class DslImportWarning(BaseModel): + """Portable DSL reference that could not be restored in the target workspace.""" + + code: str + path: str + message: str + details: dict[str, Any] = Field(default_factory=dict) + + class CheckDependenciesResult(BaseModel): leaked_dependencies: list[PluginDependency] = Field(default_factory=list) diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index ae1cd7f7e31..14ac9a21f6b 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from urllib.parse import urlparse import yaml -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -18,9 +18,11 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from models import Account from models.snippet import CustomizedSnippet, SnippetType from models.workflow import Workflow +from services.agent.dsl_service import AgentDslService +from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_version import check_version_compatibility -from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus +from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService @@ -29,7 +31,7 @@ logger = logging.getLogger(__name__) IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:" CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:" IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes -CURRENT_DSL_VERSION = "0.1.0" +CURRENT_DSL_VERSION = "0.2.0" class SnippetImportInfo(BaseModel): @@ -39,6 +41,7 @@ class SnippetImportInfo(BaseModel): current_dsl_version: str = CURRENT_DSL_VERSION imported_dsl_version: str = "" error: str = "" + warnings: list[DslImportWarning] = Field(default_factory=list) def _check_version_compatibility(imported_version: str) -> ImportStatus: @@ -62,6 +65,7 @@ class CheckDependenciesPendingData(BaseModel): class SnippetDslService: def __init__(self, session: Session): self._session = session + self._warnings: list[DslImportWarning] = [] def _snippet_service(self) -> SnippetService: return SnippetService(session=self._session) @@ -78,6 +82,7 @@ class SnippetDslService: description: str | None = None, ) -> SnippetImportInfo: """Import a snippet from YAML content or URL.""" + self._warnings = [] import_id = str(uuid.uuid4()) # Validate import mode @@ -261,9 +266,10 @@ class SnippetDslService: return SnippetImportInfo( id=import_id, - status=status, + status=self._status_with_warnings(status), snippet_id=snippet.id, imported_dsl_version=imported_version, + warnings=self._warnings, ) except yaml.YAMLError as e: @@ -274,6 +280,7 @@ class SnippetDslService: ) except Exception as e: + self._session.rollback() logger.exception("Failed to import snippet") return SnippetImportInfo( id=import_id, @@ -285,6 +292,7 @@ class SnippetDslService: """ Confirm an import that requires confirmation """ + self._warnings = [] redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" pending_data = redis_client.get(redis_key) @@ -334,12 +342,14 @@ class SnippetDslService: return SnippetImportInfo( id=import_id, - status=ImportStatus.COMPLETED, + status=self._status_with_warnings(ImportStatus.COMPLETED), snippet_id=snippet.id, imported_dsl_version=data.get("version", "0.1.0"), + warnings=self._warnings, ) except Exception as e: + self._session.rollback() logger.exception("Failed to confirm import") return SnippetImportInfo( id=import_id, @@ -418,19 +428,36 @@ class SnippetDslService: # Create or update draft workflow if workflow_data: graph = workflow_data.get("graph", {}) + raw_agent_packages = data.get("agent_packages") or {} + if not isinstance(raw_agent_packages, Mapping): + raise ValueError("agent_packages must be a mapping") + graph_for_sync = AgentDslService.graph_without_package_bindings(graph) if raw_agent_packages else graph snippet_service = self._snippet_service() # Get existing workflow hash if exists existing_workflow = snippet_service.get_draft_workflow(snippet=snippet) unique_hash = existing_workflow.unique_hash if existing_workflow else None - snippet_service.sync_draft_workflow( + draft_workflow = snippet_service.sync_draft_workflow( snippet=snippet, - graph=graph, + graph=graph_for_sync, unique_hash=unique_hash, account=account, input_fields=input_fields, + sync_agent_bindings=not raw_agent_packages, ) + if raw_agent_packages: + _, warnings = AgentDslService(self._session).import_workflow_packages( + workflow=draft_workflow, + portable_graph=graph, + raw_packages=raw_agent_packages, + account=account, + ) + self._warnings.extend(warnings) + WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( + session=self._session, + draft_workflow=draft_workflow, + ) self._session.commit() return snippet @@ -473,6 +500,11 @@ class SnippetDslService: Append workflow export data """ workflow_dict = workflow.to_dict(include_secret=include_secret) + graph, agent_packages = AgentDslService(self._session).export_workflow_packages( + workflow=workflow, + graph=workflow_dict.get("graph", {}), + ) + workflow_dict["graph"] = graph # Filter workspace related data from nodes workflow_dict["environment_variables"] = [] workflow_dict["conversation_variables"] = [] @@ -498,6 +530,11 @@ class SnippetDslService: export_data["workflow"] = workflow_dict dependencies = self._extract_dependencies_from_workflow(workflow) + dependencies.extend(AgentDslService(self._session).extract_package_dependencies(agent_packages)) + if agent_packages: + export_data["agent_packages"] = { + key: package.model_dump(mode="json") for key, package in agent_packages.items() + } export_data["dependencies"] = [ jsonable_encoder(d.model_dump()) for d in DependenciesAnalysisService.generate_dependencies( @@ -523,6 +560,11 @@ class SnippetDslService: dependencies = self._extract_dependencies_from_workflow_graph(graph) return dependencies + def _status_with_warnings(self, status: ImportStatus) -> ImportStatus: + if status == ImportStatus.COMPLETED and self._warnings: + return ImportStatus.COMPLETED_WITH_WARNINGS + return status + def _extract_dependencies_from_workflow_graph(self, graph: Mapping) -> list[str]: """ Extract dependencies from workflow graph diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index fb4d3bbc00e..23b133bf481 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -5,15 +5,22 @@ from contextlib import contextmanager from datetime import UTC, datetime from typing import Any -from sqlalchemy import delete, func, select +from sqlalchemy import delete, event, func, select from sqlalchemy.orm import Session, sessionmaker from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from graphon.enums import BuiltinNodeTypes, NodeType from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account, TagBinding +from models.agent import ( + WORKFLOW_ONLY_AGENT_SOURCES, + Agent, + AgentScope, + AgentStatus, + WorkflowAgentNodeBinding, +) from models.enums import WorkflowRunTriggeredFrom -from models.model import UploadFile +from models.model import App, AppMode, UploadFile from models.snippet import CustomizedSnippet, SnippetType from models.tools import WorkflowToolProvider from models.workflow import ( @@ -344,6 +351,7 @@ class SnippetService: *, session: Session, snippet: CustomizedSnippet, + account_id: str | None = None, ) -> bool: """ Delete a snippet. @@ -353,6 +361,52 @@ class SnippetService: :return: True if deleted successfully """ SnippetService._delete_draft_variable_files(session=session, snippet=snippet) + owned_agents = session.scalars( + select(Agent).where( + Agent.tenant_id == snippet.tenant_id, + Agent.app_id == snippet.id, + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + ) + ).all() + now = datetime.now(UTC).replace(tzinfo=None) + backing_app_ids = {agent.backing_app_id for agent in owned_agents if agent.backing_app_id} + for agent in owned_agents: + agent.status = AgentStatus.ARCHIVED + agent.archived_by = account_id + agent.archived_at = now + agent.updated_by = account_id or agent.updated_by + agent.updated_at = now + + if backing_app_ids: + session.execute( + delete(App) + .where( + App.tenant_id == snippet.tenant_id, + App.id.in_(backing_app_ids), + App.mode == AppMode.AGENT, + ) + .execution_options(synchronize_session=False) + ) + tenant_id = snippet.tenant_id + + def cleanup_backing_apps(_session: Session) -> None: + from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task + + for app_id in backing_app_ids: + remove_app_and_related_data_task.delay(tenant_id=tenant_id, app_id=app_id) + + event.listen(session, "after_commit", cleanup_backing_apps, once=True) + + session.execute( + delete(WorkflowAgentNodeBinding) + .where( + WorkflowAgentNodeBinding.tenant_id == snippet.tenant_id, + WorkflowAgentNodeBinding.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) session.execute( delete(WorkflowDraftVariable) .where(WorkflowDraftVariable.app_id == snippet.id) @@ -487,6 +541,7 @@ class SnippetService: unique_hash: str | None, account: Account, input_fields: list[dict] | None = None, + sync_agent_bindings: bool = True, ) -> Workflow: """ Sync draft workflow for snippet. @@ -539,9 +594,22 @@ class SnippetService: snippet.updated_by = account.id snippet.updated_at = datetime.now(UTC).replace(tzinfo=None) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + with self._session_scope() as session: session.add(workflow) session.add(snippet) + if sync_agent_bindings: + session.flush() + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id=account.id, + ) + WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( + session=session, + draft_workflow=workflow, + ) self._commit_if_owned(session) return workflow @@ -581,6 +649,15 @@ class SnippetService: with self._session_scope() as session: session.add(draft_workflow) + session.flush() + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( + session=session, + source_workflow=source_workflow, + draft_workflow=draft_workflow, + account_id=account.id, + ) self._commit_if_owned(session) return draft_workflow @@ -612,6 +689,13 @@ class SnippetService: SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + WorkflowAgentPublishService.validate_agent_nodes_for_publish( + session=session, + draft_workflow=draft_workflow, + ) + # Create new published workflow workflow = Workflow.new( tenant_id=snippet.tenant_id, @@ -627,6 +711,11 @@ class SnippetService: kind=WorkflowKind.SNIPPET.value, ) session.add(workflow) + WorkflowAgentPublishService.copy_agent_node_bindings_to_published( + session=session, + draft_workflow=draft_workflow, + published_workflow=workflow, + ) # Update snippet version snippet.version += 1 diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 95be0f7017a..c8d77cb7f29 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -320,9 +320,16 @@ class WorkflowService: environment_variables: Sequence[VariableBase], conversation_variables: Sequence[VariableBase], session: Session, + commit: bool = True, + sync_agent_bindings: bool = True, ) -> Workflow: """ - Sync draft workflow + Sync draft workflow. + + DSL import disables the intermediate commit and Agent binding sync so + portable package references can be materialized atomically after the + draft workflow has received its target-workspace id. + :raises WorkflowHashNotEqualError """ # fetch draft workflow by app_model @@ -363,21 +370,24 @@ class WorkflowService: from services.agent.workflow_publish_service import WorkflowAgentPublishService session.flush() - WorkflowAgentPublishService.sync_agent_bindings_for_draft( - session=session, - draft_workflow=workflow, - account_id=account.id, - ) - WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( - session=session, - draft_workflow=workflow, - ) + if sync_agent_bindings: + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id=account.id, + ) + WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( + session=session, + draft_workflow=workflow, + ) # commit db session changes - session.commit() + if commit: + session.commit() # trigger app workflow events - app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=workflow) + if commit: + app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=workflow) # return draft workflow return workflow @@ -492,6 +502,16 @@ class WorkflowService: if is_new_draft: session.add(draft_workflow) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + session.flush() + WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( + session=session, + source_workflow=source_workflow, + draft_workflow=draft_workflow, + account_id=account.id, + ) + session.commit() app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow) diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 24c14637296..1f87acfc2cb 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -11,6 +11,7 @@ import pytest import yaml from faker import Faker from flask import Flask +from sqlalchemy import select from sqlalchemy.orm import Session from core.trigger.constants import ( @@ -21,6 +22,7 @@ from core.trigger.constants import ( from extensions.ext_redis import redis_client from graphon.enums import BuiltinNodeTypes from models import Account, App, AppMode +from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentScope, AgentSource from models.model import AppModelConfig, IconType from services import app_dsl_service from services.account_service import AccountService, TenantService @@ -947,6 +949,64 @@ class TestAppDslService: assert "model_config" in exported_data assert "dependencies" in exported_data + def test_agent_app_dsl_round_trip_creates_unpublished_imported_agent( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + _, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + source_app = AppService().create_app( + account.current_tenant_id, + CreateAppParams( + name="Portable Agent", + description="Agent DSL integration test", + mode="agent", + agent_role="researcher", + icon_type="emoji", + icon="R", + icon_background="#FFFFFF", + ), + account, + session=db_session_with_containers, + ) + + yaml_content = AppDslService.export_dsl( + source_app, + include_secret=False, + session=db_session_with_containers, + ) + exported_data = yaml.safe_load(yaml_content) + serialized_package = exported_data["agent_packages"][exported_data["agent"]["package_ref"]] + assert exported_data["app"]["mode"] == AppMode.AGENT.value + assert "agent_id" not in json.dumps(serialized_package) + + result = AppDslService(db_session_with_containers).import_app( + account=account, + import_mode=ImportMode.YAML_CONTENT, + yaml_content=yaml_content, + ) + assert result.status == ImportStatus.COMPLETED + assert result.app_id is not None + db_session_with_containers.commit() + + imported_agent = db_session_with_containers.scalar( + select(Agent).where( + Agent.tenant_id == account.current_tenant_id, + Agent.app_id == result.app_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.IMPORTED, + ) + ) + assert imported_agent is not None + assert imported_agent.active_config_is_published is False + draft = db_session_with_containers.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.agent_id == imported_agent.id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.draft_owner_key == "", + ) + ) + assert draft is not None + assert draft.base_snapshot_id == imported_agent.active_config_snapshot_id + def test_export_dsl_workflow_app_success( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py index 8946b55e09f..ee569e97f34 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -298,6 +298,7 @@ def test_patch_snippet_updates_and_commits(app: Flask, monkeypatch: pytest.Monke def test_delete_snippet_deletes_and_commits(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() + user = _account() session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock()) delete_snippet = Mock() @@ -314,11 +315,11 @@ def test_delete_snippet_deletes_and_commits(app: Flask, monkeypatch: pytest.Monk handler = unwrap(api.delete) with app.test_request_context("/workspaces/current/customized-snippets/snippet-1", method="DELETE"): - response, status_code = handler(api, "tenant-1", snippet_id="snippet-1") + response, status_code = handler(api, "tenant-1", user, snippet_id="snippet-1") assert status_code == 204 assert response == "" - delete_snippet.assert_called_once_with(session=session, snippet=snippet) + delete_snippet.assert_called_once_with(session=session, snippet=snippet, account_id=user.id) session.commit.assert_called_once() @@ -357,7 +358,7 @@ def test_import_snippet_returns_202_for_pending_confirmation(app: Flask, monkeyp user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.PENDING, imported_dsl_version="999.0.0") import_snippet = Mock(return_value=result) - session = SimpleNamespace(commit=Mock()) + session = SimpleNamespace(commit=Mock(), rollback=Mock()) class _SessionContext: def __init__(self, engine): @@ -385,19 +386,20 @@ def test_import_snippet_returns_202_for_pending_confirmation(app: Flask, monkeyp method="POST", json={"mode": "yaml-content", "yaml_content": "kind: snippet"}, ): - response, status_code = handler(api, user) + response, status_code = handler(api, session, user) assert status_code == 202 assert response["status"] == ImportStatus.PENDING.value import_snippet.assert_called_once() - session.commit.assert_called_once() + session.rollback.assert_not_called() + session.commit.assert_not_called() def test_import_snippet_returns_400_for_failed_import(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.FAILED, error="Invalid DSL") import_snippet = Mock(return_value=result) - session = SimpleNamespace(commit=Mock()) + session = SimpleNamespace(commit=Mock(), rollback=Mock()) class SessionContext(_SessionContext): def __init__(self, engine, *args, **kwargs): @@ -419,18 +421,19 @@ def test_import_snippet_returns_400_for_failed_import(app: Flask, monkeypatch: p method="POST", json={"mode": "yaml-content", "yaml_content": "kind: snippet"}, ): - response, status_code = handler(api, user) + response, status_code = handler(api, session, user) assert status_code == 400 assert response["error"] == "Invalid DSL" - session.commit.assert_called_once() + session.rollback.assert_not_called() + session.commit.assert_not_called() def test_import_confirm_returns_200_for_completed_import(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.COMPLETED, snippet_id="snippet-1") confirm_import = Mock(return_value=result) - session = SimpleNamespace(commit=Mock()) + session = SimpleNamespace(commit=Mock(), rollback=Mock()) class SessionContext(_SessionContext): def __init__(self, engine, *args, **kwargs): @@ -451,12 +454,12 @@ def test_import_confirm_returns_200_for_completed_import(app: Flask, monkeypatch "/workspaces/current/customized-snippets/imports/import-1/confirm", method="POST", ): - response, status_code = handler(api, user, import_id="import-1") + response, status_code = handler(api, session, user, import_id="import-1") assert status_code == 200 assert response["snippet_id"] == "snippet-1" confirm_import.assert_called_once_with(import_id="import-1", account=user) - session.commit.assert_called_once() + session.commit.assert_not_called() def test_check_dependencies_raises_when_snippet_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): 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 ec0ce7d23b1..c00ca9ac0b1 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 @@ -15,6 +15,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, AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom +from models.agent import AgentSource _SOUL_DICT = { "model": { @@ -78,7 +79,12 @@ class TestResolveAgentById: class TestResolveAgent: def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch): - bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", active_config_is_published=True) + bound_agent = SimpleNamespace( + id="agent-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="snap-1", + active_config_is_published=True, + ) inner_agent = SimpleNamespace(id="agent-1") snapshot = _snapshot() # scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id) @@ -100,6 +106,7 @@ class TestResolveAgent: def test_unpublished_draft_still_resolves_active_snapshot(self, monkeypatch: pytest.MonkeyPatch): bound_agent = SimpleNamespace( id="agent-1", + source=AgentSource.AGENT_APP, active_config_snapshot_id="snap-1", active_config_is_published=False, ) @@ -120,9 +127,51 @@ class TestResolveAgent: assert config_version_kind == "snapshot" assert soul.prompt.system_prompt == "You are Iris." + def test_unpublished_imported_agent_is_not_available_to_public_runtime(self, monkeypatch: pytest.MonkeyPatch): + bound_agent = SimpleNamespace( + id="agent-1", + source=AgentSource.IMPORTED, + active_config_snapshot_id="snap-1", + active_config_is_published=False, + ) + _patch_session(monkeypatch, [bound_agent]) + app_model = SimpleNamespace(id="app-1", tenant_id="t1") + + with pytest.raises(AgentAppNotPublishedError, match="not been published"): + AgentAppGenerator()._resolve_agent( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=SimpleNamespace(id="user-1"), + ) # type: ignore[arg-type] + + def test_unpublished_imported_agent_remains_available_to_debugger(self, monkeypatch: pytest.MonkeyPatch): + bound_agent = SimpleNamespace( + id="agent-1", + source=AgentSource.IMPORTED, + active_config_snapshot_id="snap-1", + active_config_is_published=False, + ) + draft = SimpleNamespace(id="draft-1", draft_type="draft", config_snapshot_dict=_SOUL_DICT) + _patch_session(monkeypatch, [bound_agent, draft]) + app_model = SimpleNamespace(id="app-1", tenant_id="t1") + + agent, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent( + app_model, + invoke_from=InvokeFrom.DEBUGGER, + draft_type=None, + user=SimpleNamespace(id="user-1"), + ) # type: ignore[arg-type] + + assert agent is bound_agent + assert config_id == draft.id + assert config_version_kind == "draft" + assert soul.prompt.system_prompt == "You are Iris." + def test_agent_without_active_snapshot_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch): bound_agent = SimpleNamespace( id="agent-1", + source=AgentSource.AGENT_APP, active_config_snapshot_id=None, active_config_is_published=False, ) diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py new file mode 100644 index 00000000000..b74f5a53ec4 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -0,0 +1,729 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock, call + +import pytest +from pydantic import ValidationError + +from graphon.enums import BuiltinNodeTypes +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentIconType, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from models.model import App, IconType +from services.agent.dsl_entities import ( + AGENT_NODE_JOB_DSL_KEY, + AGENT_PACKAGE_REF_KEY, + AgentPackage, + AgentPackageMetadata, + make_portable_agent_package, +) +from services.agent.dsl_service import AgentDslService, is_agent_v2_graph +from services.entities.dsl_entities import DslImportWarning + + +def _agent() -> Agent: + agent = Agent( + tenant_id="tenant-1", + name="Portable Agent", + description="description", + role="researcher", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + icon_type=AgentIconType.EMOJI, + icon="R", + ) + agent.id = "agent-1" + return agent + + +def _snapshot(*, snapshot_id: str = "snapshot-1", soul: AgentSoulConfig | None = None) -> AgentConfigSnapshot: + snapshot = AgentConfigSnapshot( + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=soul or AgentSoulConfig(), + created_by="account-1", + ) + snapshot.id = snapshot_id + return snapshot + + +def _agent_node(node_id: str, binding: object | None = None) -> dict: + data = {"type": BuiltinNodeTypes.AGENT, "version": "2"} + if binding is not None: + data["agent_binding"] = binding + return {"id": node_id, "data": data} + + +def test_make_portable_agent_package_strips_workspace_credentials_and_assets() -> None: + soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "langgenius/openai/openai", + "model": "gpt-test", + "credential_ref": {"type": "provider", "id": "model-secret"}, + }, + "tools": { + "dify_tools": [ + { + "provider_id": "langgenius/google/google", + "tool_name": "search", + "credential_type": "api-key", + "credential_ref": {"type": "tool", "id": "tool-secret"}, + "runtime_parameters": { + "query": "hello", + "upload_file_id": "upload-1", + "api_key": "plain-secret", + }, + } + ], + "cli_tools": [ + { + "name": "cli", + "env": { + "secret_refs": [ + { + "name": "TOKEN", + "value": "plain-secret", + "credential_id": "credential-1", + } + ] + }, + } + ], + }, + "env": {"secret_refs": [{"name": "GLOBAL_TOKEN", "value": "plain-secret", "id": "secret-1"}]}, + "config_skills": [{"name": "research", "file_kind": "tool_file", "file_id": "skill-file"}], + "config_files": [{"name": "guide.md", "file_kind": "upload_file", "file_id": "config-file"}], + "human": { + "contacts": [ + { + "id": "human-1", + "tenant_id": "tenant-1", + "name": "Reviewer", + "email": "reviewer@example.com", + } + ] + }, + } + ) + + package = make_portable_agent_package(_agent(), soul) + serialized = package.model_dump(mode="json") + + assert package.soul.model is not None + assert package.soul.model.credential_ref is None + assert package.soul.tools.dify_tools[0].credential_type == "unauthorized" + assert package.soul.tools.dify_tools[0].credential_ref is None + assert package.soul.tools.dify_tools[0].runtime_parameters["upload_file_id"] is None + assert package.soul.tools.dify_tools[0].runtime_parameters["api_key"] is None + assert package.soul.config_skills == [] + assert package.soul.config_files == [] + assert [asset.kind for asset in package.omitted_assets] == ["skill", "file"] + assert "plain-secret" not in str(serialized) + assert "model-secret" not in str(serialized) + assert "tool-secret" not in str(serialized) + assert "skill-file" not in str(serialized) + assert "config-file" not in str(serialized) + assert package.soul.human.contacts[0].id is None + assert package.soul.human.contacts[0].name == "Reviewer" + + +def test_agent_package_round_trips_as_strict_dsl_dto() -> None: + package = make_portable_agent_package(_agent(), AgentSoulConfig()) + + restored = AgentPackage.model_validate(package.model_dump(mode="json")) + + assert restored == package + + +def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch) -> None: + soul = AgentSoulConfig.model_validate( + { + "tools": { + "dify_tools": [ + { + "provider_id": "langgenius/google/google", + "tool_name": "search", + "credential_type": "unauthorized", + } + ], + "cli_tools": [{"name": "cli", "env": {"secret_refs": [{"name": "CLI_TOKEN"}]}}], + }, + "env": {"secret_refs": [{"name": "GLOBAL_TOKEN"}]}, + "human": {"contacts": [{"name": "Reviewer", "email": "reviewer@example.com"}]}, + } + ) + monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", Mock(return_value={})) + + _, warnings = AgentDslService(Mock())._resolve_package_soul( + tenant_id="tenant-1", + package=make_portable_agent_package(_agent(), soul), + package_path="agent_packages.agent_1", + ) + + codes = [warning.code for warning in warnings] + assert codes.count("agent_tool_authorization_required") == 1 + assert codes.count("agent_secret_required") == 2 + assert codes.count("agent_human_contact_unresolved") == 1 + + +def test_agent_package_rejects_unknown_schema_version() -> None: + package = make_portable_agent_package(_agent(), AgentSoulConfig()).model_dump(mode="json") + package["schema_version"] = 2 + + with pytest.raises(ValidationError): + AgentPackage.model_validate(package) + + +def test_export_agent_app_requires_backing_agent() -> None: + session = Mock() + session.scalar.return_value = None + + with pytest.raises(ValueError, match="no active backing Agent"): + AgentDslService(session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) + + +@pytest.mark.parametrize("use_draft", [True, False]) +def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None: + agent = _agent() + agent.active_config_snapshot_id = "snapshot-1" + draft = SimpleNamespace(config_snapshot_dict=AgentSoulConfig(config_note="draft").model_dump(mode="json")) + session = Mock() + session.scalar.side_effect = [agent, draft if use_draft else None] + service = AgentDslService(session) + require_snapshot = Mock(return_value=_snapshot(soul=AgentSoulConfig(config_note="snapshot"))) + service._require_snapshot = require_snapshot + + package_ref, packages = service.export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) + + assert package_ref == "agent_1" + assert packages[package_ref].soul.config_note == ("draft" if use_draft else "snapshot") + assert require_snapshot.call_count == (0 if use_draft else 1) + + +def test_export_workflow_packages_deduplicates_shared_agent() -> None: + graph = {"nodes": [_agent_node("node-1"), _agent_node("node-2")], "edges": []} + bindings = [ + SimpleNamespace( + node_id=node_id, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + node_job_config_dict={"workflow_prompt": node_id}, + ) + for node_id in ("node-1", "node-2") + ] + session = Mock() + session.scalars.return_value.all.return_value = bindings + service = AgentDslService(session) + service._require_agent = Mock(return_value=_agent()) + service._require_snapshot = Mock(return_value=_snapshot()) + + portable_graph, packages = service.export_workflow_packages( + workflow=SimpleNamespace(tenant_id="tenant-1", id="workflow-1", version="draft"), + graph=graph, + ) + + assert list(packages) == ["agent_1"] + for node in portable_graph["nodes"]: + assert node["data"]["agent_binding"] == { + "binding_type": WorkflowAgentBindingType.ROSTER_AGENT.value, + AGENT_PACKAGE_REF_KEY: "agent_1", + } + assert node["data"][AGENT_NODE_JOB_DSL_KEY]["workflow_prompt"] == node["id"] + assert service._require_agent.call_count == 2 + + +def test_export_workflow_packages_rejects_incomplete_binding() -> None: + session = Mock() + session.scalars.return_value.all.return_value = [] + + with pytest.raises(ValueError, match="no complete persisted binding"): + AgentDslService(session).export_workflow_packages( + workflow=SimpleNamespace(tenant_id="tenant-1", id="workflow-1", version="draft"), + graph={"nodes": [_agent_node("node-1")], "edges": []}, + ) + + +def test_graph_without_package_bindings_removes_portable_fields() -> None: + graph = { + "nodes": [ + _agent_node( + "portable", + { + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + AGENT_PACKAGE_REF_KEY: "agent_1", + }, + ), + _agent_node("persisted", {"binding_type": "inline_agent", "agent_id": "agent-1"}), + ], + "edges": [], + } + for node in graph["nodes"]: + node["data"][AGENT_NODE_JOB_DSL_KEY] = {"workflow_prompt": "work"} + + result = AgentDslService.graph_without_package_bindings(graph) + + assert "agent_binding" not in result["nodes"][0]["data"] + assert result["nodes"][1]["data"]["agent_binding"]["agent_id"] == "agent-1" + assert all(AGENT_NODE_JOB_DSL_KEY not in node["data"] for node in result["nodes"]) + assert AGENT_NODE_JOB_DSL_KEY in graph["nodes"][0]["data"] + + +def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypatch) -> None: + session = Mock() + service = AgentDslService(session) + soul = AgentSoulConfig(config_note="portable") + warning = DslImportWarning(code="setup", path="agent.soul", message="setup required") + service._resolve_package_soul = Mock(return_value=(soul, [warning])) + service._unique_roster_name = Mock(return_value="Portable Agent import") + agent = _agent() + agent.active_config_snapshot_id = "snapshot-1" + agent.active_config_is_published = True + roster_service = Mock() + roster_service.create_backing_agent_for_app.return_value = agent + monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service)) + service._require_snapshot = Mock(return_value=_snapshot(soul=soul)) + app = SimpleNamespace( + tenant_id="tenant-1", + id="app-1", + name="", + description="", + app_model_config=None, + app_model_config_id=None, + ) + + result = service.import_agent_app_package( + app=app, + account=SimpleNamespace(id="account-1"), + package=make_portable_agent_package(_agent(), soul), + ) + + assert result.warnings == [warning] + assert agent.active_config_is_published is False + assert app.name == "Portable Agent" + assert app.description == "description" + assert session.add.call_count == 2 + assert session.flush.call_count == 2 + + +def test_import_workflow_packages_replaces_bindings_and_reuses_roster_package() -> None: + package = make_portable_agent_package(_agent(), AgentSoulConfig()) + graph = { + "nodes": [ + _agent_node( + "roster-1", + {"binding_type": WorkflowAgentBindingType.ROSTER_AGENT.value, AGENT_PACKAGE_REF_KEY: "agent_1"}, + ), + _agent_node( + "roster-2", + {"binding_type": WorkflowAgentBindingType.ROSTER_AGENT.value, AGENT_PACKAGE_REF_KEY: "agent_1"}, + ), + _agent_node( + "inline", + {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, AGENT_PACKAGE_REF_KEY: "agent_1"}, + ), + _agent_node("missing-binding"), + _agent_node("invalid-ref", {AGENT_PACKAGE_REF_KEY: 1}), + ], + "edges": [], + } + for node in graph["nodes"][:3]: + node["data"][AGENT_NODE_JOB_DSL_KEY] = {"workflow_prompt": node["id"]} + old_binding = SimpleNamespace(id="old-binding") + session = Mock() + session.scalars.return_value.all.return_value = [old_binding] + service = AgentDslService(session) + roster_result = SimpleNamespace( + agent=SimpleNamespace(id="roster-agent"), + snapshot=SimpleNamespace(id="roster-snapshot"), + warnings=[DslImportWarning(code="roster", path="agent", message="roster warning")], + ) + inline_result = SimpleNamespace( + agent=SimpleNamespace(id="inline-agent"), + snapshot=SimpleNamespace(id="inline-snapshot"), + warnings=[DslImportWarning(code="inline", path="agent", message="inline warning")], + ) + service._create_imported_roster_agent_app = Mock(return_value=roster_result) + service._create_imported_inline_agent = Mock(return_value=inline_result) + workflow = SimpleNamespace( + tenant_id="tenant-1", + app_id="app-1", + id="workflow-1", + version="draft", + graph="{}", + ) + + result, warnings = service.import_workflow_packages( + workflow=workflow, + portable_graph=graph, + raw_packages={"agent_1": package.model_dump(mode="json")}, + account=SimpleNamespace(id="account-1"), + ) + + session.delete.assert_called_once_with(old_binding) + service._create_imported_roster_agent_app.assert_called_once() + service._create_imported_inline_agent.assert_called_once() + assert [warning.code for warning in warnings] == ["roster", "roster", "inline"] + assert result["nodes"][0]["data"]["agent_binding"]["agent_id"] == "roster-agent" + assert result["nodes"][2]["data"]["agent_binding"]["agent_id"] == "inline-agent" + assert AGENT_NODE_JOB_DSL_KEY not in result["nodes"][0]["data"] + assert json.loads(workflow.graph) == result + added_bindings = [item.args[0] for item in session.add.call_args_list] + assert all(isinstance(binding, WorkflowAgentNodeBinding) for binding in added_bindings) + + +@pytest.mark.parametrize( + ("binding", "error"), + [ + ( + {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, AGENT_PACKAGE_REF_KEY: "missing"}, + "unknown package", + ), + ({"binding_type": "invalid", AGENT_PACKAGE_REF_KEY: "agent_1"}, "invalid binding type"), + ], +) +def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, error: str) -> None: + session = Mock() + session.scalars.return_value.all.return_value = [] + package = make_portable_agent_package(_agent(), AgentSoulConfig()) + + with pytest.raises(ValueError, match=error): + AgentDslService(session).import_workflow_packages( + workflow=SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1", version="draft"), + portable_graph={"nodes": [_agent_node("node-1", binding)], "edges": []}, + raw_packages={"agent_1": package.model_dump(mode="json")}, + account=SimpleNamespace(id="account-1"), + ) + + +def test_clone_inline_binding_copies_soul_and_drive_rows(monkeypatch) -> None: + session = Mock() + service = AgentDslService(session) + target_agent = SimpleNamespace(id="target-agent") + target_snapshot = SimpleNamespace(id="target-snapshot") + service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot)) + copy_rows = Mock() + monkeypatch.setattr("services.agent.composer_service.AgentComposerService._copy_agent_drive_rows", copy_rows) + source_agent = _agent() + source_snapshot = SimpleNamespace( + config_snapshot_dict=AgentSoulConfig(config_note="source").model_dump(mode="json") + ) + workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1") + node_job = WorkflowNodeJobConfig(workflow_prompt="work") + + result = service.clone_inline_binding_for_node( + workflow=workflow, + node_id="target-node", + source_agent=source_agent, + source_snapshot=source_snapshot, + node_job=node_job, + account_id="account-1", + ) + + assert result == (target_agent, target_snapshot) + create_kwargs = service._create_workflow_only_agent.call_args.kwargs + assert create_kwargs["metadata"].name == source_agent.name + assert create_kwargs["soul"].config_note == "source" + assert create_kwargs["source"] == AgentSource.WORKFLOW + copy_rows.assert_called_once_with( + tenant_id="tenant-1", + source_agent_id="agent-1", + target_agent_id="target-agent", + account_id="account-1", + agent_soul=create_kwargs["soul"], + node_job=node_job, + session=session, + ) + + +def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch) -> None: + model_dependency = Mock(side_effect=lambda provider: f"model:{provider}") + tool_dependency = Mock(side_effect=lambda provider: f"tool:{provider}") + monkeypatch.setattr( + "services.agent.dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", + model_dependency, + ) + monkeypatch.setattr( + "services.agent.dsl_service.DependenciesAnalysisService.analyze_tool_dependency", + tool_dependency, + ) + soul = AgentSoulConfig.model_validate( + { + "model": {"plugin_id": "model-plugin", "model_provider": "provider/model", "model": "model"}, + "tools": { + "dify_tools": [ + {"provider_id": "provider/tool", "credential_type": "unauthorized"}, + { + "plugin_id": "plugin-id", + "provider": "fallback-provider", + "credential_type": "unauthorized", + }, + ] + }, + "knowledge": { + "sets": [ + { + "id": "set-1", + "name": "Set", + "datasets": [{"id": "dataset-1", "name": "Docs"}], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": { + "mode": "single", + "model": {"provider": "provider/retrieval", "name": "embed", "mode": "embedding"}, + "reranking_model": {"provider": "provider/rerank", "model": "rerank"}, + }, + } + ] + }, + } + ) + + dependencies = AgentDslService(Mock()).extract_package_dependencies( + {"agent_1": make_portable_agent_package(_agent(), soul)} + ) + + assert dependencies == [ + "model:provider/model", + "tool:provider/tool", + "tool:plugin-id/fallback-provider", + "model:provider/retrieval", + "model:provider/rerank", + ] + + +def test_create_imported_roster_agent_app_prefixes_warnings(monkeypatch) -> None: + session = Mock() + service = AgentDslService(session) + service._configure_visible_agent_app_after_commit = Mock() + result = SimpleNamespace( + agent=_agent(), + snapshot=_snapshot(), + warnings=[DslImportWarning(code="setup", path="soul.model", message="setup")], + ) + service.import_agent_app_package = Mock(return_value=result) + send = Mock() + monkeypatch.setattr("services.agent.dsl_service.app_was_created.send", send) + + imported = service._create_imported_roster_agent_app( + tenant_id="tenant-1", + account=SimpleNamespace(id="account-1"), + package=make_portable_agent_package(_agent(), AgentSoulConfig()), + package_path="agent_packages.agent_1", + ) + + app = session.add.call_args.args[0] + assert isinstance(app, App) + assert app.name == "Portable Agent" + assert app.enable_site is True + assert app.enable_api is True + send.assert_called_once_with(app, account=SimpleNamespace(id="account-1")) + assert imported.warnings[0].path == "agent_packages.agent_1.soul.model" + + +def test_create_imported_inline_agent_uses_import_provenance() -> None: + service = AgentDslService(Mock()) + soul = AgentSoulConfig(config_note="inline") + warning = DslImportWarning(code="setup", path="agent", message="setup") + service._resolve_package_soul = Mock(return_value=(soul, [warning])) + service._create_workflow_only_agent = Mock(return_value=(_agent(), _snapshot(soul=soul))) + workflow = SimpleNamespace(tenant_id="tenant-1", id="workflow-1") + + result = service._create_imported_inline_agent( + workflow=workflow, + node_id="node-1", + account=SimpleNamespace(id="account-1"), + package=make_portable_agent_package(_agent(), soul), + package_path="agent_packages.agent_1", + ) + + assert result.warnings == [warning] + assert service._create_workflow_only_agent.call_args.kwargs["source"] == AgentSource.IMPORTED + assert ( + service._create_workflow_only_agent.call_args.kwargs["operation"] == AgentConfigRevisionOperation.IMPORT_PACKAGE + ) + + +def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch) -> None: + session = Mock() + service = AgentDslService(session) + roster_service = Mock() + roster_service.create_hidden_backing_app_for_workflow_agent.return_value = SimpleNamespace(id="backing-app") + monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service)) + service._create_snapshot = Mock(return_value=SimpleNamespace(id="snapshot-1")) + monkeypatch.setattr("services.agent.dsl_service.agent_soul_has_model", Mock(return_value=True)) + workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1") + + agent, snapshot = service._create_workflow_only_agent( + workflow=workflow, + node_id="node-1", + account_id="account-1", + metadata=AgentPackageMetadata(name="Inline", icon_type=AgentIconType.EMOJI.value), + soul=AgentSoulConfig(), + source=AgentSource.IMPORTED, + operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, + ) + + assert snapshot.id == "snapshot-1" + assert agent.scope == AgentScope.WORKFLOW_ONLY + assert agent.backing_app_id == "backing-app" + assert agent.active_config_snapshot_id == "snapshot-1" + assert agent.active_config_has_model is True + assert agent.active_config_is_published is True + session.add.assert_called_once_with(agent) + assert session.flush.call_count == 2 + + +def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(monkeypatch) -> None: + soul = AgentSoulConfig.model_validate( + { + "config_skills": [{"name": "skill", "file_kind": "tool_file", "file_id": "skill-file"}], + "config_files": [{"name": "Guide", "file_kind": "upload_file", "file_id": "guide-file"}], + "knowledge": { + "sets": [ + { + "id": "set-1", + "name": "Set", + "datasets": [ + {"id": "existing", "name": "Existing"}, + {"id": "missing", "name": "Missing"}, + ], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": {"mode": "multiple", "top_k": 3}, + } + ] + }, + } + ) + monkeypatch.setattr( + "services.agent.dsl_service.get_tenant_knowledge_dataset_rows", + Mock(return_value={"existing": SimpleNamespace(id="existing")}), + ) + + resolved, warnings = AgentDslService(Mock())._resolve_package_soul( + tenant_id="tenant-1", + package=make_portable_agent_package(_agent(), soul), + package_path="agent_packages.agent_1", + ) + + datasets = resolved.knowledge.sets[0].datasets + assert datasets[0].id == "existing" + assert datasets[1].id is not None + assert datasets[1].id.startswith("missing-dataset-") + assert {warning.code for warning in warnings} == { + "agent_skill_omitted", + "agent_file_omitted", + "agent_knowledge_unresolved", + } + + +def test_create_snapshot_increments_version_and_records_revision() -> None: + session = Mock() + session.scalar.return_value = 2 + service = AgentDslService(session) + + snapshot = service._create_snapshot( + tenant_id="tenant-1", + agent=_agent(), + account_id="account-1", + soul=AgentSoulConfig(config_note="version 3"), + operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, + ) + + assert snapshot.version == 3 + assert isinstance(session.add.call_args_list[0].args[0], AgentConfigSnapshot) + revision = session.add.call_args_list[1].args[0] + assert isinstance(revision, AgentConfigRevision) + assert revision.operation == AgentConfigRevisionOperation.IMPORT_PACKAGE + assert session.flush.call_count == 2 + + +def test_unique_roster_name_uses_first_available_suffix() -> None: + session = Mock() + session.scalars.return_value.all.return_value = ["Agent", "Agent import"] + + result = AgentDslService(session)._unique_roster_name(tenant_id="tenant-1", requested="Agent") + + assert result == "Agent import 2" + + +def test_configure_visible_agent_app_runs_after_commit(monkeypatch) -> None: + session = Mock() + listener = Mock() + monkeypatch.setattr("services.agent.dsl_service.event.listen", listener) + service = AgentDslService(session) + + service._configure_visible_agent_app_after_commit( + tenant_id="tenant-1", + app_id="app-1", + account_id="account-1", + ) + + listener.assert_called_once_with(session, "after_commit", listener.call_args.args[2], once=True) + configure = listener.call_args.args[2] + from services.enterprise import rbac_service + from services.enterprise.enterprise_service import EnterpriseService + from services.feature_service import FeatureService + + sync = Mock() + update_access = Mock() + monkeypatch.setattr(rbac_service, "try_sync_creator_access_policy_member_bindings", sync) + monkeypatch.setattr(EnterpriseService.WebAppAuth, "update_app_access_mode", update_access) + monkeypatch.setattr( + FeatureService, + "get_system_features", + Mock(return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), + ) + configure(session) + update_access.assert_not_called() + + FeatureService.get_system_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)) + configure(session) + update_access.assert_called_once_with("app-1", "private") + assert sync.call_args_list == [ + call("tenant-1", "account-1", rbac_service.RBACResourceType.APP, "app-1"), + call("tenant-1", "account-1", rbac_service.RBACResourceType.APP, "app-1"), + ] + + monkeypatch.setattr(rbac_service, "try_sync_creator_access_policy_member_bindings", Mock(side_effect=RuntimeError)) + logger = Mock() + monkeypatch.setattr("services.agent.dsl_service.logger", logger) + configure(session) + logger.exception.assert_called_once() + + +def test_require_helpers_and_graph_detection() -> None: + session = Mock() + service = AgentDslService(session) + agent = _agent() + snapshot = _snapshot() + session.scalar.side_effect = [agent, None, snapshot, None] + + assert service._require_agent(tenant_id="tenant-1", agent_id="agent-1") is agent + with pytest.raises(ValueError, match="source Agent"): + service._require_agent(tenant_id="tenant-1", agent_id="missing") + with pytest.raises(ValueError, match="source snapshot"): + service._require_snapshot(tenant_id="tenant-1", agent_id="agent-1", snapshot_id=None) + assert service._require_snapshot(tenant_id="tenant-1", agent_id="agent-1", snapshot_id="snapshot-1") is snapshot + with pytest.raises(ValueError, match="source snapshot"): + service._require_snapshot(tenant_id="tenant-1", agent_id="agent-1", snapshot_id="missing") + + assert AgentDslService._agent_icon_type(AgentIconType.EMOJI.value) == AgentIconType.EMOJI + assert AgentDslService._agent_icon_type(None) is None + assert AgentDslService._app_icon_type(IconType.IMAGE.value) == IconType.IMAGE + assert AgentDslService._app_icon_type(None) == IconType.EMOJI + assert is_agent_v2_graph({"nodes": [_agent_node("agent")]}) is True + assert is_agent_v2_graph({"nodes": ["invalid", {"data": {"type": "start"}}]}) is False 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 2f80599689d..2480a6ddf40 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -4295,7 +4295,7 @@ class TestWorkflowAgentDraftBindingSync: assert session.added == [] assert session.flushes == 1 - def test_rejects_inline_binding_for_agent_owned_by_another_node(self): + def test_clones_inline_binding_for_agent_owned_by_another_node(self, monkeypatch): workflow = Workflow( id="workflow-1", tenant_id="tenant-1", @@ -4334,13 +4334,18 @@ class TestWorkflowAgentDraftBindingSync: active_config_snapshot_id="inline-snapshot-1", ) session = FakeSession(scalar=[agent], scalars=[[]]) + clone = MagicMock(return_value=(SimpleNamespace(id="cloned-agent"), "cloned-snapshot")) + monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) - with pytest.raises(ValueError, match="inline_agent binding does not belong to this node"): - WorkflowAgentPublishService.sync_agent_bindings_for_draft( - session=session, - draft_workflow=workflow, - account_id="account-1", - ) + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + clone.assert_called_once() + assert session.added[0].agent_id == "cloned-agent" + assert session.added[0].current_snapshot_id == "cloned-snapshot" def test_rejects_agent_node_graph_binding_with_unsupported_type(self): workflow = Workflow( diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py new file mode 100644 index 00000000000..4a928cf1a0a --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py @@ -0,0 +1,281 @@ +from types import SimpleNamespace +from unittest.mock import ANY, Mock + +import pytest + +from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding +from models.agent_config_entities import WorkflowNodeJobConfig +from models.workflow import Workflow, WorkflowType +from services.agent.dsl_service import AgentDslService +from services.agent.workflow_publish_service import WorkflowAgentPublishService, _InlineAgentOwnershipError + + +def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSION_DRAFT) -> Workflow: + return Workflow( + id=workflow_id, + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=version, + graph={"nodes": [], "edges": []}, + features={}, + created_by="account-1", + environment_variables=[], + conversation_variables=[], + ) + + +def test_inline_binding_from_another_node_is_cloned(monkeypatch) -> None: + session = Mock() + draft_workflow = _workflow() + monkeypatch.setattr( + WorkflowAgentPublishService, + "_resolve_inline_agent_graph_binding", + Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), + ) + monkeypatch.setattr( + WorkflowAgentPublishService, + "_resolve_existing_inline_binding_agent", + Mock(return_value=None), + ) + clone = Mock(return_value=(SimpleNamespace(id="target-agent"), "target-snapshot")) + monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + + WorkflowAgentPublishService._sync_agent_binding_for_node( + session=session, + draft_workflow=draft_workflow, + node_id="pasted-node", + node_data={"agent_task": "Summarize the input"}, + node_binding={ + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + "agent_id": "source-agent", + "current_snapshot_id": "source-snapshot", + }, + existing_binding=None, + account_id="account-1", + ) + + clone.assert_called_once() + binding = session.add.call_args.args[0] + assert isinstance(binding, WorkflowAgentNodeBinding) + assert binding.agent_id == "target-agent" + assert binding.current_snapshot_id == "target-snapshot" + assert binding.node_job_config.workflow_prompt == "Summarize the input" + + +def test_restore_replaces_draft_bindings_with_published_bindings() -> None: + existing = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="draft-workflow", + workflow_version=Workflow.VERSION_DRAFT, + node_id="old-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="old-agent", + current_snapshot_id="old-snapshot", + node_job_config={}, + created_by="account-1", + ) + source = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="published-workflow", + workflow_version="2026-07-13 00:00:00", + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent", + current_snapshot_id="published-snapshot", + node_job_config={"workflow_prompt": "Use the roster agent"}, + created_by="account-1", + ) + session = Mock() + session.scalars.side_effect = [SimpleNamespace(all=lambda: [existing]), SimpleNamespace(all=lambda: [source])] + + WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( + session=session, + source_workflow=_workflow(workflow_id="published-workflow", version="2026-07-13 00:00:00"), + draft_workflow=_workflow(workflow_id="draft-workflow"), + account_id="account-2", + ) + + session.delete.assert_called_once_with(existing) + restored = session.add.call_args.args[0] + assert isinstance(restored, WorkflowAgentNodeBinding) + assert restored.workflow_id == "draft-workflow" + assert restored.workflow_version == Workflow.VERSION_DRAFT + assert restored.agent_id == "roster-agent" + assert restored.current_snapshot_id == "published-snapshot" + assert restored.node_job_config.workflow_prompt == "Use the roster agent" + session.flush.assert_called_once() + + +def test_inline_binding_reuses_existing_node_owned_agent(monkeypatch) -> None: + session = Mock() + draft_workflow = _workflow() + existing_binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="pasted-node", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="existing-agent", + current_snapshot_id="existing-snapshot", + node_job_config={}, + created_by="account-1", + ) + existing_agent = SimpleNamespace(id="existing-agent") + monkeypatch.setattr( + WorkflowAgentPublishService, + "_resolve_inline_agent_graph_binding", + Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), + ) + monkeypatch.setattr( + WorkflowAgentPublishService, + "_resolve_existing_inline_binding_agent", + Mock(return_value=existing_agent), + ) + clone = Mock() + monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + + WorkflowAgentPublishService._sync_agent_binding_for_node( + session=session, + draft_workflow=draft_workflow, + node_id="pasted-node", + node_data={"agent_task": "Summarize"}, + node_binding={ + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + "agent_id": "source-agent", + "current_snapshot_id": "source-snapshot", + }, + existing_binding=existing_binding, + account_id="account-1", + ) + + assert existing_binding.agent_id == "existing-agent" + assert existing_binding.current_snapshot_id == "existing-snapshot" + clone.assert_not_called() + + +def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monkeypatch) -> None: + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config={}, + created_by="account-1", + ) + resolved = SimpleNamespace(id="agent-1") + resolver = Mock(return_value=resolved) + monkeypatch.setattr(WorkflowAgentPublishService, "_resolve_inline_agent_graph_binding", resolver) + + assert ( + WorkflowAgentPublishService._resolve_existing_inline_binding_agent( + session=Mock(), + draft_workflow=_workflow(), + node_id="node-1", + existing_binding=binding, + ) + is resolved + ) + + resolver.side_effect = ValueError("stale") + assert ( + WorkflowAgentPublishService._resolve_existing_inline_binding_agent( + session=Mock(), + draft_workflow=_workflow(), + node_id="node-1", + existing_binding=binding, + ) + is None + ) + + +def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch) -> None: + session = Mock() + source_agent = SimpleNamespace(id="source-agent") + source_snapshot = SimpleNamespace(id="source-snapshot") + session.scalar.side_effect = [source_agent, source_snapshot] + target_agent = SimpleNamespace(id="target-agent") + target_snapshot = SimpleNamespace(id="target-snapshot") + clone = Mock(return_value=(target_agent, target_snapshot)) + monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) + node_job = WorkflowNodeJobConfig(workflow_prompt="work") + + result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node( + session=session, + draft_workflow=_workflow(), + node_id="target-node", + source_agent_id="source-agent", + source_snapshot_id="source-snapshot", + node_job=node_job, + account_id="account-1", + ) + + assert result == (target_agent, "target-snapshot") + clone.assert_called_once_with( + workflow=ANY, + node_id="target-node", + source_agent=source_agent, + source_snapshot=source_snapshot, + node_job=node_job, + account_id="account-1", + ) + + +@pytest.mark.parametrize("scalar_results", [[None], [SimpleNamespace(id="source-agent"), None]]) +def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_results: list[object | None]) -> None: + session = Mock() + session.scalar.side_effect = scalar_results + + with pytest.raises(ValueError, match="unavailable inline agent|missing inline agent config snapshot"): + WorkflowAgentPublishService._clone_inline_graph_binding_for_node( + session=session, + draft_workflow=_workflow(), + node_id="target-node", + source_agent_id="source-agent", + source_snapshot_id="source-snapshot", + node_job=WorkflowNodeJobConfig(), + account_id="account-1", + ) + + +def test_restore_clones_inline_binding_owned_by_published_workflow(monkeypatch) -> None: + source = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="published-workflow", + workflow_version="published", + node_id="agent-node", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="published-agent", + current_snapshot_id="published-snapshot", + node_job_config={"workflow_prompt": "work"}, + created_by="account-1", + ) + session = Mock() + session.scalars.side_effect = [SimpleNamespace(all=lambda: []), SimpleNamespace(all=lambda: [source])] + monkeypatch.setattr( + WorkflowAgentPublishService, + "_resolve_inline_agent_graph_binding", + Mock(side_effect=ValueError("owned by published workflow")), + ) + clone = Mock(return_value=(SimpleNamespace(id="draft-agent"), "draft-snapshot")) + monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + + WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( + session=session, + source_workflow=_workflow(workflow_id="published-workflow", version="published"), + draft_workflow=_workflow(workflow_id="draft-workflow"), + account_id="account-2", + ) + + clone.assert_called_once() + restored = session.add.call_args.args[0] + assert restored.agent_id == "draft-agent" + assert restored.current_snapshot_id == "draft-snapshot" diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py index b234d9f91fe..e527e5bd950 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -18,7 +18,7 @@ from services.snippet_dsl_service import ( [ ("not-a-version", ImportStatus.FAILED), ("999.0.0", ImportStatus.PENDING), - ("0.1.0", ImportStatus.COMPLETED), + ("0.1.0", ImportStatus.COMPLETED_WITH_WARNINGS), ], ) def test_check_version_compatibility_special_cases(version, expected): @@ -202,7 +202,7 @@ def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error def test_import_snippet_returns_failed_for_invalid_version_type() -> None: - service = SnippetDslService(session=SimpleNamespace()) + service = SnippetDslService(session=SimpleNamespace(rollback=Mock())) result = service.import_snippet( account=SimpleNamespace(current_tenant_id="tenant-1"), @@ -333,12 +333,28 @@ workflow: yaml_content=yaml_content, ) - assert result.status == ImportStatus.COMPLETED + assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS assert result.snippet_id == "snippet-1" dependencies = create_or_update.call_args.kwargs["dependencies"] assert dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" +def test_import_snippet_rolls_back_when_create_or_update_raises(monkeypatch): + session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) + service = SnippetDslService(session=session) + monkeypatch.setattr(service, "_create_or_update_snippet", Mock(side_effect=RuntimeError("boom"))) + + result = service.import_snippet( + account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "boom" + session.rollback.assert_called_once() + + def test_confirm_import_returns_failed_when_pending_data_missing(monkeypatch): service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=None)) @@ -417,7 +433,8 @@ def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch): def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) + service = SnippetDslService(session=session) pending = SnippetPendingData( import_mode="yaml-content", yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", @@ -433,6 +450,7 @@ def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch) assert result.status == ImportStatus.FAILED assert result.error == "boom" + session.rollback.assert_called_once() def test_check_dependencies_returns_empty_without_draft_workflow(monkeypatch): diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index 464f2313a6a..731d09017ed 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -191,7 +191,8 @@ def test_update_snippet_updates_optional_fields() -> None: def test_sync_draft_workflow_creates_draft_and_updates_input_fields(monkeypatch: pytest.MonkeyPatch) -> None: service = SnippetService.__new__(SnippetService) monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=None)) - session = SimpleNamespace(add=Mock(), commit=Mock()) + session = Mock() + session.scalars.return_value.all.return_value = [] service._session_maker = _session_maker(session) snippet = SimpleNamespace( id="snippet-1", @@ -249,7 +250,8 @@ def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeyp updated_at=None, ) account = SimpleNamespace(id="account-1") - session = SimpleNamespace(add=Mock(), commit=Mock()) + session = Mock() + session.scalars.return_value.all.return_value = [] monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=workflow)) service._session_maker = _session_maker(session) @@ -374,7 +376,8 @@ def test_restore_published_snippet_workflow_to_draft_copies_source_snapshot( features={}, ) service = SnippetService.__new__(SnippetService) - session = SimpleNamespace(add=Mock(), commit=Mock()) + session = Mock() + session.scalars.return_value.all.return_value = [] service._session_maker = _session_maker(session) monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow)) @@ -428,7 +431,8 @@ def test_restore_published_snippet_workflow_to_draft_adds_new_draft(monkeypatch: features={}, ) service = SnippetService.__new__(SnippetService) - session = SimpleNamespace(add=Mock(), commit=Mock()) + session = Mock() + session.scalars.return_value.all.return_value = [] service._session_maker = _session_maker(session) monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow)) @@ -568,6 +572,46 @@ def test_delete_snippet_removes_related_records() -> None: session.delete.assert_called_once_with(snippet) +def test_delete_snippet_archives_owned_agents_and_schedules_backing_app_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + agent = SimpleNamespace( + backing_app_id="backing-app-1", + status="active", + archived_by=None, + archived_at=None, + updated_by="creator-1", + updated_at=None, + ) + scalar_results = [ + SimpleNamespace(all=Mock(return_value=[])), + SimpleNamespace(all=Mock(return_value=[agent])), + ] + session = SimpleNamespace( + execute=Mock(), + scalars=Mock(side_effect=scalar_results), + delete=Mock(), + ) + listen = Mock() + monkeypatch.setattr("services.snippet_service.event.listen", listen) + + result = SnippetService.delete_snippet( + session=session, + snippet=snippet, + account_id="account-1", + ) + + assert result is True + assert agent.status == "archived" + assert agent.archived_by == "account-1" + assert agent.archived_at is not None + assert agent.updated_by == "account-1" + executed_sql = "\n".join(str(call.args[0]) for call in session.execute.call_args_list) + assert "DELETE FROM apps" in executed_sql + listen.assert_called_once_with(session, "after_commit", listen.call_args.args[2], once=True) + + def test_delete_draft_variable_files_removes_storage_objects(monkeypatch: pytest.MonkeyPatch) -> None: from extensions.ext_storage import storage diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index d2a20022984..ec29f5662b0 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -1421,6 +1421,7 @@ export type AgentUserSatisfactionRateStatisticResponse = { export type AgentConfigRevisionOperation = | 'create_version' + | 'import_package' | 'publish_draft' | 'restore_version' | 'save_current_version' diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index bedd050d498..0687f741c6d 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -1520,6 +1520,7 @@ export const zAgentStatisticSummaryEnvelopeResponse = z.object({ */ export const zAgentConfigRevisionOperation = z.enum([ 'create_version', + 'import_package', 'publish_draft', 'restore_version', 'save_current_version', diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 6dd6568c767..ad0272aae93 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -73,6 +73,7 @@ export type Import = { imported_dsl_version?: string permission_keys?: Array status: ImportStatus + warnings?: Array } export type CheckDependenciesResult = { @@ -545,6 +546,7 @@ export type AppImportResponse = { id: string imported_dsl_version?: string status: ImportStatus + warnings?: Array } export type AppExportResponse = { @@ -1371,6 +1373,15 @@ export type WorkflowPartial = { export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' +export type DslImportWarning = { + code: string + details?: { + [key: string]: unknown + } + message: string + path: string +} + export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a053eff4352..8faa637f586 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -941,18 +941,31 @@ export const zWorkflowPartial = z.object({ */ export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending']) +/** + * DslImportWarning + * + * Portable DSL reference that could not be restored in the target workspace. + */ +export const zDslImportWarning = z.object({ + code: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + message: z.string(), + path: z.string(), +}) + /** * Import */ export const zImport = z.object({ app_id: z.string().nullish(), app_mode: z.string().nullish(), - current_dsl_version: z.string().optional().default('0.6.0'), + current_dsl_version: z.string().optional().default('0.7.0'), error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), permission_keys: z.array(z.string()).optional(), status: zImportStatus, + warnings: z.array(zDslImportWarning).optional(), }) /** @@ -966,6 +979,7 @@ export const zAppImportResponse = z.object({ id: z.string(), imported_dsl_version: z.string().optional().default(''), status: zImportStatus, + warnings: z.array(zDslImportWarning).optional(), }) export const zJsonValue = z diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts index 80e918f9700..433d4259def 100644 --- a/packages/contracts/generated/api/console/snippets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -24,6 +24,11 @@ import { zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse, zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath, zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerQuery, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse, zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath, zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse, zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath, @@ -59,6 +64,18 @@ import { zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody, zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath, zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse, zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody, zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath, zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse, @@ -70,6 +87,9 @@ import { zPostSnippetsBySnippetIdWorkflowsPublishBody, zPostSnippetsBySnippetIdWorkflowsPublishPath, zPostSnippetsBySnippetIdWorkflowsPublishResponse, + zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerBody, + zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse, zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath, zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse, } from './zod.gen' @@ -332,6 +352,147 @@ export const loop = { nodes: nodes2, } +export const get8 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidates', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates', + tags: ['console'], + }) + .input( + z.object({ + params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath, + }), + ) + .output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) + +export const candidates = { + get: get8, +} + +export const post4 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoster', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) + +export const copyFromRoster = { + post: post4, +} + +export const post5 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpact', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/impact', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) + +export const impact = { + post: post5, +} + +export const post6 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoster', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) + +export const saveToRoster = { + post: post6, +} + +export const post7 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidate', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/validate', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) + +export const validate = { + post: post7, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposer', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer', + tags: ['console'], + }) + .input( + z.object({ + params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + query: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerQuery.optional(), + }), + ) + .output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposer', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer', + tags: ['console'], + }) + .input( + z.object({ + body: zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerBody, + params: zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath, + }), + ) + .output(zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) + +export const agentComposer = { + get: get9, + put, + candidates, + copyFromRoster, + impact, + saveToRoster, + validate, +} + /** * Get the last run result for a specific node in snippet draft workflow * @@ -339,7 +500,7 @@ export const loop = { * Returns the most recent execution record for the given node, * including status, inputs, outputs, and timing information. */ -export const get8 = oc +export const get10 = oc .route({ description: 'Get last run result for a node in snippet draft workflow\nReturns the most recent execution record for the given node,\nincluding status, inputs, outputs, and timing information.', @@ -354,7 +515,7 @@ export const get8 = oc .output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get8, + get: get10, } /** @@ -364,7 +525,7 @@ export const lastRun = { * Executes a specific node with provided inputs for single-step debugging. * Returns the node execution result including status, outputs, and timing. */ -export const post4 = oc +export const post8 = oc .route({ description: 'Run a single node in snippet draft workflow (single-step debugging)\nExecutes a specific node with provided inputs for single-step debugging.\nReturns the node execution result including status, outputs, and timing.', @@ -384,7 +545,7 @@ export const post4 = oc .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse) export const run3 = { - post: post4, + post: post8, } /** @@ -406,7 +567,7 @@ export const delete_ = oc /** * Get variables for a specific node (snippet draft workflow) */ -export const get9 = oc +export const get11 = oc .route({ description: 'Get variables for a specific node (snippet draft workflow)', inputStructure: 'detailed', @@ -420,10 +581,11 @@ export const get9 = oc export const variables = { delete: delete_, - get: get9, + get: get11, } export const byNodeId3 = { + agentComposer, lastRun, run: run3, variables, @@ -439,7 +601,7 @@ export const nodes3 = { * Executes the snippet's draft workflow with the provided inputs * and returns an SSE event stream with execution progress and results. */ -export const post5 = oc +export const post9 = oc .route({ description: "Executes the snippet's draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.", @@ -459,13 +621,13 @@ export const post5 = oc .output(zPostSnippetsBySnippetIdWorkflowsDraftRunResponse) export const run4 = { - post: post5, + post: post9, } /** * System variables are not used in snippet workflows; returns an empty list for API parity */ -export const get10 = oc +export const get12 = oc .route({ description: 'System variables are not used in snippet workflows; returns an empty list for API parity', @@ -479,13 +641,13 @@ export const get10 = oc .output(zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get10, + get: get12, } /** * Reset a draft workflow variable to its default value (snippet scope) */ -export const put = oc +export const put2 = oc .route({ description: 'Reset a draft workflow variable to its default value (snippet scope)', inputStructure: 'detailed', @@ -498,7 +660,7 @@ export const put = oc .output(zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse) export const reset = { - put, + put: put2, } /** @@ -520,7 +682,7 @@ export const delete2 = oc /** * Get a specific draft workflow variable (snippet scope) */ -export const get11 = oc +export const get13 = oc .route({ description: 'Get a specific draft workflow variable (snippet scope)', inputStructure: 'detailed', @@ -554,7 +716,7 @@ export const patch = oc export const byVariableId = { delete: delete2, - get: get11, + get: get13, patch, reset, } @@ -578,7 +740,7 @@ export const delete3 = oc /** * List draft workflow variables without values (paginated, snippet scope) */ -export const get12 = oc +export const get14 = oc .route({ description: 'List draft workflow variables without values (paginated, snippet scope)', inputStructure: 'detailed', @@ -597,14 +759,14 @@ export const get12 = oc export const variables2 = { delete: delete3, - get: get12, + get: get14, byVariableId, } /** * Get draft workflow for snippet */ -export const get13 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -619,7 +781,7 @@ export const get13 = oc /** * Sync draft workflow for snippet */ -export const post6 = oc +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -637,8 +799,8 @@ export const post6 = oc .output(zPostSnippetsBySnippetIdWorkflowsDraftResponse) export const draft = { - get: get13, - post: post6, + get: get15, + post: post10, config, conversationVariables, environmentVariables, @@ -653,7 +815,7 @@ export const draft = { /** * Get published workflow for snippet */ -export const get14 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -668,7 +830,7 @@ export const get14 = oc /** * Publish snippet workflow */ -export const post7 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -686,8 +848,8 @@ export const post7 = oc .output(zPostSnippetsBySnippetIdWorkflowsPublishResponse) export const publish = { - get: get14, - post: post7, + get: get16, + post: post11, } /** @@ -695,7 +857,7 @@ export const publish = { * * Restore a published snippet workflow version into the draft workflow */ -export const post8 = oc +export const post12 = oc .route({ description: 'Restore a published snippet workflow version into the draft workflow', inputStructure: 'detailed', @@ -709,7 +871,7 @@ export const post8 = oc .output(zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post8, + post: post12, } /** @@ -745,7 +907,7 @@ export const byWorkflowId = { * * Get all published workflows for a snippet */ -export const get15 = oc +export const get17 = oc .route({ description: 'Get all published workflows for a snippet', inputStructure: 'detailed', @@ -764,7 +926,7 @@ export const get15 = oc .output(zGetSnippetsBySnippetIdWorkflowsResponse) export const workflows = { - get: get15, + get: get17, defaultWorkflowBlockConfigs, draft, publish, diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 47cf1a4781d..c32ec0cd610 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -118,6 +118,74 @@ export type SnippetLoopNodeRunPayload = { } | null } +export type WorkflowAgentComposerResponse = { + active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null + agent?: AgentComposerAgentResponse | null + agent_soul: AgentSoulConfig + app_id?: string | null + backing_app_id?: string | null + binding?: AgentComposerBindingResponse | null + chat_endpoint?: string | null + debug_conversation_has_messages?: boolean + debug_conversation_id?: string | null + debug_conversation_message_count?: number + effective_declared_outputs?: Array + hidden_app_backed?: boolean + impact_summary?: AgentComposerImpactResponse | null + node_id?: string | null + node_job: WorkflowNodeJobConfig + save_options: Array + soul_lock: AgentComposerSoulLockResponse + validation?: ComposerValidationFindingsResponse | null + variant: 'workflow' + workflow_id?: string | null +} + +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig | null + binding?: ComposerBindingPayload | null + client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig | null + role?: string | null + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + +export type AgentComposerCandidatesResponse = { + allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse + allowed_soul_candidates?: AgentComposerSoulCandidatesResponse + capabilities?: ComposerCandidateCapabilities + truncated?: boolean + variant: ComposerVariant +} + +export type WorkflowComposerCopyFromRosterPayload = { + idempotency_key?: string | null + source_agent_id: string + source_snapshot_id?: string | null +} + +export type AgentComposerImpactResponse = { + bindings?: Array + current_snapshot_id?: string | null + workflow_node_count: number +} + +export type AgentComposerValidateResponse = { + errors?: Array + knowledge_retrieval_placeholder?: Array + result: 'success' + warnings?: Array +} + export type WorkflowRunNodeExecutionResponse = { created_at?: number | null created_by_account?: SimpleAccountResponse | null @@ -299,6 +367,174 @@ export type JsonValue = | Array | 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 + 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 AgentSoulConfig = { + app_features?: AgentSoulAppFeaturesConfig + app_variables?: Array + config_files?: Array + config_note?: string + config_skills?: Array + env?: AgentSoulEnvConfig + files?: AgentSoulFilesConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: AgentSoulAppFeaturesConfig + model?: AgentSoulModelConfig | null + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + tools?: AgentSoulToolsConfig +} + +export type AgentComposerBindingResponse = { + agent_id?: string | null + binding_type: WorkflowAgentBindingType + current_snapshot_id?: string | null + id: string + node_id: string + workflow_id: string +} + +export type DeclaredOutputConfig = { + array_item?: DeclaredArrayItem | null + check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + failure_strategy?: DeclaredOutputFailureStrategy + file?: DeclaredOutputFileConfig | null + id?: string | null + name: string + required?: boolean + type: DeclaredOutputType +} + +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array + metadata?: WorkflowNodeJobMetadata + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array + schema_version?: number + 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 AgentComposerSoulLockResponse = { + can_unlock?: boolean + locked: boolean + reason?: string | null +} + +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type AgentIconType = 'emoji' | 'image' | 'link' + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + +export type AgentComposerNodeJobCandidatesResponse = { + declare_output_types?: Array + human_contacts?: Array + previous_node_outputs?: Array +} + +export type AgentComposerSoulCandidatesResponse = { + cli_tools?: Array + dify_tools?: Array + human_contacts?: Array + knowledge_sets?: Array +} + +export type ComposerCandidateCapabilities = { + human_roster_available?: boolean +} + +export type AgentComposerImpactBindingResponse = { + app_id: string + node_id: string + workflow_id: string +} + +export type ComposerKnowledgePlaceholderResponse = { + id: string + placeholder_name: string +} + +export type ComposerValidationWarningResponse = { + code: string + id?: string | null + kind?: string | null + message?: string | null + surface?: string | null +} + export type WorkflowDraftVariableWithoutValue = { description?: string edited?: boolean @@ -311,6 +547,581 @@ export type WorkflowDraftVariableWithoutValue = { visible?: boolean } +export type AgentScope = 'roster' | 'workflow_only' + +export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' + +export type AgentStatus = 'active' | 'archived' + +export type AgentSoulAppFeaturesConfig = { + file_upload?: AgentFileUploadFeatureConfig + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null + [key: string]: unknown +} + +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentConfigFileRefConfig = { + file_id: string + file_kind: 'tool_file' | 'upload_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentConfigSkillRefConfig = { + description?: string + file_id: string + file_kind?: 'tool_file' + hash?: string | null + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentSoulFilesConfig = { + files?: Array + skills?: Array +} + +export type AgentSoulHumanConfig = { + contacts?: Array + tools?: Array +} + +export type AgentSoulKnowledgeConfig = { + sets?: Array +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array + budget?: string | null + scope?: string | null +} + +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef | null + model: string + model_provider: string + model_settings?: AgentSoulModelSettings + plugin_id: string +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: AgentSandboxProviderConfig + provider?: string | null +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array + dify_tools?: Array +} + +export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent' + +export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + type: DeclaredOutputType +} + +export type DeclaredOutputCheckConfig = { + benchmark_file_ref?: AgentFileRefConfig | null + enabled?: boolean + model_ref?: AgentSoulModelConfig | null + prompt?: string | null +} + +export type DeclaredOutputFailureStrategy = { + default_value?: unknown + on_failure?: OutputErrorStrategy + retry?: DeclaredOutputRetryConfig +} + +export type DeclaredOutputFileConfig = { + extensions?: Array + mime_types?: Array +} + +export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + +export type AgentHumanContactConfig = { + channel?: string | null + contact_id?: string | null + contact_method?: string | null + email?: string | null + human_id?: string | null + id?: string | null + method?: string | null + name?: string | null + tenant_id?: string | null + [key: string]: unknown +} + +export type WorkflowNodeJobMetadata = { + agent_soul?: { + [key: string]: unknown + } | null + file_refs?: Array | null +} + +export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' + +export type WorkflowPreviousNodeOutputRef = { + key?: string | null + name?: string | null + node_id?: string | null + output?: string | null + selector?: Array | null + value_selector?: Array | null + variable?: string | null + variable_selector?: Array | null + [key: string]: unknown +} + +export type AgentCliToolConfig = { + approved?: boolean + authorization_status?: AgentCliToolAuthorizationStatus | null + command?: string | null + dangerous?: boolean + dangerous_accepted?: boolean + dangerous_acknowledged?: boolean + dangerous_command?: boolean + description?: string | null + enabled?: boolean + env?: AgentCliToolEnvConfig + id?: string | null + inferred_from?: string | null + install?: string | null + install_command?: string | null + install_commands?: Array + invoke_metadata?: { + [key: string]: unknown + } + label?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + pre_authorized?: boolean | null + requires_confirmation?: boolean + risk_accepted?: boolean + risk_level?: AgentCliToolRiskLevel | null + setup_command?: string | null + tool_name?: string | null + [key: string]: unknown +} + +export type AgentComposerDifyToolCandidateResponse = { + description?: string | null + granularity?: string | null + id?: string | null + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + tools_count?: number | null +} + +export type AgentComposerKnowledgeSetCandidateResponse = { + datasets?: Array + description?: string | null + id: string + missing_dataset_ids?: Array + name: string +} + +export type AgentFileUploadFeatureConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + enabled?: boolean + image?: AgentFileUploadImageFeatureConfig + number_limits?: number + [key: string]: unknown +} + +export type AgentFeatureToggleConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentSensitiveWordAvoidanceFeatureConfig = { + config?: AgentModerationProviderConfig | null + enabled?: boolean + type?: string | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { + enabled?: boolean + model?: AgentSuggestedQuestionsAfterAnswerModelConfig | null + prompt?: string | null + [key: string]: unknown +} + +export type AgentTextToSpeechFeatureConfig = { + autoPlay?: string | null + enabled?: boolean + language?: string | null + voice?: string | null + [key: string]: unknown +} + +export type AgentSecretRefConfig = { + credential_id?: string | null + env_name?: string | null + id?: string | null + key?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + permission_status?: string | null + provider?: string | null + provider_credential_id?: string | null + ref?: string | null + type?: string | null + value?: string | null + variable?: string | null + [key: string]: unknown +} + +export type AgentEnvVariableConfig = { + default?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + env_name?: string | null + key?: string | null + name?: string | null + required?: boolean + type?: string | null + value?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + variable?: string | null + [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 + name?: string | 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 AgentMemoryArtifactConfig = { + id?: string | null + name?: string | null + type?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSoulModelCredentialRef = { + id?: string | null + provider?: string | null + type: string +} + +export type AgentSoulModelSettings = { + frequency_penalty?: number | null + max_tokens?: number | null + presence_penalty?: number | null + response_format?: AgentModelResponseFormatConfig | null + stop?: Array | null + temperature?: number | null + top_p?: number | null +} + +export type AgentSandboxProviderConfig = { + cpu?: number | null + env?: Array + image?: string | null + working_dir?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolConfig = { + credential_ref?: AgentSoulDifyToolCredentialRef | null + credential_type?: 'api-key' | 'oauth2' | 'unauthorized' + description?: string | null + enabled?: boolean + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + provider_type?: string + runtime_parameters?: { + [key: string]: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + } + tool_name?: string | null +} + +export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' + +export type DeclaredOutputRetryConfig = { + enabled?: boolean + max_retries?: number + retry_interval_ms?: number +} + +export type AgentCliToolAuthorizationStatus = + | 'allowed' + | 'authorized' + | 'denied' + | 'forbidden' + | 'not_required' + | 'pending' + | 'pre_authorized' + | 'unauthorized' + +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentPermissionConfig = { + allowed?: boolean | null + state?: string | null + status?: string | null +} + +export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' + +export type AgentComposerKnowledgeDatasetCandidateResponse = { + description?: string | null + id?: string | null + missing?: boolean + name?: string | null +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type AgentFileUploadImageFeatureConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentModerationProviderConfig = { + api_based_extension_id?: string | null + inputs_config?: AgentModerationIoConfig | null + keywords?: string | null + outputs_config?: AgentModerationIoConfig | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerModelConfig = { + completion_params?: { + [key: string]: unknown + } | null + mode?: string | null + name: string + provider: string + [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 +} + +export type AgentSoulDifyToolCredentialRef = { + id?: string | null + provider?: string | null + type?: 'provider' | 'tool' +} + +export type AgentModerationIoConfig = { + enabled?: boolean + preset_response?: string | null + [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 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 GetSnippetsBySnippetIdWorkflowRunsData = { body?: never path: { @@ -558,6 +1369,128 @@ export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses = export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses] +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerData = { + body?: never + path: { + node_id: string + snippet_id: string + } + query?: { + snapshot_id?: string + } + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { + 200: WorkflowAgentComposerResponse +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] + +export type PutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerData = { + body: ComposerSavePayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' +} + +export type PutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = { + 200: WorkflowAgentComposerResponse +} + +export type PutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + PutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses[keyof PutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData = { + body?: never + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = { + 200: AgentComposerCandidatesResponse +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = + GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = { + body: WorkflowComposerCopyFromRosterPayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses = + { + 200: WorkflowAgentComposerResponse + } + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse = + PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { + body: ComposerSavePayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/impact' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = { + 200: AgentComposerImpactResponse +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = + PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterData = { + body: ComposerSavePayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = { + 200: WorkflowAgentComposerResponse +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse = + PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData = { + body: ComposerSavePayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/validate' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = { + 200: AgentComposerValidateResponse +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = + PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses] + export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 99d9eeaf677..37e7525736e 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -60,6 +60,15 @@ export const zSnippetLoopNodeRunPayload = z.object({ inputs: z.record(z.string(), z.unknown()).nullish(), }) +/** + * WorkflowComposerCopyFromRosterPayload + */ +export const zWorkflowComposerCopyFromRosterPayload = z.object({ + idempotency_key: z.string().max(255).nullish(), + source_agent_id: z.string().min(1).max(255), + source_snapshot_id: z.string().max(255).nullish(), +}) + /** * SnippetDraftNodeRunPayload * @@ -358,6 +367,132 @@ export const zJsonValue = z */ export const zGeneratedAppResponse = zJsonValue +/** + * AgentConfigSnapshotSummaryResponse + */ +export const zAgentConfigSnapshotSummaryResponse = z.object({ + agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + display_version: z.int().nullish(), + id: z.string(), + snapshot_version: z.int().nullish(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + +/** + * AgentComposerSoulLockResponse + */ +export const zAgentComposerSoulLockResponse = z.object({ + can_unlock: z.boolean().optional().default(false), + locked: z.boolean(), + reason: 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']) + +/** + * 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 + */ +export const zComposerCandidateCapabilities = z.object({ + human_roster_available: z.boolean().optional().default(false), +}) + +/** + * AgentComposerImpactBindingResponse + */ +export const zAgentComposerImpactBindingResponse = z.object({ + app_id: z.string(), + node_id: z.string(), + workflow_id: z.string(), +}) + +/** + * AgentComposerImpactResponse + */ +export const zAgentComposerImpactResponse = z.object({ + bindings: z.array(zAgentComposerImpactBindingResponse).optional(), + current_snapshot_id: z.string().nullish(), + workflow_node_count: z.int(), +}) + +/** + * ComposerKnowledgePlaceholderResponse + */ +export const zComposerKnowledgePlaceholderResponse = z.object({ + id: z.string(), + placeholder_name: z.string(), +}) + +/** + * ComposerValidationWarningResponse + */ +export const zComposerValidationWarningResponse = z.object({ + code: z.string(), + id: z.string().nullish(), + kind: z.string().nullish(), + message: z.string().nullish(), + surface: z.string().nullish(), +}) + +/** + * AgentComposerValidateResponse + */ +export const zAgentComposerValidateResponse = z.object({ + errors: z.array(z.string()).optional(), + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + result: z.literal('success'), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * ComposerValidationFindingsResponse + */ +export const zComposerValidationFindingsResponse = z.object({ + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + export const zWorkflowDraftVariableWithoutValue = z.object({ description: z.string().optional(), edited: z.boolean().optional(), @@ -375,6 +510,1072 @@ export const zWorkflowDraftVariableListWithoutValue = z.object({ total: z.int().optional(), }) +/** + * AgentScope + * + * Visibility and lifecycle scope of an Agent record. + */ +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 + * + * Soft lifecycle state for Agent records. + */ +export const zAgentStatus = z.enum(['active', 'archived']) + +/** + * AgentComposerAgentResponse + */ +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, +}) + +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentConfigFileRefConfig + * + * Stable Agent Soul reference to one config file payload. + */ +export const zAgentConfigFileRefConfig = z.object({ + file_id: z.string().min(1).max(255), + file_kind: z.enum(['tool_file', 'upload_file']), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + +/** + * AgentConfigSkillRefConfig + * + * Stable Agent Soul reference to one normalized skill archive. + */ +export const zAgentConfigSkillRefConfig = z.object({ + description: z.string().optional().default(''), + file_id: z.string().min(1).max(255), + file_kind: z.literal('tool_file').optional().default('tool_file'), + hash: z.string().nullish(), + mime_type: z.string().nullish().default('application/zip'), + name: z.string().min(1).max(255), + size: z.int().nullish(), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + +/** + * WorkflowAgentBindingType + * + * How a workflow node is bound to an Agent. + */ +export const zWorkflowAgentBindingType = z.enum(['inline_agent', 'roster_agent']) + +/** + * AgentComposerBindingResponse + */ +export const zAgentComposerBindingResponse = z.object({ + agent_id: z.string().nullish(), + binding_type: zWorkflowAgentBindingType, + current_snapshot_id: z.string().nullish(), + id: z.string(), + node_id: z.string(), + workflow_id: z.string(), +}) + +/** + * DeclaredOutputFileConfig + * + * File-type output metadata. Both lists empty means "any file accepted". + */ +export const zDeclaredOutputFileConfig = z.object({ + extensions: z.array(z.string()).optional(), + mime_types: z.array(z.string()).optional(), +}) + +/** + * DeclaredOutputType + */ +export const zDeclaredOutputType = z.enum([ + 'array', + 'boolean', + 'file', + 'number', + 'object', + 'string', +]) + +/** + * DeclaredArrayItem + * + * Per-item shape for an ``array``-typed declared output. + * + * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * are rejected so the runtime type checker and JSON Schema stay easy to reason + * about. Stage 4 §4.2. + */ +export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + type: zDeclaredOutputType, +}) + +/** + * AgentHumanContactConfig + */ +export const zAgentHumanContactConfig = z.object({ + channel: z.string().max(64).nullish(), + contact_id: z.string().max(255).nullish(), + contact_method: z.string().max(64).nullish(), + email: z.string().max(255).nullish(), + human_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + method: z.string().max(64).nullish(), + name: z.string().max(255).nullish(), + tenant_id: z.string().max(255).nullish(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + +/** + * WorkflowPreviousNodeOutputRef + */ +export const zWorkflowPreviousNodeOutputRef = z.object({ + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + node_id: z.string().max(255).nullish(), + output: z.string().max(255).nullish(), + selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), + value_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), + variable: z.string().max(255).nullish(), + variable_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), +}) + +/** + * AgentComposerNodeJobCandidatesResponse + */ +export const zAgentComposerNodeJobCandidatesResponse = z.object({ + declare_output_types: z.array(zDeclaredOutputType).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), +}) + +/** + * AgentComposerDifyToolCandidateResponse + */ +export const zAgentComposerDifyToolCandidateResponse = z.object({ + description: z.string().nullish(), + granularity: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + plugin_id: z.string().nullish(), + provider: z.string().nullish(), + provider_id: z.string().nullish(), + tools_count: z.int().nullish(), +}) + +/** + * AgentFeatureToggleConfig + */ +export const zAgentFeatureToggleConfig = z.object({ + enabled: z.boolean().optional().default(false), +}) + +/** + * AgentTextToSpeechFeatureConfig + */ +export const zAgentTextToSpeechFeatureConfig = z.object({ + autoPlay: z.string().nullish(), + enabled: z.boolean().optional().default(false), + language: z.string().nullish(), + voice: z.string().nullish(), +}) + +/** + * AgentEnvVariableConfig + */ +export const zAgentEnvVariableConfig = z.object({ + default: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + env_name: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + required: z.boolean().optional().default(false), + type: z.string().max(64).nullish(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + 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 + */ +export const zAgentHumanToolConfig = z.object({ + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), +}) + +/** + * AgentSoulHumanConfig + */ +export const zAgentSoulHumanConfig = z.object({ + contacts: z.array(zAgentHumanContactConfig).optional(), + tools: z.array(zAgentHumanToolConfig).optional(), +}) + +/** + * AgentMemoryArtifactConfig + */ +export const zAgentMemoryArtifactConfig = z.object({ + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + url: z.string().nullish(), +}) + +/** + * AgentSoulMemoryConfig + */ +export const zAgentSoulMemoryConfig = z.object({ + artifacts: z.array(zAgentMemoryArtifactConfig).optional(), + budget: z.string().nullish(), + scope: z.string().nullish(), +}) + +/** + * AgentSoulModelCredentialRef + * + * Reference to model credentials resolved only at runtime. + */ +export const zAgentSoulModelCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.string().min(1).max(64), +}) + +/** + * AgentSandboxProviderConfig + */ +export const zAgentSandboxProviderConfig = z.object({ + cpu: z.int().gte(1).nullish(), + env: z.array(zAgentEnvVariableConfig).optional(), + image: z.string().nullish(), + working_dir: z.string().nullish(), +}) + +/** + * AgentSoulSandboxConfig + */ +export const zAgentSoulSandboxConfig = z.object({ + config: zAgentSandboxProviderConfig.optional(), + provider: z.string().nullish(), +}) + +/** + * OutputErrorStrategy + * + * Per-output failure handling strategy. + * + * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of + * a Workflow Agent Node. The runtime applies the strategy after type check or + * output check fails and any configured retry attempts have been exhausted. + */ +export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) + +/** + * DeclaredOutputRetryConfig + * + * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + */ +export const zDeclaredOutputRetryConfig = z.object({ + enabled: z.boolean().optional().default(false), + max_retries: z.int().gte(0).lte(10).optional().default(0), + retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), +}) + +/** + * DeclaredOutputFailureStrategy + * + * Per-output failure handling. + * + * A single strategy applies to both ``type_check`` and ``output_check`` failures + * (PRD does not distinguish them at the UX level). Stage 4 §4.4. + */ +export const zDeclaredOutputFailureStrategy = z.object({ + default_value: z.unknown().optional(), + on_failure: zOutputErrorStrategy.optional().default('stop'), + retry: zDeclaredOutputRetryConfig.optional(), +}) + +/** + * AgentCliToolAuthorizationStatus + * + * Authorization state for Agent-scoped CLI tools. + * + * Missing status keeps backward compatibility with draft rows and CLI tools that + * do not need pre-authorization. Explicit denied-like states are blocked by the + * composer/publish validators and skipped by runtime request builders. + */ +export const zAgentCliToolAuthorizationStatus = z.enum([ + 'allowed', + 'authorized', + 'denied', + 'forbidden', + 'not_required', + 'pending', + 'pre_authorized', + 'unauthorized', +]) + +/** + * AgentPermissionConfig + */ +export const zAgentPermissionConfig = z.object({ + allowed: z.boolean().nullish(), + state: z.string().max(64).nullish(), + status: z.string().max(64).nullish(), +}) + +/** + * AgentSecretRefConfig + */ +export const zAgentSecretRefConfig = z.object({ + credential_id: z.string().max(255).nullish(), + env_name: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + permission_status: z.string().max(64).nullish(), + provider: z.string().max(255).nullish(), + provider_credential_id: z.string().max(255).nullish(), + ref: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + value: z.string().nullish(), + variable: z.string().max(255).nullish(), +}) + +/** + * AgentSoulEnvConfig + */ +export const zAgentSoulEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolEnvConfig + */ +export const zAgentCliToolEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolRiskLevel + * + * Risk marker for CLI tool bootstrap commands. + */ +export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) + +/** + * AgentCliToolConfig + */ +export const zAgentCliToolConfig = z.object({ + approved: z.boolean().optional().default(false), + authorization_status: zAgentCliToolAuthorizationStatus.nullish(), + command: z.string().nullish(), + dangerous: z.boolean().optional().default(false), + dangerous_accepted: z.boolean().optional().default(false), + dangerous_acknowledged: z.boolean().optional().default(false), + dangerous_command: z.boolean().optional().default(false), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + env: zAgentCliToolEnvConfig.optional(), + id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), + install: z.string().nullish(), + install_command: z.string().nullish(), + install_commands: z.array(z.string()).optional(), + invoke_metadata: z.record(z.string(), z.unknown()).optional(), + label: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + pre_authorized: z.boolean().nullish(), + requires_confirmation: z.boolean().optional().default(false), + risk_accepted: z.boolean().optional().default(false), + risk_level: zAgentCliToolRiskLevel.nullish(), + setup_command: z.string().nullish(), + 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 + */ +export const zAgentComposerSoulCandidatesResponse = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(), +}) + +/** + * AgentComposerCandidatesResponse + */ +export const zAgentComposerCandidatesResponse = z.object({ + allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(), + allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(), + capabilities: zComposerCandidateCapabilities.optional(), + truncated: z.boolean().optional().default(false), + variant: zComposerVariant, +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * AgentFileUploadImageFeatureConfig + */ +export const zAgentFileUploadImageFeatureConfig = z.object({ + enabled: z.boolean().optional().default(true), +}) + +/** + * AgentFileUploadFeatureConfig + */ +export const zAgentFileUploadFeatureConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + enabled: z.boolean().optional().default(true), + image: zAgentFileUploadImageFeatureConfig.optional(), + number_limits: z.int().optional().default(3), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerModelConfig + * + * Legacy Chat App model config used only for follow-up question generation. + */ +export const zAgentSuggestedQuestionsAfterAnswerModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).nullish(), + mode: z.string().max(64).nullish(), + name: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSuggestedQuestionsAfterAnswerModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + +/** + * AgentModelResponseFormatConfig + */ +export const zAgentModelResponseFormatConfig = z.object({ + type: z.string().max(64).nullish(), +}) + +/** + * AgentSoulModelSettings + */ +export const zAgentSoulModelSettings = z.object({ + frequency_penalty: z.number().nullish(), + max_tokens: z.int().nullish(), + presence_penalty: z.number().nullish(), + response_format: zAgentModelResponseFormatConfig.nullish(), + stop: z.array(z.string()).nullish(), + temperature: z.number().nullish(), + top_p: z.number().nullish(), +}) + +/** + * AgentSoulModelConfig + * + * Stable model selection for Agent runtime without storing secret values. + */ +export const zAgentSoulModelConfig = z.object({ + credential_ref: zAgentSoulModelCredentialRef.nullish(), + model: z.string().min(1).max(255), + model_provider: z.string().min(1).max(255), + model_settings: zAgentSoulModelSettings.optional(), + plugin_id: z.string().min(1).max(255), +}) + +/** + * DeclaredOutputCheckConfig + * + * File-output content check via a model-based comparison against a benchmark file. + * + * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + */ +export const zDeclaredOutputCheckConfig = z.object({ + benchmark_file_ref: zAgentFileRefConfig.nullish(), + enabled: z.boolean().optional().default(false), + model_ref: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * DeclaredOutputConfig + * + * One declared output of a Workflow Agent Node. + * + * Stage 4 normalizes the shape: ``check`` is singular (was ``checks: list`` in + * stage 3), and ``failure_strategy`` defaults to a populated value so runtime + * code can call ``output.failure_strategy.on_failure`` without None-guards. + */ +export const zDeclaredOutputConfig = z.object({ + array_item: zDeclaredArrayItem.nullish(), + check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + failure_strategy: zDeclaredOutputFailureStrategy.optional(), + file: zDeclaredOutputFileConfig.nullish(), + id: z.string().nullish(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(true), + type: zDeclaredOutputType, +}) + +/** + * WorkflowNodeJobConfig + */ +export const zWorkflowNodeJobConfig = z.object({ + declared_outputs: z.array(zDeclaredOutputConfig).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + metadata: zWorkflowNodeJobMetadata.optional(), + mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'), + previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(), + schema_version: z.int().optional().default(1), + workflow_prompt: z.string().optional().default(''), +}) + +/** + * AgentSoulDifyToolCredentialRef + * + * Reference to a stored Dify Plugin Tool credential. + * + * Secret values are resolved only at runtime. The legacy ``credential_id`` + * field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so + * old Agent tool payloads can be read while new payloads stay explicit. + */ +export const zAgentSoulDifyToolCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.enum(['provider', 'tool']).optional().default('tool'), +}) + +/** + * AgentSoulDifyToolConfig + * + * One Dify tool configured on Agent Soul. + * + * The API backend prepares this persisted product shape into + * either ``DifyPluginToolConfig`` or ``DifyCoreToolConfig`` before sending a + * run request to Agent backend. ``plugin`` providers keep the direct + * ``dify.plugin.tools`` transport; ``builtin`` / ``api`` / ``workflow`` / + * ``mcp`` providers are prepared for ``dify.core.tools``. ``provider_id`` + * keeps compatibility with existing Agent tool config payloads; new callers + * should send ``plugin_id`` + ``provider`` when available. + */ +export const zAgentSoulDifyToolConfig = z.object({ + credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), + credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), + plugin_id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + provider_id: z.string().max(255).nullish(), + provider_type: z.string().optional().default('plugin'), + runtime_parameters: z + .record( + z.string(), + z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullable(), + ) + .optional(), + tool_name: z.string().min(1).max(255).nullish(), +}) + +/** + * AgentSoulToolsConfig + */ +export const zAgentSoulToolsConfig = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), +}) + +/** + * AgentModerationIOConfig + */ +export const zAgentModerationIoConfig = z.object({ + enabled: z.boolean().optional().default(false), + preset_response: z.string().nullish(), +}) + +/** + * AgentModerationProviderConfig + */ +export const zAgentModerationProviderConfig = z.object({ + api_based_extension_id: z.string().nullish(), + inputs_config: zAgentModerationIoConfig.nullish(), + keywords: z.string().nullish(), + outputs_config: zAgentModerationIoConfig.nullish(), +}) + +/** + * AgentSensitiveWordAvoidanceFeatureConfig + */ +export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ + config: zAgentModerationProviderConfig.nullish(), + enabled: z.boolean().optional().default(false), + type: z.string().nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + file_upload: zAgentFileUploadFeatureConfig.optional(), + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * 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. Mode-dependent completeness, such as + * requiring ``value`` for ``user_query``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. + */ +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. Mode-dependent completeness, such as requiring ``top_k`` for + * ``multiple`` or a model for ``single``, is enforced by composer publish + * validation so draft saves can persist partially configured knowledge sets. + */ +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(), +}) + +/** + * 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. + * Mode-dependent completeness is enforced by composer publish validation so + * draft saves can persist partially configured metadata filters. + */ +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(), + config_files: z.array(zAgentConfigFileRefConfig).optional(), + config_note: z.string().optional().default(''), + config_skills: z.array(zAgentConfigSkillRefConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + files: zAgentSoulFilesConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + 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(), + debug_conversation_has_messages: z.boolean().optional().default(false), + debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), + effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), + hidden_app_backed: z.boolean().optional().default(false), + impact_summary: zAgentComposerImpactResponse.nullish(), + 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(), +}) + export const zGetSnippetsBySnippetIdWorkflowRunsPath = z.object({ snippet_id: z.uuid(), }) @@ -521,6 +1722,106 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z. export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = zGeneratedAppResponse +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ + node_id: z.string(), + snippet_id: z.uuid(), +}) + +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerQuery = z.object({ + snapshot_id: z.string().max(255).optional(), +}) + +/** + * Snippet agent composer state + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + zWorkflowAgentComposerResponse + +export const zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerBody = + zComposerSavePayload + +export const zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ + node_id: z.string(), + snippet_id: z.uuid(), +}) + +/** + * Snippet agent composer saved + */ +export const zPutSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = + zWorkflowAgentComposerResponse + +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = + z.object({ + node_id: z.string(), + snippet_id: z.uuid(), + }) + +/** + * Snippet agent composer candidates + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = + zAgentComposerCandidatesResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody = + zWorkflowComposerCopyFromRosterPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = + z.object({ + node_id: z.string(), + snippet_id: z.uuid(), + }) + +/** + * Roster agent copied into snippet + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse = + zWorkflowAgentComposerResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = + zComposerSavePayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({ + node_id: z.string(), + snippet_id: z.uuid(), +}) + +/** + * Snippet agent composer impact + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = + zAgentComposerImpactResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody = + zComposerSavePayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath = + z.object({ + node_id: z.string(), + snippet_id: z.uuid(), + }) + +/** + * Snippet agent saved to roster + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse = + zWorkflowAgentComposerResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody = + zComposerSavePayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath = + z.object({ + node_id: z.string(), + snippet_id: z.uuid(), + }) + +/** + * Snippet agent composer validation + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = + zAgentComposerValidateResponse + export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), snippet_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 7c051e12935..9ad0758b25e 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -90,6 +90,7 @@ export type SnippetImportResponse = { imported_dsl_version: string snippet_id: string | null status: ImportStatus + warnings: Array } export type UpdateSnippetPayload = { @@ -1093,6 +1094,15 @@ export type SnippetType = 'group' | 'node' export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' +export type DslImportWarning = { + code: string + details?: { + [key: string]: unknown + } + message: string + path: string +} + export type PluginDependency = { current_identifier?: string | null type: PluginDependencyType diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 16ba4ff598f..d91a4e7105d 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -788,6 +788,18 @@ export const zSnippetPaginationResponse = z.object({ */ export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending']) +/** + * DslImportWarning + * + * Portable DSL reference that could not be restored in the target workspace. + */ +export const zDslImportWarning = z.object({ + code: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + message: z.string(), + path: z.string(), +}) + /** * SnippetImportResponse */ @@ -798,6 +810,7 @@ export const zSnippetImportResponse = z.object({ imported_dsl_version: z.string(), snippet_id: z.string().nullable(), status: zImportStatus, + warnings: z.array(zDslImportWarning), }) /** diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 08b572368ec..4c1f59b3ce7 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -173,6 +173,15 @@ export type DeviceTokenResponse = { workspaces?: Array } +export type DslImportWarning = { + code: string + details?: { + [key: string]: unknown + } + message: string + path: string +} + export type ErrorBody = { code: string details?: Array | null @@ -252,6 +261,7 @@ export type Import = { imported_dsl_version?: string permission_keys?: Array status: ImportStatus + warnings?: Array } export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index b271648f8b2..5c45490ba64 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -198,6 +198,18 @@ export const zDevicePollRequest = z.object({ device_code: z.string(), }) +/** + * DslImportWarning + * + * Portable DSL reference that could not be restored in the target workspace. + */ +export const zDslImportWarning = z.object({ + code: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + message: z.string(), + path: z.string(), +}) + /** * ErrorDetail */ @@ -301,12 +313,13 @@ export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'fa export const zImport = z.object({ app_id: z.string().nullish(), app_mode: z.string().nullish(), - current_dsl_version: z.string().optional().default('0.6.0'), + current_dsl_version: z.string().optional().default('0.7.0'), error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), permission_keys: z.array(z.string()).optional(), status: zImportStatus, + warnings: z.array(zDslImportWarning).optional(), }) export const zJsonValue = z.unknown()