diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 93c8bf22448..1cc5d63eef3 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -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, diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index d5768f191c0..0dfa00e205a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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( diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index bb8ce0fc2e5..06428dcc2d3 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -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, }, }, diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 5aca997b78a..47328410f10 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -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] diff --git a/web/app/components/workflow/candidate-node-main.tsx b/web/app/components/workflow/candidate-node-main.tsx index 0520af47ace..968fefc9721 100644 --- a/web/app/components/workflow/candidate-node-main.tsx +++ b/web/app/components/workflow/candidate-node-main.tsx @@ -96,6 +96,7 @@ const CandidateNodeMain: FC = ({ if (shouldCreateInlineAgentBinding) { workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id) + handleSyncWorkflowDraft(true, true) createInlineAgentBinding(candidateNode.id, { onError: () => { const { nodes, setNodes } = collaborativeWorkflow.getState() diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index afd0bff2acc..f06a0288edf 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -194,6 +194,7 @@ export const useNodesInteractions = () => { onError?: () => void }) => { workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId) + handleSyncWorkflowDraft(true, true) createInlineAgentBinding(nodeId, { onError: () => { options?.onError?.()