mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 00:53:34 -04:00
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:
@@ -1 +1 @@
|
||||
CURRENT_APP_DSL_VERSION = "0.6.0"
|
||||
CURRENT_APP_DSL_VERSION = "0.7.0"
|
||||
|
||||
@@ -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__])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
+2
-2
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1421,6 +1421,7 @@ export type AgentUserSatisfactionRateStatisticResponse = {
|
||||
|
||||
export type AgentConfigRevisionOperation =
|
||||
| 'create_version'
|
||||
| 'import_package'
|
||||
| 'publish_draft'
|
||||
| 'restore_version'
|
||||
| 'save_current_version'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user