diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 6eadd4ce3d8..fb45a783031 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -78,11 +78,22 @@ def _filter_snapshot_to_specs( return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers) -def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]: - deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} - if include_drive: - deps["drive"] = DIFY_DRIVE_LAYER_ID - return deps +def _shell_layer_deps() -> dict[str, str]: + return {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + + +def _drive_layer_deps() -> dict[str, str]: + return {"shell": DIFY_SHELL_LAYER_ID} + + +def _shell_config_with_drive_ref( + shell_config: DifyShellLayerConfig | None, + drive_config: DifyDriveLayerConfig | None, +) -> DifyShellLayerConfig: + config = shell_config or DifyShellLayerConfig() + if drive_config is None: + return config + return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref}) class AgentBackendModelConfig(BaseModel): @@ -263,14 +274,29 @@ class AgentBackendRunRequestBuilder: ] ) + include_shell = run_input.include_shell or run_input.drive_config is not None + if include_shell: + # Sandboxed bash workspace (dify.shell). It enters before drive so + # drive can materialize mentioned targets with `dify-agent drive pull` + # in the same shell-visible filesystem used by model commands. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + deps=_shell_layer_deps(), + metadata=run_input.metadata, + config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + ) + ) + if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): a config-only index; - # the agent pulls listed entries through the back proxy by drive_ref. + # Drive Skills & Files declaration (dify.drive): the catalog plus + # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) @@ -312,7 +338,7 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, @@ -336,21 +362,6 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context - # so the agent server can mint per-command Agent Stub env, and on - # drive when present so that env points at /mnt/drive/. - # shellctl connection itself is server-injected. - layers.append( - RunLayerSpec( - name=DIFY_SHELL_LAYER_ID, - type=DIFY_SHELL_LAYER_TYPE_ID, - deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), - metadata=run_input.metadata, - config=run_input.shell_config or DifyShellLayerConfig(), - ) - ) - if run_input.output is not None: layers.append( RunLayerSpec( @@ -445,7 +456,7 @@ class AgentBackendRunRequestBuilder: name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "workflow_node_job"}, - config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt), + config=PromptLayerConfig(user=run_input.workflow_node_job_prompt), ), RunLayerSpec( name=WORKFLOW_USER_PROMPT_LAYER_ID, @@ -462,14 +473,29 @@ class AgentBackendRunRequestBuilder: ] ) + include_shell = run_input.include_shell or run_input.drive_config is not None + if include_shell: + # Sandboxed bash workspace (dify.shell). It enters before drive so + # drive can materialize mentioned targets with `dify-agent drive pull` + # in the same shell-visible filesystem used by model commands. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + deps=_shell_layer_deps(), + metadata=run_input.metadata, + config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config), + ) + ) + if run_input.drive_config is not None: - # Drive Skills & Files declaration (dify.drive): a config-only index; - # the agent pulls listed entries through the back proxy by drive_ref. + # Drive Skills & Files declaration (dify.drive): the catalog plus + # prompt-mentioned entries eagerly pulled through the shell layer. layers.append( RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_drive_layer_deps(), metadata=run_input.metadata, config=run_input.drive_config, ) @@ -513,7 +539,7 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + if run_input.knowledge is not None and run_input.knowledge.sets: layers.append( RunLayerSpec( name=DIFY_KNOWLEDGE_BASE_LAYER_ID, @@ -537,21 +563,6 @@ class AgentBackendRunRequestBuilder: ) ) - if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context - # so the agent server can mint per-command Agent Stub env, and on - # drive when present so that env points at /mnt/drive/. - # shellctl connection itself is server-injected. - layers.append( - RunLayerSpec( - name=DIFY_SHELL_LAYER_ID, - type=DIFY_SHELL_LAYER_TYPE_ID, - deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), - metadata=run_input.metadata, - config=run_input.shell_config or DifyShellLayerConfig(), - ) - ) - if run_input.output is not None: layers.append( RunLayerSpec( diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 0d65d3de97e..58228cbfb02 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings): description=( "Inject the dify.drive layer (Skills & Files drive manifest declaration) " "into Agent runs. The declaration is an index only — the agent backend " - "pulls the actual SKILL.md / files through the back proxy. Keep it off " - "until the agent backend registers the dify.drive layer type." + "pulls the actual SKILL.md / files through the back proxy. Set this to " + "false only when temporarily rolling back the drive integration." ), - default=False, + default=True, ) diff --git a/api/controllers/console/agent/app_helpers.py b/api/controllers/console/agent/app_helpers.py index 51adc1e136e..7af38b0164d 100644 --- a/api/controllers/console/agent/app_helpers.py +++ b/api/controllers/console/agent/app_helpers.py @@ -6,5 +6,15 @@ from services.agent.roster_service import AgentRosterService def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App: - """Resolve the hidden Agent App backing an Agent Console resource.""" + """Resolve a roster Agent's public Agent App.""" return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + + +def resolve_agent_runtime_app_model(*, tenant_id: str, agent_id: UUID) -> App: + """Resolve the App that backs an Agent runtime surface. + + This accepts both roster Agent Apps and workflow-only inline Agents with a + hidden backing App. + """ + + return AgentRosterService(db.session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index b54cf4b6daf..7413be95d75 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -1,10 +1,10 @@ from uuid import UUID +from flask import request from flask_restx import Resource -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( RBACPermission, @@ -28,9 +28,15 @@ from libs.login import login_required from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload +from services.entities.agent_entities import ( + ComposerSavePayload, + WorkflowAgentComposerQuery, + WorkflowComposerCopyFromRosterPayload, +) -register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload) +register_schema_models( + console_ns, ComposerSavePayload, WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload +) register_response_schema_models( console_ns, AgentAppComposerResponse, @@ -41,27 +47,26 @@ register_response_schema_models( ) -def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str: - return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id - - @console_ns.route("/apps//workflows/draft/nodes//agent-composer") class WorkflowAgentComposerApi(Resource): @console_ns.response( 200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__] ) + @console_ns.doc(params=query_params_from_model(WorkflowAgentComposerQuery)) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_tenant_id def get(self, tenant_id: str, app_model: App, node_id: str): + query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True)) return dump_response( WorkflowAgentComposerResponse, AgentComposerService.load_workflow_composer( tenant_id=tenant_id, app_id=app_model.id, node_id=node_id, + snapshot_id=query.snapshot_id, ), ) @@ -137,6 +142,7 @@ class WorkflowAgentComposerValidateApi(Resource): def post(self, tenant_id: str, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_publish_payload(payload) + AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -228,10 +234,9 @@ class AgentComposerApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentAppComposerResponse, - AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id), + AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)), ) @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @@ -244,13 +249,12 @@ class AgentComposerApi(Resource): @with_current_user_id @with_current_tenant_id def put(self, tenant_id: str, account_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return dump_response( AgentAppComposerResponse, - AgentComposerService.save_agent_app_composer( + AgentComposerService.save_agent_composer( tenant_id=tenant_id, - app_id=app_id, + agent_id=str(agent_id), account_id=account_id, payload=payload, ), @@ -268,9 +272,10 @@ class AgentComposerValidateApi(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID): - _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) + AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_publish_payload(payload) + AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -290,12 +295,11 @@ class AgentComposerCandidatesApi(Resource): @with_current_user_id @with_current_tenant_id def get(self, tenant_id: str, current_user_id: str, agent_id: UUID): - app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentComposerCandidatesResponse, AgentComposerService.get_agent_app_candidates( tenant_id=tenant_id, - app_id=app_id, + agent_id=str(agent_id), user_id=current_user_id, ), ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index ac3f7ef4824..3b33dc68c72 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -7,7 +7,7 @@ from sqlalchemy import func, select from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model, resolve_agent_runtime_app_model from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource from controllers.console.app.app import ( AppDetailWithSite as GenericAppDetailWithSite, @@ -54,8 +54,10 @@ from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account +from models.agent import Agent, AgentStatus from models.enums import ApiTokenType from models.model import ApiToken, App, IconType +from services.agent.composer_service import AgentComposerService from services.agent.errors import AgentNotFoundError from services.agent.observability_service import ( AgentLogQueryParams, @@ -65,7 +67,7 @@ from services.agent.observability_service import ( from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService -from services.entities.agent_entities import RosterListQuery +from services.entities.agent_entities import ComposerSavePayload, RosterListQuery from services.feature_service import FeatureService @@ -232,6 +234,8 @@ class AgentStatisticsQuery(BaseModel): class AgentAppPartial(GenericAppPartial): app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -241,6 +245,8 @@ class AgentAppPartial(GenericAppPartial): class AgentAppDetailWithSite(GenericAppDetailWithSite): app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -250,6 +256,36 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_id: str +class AgentPublishPayload(BaseModel): + version_note: str | None = Field(default=None, description="Optional note for this published Agent version") + + +class AgentPublishResponse(BaseModel): + result: str + active_config_snapshot_id: str + active_config_snapshot: dict[str, object] | None = None + draft: dict[str, object] | None = None + + +class AgentBuildDraftCheckoutPayload(BaseModel): + force: bool = Field(default=False, description="Overwrite the existing current-user build draft") + + +class AgentBuildDraftResponse(BaseModel): + variant: str + draft: dict[str, object] + agent_soul: dict[str, object] + + +class AgentBuildDraftApplyResponse(BaseModel): + result: str + draft: dict[str, object] + + +class AgentSimpleResultResponse(BaseModel): + result: str + + class AgentAppPagination(GenericAppPagination): data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] validation_alias=AliasChoices("items", "data") @@ -261,6 +297,9 @@ register_schema_models( AgentAppCreatePayload, AgentAppUpdatePayload, AgentAppCopyPayload, + AgentPublishPayload, + AgentBuildDraftCheckoutPayload, + ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, AgentLogsQuery, @@ -277,6 +316,10 @@ register_response_schema_models( AgentAppDetailWithSite, AgentAppPartial, AgentDebugConversationRefreshResponse, + AgentPublishResponse, + AgentBuildDraftResponse, + AgentBuildDraftApplyResponse, + AgentSimpleResultResponse, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentConfigSnapshotRestoreResponse, @@ -294,7 +337,7 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: +def _serialize_agent_app_detail(app_model, *, current_user: Account, agent_id: str | None = None) -> dict: """Serialize an Agent App detail using roster-only DTOs. `/agent` responses are roster-shaped rather than raw app-shaped: `id` @@ -311,11 +354,23 @@ def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: roster_service = _agent_roster_service() payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") - agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id)) + agent = ( + db.session.scalar( + select(Agent).where( + Agent.tenant_id == app_model.tenant_id, + Agent.id == agent_id, + Agent.status == AgentStatus.ACTIVE, + ) + ) + if agent_id + else roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id)) + ) if not agent: raise AgentNotFoundError() payload.pop("bound_agent_id", None) - payload["app_id"] = str(app_model.id) + payload["app_id"] = agent.app_id + payload["backing_app_id"] = roster_service.runtime_backing_app_id(agent) + payload["hidden_app_backed"] = bool(agent.backing_app_id and agent.backing_app_id != agent.app_id) payload["id"] = agent.id payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id( tenant_id=app_model.tenant_id, @@ -365,6 +420,8 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_u agent = agents_by_app_id.get(app_id) if agent: item["app_id"] = app_id + item["backing_app_id"] = agent.backing_app_id or app_id + item["hidden_app_backed"] = False item["id"] = agent.id item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id) item["role"] = agent.role or "" @@ -516,8 +573,8 @@ class AgentAppApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _serialize_agent_app_detail(app_model, current_user=current_user) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _serialize_agent_app_detail(app_model, current_user=current_user, agent_id=str(agent_id)) @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) @console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -583,6 +640,112 @@ class AgentDebugConversationRefreshApi(Resource): ) +@console_ns.route("/agent//publish") +class AgentPublishApi(Resource): + @console_ns.expect(console_ns.models[AgentPublishPayload.__name__]) + @console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__]) + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentPublishPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.publish_agent_app_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + version_note=args.version_note, + ) + + +@console_ns.route("/agent//build-draft/checkout") +class AgentBuildDraftCheckoutApi(Resource): + @console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__]) + @console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.checkout_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + force=args.force, + ) + + +@console_ns.route("/agent//build-draft") +class AgentBuildDraftApi(Resource): + @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.load_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, agent_id: UUID): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + payload=payload, + ) + + @console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.discard_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + +@console_ns.route("/agent//build-draft/apply") +class AgentBuildDraftApplyApi(Resource): + @console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.apply_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.route("/agent//copy") class AgentAppCopyApi(Resource): @console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__]) @@ -712,7 +875,7 @@ class AgentLogsApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) query_data["sources"] = _multi_query_values("sources", "source") query_data["statuses"] = _multi_query_values("statuses", "status") @@ -749,7 +912,7 @@ class AgentLogMessagesApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) query_data["sources"] = _multi_query_values("sources", "source") query_data["statuses"] = _multi_query_values("statuses", "status") @@ -786,7 +949,7 @@ class AgentLogSourcesApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id)) return dump_response(AgentLogSourceListResponse, payload) @@ -805,7 +968,7 @@ class AgentStatisticsSummaryApi(Resource): @with_current_user @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) timezone = current_user.timezone or "UTC" start, end = _parse_observability_time_range(query.start, query.end, current_user) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 86a3c473547..99164b4755a 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -13,7 +13,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( RBACPermission, @@ -351,7 +351,7 @@ class AgentSkillUploadByAgentApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _upload_skill_for_app(current_user=current_user, app_model=app_model) @@ -394,7 +394,7 @@ class AgentDriveFilesByAgentApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) @console_ns.doc("delete_agent_drive_file_by_agent") @@ -407,7 +407,7 @@ class AgentDriveFilesByAgentApi(Resource): @with_current_user @with_current_tenant_id def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) @@ -454,7 +454,7 @@ class AgentSkillByAgentApi(Resource): @with_current_user @with_current_tenant_id def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False) @@ -494,7 +494,7 @@ class AgentSkillInferToolsByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID, slug: str): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) return _infer_skill_tools_for_app(app_model=app_model, slug=slug) diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 358e552beb0..be6b9b28543 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -87,7 +87,7 @@ class AgentAppFeatureConfigResource(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {}) new_app_model_config = AgentAppFeatureConfigService.update_features( diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index f9bda13c63a..9d92c078bd4 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -22,7 +22,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel @@ -144,7 +144,7 @@ class AgentAppSandboxListResource(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxListQuery) try: result = AgentAppSandboxService().list_files( @@ -169,7 +169,7 @@ class AgentAppSandboxReadResource(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxFileQuery) try: result = AgentAppSandboxService().read_file( @@ -194,7 +194,7 @@ class AgentAppSandboxUploadResource(Resource): @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) try: result = AgentAppSandboxService().upload_file( diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py index bd639955d9c..473e7364b3e 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -25,7 +25,7 @@ from controllers.common.schema import ( register_response_schema_models, ) from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel @@ -182,7 +182,7 @@ class AgentDriveListByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveListByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix) except AgentDriveError as exc: @@ -201,7 +201,7 @@ class AgentDriveSkillListByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id)) except AgentDriveError as exc: @@ -220,7 +220,7 @@ class AgentDriveSkillInspectByAgentApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID, skill_path: str): - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: return _json_response( AgentDriveService().inspect_skill( @@ -245,7 +245,7 @@ class AgentDrivePreviewByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: @@ -264,7 +264,7 @@ class AgentDriveDownloadByAgentApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, agent_id: UUID): query = query_params_from_request(AgentDriveFileByAgentQuery) - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) try: url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) except AgentDriveError as exc: diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 7ed527d6876..4c703ad73f6 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -331,7 +331,7 @@ class ModelConfig(ResponseModel): return to_timestamp(value) -class Site(ResponseModel): +class AppDetailSiteResponse(ResponseModel): access_token: str | None = Field(default=None, validation_alias="code") code: str | None = None title: str | None = None @@ -462,7 +462,7 @@ class AppDetailWithSite(AppDetail): api_base_url: str | None = None max_active_requests: int | None = None deleted_tools: list[DeletedTool] = Field(default_factory=list) - site: Site | None = None + site: AppDetailSiteResponse | None = None # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None # For Agent App responses exposed through /agent. @@ -547,7 +547,7 @@ register_schema_models( WorkflowPartial, ModelConfigPartial, ModelConfig, - Site, + AppDetailSiteResponse, DeletedTool, AppDetail, AppExportResponse, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 545fad34cde..16c1a2f570b 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -11,7 +11,7 @@ import services from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( AppUnavailableError, CompletionRequestError, @@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload): query: str = Field(..., description="User query") conversation_id: str | None = Field(default=None, description="Conversation ID") parent_message_id: str | None = Field(default=None, description="Parent message ID") + draft_type: Literal["draft", "debug_build"] = Field( + default="draft", + description="Agent App debug config source. Use debug_build while the Agent is in build mode.", + ) @field_validator("conversation_id", "parent_message_id") @classmethod @@ -218,7 +222,7 @@ class AgentChatMessageApi(Resource): @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _create_chat_message( current_tenant_id=current_tenant_id, current_user=current_user, @@ -254,7 +258,7 @@ class AgentChatMessageStopApi(Resource): @with_current_user_id @with_current_tenant_id def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index f987ecca745..3e24dbdcff0 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -13,7 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes from controllers.common.fields import SimpleResultResponse, TextFileResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( CompletionRequestError, ProviderModelCurrentlyNotSupportError, @@ -214,7 +214,7 @@ class AgentChatMessageListApi(Resource): @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _list_chat_messages(app_model=app_model, current_user=current_user) @@ -250,7 +250,7 @@ class AgentMessageFeedbackApi(Resource): @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _update_message_feedback(current_user=current_user, app_model=app_model) @@ -315,7 +315,7 @@ class AgentMessageSuggestedQuestionApi(Resource): @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @@ -393,7 +393,7 @@ class AgentMessageApi(Resource): @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID): - app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id) return _get_message_detail(app_model=app_model, message_id=message_id) diff --git a/api/controllers/files/__init__.py b/api/controllers/files/__init__.py index f8976b86b9f..5d26308e430 100644 --- a/api/controllers/files/__init__.py +++ b/api/controllers/files/__init__.py @@ -14,11 +14,12 @@ api = ExternalApi( files_ns = Namespace("files", description="File operations", path="/") -from . import image_preview, tool_files, upload +from . import agent_drive_archive, image_preview, tool_files, upload api.add_namespace(files_ns) __all__ = [ + "agent_drive_archive", "api", "bp", "files_ns", diff --git a/api/controllers/files/agent_drive_archive.py b/api/controllers/files/agent_drive_archive.py new file mode 100644 index 00000000000..afa6ac79483 --- /dev/null +++ b/api/controllers/files/agent_drive_archive.py @@ -0,0 +1,67 @@ +from urllib.parse import quote + +from flask import Response, request +from flask_restx import Resource +from pydantic import BaseModel, Field +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models +from controllers.files import files_ns +from models.agent import AgentDriveFileKind +from services.agent_drive_service import AgentDriveError, AgentDriveService + + +class AgentDriveArchiveMemberQuery(BaseModel): + tenant_id: str = Field(..., description="Tenant ID") + agent_id: str = Field(..., description="Agent ID") + key: str = Field(..., description="Virtual drive key") + archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind") + archive_file_id: str = Field(..., description="Archive file id") + member_path: str = Field(..., description="Zip member path") + timestamp: str = Field(..., description="Unix timestamp") + nonce: str = Field(..., description="Random nonce") + sign: str = Field(..., description="HMAC signature") + as_attachment: bool = Field(default=False, description="Download as attachment") + + +register_schema_models(files_ns, AgentDriveArchiveMemberQuery) + + +@files_ns.route("/agent-drive/archive-member") +class AgentDriveArchiveMemberApi(Resource): + @files_ns.doc("get_agent_drive_archive_member") + @files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters") + def get(self): + args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True)) + if not AgentDriveService.verify_archive_member_signature( + tenant_id=args.tenant_id, + agent_id=args.agent_id, + key=args.key, + archive_file_kind=args.archive_file_kind, + archive_file_id=args.archive_file_id, + member_path=args.member_path, + timestamp=args.timestamp, + nonce=args.nonce, + sign=args.sign, + ): + raise Forbidden("Invalid request.") + try: + payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request( + tenant_id=args.tenant_id, + agent_id=args.agent_id, + key=args.key, + archive_file_kind=args.archive_file_kind, + archive_file_id=args.archive_file_id, + member_path=args.member_path, + ) + except AgentDriveError as exc: + raise NotFound(exc.message) from exc + + response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={}) + response.headers["Content-Length"] = str(len(payload)) + if args.as_attachment and filename: + encoded_filename = quote(filename) + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="") + return response diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index f816b1fa477..4f2c546fb7b 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -16,7 +16,7 @@ from collections.abc import Generator, Mapping from typing import Any from flask import Flask, current_app -from sqlalchemy import select +from sqlalchemy import and_, or_, select from clients.agent_backend import AgentBackendRunEventAdapter from clients.agent_backend.factory import create_agent_backend_run_client @@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access from core.ops.ops_trace_manager import TraceQueueManager from extensions.ext_database import db from models import Account, App, EndUser, Message -from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) from models.agent_config_entities import AgentSoulConfig from services.conversation_service import ConversationService @@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): inputs = args["inputs"] # Resolve the bound roster Agent + its current Agent Soul snapshot. - agent, snapshot, agent_soul = self._resolve_agent(app_model) + agent, agent_config_id, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type=args.get("draft_type"), + user=user, + ) runtime_session_snapshot_id = self._runtime_session_snapshot_id( invoke_from=invoke_from, - snapshot_id=snapshot.id, + snapshot_id=agent_config_id, ) conversation = None @@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, agent_runtime_session_snapshot_id=runtime_session_snapshot_id, ) @@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): persisted to the conversation. Live streaming to a reconnected client is out of scope here — the message is persisted and can be re-fetched. """ - agent, snapshot, agent_soul = self._resolve_agent(app_model) + agent, agent_config_id, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type="draft", + user=user, + ) conversation = ConversationService.get_conversation( app_model=app_model, conversation_id=conversation_id, user=user ) @@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -421,50 +439,135 @@ class AgentAppGenerator(MessageBasedAppGenerator): return False, query - def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + def _resolve_agent( + self, + app_model: App, + *, + invoke_from: InvokeFrom, + draft_type: Any, + user: Account | EndUser, + ) -> tuple[Agent, str, AgentSoulConfig]: agent = db.session.scalar( - select(Agent).where( - Agent.app_id == app_model.id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + select(Agent) + .where( + Agent.tenant_id == app_model.tenant_id, Agent.status == AgentStatus.ACTIVE, + or_( + and_( + Agent.app_id == app_model.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + ), + Agent.backing_app_id == app_model.id, + ), ) + .order_by(Agent.created_at.desc()) + .limit(1) ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") - return self._resolve_agent_by_id( - tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id + if invoke_from == InvokeFrom.DEBUGGER: + draft = self._resolve_debug_draft( + tenant_id=app_model.tenant_id, + agent=agent, + draft_type=draft_type, + account_id=user.id if isinstance(user, Account) else None, + ) + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + return agent, draft.id, agent_soul + _, snapshot, agent_soul = self._resolve_agent_by_id( + tenant_id=app_model.tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, ) + return agent, snapshot.id, agent_soul @staticmethod def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: """Return the session scope snapshot id for Agent App runtime state. - Console preview/debug chat is an editing workspace: saving Agent Soul - creates replacement snapshots, but the user expects the same preview - conversation to keep context while trying prompt changes. Use a stable - NULL snapshot scope for debugger runs so each turn can use the latest - Agent Soul while reusing the conversation history. Published/web/API - runs keep snapshot-scoped sessions for reproducible runtime state. + Console preview/debug chat uses a stable Agent draft row id; build mode + uses the current user's build-draft row id. Published/web/API runs use + immutable published snapshot ids. This keeps runtime session continuity + inside one editable surface without mixing draft/build/published state. """ - if invoke_from == InvokeFrom.DEBUGGER: - return None return snapshot_id + @staticmethod + def _resolve_debug_draft( + *, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None + ) -> AgentConfigDraft: + effective_draft_type = ( + AgentConfigDraftType.DEBUG_BUILD + if draft_type == AgentConfigDraftType.DEBUG_BUILD.value + else AgentConfigDraftType.DRAFT + ) + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent.id, + AgentConfigDraft.draft_type == effective_draft_type, + ) + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + if not account_id: + raise AgentAppGeneratorError("Build draft requires an account user") + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + if draft is not None: + return draft + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + raise AgentAppGeneratorError("Agent build draft not found") + _, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id( + tenant_id=tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=agent_soul, + created_by=agent.created_by, + updated_by=agent.updated_by, + ) + db.session.add(draft) + db.session.flush() + return draft + @staticmethod def _resolve_agent_by_id( *, tenant_id: str, agent_id: str, snapshot_id: str | None - ) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + ) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]: agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) if agent is None: raise AgentAppGeneratorError("Agent not found") if not snapshot_id: raise AgentAppGeneratorError("Agent has no published version") - snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id)) - if snapshot is None: + snapshot = db.session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == snapshot_id, + ) + ) + if snapshot is not None: + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + return agent, snapshot, agent_soul + draft = db.session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.id == snapshot_id, + ) + ) + if draft is None: raise AgentAppGeneratorError("Agent published version not found") - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - return agent, snapshot, agent_soul + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + return agent, draft, agent_soul __all__ = ["AgentAppGenerator", "AgentAppGeneratorError"] diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index 8adb27240c7..1268cde65bd 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -31,6 +31,7 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, Wo from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent from graphon.nodes.base.node import Node from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from services.agent.prompt_mentions import extract_workflow_node_output_selectors from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form @@ -688,5 +689,20 @@ class DifyAgentNode(Node[DifyAgentNodeData]): node_id: str, node_data: DifyAgentNodeData, ) -> Mapping[str, Sequence[str]]: - del graph_config, node_id, node_data - return {} + """Reuse frontend workflow-marker parsing for graph variable loading. + + This follows the same marker parser used by publish sync and runtime + request building, including reserved-prefix exclusion. + """ + del graph_config + + agent_task = ( + node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None) + ) + if not isinstance(agent_task, str): + return {} + + return { + f"{node_id}.{'.'.join(selector)}": list(selector) + for selector in extract_workflow_node_output_selectors(agent_task) + } diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index fa7b28cbb0a..8fd2783f61f 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from models.agent_config_entities import AgentSoulConfig +from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( { @@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any ) reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") - reserved_status["knowledge"] = ( - "supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured" - ) + reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured" reserved_status["tools.dify_tools"] = "supported_when_config_valid" reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" reserved_status["env"] = "supported_by_shell_bootstrap" @@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: - """Return the normalized knowledge dataset ids that can produce a runtime layer. + """Return normalized dataset ids selected by Agent v2 knowledge sets. ``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()`` - must stay aligned: both decide knowledge support from this effective, - non-blank dataset-id set rather than from raw - ``agent_soul.knowledge.datasets`` entries. + stay aligned on the set-based contract: DTO validation rejects blank dataset + ids before runtime, so this helper only flattens configured set datasets for + metadata/diagnostic surfaces that still need a dataset-id summary. """ - return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())] + return list_agent_soul_knowledge_dataset_ids(agent_soul) def _get_nested(value: dict[str, Any], path: str) -> Any: diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index e5a541ed350..f4a81bdcc54 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -1,10 +1,12 @@ from __future__ import annotations +import json from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot +from dify_agent.agent_stub.protocol import AgentStubFileMapping from dify_agent.layers.ask_human import DifyAskHumanLayerConfig from dify_agent.layers.drive import ( DifyDriveLayerConfig, @@ -15,7 +17,16 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) -from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig +from dify_agent.layers.knowledge import ( + DifyKnowledgeBaseLayerConfig, + DifyKnowledgeDatasetConfig, + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeModelConfig, + DifyKnowledgeQueryConfig, + DifyKnowledgeRerankingModelConfig, + DifyKnowledgeRetrievalConfig, + DifyKnowledgeSetConfig, +) from dify_agent.layers.shell import ( DifyShellCliToolConfig, DifyShellEnvVarConfig, @@ -24,7 +35,7 @@ from dify_agent.layers.shell import ( DifyShellSecretRefConfig, ) from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from clients.agent_backend import ( AgentBackendModelConfig, @@ -36,11 +47,13 @@ from clients.agent_backend import ( from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.file import FileTransferMethod +from graphon.file import File, FileTransferMethod from graphon.variables.segments import Segment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( - AgentKnowledgeQueryConfig, + AgentKnowledgeMetadataFilteringConfig, + AgentKnowledgeModelConfig, + AgentKnowledgeRetrievalConfig, AgentSoulConfig, DeclaredArrayItem, DeclaredOutputChildConfig, @@ -58,13 +71,16 @@ from services.agent.prompt_mentions import ( build_node_job_mention_resolver, build_soul_mention_resolver, expand_prompt_mentions, + extract_workflow_node_output_selectors, + normalize_previous_node_output_selector, parse_prompt_mentions, + workflow_previous_node_output_refs_from_selectors, ) from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError -from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids +from .runtime_feature_manifest import build_runtime_feature_manifest _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) _DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") @@ -74,6 +90,13 @@ _DANGEROUS_ACK_KEYS = ( "risk_accepted", "approved", ) +type AgentStubFileTransferMethod = Literal["local_file", "tool_file", "datasource_file", "remote_url"] +_AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTransferMethod] = { + FileTransferMethod.LOCAL_FILE: "local_file", + FileTransferMethod.TOOL_FILE: "tool_file", + FileTransferMethod.DATASOURCE_FILE: "datasource_file", + FileTransferMethod.REMOTE_URL: "remote_url", +} class WorkflowAgentRuntimeRequestBuildError(ValueError): @@ -126,6 +149,9 @@ class WorkflowAgentRuntimeRequest: class WorkflowAgentRuntimeRequestBuilder: """Build public Dify Agent run requests from workflow Agent v2 runtime state.""" + _WORKFLOW_USER_PROMPT_FALLBACK = "Use the current workflow context." + _WORKFLOW_JOB_PROMPT_FALLBACK = "Use the workflow user prompt for this run." + def __init__( self, *, @@ -147,15 +173,13 @@ class WorkflowAgentRuntimeRequestBuilder: ) metadata = self._build_metadata(context, agent_soul, node_job) - workflow_context_prompt = self._build_workflow_context_prompt(context, node_job) - # ENG-616: expand slash-menu mention tokens into model-readable names. - # node_output mentions expand to their reference name only — the value - # stays in the Workflow context block (user_prompt) below. - workflow_job_prompt = ( - expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip() - or "Run this workflow Agent Node for the current run." + effective_node_job = node_job.model_copy( + update={"previous_node_output_refs": self._effective_previous_node_output_refs(node_job)} ) - user_prompt = workflow_context_prompt.strip() or "Use the current workflow context." + workflow_task_prompt = self._build_workflow_task_prompt(context, effective_node_job) + workflow_context_prompt = self._build_workflow_context_prompt(context, effective_node_job) + workflow_job_prompt = workflow_task_prompt or self._WORKFLOW_JOB_PROMPT_FALLBACK + user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model) try: tools_layer = self._plugin_tools_builder.build( @@ -310,15 +334,19 @@ class WorkflowAgentRuntimeRequestBuilder: context: WorkflowAgentRuntimeBuildContext, node_job: WorkflowNodeJobConfig, ) -> str: - lines = ["Workflow context loaded for this run:"] + lines: list[str] = [] query = get_system_text(context.variable_pool, SystemVariableKey.QUERY) - if query: - lines.append(f"- User query: {query}") - resolved_outputs = self._resolve_previous_node_outputs( context.variable_pool, node_job.previous_node_output_refs, ) + if not query and not resolved_outputs: + return "" + + lines.append("Workflow context loaded for this run:") + if query: + lines.append(f"- User query: {query}") + if resolved_outputs: lines.append("- Previous node outputs:") for item in resolved_outputs: @@ -327,6 +355,31 @@ class WorkflowAgentRuntimeRequestBuilder: lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.") return "\n".join(lines) + def _build_workflow_task_prompt( + self, + context: WorkflowAgentRuntimeBuildContext, + node_job: WorkflowNodeJobConfig, + ) -> str: + del context + return expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip() + + def _effective_previous_node_output_refs( + self, + node_job: WorkflowNodeJobConfig, + ) -> list[WorkflowPreviousNodeOutputRef]: + """Derive effective refs from the current frontend task markers. + + The task text is the source of truth for previous-node context. When + the prompt has no frontend markers, stale persisted refs are discarded + instead of being carried forward into workflow context. + """ + if not node_job.workflow_prompt: + return list(node_job.previous_node_output_refs) + + return workflow_previous_node_output_refs_from_selectors( + extract_workflow_node_output_selectors(node_job.workflow_prompt) + ) + def _resolve_previous_node_outputs( self, variable_pool: VariablePoolReader, @@ -334,7 +387,7 @@ class WorkflowAgentRuntimeRequestBuilder: ) -> list[dict[str, Any]]: resolved: list[dict[str, Any]] = [] for ref in refs: - selector = self._selector_from_ref(ref) + selector = normalize_previous_node_output_selector(ref) if not selector: raise WorkflowAgentRuntimeRequestBuildError( "invalid_previous_node_output_ref", @@ -355,25 +408,73 @@ class WorkflowAgentRuntimeRequestBuilder: ) return resolved - @staticmethod - def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None: - for key in ("selector", "variable_selector", "value_selector"): - value = ref.get(key) - if isinstance(value, list) and all(isinstance(item, str) for item in value): - return value - node_id = ref.get("node_id") - output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key") - if isinstance(node_id, str) and isinstance(output_name, str): - return [node_id, output_name] - return None - @staticmethod def _summarize_value(value: Any) -> str: + prompt_payload, used_download_mapping = WorkflowAgentRuntimeRequestBuilder._resolve_prompt_payload_value(value) + if used_download_mapping: + return json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":")) + text = str(value) if len(text) > 2000: return text[:2000] + "...[truncated]" return text + @classmethod + def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]: + # File-valued workflow context must surface as Agent Stub download + # mappings so the model can materialize those inputs with + # `dify-agent file download --mapping ...` inside the sandbox. + download_mapping = cls._agent_stub_download_mapping(value) + if download_mapping is not None: + return download_mapping, True + + if isinstance(value, list | tuple): + changed = False + resolved_items: list[Any] = [] + for item in value: + resolved_item, item_changed = cls._resolve_prompt_payload_value(item) + resolved_items.append(resolved_item) + changed = changed or item_changed + return resolved_items, changed + + if isinstance(value, Mapping): + changed = False + resolved_items_by_key: dict[str, Any] = {} + for key, item in value.items(): + resolved_item, item_changed = cls._resolve_prompt_payload_value(item) + resolved_items_by_key[str(key)] = resolved_item + changed = changed or item_changed + return resolved_items_by_key, changed + + return value, False + + @staticmethod + def _agent_stub_download_mapping(value: Any) -> dict[str, Any] | None: + try: + if isinstance(value, File): + transfer_method = _AGENT_STUB_FILE_TRANSFER_METHODS.get(value.transfer_method) + if transfer_method is None: + return None + if value.transfer_method == FileTransferMethod.REMOTE_URL: + url = value.remote_url + if not isinstance(url, str) or not url: + return None + mapping = AgentStubFileMapping(transfer_method=transfer_method, url=url) + else: + reference = value.reference + if not isinstance(reference, str) or not reference: + return None + mapping = AgentStubFileMapping(transfer_method=transfer_method, reference=reference) + return mapping.model_dump(mode="json", exclude_none=True) + + if isinstance(value, Mapping): + mapping = AgentStubFileMapping.model_validate(value) + return mapping.model_dump(mode="json", exclude_none=True) + except ValidationError: + return None + + return None + @staticmethod def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None: """Build the structured-output layer config sent to Agent backend. @@ -547,42 +648,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: - """Map Agent Soul knowledge config into the fixed Dify knowledge-base layer. + """Map Agent Soul knowledge sets into one Dify knowledge-base layer. - Normalization intentionally matches the current dify-agent runtime contract: - - - blank or missing dataset ids are ignored; - - if no valid dataset ids remain, no knowledge layer is injected; - - retrieval mode is always forced to ``multiple`` in this first wiring pass; - - ``top_k`` falls back to a stable runtime default when the soul omits it; - - ``score_threshold`` is only forwarded when the product config explicitly - enables it, otherwise the layer keeps the disabled/default ``0.0`` value; - - metadata filtering stays at the layer DTO default (disabled). + Agent Soul DTO validation owns malformed set rejection. Runtime mapping is + intentionally lossless: every configured set is forwarded with its query + policy, dataset refs, retrieval controls, and metadata-filtering controls. + ``score_threshold=None`` means disabled threshold filtering and maps to the + inner retrieval request's ``0.0`` default through the Agent backend DTO. """ - dataset_ids = list_configured_knowledge_dataset_ids(agent_soul) - if not dataset_ids: + if not agent_soul.knowledge.sets: return None - query_config = agent_soul.knowledge.query_config return DifyKnowledgeBaseLayerConfig( - dataset_ids=dataset_ids, - retrieval=DifyKnowledgeRetrievalConfig( - mode="multiple", - top_k=_knowledge_top_k(query_config), - score_threshold=_knowledge_score_threshold(query_config), - ), + sets=[ + DifyKnowledgeSetConfig( + id=knowledge_set.id, + name=knowledge_set.name, + description=knowledge_set.description, + datasets=[ + DifyKnowledgeDatasetConfig( + id=dataset.id or "", + name=dataset.name, + description=dataset.description, + ) + for dataset in knowledge_set.datasets + ], + query=DifyKnowledgeQueryConfig( + mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value), + value=knowledge_set.query.value, + ), + retrieval=_knowledge_retrieval_config(knowledge_set.retrieval), + metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering), + ) + for knowledge_set in agent_soul.knowledge.sets + ], ) -def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int: - top_k = query_config.top_k - return top_k if isinstance(top_k, int) and top_k >= 1 else 4 +def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig: + return DifyKnowledgeRetrievalConfig( + mode=retrieval.mode, + top_k=retrieval.top_k, + score_threshold=retrieval.score_threshold or 0.0, + reranking_mode=retrieval.reranking_mode, + reranking_enable=retrieval.reranking_enable, + reranking_model=DifyKnowledgeRerankingModelConfig( + provider=retrieval.reranking_model.provider, + model=retrieval.reranking_model.model, + ) + if retrieval.reranking_model is not None + else None, + weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True)) + if retrieval.weights is not None + else None, + model=_knowledge_model_config(retrieval.model), + ) -def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float: - if query_config.score_threshold_enabled and query_config.score_threshold is not None: - return query_config.score_threshold - return 0.0 +def _knowledge_metadata_filtering_config( + metadata_filtering: AgentKnowledgeMetadataFilteringConfig, +) -> DifyKnowledgeMetadataFilteringConfig: + return DifyKnowledgeMetadataFilteringConfig( + mode=metadata_filtering.mode, + model_config=_knowledge_model_config(metadata_filtering.metadata_model_config), + conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json")) + if metadata_filtering.conditions is not None + else None, + ) + + +def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None: + if model is None: + return None + return DifyKnowledgeModelConfig( + provider=model.provider, + name=model.name, + mode=model.mode, + completion_params=model.completion_params, + ) def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 2eabac10dd6..7b915fe02be 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -18,6 +18,7 @@ from models.agent_config_entities import ( ) from models.model import UploadFile from models.workflow import Workflow +from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids from .entities import DifyAgentNodeData @@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator: ) cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul) cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul) + cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul) node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology) @@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator: ) cli_tool_names.add(normalized_name) + @classmethod + def _validate_agent_soul_knowledge( + cls, + *, + binding: WorkflowAgentNodeBinding, + agent_soul: AgentSoulConfig, + ) -> None: + """Validate knowledge set dataset rows against the publishing tenant.""" + missing_ids = list_missing_tenant_knowledge_dataset_ids( + tenant_id=binding.tenant_id, + agent_soul=agent_soul, + ) + if missing_ids: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: " + f"{', '.join(missing_ids)}." + ) + @classmethod def _validate_agent_soul_env( cls, diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index e60a6b01426..e045ee39afc 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -6,6 +6,7 @@ from pydantic import Field, field_validator from fields.base import ResponseModel from libs.helper import to_timestamp from models.agent import ( + AgentConfigDraftType, AgentConfigRevisionOperation, AgentIconType, AgentKind, @@ -47,6 +48,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel): created_at: int | None = None +class AgentConfigDraftSummaryResponse(ResponseModel): + id: str + agent_id: str + draft_type: AgentConfigDraftType + account_id: str | None = None + base_snapshot_id: str | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: int | None = None + updated_at: int | None = None + + class AgentPublishedReferenceResponse(ResponseModel): app_id: str app_name: str @@ -72,6 +85,8 @@ class AgentRosterResponse(ResponseModel): scope: AgentScope source: AgentSource app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False workflow_id: str | None = None workflow_node_id: str | None = None active_config_snapshot_id: str | None = None @@ -292,14 +307,24 @@ class AgentConfigSnapshotListResponse(ResponseModel): class AgentConfigSnapshotRestoreResponse(ResponseModel): result: Literal["success"] active_config_snapshot_id: str + draft_config_id: str | None = None + restored_version_id: str | None = None class AgentComposerAgentResponse(ResponseModel): id: str name: str description: str + role: str | None = None + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None scope: AgentScope + source: AgentSource | None = None status: AgentStatus + app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False active_config_snapshot_id: str | None = None @@ -343,6 +368,9 @@ class WorkflowAgentComposerResponse(ResponseModel): impact_summary: AgentComposerImpactResponse | None = None validation: "ComposerValidationFindingsResponse | None" = None app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False + chat_endpoint: str | None = None workflow_id: str | None = None node_id: str | None = None @@ -350,10 +378,15 @@ class WorkflowAgentComposerResponse(ResponseModel): class AgentAppComposerResponse(ResponseModel): variant: Literal[ComposerVariant.AGENT_APP] agent: AgentComposerAgentResponse - active_config_snapshot: AgentConfigSnapshotSummaryResponse + active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + draft: AgentConfigDraftSummaryResponse | None = None agent_soul: AgentSoulConfig save_options: list[ComposerSaveStrategy] validation: "ComposerValidationFindingsResponse | None" = None + app_id: str | None = None + backing_app_id: str | None = None + hidden_app_backed: bool = False + chat_endpoint: str | None = None class ComposerValidationWarningResponse(ResponseModel): @@ -400,10 +433,22 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel): human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list) +class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig): + missing: bool = False + + +class AgentComposerKnowledgeSetCandidateResponse(ResponseModel): + id: str + name: str + description: str | None = None + datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list) + missing_dataset_ids: list[str] = Field(default_factory=list) + + class AgentComposerSoulCandidatesResponse(ResponseModel): dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list) cli_tools: list[AgentCliToolConfig] = Field(default_factory=list) - knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) + knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list) human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list) diff --git a/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py b/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py index 7348e19b3cc..8078c83fa14 100644 --- a/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py +++ b/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py @@ -1,16 +1,5 @@ """add workflow_version to workflow_agent_node_bindings -Restores the stage 1 §5.3 unique key -``(tenant_id, workflow_id, workflow_version, node_id)`` so draft and published -workflow bindings can coexist at the same workflow_id once we want to track -them per workflow version. ``workflow_version`` mirrors ``workflows.version`` -("draft" or a published version string). - -Because the New Agent Experience feature is pre-release, this table is empty -in every environment that matters; the ``server_default='draft'`` only exists -to keep developer-local rows valid during the alter and is dropped immediately -afterward so application code must specify ``workflow_version`` explicitly. - Revision ID: 97e2e1a644e8 Revises: f8b6b7e9c421 Create Date: 2026-05-25 11:43:37.611300 @@ -33,10 +22,8 @@ def upgrade(): 'workflow_version', sa.String(length=255), nullable=False, - server_default='draft', ) ) - batch_op.alter_column('workflow_version', server_default=None) batch_op.drop_constraint( batch_op.f('workflow_agent_node_binding_node_unique'), type_='unique' ) diff --git a/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py index 900f7da06fc..ee30abaa45b 100644 --- a/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py +++ b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py @@ -18,8 +18,7 @@ depends_on = None def upgrade(): with op.batch_alter_table("agents", schema=None) as batch_op: - batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default="")) - batch_op.alter_column("role", server_default=None) + batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False)) def downgrade(): diff --git a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py index 3398c2eb018..9dc85d2a89b 100644 --- a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py +++ b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py @@ -6,15 +6,9 @@ Create Date: 2026-06-18 23:00:00.000000 """ -from __future__ import annotations - -import json -from typing import Any - -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import mysql -from sqlalchemy.engine.mock import MockConnection # revision identifiers, used by Alembic. revision = "b2515f9d4c2a" @@ -37,46 +31,9 @@ def upgrade() -> None: "agent_drive_files", ["tenant_id", "agent_id", "is_skill", "key"], ) - _remove_skills_files_from_snapshots() def downgrade() -> None: op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files") op.drop_column("agent_drive_files", "skill_metadata") op.drop_column("agent_drive_files", "is_skill") - - -def _remove_skills_files_from_snapshots() -> None: - connection = op.get_bind() - if connection is None or isinstance(connection, MockConnection): - return - snapshots = sa.table( - "agent_config_snapshots", - sa.column("id", sa.String()), - sa.column("config_snapshot", sa.Text()), - ) - rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall() - for row in rows: - cleaned = _strip_skills_files(row.config_snapshot) - if cleaned is None: - continue - connection.execute( - snapshots.update() - .where(snapshots.c.id == row.id) - .values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True)) - ) - - -def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None: - if raw_snapshot is None: - return None - if isinstance(raw_snapshot, str): - snapshot = json.loads(raw_snapshot) - elif isinstance(raw_snapshot, dict): - snapshot = dict(raw_snapshot) - else: - snapshot = dict(raw_snapshot) - if not isinstance(snapshot, dict) or "skills_files" not in snapshot: - return None - snapshot.pop("skills_files", None) - return snapshot diff --git a/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py new file mode 100644 index 00000000000..f67fc55bb90 --- /dev/null +++ b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py @@ -0,0 +1,56 @@ +"""add agent config drafts + +Revision ID: e4f5a6b7c8d9 +Revises: a6f1c9d2e8b4 +Create Date: 2026-06-24 20:15:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "e4f5a6b7c8d9" +down_revision = "a6f1c9d2e8b4" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "agent_config_drafts", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("draft_type", sa.String(length=32), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=True), + sa.Column("draft_owner_key", sa.String(length=255), nullable=False), + sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("config_snapshot", models.types.LongText(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name=op.f("agent_config_draft_agent_type_account_unique"), + ), + ) + op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"]) + op.create_index( + "agent_config_draft_base_snapshot_idx", + "agent_config_drafts", + ["tenant_id", "base_snapshot_id"], + ) + + +def downgrade(): + op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts") + op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts") + op.drop_table("agent_config_drafts") diff --git a/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py b/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py new file mode 100644 index 00000000000..be19ecf7ed9 --- /dev/null +++ b/api/migrations/versions/2026_06_25_1100-a2b3c4d5e6f7_add_agent_backing_app_id.py @@ -0,0 +1,30 @@ +"""add agent backing app id + +Revision ID: a2b3c4d5e6f7 +Revises: e4f5a6b7c8d9 +Create Date: 2026-06-25 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "a2b3c4d5e6f7" +down_revision = "e4f5a6b7c8d9" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column(sa.Column("backing_app_id", models.types.StringUUID(), nullable=True)) + op.create_index("agent_tenant_backing_app_id_idx", "agents", ["tenant_id", "backing_app_id"]) + + +def downgrade(): + op.drop_index("agent_tenant_backing_app_id_idx", table_name="agents") + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_column("backing_app_id") diff --git a/api/migrations/versions/2026_06_26_1000-c3d4e5f6a7b8_add_agent_active_config_is_published.py b/api/migrations/versions/2026_06_26_1000-c3d4e5f6a7b8_add_agent_active_config_is_published.py new file mode 100644 index 00000000000..5040287ca2e --- /dev/null +++ b/api/migrations/versions/2026_06_26_1000-c3d4e5f6a7b8_add_agent_active_config_is_published.py @@ -0,0 +1,37 @@ +"""add agent active config is published + +Revision ID: c3d4e5f6a7b8 +Revises: a2b3c4d5e6f7 +Create Date: 2026-06-26 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "a2b3c4d5e6f7" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "active_config_is_published", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment=( + "Whether the normal shared Agent draft has been published into the active config snapshot. " + "User-scoped debug drafts do not affect this flag." + ), + ) + ) + + +def downgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_column("active_config_is_published") diff --git a/api/models/__init__.py b/api/models/__init__.py index 9992de982c4..ac90eef0962 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,8 @@ from .account import ( ) from .agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -154,6 +156,8 @@ __all__ = [ "AccountStatus", "AccountTrialAppRecord", "Agent", + "AgentConfigDraft", + "AgentConfigDraftType", "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", diff --git a/api/models/agent.py b/api/models/agent.py index 46044edd5e7..f91a05266ee 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum): SAVE_TO_ROSTER = "save_to_roster" # Switches the Agent's current published config back to an existing version. RESTORE_VERSION = "restore_version" + # Publishes the editable Agent Soul draft as a new immutable version. + PUBLISH_DRAFT = "publish_draft" + + +class AgentConfigDraftType(StrEnum): + """Editable Agent Soul draft workspace type.""" + + # Shared Agent Console draft edited by users before publishing. + DRAFT = "draft" + # Per-editor build draft mutated during debug/build mode. + DEBUG_BUILD = "debug_build" class WorkflowAgentBindingType(StrEnum): @@ -134,6 +145,7 @@ class Agent(DefaultFieldsMixin, Base): Index("agent_tenant_scope_idx", "tenant_id", "scope"), Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), + Index("agent_tenant_backing_app_id_idx", "tenant_id", "backing_app_id"), Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), Index( "agent_tenant_invitable_idx", @@ -162,12 +174,30 @@ class Agent(DefaultFieldsMixin, Base): scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False) source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False) app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + backing_app_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + comment=( + "Runtime Agent App used for chat/log/monitoring. For workflow-only agents, " + "app_id remains the parent workflow app id and this points to the hidden backing app." + ), + ) workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) active_config_has_model: Mapped[bool] = mapped_column( sa.Boolean, nullable=False, default=False, server_default=sa.text("false") ) + active_config_is_published: Mapped[bool] = mapped_column( + sa.Boolean, + nullable=False, + default=False, + server_default=sa.text("false"), + comment=( + "Whether the normal shared Agent draft has been published into the active config snapshot. " + "User-scoped debug drafts do not affect this flag." + ), + ) status: Mapped[AgentStatus] = mapped_column( EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE ) @@ -210,6 +240,44 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) +class AgentConfigDraft(DefaultFieldsMixin, Base): + """Editable Agent Soul draft separated from immutable published snapshots.""" + + __tablename__ = "agent_config_drafts" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"), + UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name="agent_config_draft_agent_type_account_unique", + ), + Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"), + Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column(EnumText(AgentConfigDraftType, length=32), nullable=False) + account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="") + base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + + @property + def config_snapshot_dict(self) -> dict[str, Any]: + if not self.config_snapshot: + return {} + if hasattr(self.config_snapshot, "model_dump"): + return self.config_snapshot.model_dump(mode="json") + if isinstance(self.config_snapshot, str): + return json.loads(self.config_snapshot) + return dict(self.config_snapshot) + + class AgentConfigSnapshot(DefaultFieldsMixin, Base): """Immutable Agent Soul snapshot. @@ -355,9 +423,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): agent_config_snapshot_id / composition_layer_specs`` columns are set. - Agent App conversations: ``owner_type = conversation``; the ``conversation_id`` column is set and the workflow columns stay NULL. - Published/web/API runs scope runtime state by ``agent_config_snapshot_id``; - console debugger runs may keep it NULL so prompt-only draft saves can reuse - the same preview conversation state while executing the latest Agent Soul. + Runtime state is scoped by ``agent_config_snapshot_id``. For published + web/API runs this points to an immutable AgentConfigSnapshot; for console + debugger/build runs it points to the editable AgentConfigDraft row. The snapshot is runtime state returned by Agent backend, kept separate from Agent Soul snapshots and workflow node-job config. diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 2503ba66f06..c1faa5a6975 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -2,10 +2,11 @@ from __future__ import annotations import re from enum import StrEnum -from typing import Annotated, Any, Final, Literal +from typing import Annotated, Any, Final, Literal, Self from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator +from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator from core.workflow.file_reference import is_canonical_file_reference from graphon.file import FileTransferMethod @@ -161,6 +162,11 @@ class AgentSkillRefConfig(AgentFlexibleConfig): manifest_files: list[str] | None = None +class AgentSoulFilesConfig(BaseModel): + skills: list[AgentSkillRefConfig] = Field(default_factory=list) + files: list[AgentFileRefConfig] = Field(default_factory=list) + + class AgentPermissionConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -236,17 +242,161 @@ class AgentCliToolConfig(AgentFlexibleConfig): inferred_from: str | None = Field(default=None, max_length=255) -class AgentKnowledgeDatasetConfig(AgentFlexibleConfig): +class AgentKnowledgeDatasetConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) description: str | None = None -class AgentKnowledgeQueryConfig(AgentFlexibleConfig): - query: str | None = None +class AgentKnowledgeQueryConfig(BaseModel): + """Per-set query policy for Agent v2 knowledge retrieval. + + Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + set owns its own query policy, so ``user_query`` must carry an explicit + ``value`` while ``generated_query`` leaves that value empty. + """ + + model_config = ConfigDict(extra="forbid") + + mode: AgentKnowledgeQueryMode + value: str | None = None + + @model_validator(mode="after") + def validate_query(self) -> Self: + if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip(): + raise ValueError("knowledge query.value is required for user_query mode") + return self + + +class AgentKnowledgeModelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + mode: str = Field(min_length=1, max_length=64) + completion_params: dict[str, Any] = Field(default_factory=dict) + + +class AgentKnowledgeRerankingModelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1, max_length=255) + model: str = Field(min_length=1, max_length=255) + + +class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig): + weight_type: str | None = Field(default=None, max_length=64) + vector_setting: dict[str, Any] | None = None + keyword_setting: dict[str, Any] | None = None + + +class AgentKnowledgeRetrievalConfig(BaseModel): + """Per-set retrieval policy for Agent v2 knowledge retrieval. + + Retrieval settings now live on each knowledge set instead of one shared + flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + ``single`` retrieval with a required model config. + """ + + model_config = ConfigDict(extra="forbid") + + mode: Literal["single", "multiple"] top_k: int | None = Field(default=None, ge=1) score_threshold: float | None = Field(default=None, ge=0, le=1) - score_threshold_enabled: bool | None = None + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: AgentKnowledgeRerankingModelConfig | None = None + weights: AgentKnowledgeWeightedScoreConfig | None = None + model: AgentKnowledgeModelConfig | None = None + + @model_validator(mode="after") + def validate_mode_fields(self) -> Self: + if self.mode == "multiple" and self.top_k is None: + raise ValueError("knowledge retrieval.top_k is required for multiple mode") + if self.mode == "single" and self.model is None: + raise ValueError("knowledge retrieval.model is required for single mode") + return self + + +class AgentKnowledgeMetadataCondition(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + comparison_operator: SupportedComparisonOperator + value: ConditionValue = None + + +class AgentKnowledgeMetadataConditions(BaseModel): + model_config = ConfigDict(extra="forbid") + + logical_operator: Literal["and", "or"] = "and" + conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list) + + +class AgentKnowledgeMetadataFilteringConfig(BaseModel): + """Per-set metadata filtering policy. + + The Python attribute uses ``metadata_model_config`` for clarity because the + model belongs to metadata filtering specifically, while the external API and + generated schema keep the historical ``model_config`` field name via alias. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + # Internal name is explicit; wire format remains ``model_config``. + metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config") + conditions: AgentKnowledgeMetadataConditions | None = None + + @model_validator(mode="after") + def validate_mode_fields(self) -> Self: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + +class AgentKnowledgeSetConfig(BaseModel): + """One explicit knowledge set in Agent v2. + + ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + its datasets plus query, retrieval, and metadata policies. An individual + set must contain at least one dataset id even though the overall knowledge + section may be empty, which is how callers express "no knowledge layer". + """ + + model_config = ConfigDict(extra="forbid") + + id: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + datasets: list[AgentKnowledgeDatasetConfig] + query: AgentKnowledgeQueryConfig + retrieval: AgentKnowledgeRetrievalConfig + metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field( + default_factory=AgentKnowledgeMetadataFilteringConfig + ) + + @field_validator("id", "name") + @classmethod + def validate_non_blank_identity(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("knowledge set id and name must not be blank") + return normalized + + @model_validator(mode="after") + def validate_datasets(self) -> Self: + dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets] + if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids): + raise ValueError("knowledge set requires at least one dataset id") + if len(dataset_ids) != len(set(dataset_ids)): + raise ValueError("knowledge set dataset ids must be unique") + return self class AgentHumanContactConfig(AgentFlexibleConfig): @@ -453,9 +603,28 @@ class AgentSoulToolsConfig(BaseModel): class AgentSoulKnowledgeConfig(BaseModel): - datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) - query_mode: AgentKnowledgeQueryMode | None = None - query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig) + """Top-level Agent v2 knowledge config. + + Agent v2 models knowledge as explicit sets instead of one flat + ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + list means no knowledge layer should be emitted at runtime, while set-name + uniqueness stays case-insensitive because runtime selection addresses sets + by name. + """ + + model_config = ConfigDict(extra="forbid") + + sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_unique_sets(self) -> Self: + set_ids = [item.id.strip() for item in self.sets] + if len(set_ids) != len(set(set_ids)): + raise ValueError("knowledge set ids must be unique") + set_names = [item.name.strip().lower() for item in self.sets] + if len(set_names) != len(set(set_names)): + raise ValueError("knowledge set names must be unique") + return self class AgentSoulHumanConfig(BaseModel): @@ -513,6 +682,7 @@ class AgentSoulConfig(BaseModel): knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) + files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig) sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) model: AgentSoulModelConfig | None = None diff --git a/api/models/model.py b/api/models/model.py index 05399b07c5f..ac26f0cd80b 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -487,9 +487,15 @@ class App(Base): agent = db.session.scalar( select(Agent).where( - Agent.app_id == self.id, - Agent.scope == AgentScope.ROSTER, - Agent.source == AgentSource.AGENT_APP, + Agent.tenant_id == self.tenant_id, + sa.or_( + sa.and_( + Agent.app_id == self.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + ), + Agent.backing_app_id == self.id, + ), Agent.status == AgentStatus.ACTIVE, ) ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 0debde6c238..2c86c4f70b1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -465,6 +465,83 @@ Check if activation token is valid | ---- | ----------- | | 204 | Agent service API key deleted | +### [DELETE] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft discarded | **application/json**: [AgentSimpleResultResponse](#agentsimpleresultresponse)
| + +### [GET] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + +### [PUT] /agent/{agent_id}/build-draft +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft saved | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + +### [POST] /agent/{agent_id}/build-draft/apply +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft applied | **application/json**: [AgentBuildDraftApplyResponse](#agentbuilddraftapplyresponse)
| + +### [POST] /agent/{agent_id}/build-draft/checkout +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentBuildDraftCheckoutPayload](#agentbuilddraftcheckoutpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent build draft checked out | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| + ### [GET] /agent/{agent_id}/chat-messages Get Agent App chat messages for a conversation with pagination @@ -856,6 +933,26 @@ Get Agent App message details by ID | 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)
| | 404 | Agent or message not found | | +### [POST] /agent/{agent_id}/publish +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentPublishPayload](#agentpublishpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent draft published | **application/json**: [AgentPublishResponse](#agentpublishresponse)
| +| 403 | Insufficient permissions | | + ### [GET] /agent/{agent_id}/referencing-workflows List workflow apps that reference this Agent App's bound Agent (read-only) @@ -3767,6 +3864,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| snapshot_id | query | | No | string | | app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | @@ -12173,9 +12271,14 @@ Default namespace | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes | +| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes | | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes | +| app_id | string | | No | +| backing_app_id | string | | No | +| chat_endpoint | string | | No | +| draft | [AgentConfigDraftSummaryResponse](#agentconfigdraftsummaryresponse) | | No | +| hidden_app_backed | boolean | | No | | save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes | | validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No | | variant | string | | Yes | @@ -12210,6 +12313,7 @@ Default namespace | active_config_is_published | boolean | | No | | api_base_url | string | | No | | app_id | string | | No | +| backing_app_id | string | | No | | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | @@ -12218,6 +12322,7 @@ Default namespace | description | string | | No | | enable_api | boolean | | Yes | | enable_site | boolean | | Yes | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | @@ -12230,7 +12335,7 @@ Default namespace | name | string | | Yes | | permission_keys | [ string ] | | No | | role | string | | No | -| site | [Site](#site) | | No | +| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -12273,6 +12378,7 @@ default (the config form sends the full desired feature state on save). | active_config_is_published | boolean | | No | | app_id | string | | No | | author_name | string | | No | +| backing_app_id | string | | No | | bound_agent_id | string | | No | | create_user_name | string | | No | | created_at | integer | | No | @@ -12280,6 +12386,7 @@ default (the config form sends the full desired feature state on save). | debug_conversation_id | string | | No | | description | string | | No | | has_draft_trigger | boolean | | No | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | @@ -12338,6 +12445,27 @@ default (the config form sends the full desired feature state on save). | date | string | | Yes | | interactions | number | | Yes | +#### AgentBuildDraftApplyResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft | object | | Yes | +| result | string | | Yes | + +#### AgentBuildDraftCheckoutPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| force | boolean | Overwrite the existing current-user build draft | No | + +#### AgentBuildDraftResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_soul | object | | Yes | +| draft | object | | Yes | +| variant | string | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -12400,10 +12528,18 @@ Risk marker for CLI tool bootstrap commands. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | active_config_snapshot_id | string | | No | +| app_id | string | | No | +| backing_app_id | string | | No | | description | string | | Yes | +| hidden_app_backed | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | | id | string | | Yes | | name | string | | Yes | +| role | string | | No | | scope | [AgentScope](#agentscope) | | Yes | +| source | [AgentSource](#agentsource) | | No | | status | [AgentStatus](#agentstatus) | | Yes | #### AgentComposerBindingResponse @@ -12456,6 +12592,25 @@ Risk marker for CLI tool bootstrap commands. | current_snapshot_id | string | | No | | workflow_node_count | integer | | Yes | +#### AgentComposerKnowledgeDatasetCandidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| id | string | | No | +| missing | boolean | | No | +| name | string | | No | + +#### AgentComposerKnowledgeSetCandidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No | +| description | string | | No | +| id | string | | Yes | +| missing_dataset_ids | [ string ] | | No | +| name | string | | Yes | + #### AgentComposerNodeJobCandidatesResponse | Name | Type | Description | Required | @@ -12471,7 +12626,7 @@ Risk marker for CLI tool bootstrap commands. | cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No | | dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No | | human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No | -| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No | +| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No | #### AgentComposerSoulLockResponse @@ -12490,6 +12645,28 @@ Risk marker for CLI tool bootstrap commands. | result | string | | Yes | | warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No | +#### AgentConfigDraftSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | No | +| agent_id | string | | Yes | +| base_snapshot_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | Yes | +| id | string | | Yes | +| updated_at | integer | | No | +| updated_by | string | | No | + +#### AgentConfigDraftType + +Editable Agent Soul draft workspace type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentConfigDraftType | string | Editable Agent Soul draft workspace type. | | + #### AgentConfigRevisionOperation Audit operation recorded for Agent Soul version/revision changes. @@ -12539,6 +12716,8 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | active_config_snapshot_id | string | | Yes | +| draft_config_id | string | | No | +| restored_version_id | string | | No | | result | string | | Yes | #### AgentConfigSnapshotSummaryResponse @@ -12793,10 +12972,12 @@ Supported icon storage formats for Agent roster entries. | app_id | string | | No | | archived_at | integer | | No | | archived_by | string | | No | +| backing_app_id | string | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | Yes | | existing_node_ids | [ string ] | | No | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | [AgentIconType](#agenticontype) | | No | @@ -12865,14 +13046,57 @@ the current roster/workflow APIs scoped to Dify Agent. | id | string | | No | | name | string | | No | -#### AgentKnowledgeQueryConfig +#### AgentKnowledgeMetadataCondition | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| query | string | | No | -| score_threshold | number | | No | -| score_threshold_enabled | boolean | | No | -| top_k | integer | | No | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | | Yes | +| value | string
[ string ]
number | | No | + +#### AgentKnowledgeMetadataConditions + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No | +| logical_operator | string,
**Available values:** "and", "or",
**Default:** and | *Enum:* `"and"`, `"or"` | No | + +#### AgentKnowledgeMetadataFilteringConfig + +Per-set metadata filtering policy. + +The Python attribute uses ``metadata_model_config`` for clarity because the +model belongs to metadata filtering specifically, while the external API and +generated schema keep the historical ``model_config`` field name via alias. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No | +| mode | string,
**Available values:** "automatic", "disabled", "manual",
**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No | +| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No | + +#### AgentKnowledgeModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_params | object | | No | +| mode | string | | Yes | +| name | string | | Yes | +| provider | string | | Yes | + +#### AgentKnowledgeQueryConfig + +Per-set query policy for Agent v2 knowledge retrieval. + +Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the +legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each +set owns its own query policy, so ``user_query`` must carry an explicit +``value`` while ``generated_query`` leaves that value empty. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes | +| value | string | | No | #### AgentKnowledgeQueryMode @@ -12880,6 +13104,59 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentKnowledgeRerankingModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | +| provider | string | | Yes | + +#### AgentKnowledgeRetrievalConfig + +Per-set retrieval policy for Agent v2 knowledge retrieval. + +Retrieval settings now live on each knowledge set instead of one shared +flat config. A set may use either ``multiple`` retrieval with ``top_k`` or +``single`` retrieval with a required model config. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string,
**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes | +| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No | +| reranking_enable | boolean,
**Default:** true | | No | +| reranking_mode | string,
**Default:** reranking_model | | No | +| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No | +| score_threshold | number | | No | +| top_k | integer | | No | +| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No | + +#### AgentKnowledgeSetConfig + +One explicit knowledge set in Agent v2. + +``knowledge.sets`` replaces the old flat knowledge config. Each set owns +its datasets plus query, retrieval, and metadata policies. An individual +set must contain at least one dataset id even though the overall knowledge +section may be empty, which is how callers express "no knowledge layer". + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes | +| description | string | | No | +| id | string | | Yes | +| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No | +| name | string | | Yes | +| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes | +| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes | + +#### AgentKnowledgeWeightedScoreConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_setting | object | | No | +| vector_setting | object | | No | +| weight_type | string | | No | + #### AgentLogConversationItemResponse | Name | Type | Description | Required | @@ -13063,6 +13340,21 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentProviderResponse | object | | | +#### AgentPublishPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| version_note | string | Optional note for this published Agent version | No | + +#### AgentPublishResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_config_snapshot | object | | No | +| active_config_snapshot_id | string | | Yes | +| draft | object | | No | +| result | string | | Yes | + #### AgentPublishedReferenceResponse | Name | Type | Description | Required | @@ -13120,9 +13412,11 @@ the current roster/workflow APIs scoped to Dify Agent. | app_id | string | | No | | archived_at | integer | | No | | archived_by | string | | No | +| backing_app_id | string | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | Yes | +| hidden_app_backed | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | [AgentIconType](#agenticontype) | | No | @@ -13190,6 +13484,27 @@ Visibility and lifecycle scope of an Agent record. | enabled | boolean | | No | | type | string | | No | +#### AgentSimpleResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + +#### AgentSkillRefConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| file_id | string | | No | +| full_archive_file_id | string | | No | +| full_archive_key | string | | No | +| id | string | | No | +| manifest_files | [ string ] | | No | +| name | string | | No | +| path | string | | No | +| skill_md_file_id | string | | No | +| skill_md_key | string | | No | + #### AgentSkillUploadResponse | Name | Type | Description | Required | @@ -13216,6 +13531,7 @@ Visibility and lifecycle scope of an Agent record. | app_features | [AgentSoulAppFeaturesConfig](#agentsoulappfeaturesconfig) | | No | | app_variables | [ [AppVariableConfig](#appvariableconfig) ] | | No | | env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No | +| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No | | human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No | | knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No | | memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No | @@ -13270,6 +13586,13 @@ old Agent tool payloads can be read while new payloads stay explicit. | secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No | | variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No | +#### AgentSoulFilesConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No | +| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No | + #### AgentSoulHumanConfig | Name | Type | Description | Required | @@ -13279,11 +13602,17 @@ old Agent tool payloads can be read while new payloads stay explicit. #### AgentSoulKnowledgeConfig +Top-level Agent v2 knowledge config. + +Agent v2 models knowledge as explicit sets instead of one flat +``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` +list means no knowledge layer should be emitted at runtime, while set-name +uniqueness stays case-insensitive because runtime selection addresses sets +by name. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No | -| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No | -| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No | +| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No | #### AgentSoulMemoryConfig @@ -13756,6 +14085,36 @@ Enum class for api provider schema type. | use_icon_as_answer_icon | boolean | | No | | workflow | [WorkflowPartial](#workflowpartial) | | No | +#### AppDetailSiteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | No | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| code | string | | No | +| copyright | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string
[IconType](#icontype) | | No | +| icon_url | string | | Yes | +| input_placeholder | string | | No | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | + #### AppDetailWithSite | Name | Type | Description | Required | @@ -13781,7 +14140,7 @@ Enum class for api provider schema type. | model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | -| site | [Site](#site) | | No | +| site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -14187,6 +14546,7 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation ID | No | +| draft_type | string,
**Available values:** "debug_build", "draft",
**Default:** draft | Agent App debug config source. Use debug_build while the Agent is in build mode.
*Enum:* `"debug_build"`, `"draft"` | No | | files | [ object ] | Uploaded files | No | | inputs | object | | Yes | | model_config | object | | No | @@ -20355,6 +20715,12 @@ How a workflow node is bound to an Agent. | ---- | ---- | ----------- | -------- | | WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | | +#### WorkflowAgentComposerQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| snapshot_id | string | | No | + #### WorkflowAgentComposerResponse | Name | Type | Description | Required | @@ -20363,8 +20729,11 @@ How a workflow node is bound to an Agent. | agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No | | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes | | app_id | string | | No | +| backing_app_id | string | | No | | binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No | +| chat_endpoint | string | | No | | effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No | +| hidden_app_backed | boolean | | No | | impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No | | node_id | string | | No | | node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes | diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py index 7868f2a2f63..a650b16e9bc 100644 --- a/api/services/agent/composer_candidates.py +++ b/api/services/agent/composer_candidates.py @@ -25,6 +25,7 @@ from models.agent_config_entities import ( AgentSoulConfig, DeclaredOutputConfig, ) +from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids MAX_CANDIDATES_PER_LIST = 200 @@ -139,19 +140,34 @@ def soul_candidates( cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled] - dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id] + dataset_ids = list_agent_soul_knowledge_dataset_ids(soul) dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {} - knowledge_datasets: list[dict[str, Any]] = [] - for dataset in soul.knowledge.datasets: - if not dataset.id: - continue - row = dataset_rows.get(dataset.id) - knowledge_datasets.append( + knowledge_sets: list[dict[str, Any]] = [] + for knowledge_set in soul.knowledge.sets: + missing_dataset_ids: list[str] = [] + datasets: list[dict[str, Any]] = [] + for dataset in knowledge_set.datasets: + dataset_id = (dataset.id or "").strip() + if not dataset_id: + continue + row = dataset_rows.get(dataset_id) + if row is None: + missing_dataset_ids.append(dataset_id) + datasets.append( + { + "id": dataset_id, + "name": (getattr(row, "name", None) or dataset.name or dataset_id), + "description": getattr(row, "description", None) or dataset.description, + "missing": row is None, + } + ) + knowledge_sets.append( { - "id": dataset.id, - "name": (getattr(row, "name", None) or dataset.name or dataset.id), - "description": getattr(row, "description", None) or dataset.description, - "missing": row is None, + "id": knowledge_set.id, + "name": knowledge_set.name, + "description": knowledge_set.description, + "datasets": datasets, + "missing_dataset_ids": missing_dataset_ids, } ) @@ -161,7 +177,7 @@ def soul_candidates( lists = { "dify_tools": dify_tools, "cli_tools": cli_tools, - "knowledge_datasets": knowledge_datasets, + "knowledge_sets": knowledge_sets, "human_contacts": human_contacts, } capped: dict[str, list[dict[str, Any]]] = {} diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 8c83ee80031..ec81ab3daf2 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -11,6 +11,8 @@ from libs.helper import to_timestamp from models import Account from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -37,6 +39,10 @@ from services.agent.errors import ( AgentVersionNotFoundError, InvalidComposerConfigError, ) +from services.agent.knowledge_datasets import ( + get_tenant_knowledge_dataset_rows, + list_missing_tenant_knowledge_dataset_ids, +) from services.agent.roster_service import AgentRosterService from services.app_service import AppService, CreateAppParams from services.entities.agent_entities import ( @@ -92,24 +98,62 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non class AgentComposerService: @classmethod - def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]: + def load_workflow_composer( + cls, *, tenant_id: str, app_id: str, node_id: str, snapshot_id: str | None = None + ) -> dict[str, Any]: workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) if not binding: + if snapshot_id: + raise AgentVersionNotFoundError() return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id) agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version = cls._workflow_composer_version( + tenant_id=tenant_id, + binding=binding, + agent=agent, + snapshot_id=snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + + @classmethod + def _workflow_composer_version( + cls, + *, + tenant_id: str, + binding: WorkflowAgentNodeBinding, + agent: Agent | None, + snapshot_id: str | None, + ) -> AgentConfigSnapshot | None: + if snapshot_id: + if agent is None: + raise AgentVersionNotFoundError() + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT: + if agent.scope != AgentScope.ROSTER: + raise AgentVersionNotFoundError() + elif binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or agent.app_id != binding.app_id + or agent.workflow_id != binding.workflow_id + or agent.workflow_node_id != binding.node_id + ): + raise AgentVersionNotFoundError() + else: + raise AgentVersionNotFoundError() + return cls._require_version(tenant_id=tenant_id, agent_id=agent.id, version_id=snapshot_id) + version_id = ( agent.active_config_snapshot_id if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT else binding.current_snapshot_id ) - version = cls._get_version_if_present( + return cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id if agent else None, version_id=version_id, ) - return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) @classmethod def save_workflow_composer( @@ -120,6 +164,7 @@ class AgentComposerService: _backfill_cli_tool_ids(payload.agent_soul) _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) @@ -259,31 +304,37 @@ class AgentComposerService: @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) + agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent) + + @classmethod + def load_agent_composer(cls, *, tenant_id: str, agent_id: str) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent) + + @classmethod + def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent) -> dict[str, Any]: + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - if not agent: - raise AgentNotFoundError() - version = cls._require_version( + version = cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id ) return { "variant": ComposerVariant.AGENT_APP.value, "agent": cls._serialize_agent(agent), "active_config_snapshot": cls._serialize_version(version), - "agent_soul": version.config_snapshot_dict, - "save_options": [ - ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, - ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, - ], + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, + "save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value], + "app_id": agent.app_id, + "backing_app_id": agent.backing_app_id or agent.app_id, + "hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), + "chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages", } @classmethod @@ -292,22 +343,17 @@ class AgentComposerService: ) -> dict[str, Any]: if payload.variant != ComposerVariant.AGENT_APP: raise ValueError("Agent App composer endpoint only accepts agent_app variant") - _backfill_cli_tool_ids(payload.agent_soul) - _validate_composer_payload_for_strategy(payload) + if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION: + raise InvalidComposerConfigError( + "Agent App composer only saves the normal draft. Use the publish endpoint to create a version." + ) if payload.agent_soul is None: raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) if not agent: agent = Agent( tenant_id=tenant_id, @@ -317,6 +363,7 @@ class AgentComposerService: scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, app_id=app_id, + backing_app_id=app_id, status=AgentStatus.ACTIVE, created_by=account_id, updated_by=account_id, @@ -327,35 +374,55 @@ class AgentComposerService: except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc + return cls._save_agent_composer_for_agent( + tenant_id=tenant_id, + agent=agent, + account_id=account_id, + payload=payload, + ) - if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: - version = cls._create_config_version( - tenant_id=tenant_id, - agent_id=agent.id, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, - version_note=payload.version_note, + @classmethod + def save_agent_composer( + cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.variant != ComposerVariant.AGENT_APP: + raise ValueError("Agent composer endpoint only accepts agent_app variant") + if payload.save_strategy != ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION: + raise InvalidComposerConfigError( + "Agent composer only saves the normal draft. Use the publish endpoint to create a version." ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - else: - current_snapshot = cls._require_version( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id - ) - version = cls._update_current_version( - current_snapshot=current_snapshot, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, - version_note=payload.version_note, - ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - agent.updated_by = account_id + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + _validate_composer_payload_for_strategy(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + return cls._save_agent_composer_for_agent( + tenant_id=tenant_id, + agent=agent, + account_id=account_id, + payload=payload, + ) + + @classmethod + def _save_agent_composer_for_agent( + cls, *, tenant_id: str, agent: Agent, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + agent.updated_by = account_id + agent.active_config_is_published = False db.session.commit() - state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id) + state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id) state["validation"] = cls.collect_validation_findings( tenant_id=tenant_id, payload=payload, @@ -363,6 +430,161 @@ class AgentComposerService: ) return state + @classmethod + def publish_agent_app_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP: + raise AgentNotFoundError() + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + ComposerConfigValidator.validate_publish_payload( + ComposerSavePayload( + variant=ComposerVariant.AGENT_APP, + agent_soul=agent_soul, + save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + version_note=version_note, + ) + ) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul) + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, + version_note=version_note, + previous_snapshot_id=agent.active_config_snapshot_id, + ) + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.active_config_is_published = True + agent.updated_by = account_id + draft.base_snapshot_id = version.id + draft.updated_by = account_id + db.session.commit() + return { + "result": "success", + "active_config_snapshot_id": version.id, + "active_config_snapshot": cls._serialize_version(version), + "draft": cls._serialize_draft(draft), + } + + @classmethod + def checkout_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + normal_draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None and not force: + return cls._serialize_build_draft_state(build_draft) + if build_draft is None: + build_draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + draft_owner_key=account_id, + created_by=account_id, + ) + db.session.add(build_draft) + build_draft.base_snapshot_id = normal_draft.base_snapshot_id + build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict) + build_draft.updated_by = account_id + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def save_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + ComposerConfigValidator.validate_draft_save_payload(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + build_draft = cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + normal_draft = cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict), + account_id_for_audit=account_id, + base_snapshot_id=build_draft.base_snapshot_id, + ) + agent.active_config_is_published = False + agent.updated_by = account_id + db.session.delete(build_draft) + db.session.commit() + return {"result": "success", "draft": cls._serialize_draft(normal_draft)} + + @classmethod + def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None: + db.session.delete(build_draft) + db.session.commit() + return {"result": "success"} + @classmethod def collect_validation_findings( cls, @@ -372,19 +594,15 @@ class AgentComposerService: agent_id: str | None = None, ) -> dict[str, Any]: """ENG-617 soft findings, with DB-backed dataset and drive mention checks.""" - from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions - - mentioned_ids: set[str] = set() - if payload.agent_soul is not None: - mentioned_ids |= { - mention.ref_id - for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt) - if mention.kind == MentionKind.KNOWLEDGE - } - existing_dataset_ids: set[str] | None = None - if mentioned_ids: - existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids))) - findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) + existing_knowledge_set_ids = ( + {knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets} + if payload.agent_soul is not None + else None + ) + findings = ComposerConfigValidator.collect_soft_findings( + payload, + existing_knowledge_set_ids=existing_knowledge_set_ids, + ) if agent_id and payload.agent_soul is not None: findings["warnings"].extend( cls._drive_mention_findings( @@ -395,6 +613,24 @@ class AgentComposerService: ) return findings + @classmethod + def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None: + """Hard-validate tenant-scoped knowledge set datasets before saving. + + DTO validators own set shape, duplicate set ids/names, and duplicate + dataset ids within one set. This service-level check owns database + existence and tenant ownership so invalid or cross-tenant datasets fail + before Agent Soul snapshots are persisted. + """ + if agent_soul is None: + return + missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul) + if missing_ids: + raise InvalidComposerConfigError( + "knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: " + + ", ".join(missing_ids) + ) + @classmethod def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None: """The Agent App's bound roster agent id, if any (validate-endpoint context).""" @@ -509,7 +745,7 @@ class AgentComposerService: soul_lists, soul_truncated = soul_candidates( agent_soul=agent_soul, - dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids), + dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids), workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id), ) truncated = truncated or soul_truncated @@ -529,14 +765,14 @@ class AgentComposerService: return response.model_dump(mode="json") @classmethod - def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]: + def get_agent_app_candidates(cls, *, tenant_id: str, agent_id: str, user_id: str) -> dict[str, Any]: """Slash-menu data source for the Agent App (Console) composer (ENG-615).""" from services.agent.composer_candidates import soul_candidates - agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id) + agent_soul = cls._load_agent_soul(tenant_id=tenant_id, agent_id=agent_id) soul_lists, truncated = soul_candidates( agent_soul=agent_soul, - dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids), + dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids), workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id), ) response = ComposerCandidatesResponse( @@ -568,24 +804,18 @@ class AgentComposerService: return cls._parse_soul_snapshot(version) @classmethod - def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + def _load_agent_soul(cls, *, tenant_id: str, agent_id: str) -> AgentSoulConfig | None: + agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id) if agent is None: return None - version = cls._get_version_if_present( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - return cls._parse_soul_snapshot(version) + return AgentSoulConfig.model_validate(draft.config_snapshot_dict) @staticmethod def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None: @@ -629,30 +859,6 @@ class AgentComposerService: variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id) return [(variable.name, variable.value_type.value) for variable in variables.variables] - @staticmethod - def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]: - """Tenant-scoped dataset lookup tolerating malformed ids. - - Mention ids come from user-editable prompt text; a non-UUID id can never - match a dataset row, so it is simply absent from the result (-> missing/ - placeholder semantics) instead of breaking the UUID-typed query. - """ - from uuid import UUID - - from services.dataset_service import DatasetService - - valid_ids: list[str] = [] - for dataset_id in dataset_ids: - try: - UUID(dataset_id) - except (ValueError, TypeError): - continue - valid_ids.append(dataset_id) - if not valid_ids: - return {} - rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id) - return {str(row.id): row for row in rows} - @staticmethod def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]: """Workspace Dify Plugin tools, same source as the tool selector. @@ -780,6 +986,7 @@ class AgentComposerService: raise ValueError("Inline workflow agent binding must point to a workflow-only agent") agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.active_config_is_published = True agent.updated_by = account_id binding.current_snapshot_id = version.id binding.updated_by = account_id @@ -878,6 +1085,7 @@ class AgentComposerService: agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.active_config_is_published = True agent.updated_by = account_id binding.current_snapshot_id = version.id if payload.node_job is not None: @@ -908,6 +1116,7 @@ class AgentComposerService: agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.active_config_is_published = True agent.updated_by = account_id binding.current_snapshot_id = version.id binding.updated_by = account_id @@ -1028,6 +1237,15 @@ class AgentComposerService: icon: str | None = None, icon_background: str | None = None, ) -> Agent: + backing_app = AgentRosterService(db.session).create_hidden_backing_app_for_workflow_agent( + tenant_id=tenant_id, + account_id=account_id, + name=name or f"Workflow Agent {node_id}", + description=description, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + ) agent = Agent( tenant_id=tenant_id, name=name or f"Workflow Agent {node_id}", @@ -1040,6 +1258,7 @@ class AgentComposerService: scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id=app_id, + backing_app_id=backing_app.id, workflow_id=workflow_id, workflow_node_id=node_id, status=AgentStatus.ACTIVE, @@ -1058,6 +1277,7 @@ class AgentComposerService: ) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.active_config_is_published = True return agent @classmethod @@ -1205,6 +1425,7 @@ class AgentComposerService: ) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.active_config_is_published = True agent.updated_by = account_id return agent @@ -1285,6 +1506,145 @@ class AgentComposerService: or 0 ) + 1 + @classmethod + def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None: + return db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + + @classmethod + def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent: + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + if agent is None: + raise AgentNotFoundError() + return agent + + @classmethod + def _get_agent_draft( + cls, + *, + tenant_id: str, + agent_id: str, + draft_type: AgentConfigDraftType, + account_id: str | None, + ) -> AgentConfigDraft | None: + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == draft_type, + ) + if draft_type == AgentConfigDraftType.DEBUG_BUILD: + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + + @classmethod + def _get_or_create_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + created_by: str | None, + ) -> AgentConfigDraft: + draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id, + ) + if draft is not None: + return draft + base_snapshot = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + agent_soul = ( + AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict) + if base_snapshot is not None + else AgentSoulConfig() + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None, + draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "", + base_snapshot_id=base_snapshot.id if base_snapshot else None, + config_snapshot=agent_soul, + created_by=created_by, + updated_by=created_by, + ) + db.session.add(draft) + db.session.flush() + return draft + + @classmethod + def _save_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + agent_soul: AgentSoulConfig, + account_id_for_audit: str, + base_snapshot_id: str | None = None, + ) -> AgentConfigDraft: + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=draft_type, + account_id=account_id, + created_by=account_id_for_audit, + ) + draft.config_snapshot = agent_soul + if base_snapshot_id is not None: + draft.base_snapshot_id = base_snapshot_id + elif draft.base_snapshot_id is None: + draft.base_snapshot_id = agent.active_config_snapshot_id + draft.updated_by = account_id_for_audit + if draft_type == AgentConfigDraftType.DRAFT and account_id is None: + agent.active_config_is_published = False + db.session.flush() + return draft + + @classmethod + def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None: + if draft is None: + return None + return { + "id": draft.id, + "agent_id": draft.agent_id, + "draft_type": draft.draft_type.value, + "account_id": draft.account_id, + "base_snapshot_id": draft.base_snapshot_id, + "created_by": draft.created_by, + "updated_by": draft.updated_by, + "created_at": to_timestamp(draft.created_at), + "updated_at": to_timestamp(draft.updated_at), + } + + @classmethod + def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]: + return { + "variant": ComposerVariant.AGENT_APP.value, + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, + } + @classmethod def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow: workflow = db.session.scalar( @@ -1471,6 +1831,12 @@ class AgentComposerService: "impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id) if version else None, + "app_id": binding.app_id, + "backing_app_id": agent.backing_app_id if agent else None, + "hidden_app_backed": bool(agent and agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), + "chat_endpoint": f"/console/api/agent/{agent.id}/chat-messages" if agent else None, + "workflow_id": binding.workflow_id, + "node_id": binding.node_id, } @classmethod @@ -1479,7 +1845,15 @@ class AgentComposerService: "id": agent.id, "name": agent.name, "description": agent.description, + "role": agent.role, + "icon_type": agent.icon_type, + "icon": agent.icon, + "icon_background": agent.icon_background, "scope": agent.scope.value, + "source": agent.source.value, + "app_id": agent.app_id, + "backing_app_id": agent.backing_app_id or agent.app_id, + "hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id), "status": agent.status.value, "active_config_snapshot_id": agent.active_config_snapshot_id, } diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 34b80b8a9d0..4e7d4ff3c63 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -148,15 +148,15 @@ class ComposerConfigValidator: cls, payload: ComposerSavePayload, *, - existing_dataset_ids: set[str] | None = None, + existing_knowledge_set_ids: set[str] | None = None, ) -> dict[str, Any]: """ENG-617 §5.3/§5.4 soft findings — never block save. ``warnings`` carries ``mention_target_missing`` / ``mention_malformed`` - entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge + entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set mentions with a placeholder name (0522 consensus) instead of dropping or - rejecting them. With ``existing_dataset_ids`` provided, configured-but- - deleted datasets surface as placeholders too. + rejecting them. With ``existing_knowledge_set_ids`` provided, mentions + that no longer exist in the current Agent Soul surface as placeholders too. """ warnings: list[dict[str, Any]] = [] placeholders: list[dict[str, str]] = [] @@ -188,7 +188,7 @@ class ComposerConfigValidator: resolved = resolver(mention) if mention.kind == MentionKind.KNOWLEDGE: dangling = resolved is None or ( - existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids + existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids ) if dangling: placeholders.append( diff --git a/api/services/agent/knowledge_datasets.py b/api/services/agent/knowledge_datasets.py new file mode 100644 index 00000000000..962c562ce15 --- /dev/null +++ b/api/services/agent/knowledge_datasets.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from models.agent_config_entities import AgentSoulConfig + + +def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: + """Return normalized unique knowledge dataset ids in config order. + + Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This + helper keeps composer, workflow validation, candidates, and runtime + diagnostics aligned on the same normalization rules: strip whitespace, drop + blanks, preserve first-seen order, and deduplicate. + """ + dataset_ids: list[str] = [] + seen: set[str] = set() + for knowledge_set in agent_soul.knowledge.sets: + for dataset in knowledge_set.datasets: + dataset_id = (dataset.id or "").strip() + if not dataset_id or dataset_id in seen: + continue + seen.add(dataset_id) + dataset_ids.append(dataset_id) + return dataset_ids + + +def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]: + """Return tenant-scoped dataset rows for normalized knowledge dataset ids. + + Knowledge ids come from user-editable config. Malformed ids can never match + a dataset row, so they are treated as missing instead of breaking the + UUID-typed dataset lookup. + """ + from services.dataset_service import DatasetService + + valid_ids: list[str] = [] + for dataset_id in dataset_ids: + try: + UUID(dataset_id) + except (TypeError, ValueError): + continue + valid_ids.append(dataset_id) + + if not valid_ids: + return {} + + rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id) + return {str(row.id): row for row in rows} + + +def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]: + """Return normalized knowledge dataset ids missing from the tenant scope.""" + if agent_soul is None: + return [] + + dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul) + if not dataset_ids: + return [] + + rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids) + return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows] diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 27bed49c53b..520c36043bd 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -1,23 +1,26 @@ -"""Prompt mention (slash-reference) serialization contract — ENG-616. +"""Prompt mention and workflow-marker parsing helpers for Agent surfaces. -Slash-menu insertions are stored inline in the plain-string prompt as tokens: +Slash-menu insertions are stored inline as mention tokens: [§:[: