feat(agent): support new Agent DSL import and export (#38849)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-07-15 10:02:14 +08:00 committed by GitHub
parent 38aec8b506
commit f86bfb2a31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 5477 additions and 143 deletions

View File

@ -1 +1 @@
CURRENT_APP_DSL_VERSION = "0.6.0"
CURRENT_APP_DSL_VERSION = "0.7.0"

View File

@ -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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:agent_id>/composer")
class AgentComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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):

View File

@ -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,
),

View File

@ -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)<br> |
| 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)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Snippet agent composer saved | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)<br> |
### [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)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Roster agent copied into snippet | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Snippet agent composer impact | **application/json**: [AgentComposerImpactResponse](#agentcomposerimpactresponse)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Snippet agent saved to roster | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Snippet agent composer validation | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)<br> |
### [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, <br>**Default:** 0.6.0 | | No |
| current_dsl_version | string, <br>**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

View File

@ -712,6 +712,17 @@ mode is a closed enum of listable app types.
| token_id | string | | Yes |
| workspaces | [ [WorkspacePayload](#workspacepayload) ], <br>**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, <br>**Default:** 0.6.0 | | No |
| current_dsl_version | string, <br>**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

View File

@ -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())

View File

@ -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,
)

View File

@ -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)
)

View File

@ -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."""

View File

@ -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()

View File

@ -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):
"""

View File

@ -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,
)
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
):

View File

@ -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):

View File

@ -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,
)

View File

@ -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

View File

@ -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(

View File

@ -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"

View File

@ -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):

View File

@ -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

View File

@ -1421,6 +1421,7 @@ export type AgentUserSatisfactionRateStatisticResponse = {
export type AgentConfigRevisionOperation =
| 'create_version'
| 'import_package'
| 'publish_draft'
| 'restore_version'
| 'save_current_version'

View File

@ -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',

View File

@ -73,6 +73,7 @@ export type Import = {
imported_dsl_version?: string
permission_keys?: Array<string>
status: ImportStatus
warnings?: Array<DslImportWarning>
}
export type CheckDependenciesResult = {
@ -545,6 +546,7 @@ export type AppImportResponse = {
id: string
imported_dsl_version?: string
status: ImportStatus
warnings?: Array<DslImportWarning>
}
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

View File

@ -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

View File

@ -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,

View File

@ -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<DeclaredOutputConfig>
hidden_app_backed?: boolean
impact_summary?: AgentComposerImpactResponse | null
node_id?: string | null
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
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<AgentComposerImpactBindingResponse>
current_snapshot_id?: string | null
workflow_node_count: number
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
result: 'success'
warnings?: Array<ComposerValidationWarningResponse>
}
export type WorkflowRunNodeExecutionResponse = {
created_at?: number | null
created_by_account?: SimpleAccountResponse | null
@ -299,6 +367,174 @@ export type JsonValue =
| Array<unknown>
| 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<AppVariableConfig>
config_files?: Array<AgentConfigFileRefConfig>
config_note?: string
config_skills?: Array<AgentConfigSkillRefConfig>
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<DeclaredOutputConfig>
human_contacts?: Array<AgentHumanContactConfig>
metadata?: WorkflowNodeJobMetadata
mode?: WorkflowNodeJobMode
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
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<ComposerKnowledgePlaceholderResponse>
warnings?: Array<ComposerValidationWarningResponse>
}
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<DeclaredOutputType>
human_contacts?: Array<AgentHumanContactConfig>
previous_node_outputs?: Array<WorkflowPreviousNodeOutputRef>
}
export type AgentComposerSoulCandidatesResponse = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_sets?: Array<AgentComposerKnowledgeSetCandidateResponse>
}
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<string> | 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<AgentSecretRefConfig>
variables?: Array<AgentEnvVariableConfig>
}
export type AgentSoulFilesConfig = {
files?: Array<AgentFileRefConfig>
skills?: Array<AgentSkillRefConfig>
}
export type AgentSoulHumanConfig = {
contacts?: Array<AgentHumanContactConfig>
tools?: Array<AgentHumanToolConfig>
}
export type AgentSoulKnowledgeConfig = {
sets?: Array<AgentKnowledgeSetConfig>
}
export type AgentSoulMemoryConfig = {
artifacts?: Array<AgentMemoryArtifactConfig>
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<AgentCliToolConfig>
dify_tools?: Array<AgentSoulDifyToolConfig>
}
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<string>
mime_types?: Array<string>
}
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<AgentFileRefConfig> | 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<string | number | number | boolean | null> | null
value_selector?: Array<string | number | number | boolean | null> | null
variable?: string | null
variable_selector?: Array<string | number | number | boolean | null> | 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<string>
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<AgentComposerKnowledgeDatasetCandidateResponse>
description?: string | null
id: string
missing_dataset_ids?: Array<string>
name: string
}
export type AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
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<string>
| Array<number>
| Array<number>
| Array<boolean>
| null
env_name?: string | null
key?: string | null
name?: string | null
required?: boolean
type?: string | null
value?:
| string
| number
| number
| boolean
| Array<string>
| Array<number>
| Array<number>
| Array<boolean>
| 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<string> | 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<AgentKnowledgeDatasetConfig>
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<string> | null
temperature?: number | null
top_p?: number | null
}
export type AgentSandboxProviderConfig = {
cpu?: number | null
env?: Array<AgentEnvVariableConfig>
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<string>
| Array<number>
| Array<number>
| Array<boolean>
| 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<AgentSecretRefConfig>
variables?: Array<AgentEnvVariableConfig>
}
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<AgentKnowledgeMetadataCondition>
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<string> | 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: {

File diff suppressed because it is too large Load Diff

View File

@ -90,6 +90,7 @@ export type SnippetImportResponse = {
imported_dsl_version: string
snippet_id: string | null
status: ImportStatus
warnings: Array<DslImportWarning>
}
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

View File

@ -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),
})
/**

View File

@ -173,6 +173,15 @@ export type DeviceTokenResponse = {
workspaces?: Array<WorkspacePayload>
}
export type DslImportWarning = {
code: string
details?: {
[key: string]: unknown
}
message: string
path: string
}
export type ErrorBody = {
code: string
details?: Array<ErrorDetail> | null
@ -252,6 +261,7 @@ export type Import = {
imported_dsl_version?: string
permission_keys?: Array<string>
status: ImportStatus
warnings?: Array<DslImportWarning>
}
export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending'

View File

@ -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()