mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 10:00:43 -04:00
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:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,4 +47,5 @@ class DifyOutputLayerConfig(LayerConfig):
|
||||
raise ValueError("Schema must declare an object output.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user