From 5f7eb7bde9c5b80bc358f3ee0562c2eaeafc47d0 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 25 May 2026 14:26:19 +0800 Subject: [PATCH] feat: add workflow_version to workflow_agent_node_bindings (#36603) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/request_builder.py | 9 ++- ...add_workflow_version_to_workflow_agent_.py | 65 +++++++++++++++++++ api/models/agent.py | 14 +++- api/services/agent/composer_service.py | 11 ++++ .../agent/workflow_publish_service.py | 2 + .../layers/pydantic_ai/history.py | 4 +- .../src/dify_agent/layers/output/configs.py | 1 + .../dify_agent/layers/output/output_layer.py | 2 + .../local/dify_agent/runtime/test_runner.py | 24 +++++-- 9 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index a886fe849f..392eee641b 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -55,10 +55,14 @@ class AgentBackendModelConfig(BaseModel): class AgentBackendOutputConfig(BaseModel): - """API-side structured output declaration for the conventional output layer.""" + """API-side structured output declaration for the conventional output layer. + + The structured-output tool name is fixed to ``final_output`` inside + ``dify_agent.layers.output`` so callers only control the JSON Schema plus + optional description/strictness metadata. + """ json_schema: dict[str, JsonValue] - name: str = "final_result" description: str | None = None strict: bool | None = None @@ -153,7 +157,6 @@ class AgentBackendRunRequestBuilder: metadata=run_input.metadata, config=DifyOutputLayerConfig( json_schema=run_input.output.json_schema, - name=run_input.output.name, description=run_input.output.description, strict=run_input.output.strict, ), 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 new file mode 100644 index 0000000000..7348e19b3c --- /dev/null +++ b/api/migrations/versions/2026_05_25_1143-97e2e1a644e8_add_workflow_version_to_workflow_agent_.py @@ -0,0 +1,65 @@ +"""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 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '97e2e1a644e8' +down_revision = 'f8b6b7e9c421' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('workflow_agent_node_bindings', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + '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' + ) + batch_op.create_unique_constraint( + 'workflow_agent_node_binding_node_version_unique', + ['tenant_id', 'workflow_id', 'workflow_version', 'node_id'], + ) + batch_op.create_index( + 'workflow_agent_node_binding_workflow_version_idx', + ['tenant_id', 'workflow_id', 'workflow_version'], + unique=False, + ) + + +def downgrade(): + with op.batch_alter_table('workflow_agent_node_bindings', schema=None) as batch_op: + batch_op.drop_index('workflow_agent_node_binding_workflow_version_idx') + batch_op.drop_constraint( + 'workflow_agent_node_binding_node_version_unique', type_='unique' + ) + batch_op.create_unique_constraint( + batch_op.f('workflow_agent_node_binding_node_unique'), + ['tenant_id', 'workflow_id', 'node_id'], + postgresql_nulls_not_distinct=False, + ) + batch_op.drop_column('workflow_version') diff --git a/api/models/agent.py b/api/models/agent.py index 15ad423bab..a8f048eef5 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -231,17 +231,29 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base): UniqueConstraint( "tenant_id", "workflow_id", + "workflow_version", "node_id", - name="workflow_agent_node_binding_node_unique", + name="workflow_agent_node_binding_node_version_unique", ), Index("workflow_agent_node_binding_agent_idx", "tenant_id", "agent_id"), Index("workflow_agent_node_binding_current_snapshot_idx", "tenant_id", "current_snapshot_id"), Index("workflow_agent_node_binding_app_idx", "tenant_id", "app_id"), + Index( + "workflow_agent_node_binding_workflow_version_idx", + "tenant_id", + "workflow_id", + "workflow_version", + ), ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + # Tracks which workflow version (draft or a published version string) this + # binding belongs to. Mirrors ``Workflow.version`` and lets us keep separate + # rows for the draft workflow and each published copy under the same + # workflow_id, restoring the stage 1 §5.3 unique key. + workflow_version: Mapped[str] = mapped_column(String(255), nullable=False) node_id: Mapped[str] = mapped_column(String(255), nullable=False) binding_type: Mapped[WorkflowAgentBindingType] = mapped_column( EnumText(WorkflowAgentBindingType, length=32), nullable=False diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index c1b396cb82..27f3771408 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -28,6 +28,10 @@ from services.entities.agent_entities import ( WorkflowNodeJobConfig, ) +# WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row. +# Mirrors Workflow.version when it is "draft" (see models/workflow.py). +_DRAFT_WORKFLOW_VERSION = "draft" + class AgentComposerService: @classmethod @@ -284,6 +288,7 @@ class AgentComposerService: tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, + workflow_version=_DRAFT_WORKFLOW_VERSION, node_id=node_id, binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id=agent.id, @@ -387,6 +392,7 @@ class AgentComposerService: tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, + workflow_version=_DRAFT_WORKFLOW_VERSION, node_id=node_id, created_by=account_id, ) @@ -606,11 +612,16 @@ class AgentComposerService: def _get_workflow_binding( cls, *, tenant_id: str, workflow_id: str, node_id: str ) -> WorkflowAgentNodeBinding | None: + # Composer always operates against the draft workflow row, so this lookup + # is scoped to ``workflow_version="draft"``. Published bindings are + # materialized by WorkflowAgentPublishService.copy_agent_node_bindings_to_published + # and are not edited through the Composer. return db.session.scalar( select(WorkflowAgentNodeBinding) .where( WorkflowAgentNodeBinding.tenant_id == tenant_id, WorkflowAgentNodeBinding.workflow_id == workflow_id, + WorkflowAgentNodeBinding.workflow_version == _DRAFT_WORKFLOW_VERSION, WorkflowAgentNodeBinding.node_id == node_id, ) .limit(1) diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 06985dc3fa..af3e511229 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -39,6 +39,7 @@ class WorkflowAgentPublishService: WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == draft_workflow.version, WorkflowAgentNodeBinding.node_id.in_(node_ids), ) ).all() @@ -48,6 +49,7 @@ class WorkflowAgentPublishService: tenant_id=binding.tenant_id, app_id=binding.app_id, workflow_id=published_workflow.id, + workflow_version=published_workflow.version, node_id=binding.node_id, binding_type=binding.binding_type, agent_id=binding.agent_id, diff --git a/dify-agent/src/agenton_collections/layers/pydantic_ai/history.py b/dify-agent/src/agenton_collections/layers/pydantic_ai/history.py index 80aa3076bb..ac91e4006a 100644 --- a/dify-agent/src/agenton_collections/layers/pydantic_ai/history.py +++ b/dify-agent/src/agenton_collections/layers/pydantic_ai/history.py @@ -31,9 +31,7 @@ class PydanticAIHistoryRuntimeState(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True) -class PydanticAIHistoryLayer( - PydanticAILayer[NoLayerDeps, object, EmptyLayerConfig, PydanticAIHistoryRuntimeState] -): +class PydanticAIHistoryLayer(PydanticAILayer[NoLayerDeps, object, EmptyLayerConfig, PydanticAIHistoryRuntimeState]): """State-only layer that stores pydantic-ai message history. The mutable history lives only in ``runtime_state.messages``. Helper methods diff --git a/dify-agent/src/dify_agent/layers/output/configs.py b/dify-agent/src/dify_agent/layers/output/configs.py index 719d8a38c1..0d73ef5411 100644 --- a/dify-agent/src/dify_agent/layers/output/configs.py +++ b/dify-agent/src/dify_agent/layers/output/configs.py @@ -47,4 +47,5 @@ class DifyOutputLayerConfig(LayerConfig): raise ValueError("Schema must declare an object output.") return value + __all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"] diff --git a/dify-agent/src/dify_agent/layers/output/output_layer.py b/dify-agent/src/dify_agent/layers/output/output_layer.py index 4667fdd308..206f0cceb2 100644 --- a/dify-agent/src/dify_agent/layers/output/output_layer.py +++ b/dify-agent/src/dify_agent/layers/output/output_layer.py @@ -187,6 +187,8 @@ def _build_exposed_json_schema( if description is not None: exposed_schema["description"] = description return exposed_schema + + def _reject_non_local_refs(schema: JsonValue) -> None: """Reject references that would require external fetching or non-local state. diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index 361b2b7cfa..ddf860beb6 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -5,7 +5,15 @@ from typing import Any import httpx import pytest from pydantic_ai.exceptions import UnexpectedModelBehavior -from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, SystemPromptPart, TextPart, ToolCallPart, UserPromptPart +from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelResponse, + SystemPromptPart, + TextPart, + ToolCallPart, + UserPromptPart, +) from pydantic_ai.models import ModelRequestParameters from pydantic_ai.models.test import TestModel from pydantic_ai.settings import ModelSettings @@ -163,11 +171,15 @@ def _history_session_snapshot( runtime_state=PydanticAIHistoryRuntimeState(messages=messages).model_dump(mode="json"), ), LayerSessionSnapshot(name="plugin", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), - LayerSessionSnapshot(name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} + ), ] if include_output: layers.append( - LayerSessionSnapshot(name=DIFY_AGENT_OUTPUT_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}) + LayerSessionSnapshot( + name=DIFY_AGENT_OUTPUT_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} + ) ) return CompositorSessionSnapshot(layers=layers) @@ -257,7 +269,11 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk assert request_parts[1].content == "current user" terminal = sink.events["run-no-history"][-1] assert isinstance(terminal, RunSucceededEvent) - assert [layer.name for layer in terminal.data.session_snapshot.layers] == ["prompt", "plugin", DIFY_AGENT_MODEL_LAYER_ID] + assert [layer.name for layer in terminal.data.session_snapshot.layers] == [ + "prompt", + "plugin", + DIFY_AGENT_MODEL_LAYER_ID, + ] def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_only_new_messages(