fix(agent-v2): recover inline binding during draft refresh

This commit is contained in:
yyh 2026-06-24 19:17:13 +08:00
parent 47de929a00
commit b72a51cf65
No known key found for this signature in database
6 changed files with 185 additions and 13 deletions

View File

@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator
from models import ToolFile, UploadFile
from models.agent import (
Agent,
AgentConfigSnapshot,
@ -19,7 +20,6 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
from models import ToolFile, UploadFile
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.soul_files_service import AgentSoulFilesService
@ -41,10 +41,10 @@ class WorkflowAgentPublishService:
@classmethod
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
"""Return draft graph with persisted Agent node job config projected into node data.
"""Return draft graph with persisted Agent binding fields projected into node data.
Workflow draft graph is the front-end's editing source of truth, while
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
runtime/publish reads WorkflowAgentNodeBinding. This
response-only projection keeps reads aligned without writing binding
details back into the stored graph JSON.
"""
@ -66,6 +66,18 @@ class WorkflowAgentPublishService:
node_data = agent_nodes.get(binding.node_id)
if not isinstance(node_data, dict):
continue
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
is_pending_inline_graph_binding = (
isinstance(graph_binding, Mapping)
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
)
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
node_data[cls._AGENT_BINDING_KEY] = {
"binding_type": binding.binding_type.value,
"agent_id": binding.agent_id,
"current_snapshot_id": binding.current_snapshot_id,
}
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
if node_job.workflow_prompt is not None:
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
@ -273,6 +285,11 @@ class WorkflowAgentPublishService:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
if (
binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
and (not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id"))
):
continue
cls._sync_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,

View File

@ -2878,6 +2878,11 @@ class TestWorkflowAgentDraftBindingSync:
)
node_data = graph["nodes"][0]["data"]
assert node_data["agent_binding"] == {
"binding_type": "roster_agent",
"agent_id": "agent-1",
"current_snapshot_id": "snapshot-1",
}
assert node_data["agent_task"] == "Summarize the upstream result."
assert node_data["agent_declared_outputs"][0]["name"] == "summary"
assert node_data["agent_declared_outputs"][0]["type"] == "string"
@ -2887,6 +2892,103 @@ class TestWorkflowAgentDraftBindingSync:
assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city"
assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"]
def test_projects_inline_binding_over_pending_inline_graph_response(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
],
"edges": [],
}
),
)
binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="inline-agent-1",
current_snapshot_id="inline-snapshot-1",
)
session = FakeSession(scalars=[[binding]])
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=session,
draft_workflow=workflow,
)
assert graph["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "inline-snapshot-1",
}
assert workflow.graph_dict["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
}
def test_keeps_pending_inline_graph_response_over_existing_roster_binding(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
],
"edges": [],
}
),
)
binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
)
session = FakeSession(scalars=[[binding]])
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=session,
draft_workflow=workflow,
)
assert graph["nodes"][0]["data"]["agent_binding"] == {
"binding_type": "inline_agent",
}
def test_creates_roster_binding_from_agent_node_graph(self):
workflow = Workflow(
id="workflow-1",
@ -3014,6 +3116,52 @@ class TestWorkflowAgentDraftBindingSync:
workflow_prompt="Use the current node context.",
).model_dump(mode="json")
def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
},
},
}
]
}
),
)
existing_binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
)
session = FakeSession(scalars=[[existing_binding]])
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
assert session.deleted == []
assert session.added == []
assert session.flushes == 1
def test_rejects_inline_binding_for_agent_owned_by_another_node(self):
workflow = Workflow(
id="workflow-1",
@ -3093,7 +3241,7 @@ class TestWorkflowAgentDraftBindingSync:
account_id="account-1",
)
def test_rejects_inline_binding_without_current_snapshot_id(self):
def test_treats_partial_inline_binding_as_pending_draft_state(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
@ -3118,12 +3266,17 @@ class TestWorkflowAgentDraftBindingSync:
),
)
with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=FakeSession(scalars=[[]]),
draft_workflow=workflow,
account_id="account-1",
)
session = FakeSession(scalars=[[]])
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
assert session.added == []
assert session.deleted == []
assert session.flushes == 1
def test_rejects_inline_binding_with_missing_snapshot(self):
workflow = Workflow(

View File

@ -320,6 +320,9 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
desc: '',
agent_node_kind: 'dify_agent',
version: '2',
agent_binding: {
binding_type: 'inline_agent',
},
selected: true,
},
},

View File

@ -71,9 +71,6 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
const features = featuresStore!.getState().features
const producedNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data))
delete node.data.agent_binding
Object.keys(node.data).forEach((key) => {
if (key.startsWith('_'))
delete node.data[key]

View File

@ -96,6 +96,7 @@ const CandidateNodeMain: FC<Props> = ({
if (shouldCreateInlineAgentBinding) {
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
handleSyncWorkflowDraft(true, true)
createInlineAgentBinding(candidateNode.id, {
onError: () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()

View File

@ -194,6 +194,7 @@ export const useNodesInteractions = () => {
onError?: () => void
}) => {
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
handleSyncWorkflowDraft(true, true)
createInlineAgentBinding(nodeId, {
onError: () => {
options?.onError?.()