feat: add workflow_version to workflow_agent_node_bindings (#36603)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123
2026-05-25 14:26:19 +08:00
committed by GitHub
parent eb41c9b769
commit 5f7eb7bde9
9 changed files with 121 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,4 +47,5 @@ class DifyOutputLayerConfig(LayerConfig):
raise ValueError("Schema must declare an object output.")
return value
__all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"]

View File

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

View File

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