diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 16ab3627929..af6be0abfa6 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -830,6 +830,16 @@ class AgentComposerService: ) -> WorkflowAgentNodeBinding: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: + if cls._is_start_from_scratch_request(binding=binding, payload=payload): + return cls._switch_roster_binding_to_inline_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + account_id=account_id, + binding=binding, + payload=payload, + ) binding.node_job_config = node_job if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: current_snapshot = cls._require_version( @@ -880,6 +890,46 @@ class AgentComposerService: db.session.flush() return binding + @classmethod + def _is_start_from_scratch_request(cls, *, binding: WorkflowAgentNodeBinding, payload: ComposerSavePayload) -> bool: + return ( + binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + and payload.binding is not None + and payload.binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT.value + ) + + @classmethod + def _switch_roster_binding_to_inline_agent( + cls, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + if payload.binding and (payload.binding.agent_id or payload.binding.current_snapshot_id): + raise ValueError("Start from Scratch must not provide an existing inline agent binding.") + + agent_soul = payload.agent_soul or AgentSoulConfig() + agent = cls._create_workflow_only_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + account_id=account_id, + agent_soul=agent_soul, + ) + binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + binding.agent_id = agent.id + binding.current_snapshot_id = agent.active_config_snapshot_id + binding.node_job_config = payload.node_job or binding.node_job_config + binding.updated_by = account_id + db.session.flush() + return binding + @classmethod def _save_to_current_version( cls, 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 e6ca3bb7ca0..bad6e0f3151 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -533,6 +533,100 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch assert inline_agent.updated_by == "account-1" +def test_node_job_only_switches_roster_binding_to_inline_agent(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + created_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") + captured: dict[str, object] = {} + + def fake_create_workflow_only_agent(**kwargs): + captured.update(kwargs) + return created_agent + + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) + existing_node_job = WorkflowNodeJobConfig(workflow_prompt="keep the existing task") + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=existing_node_job, + created_by="account-1", + updated_by="account-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "binding": {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value}, + "agent_soul": {"prompt": {"system_prompt": "start from scratch"}}, + } + ) + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + assert updated_binding is binding + assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + assert binding.agent_id == "inline-agent-1" + assert binding.current_snapshot_id == "inline-version-1" + assert binding.node_job_config is existing_node_job + assert binding.updated_by == "account-1" + assert captured["tenant_id"] == "tenant-1" + assert captured["app_id"] == "app-1" + assert captured["workflow_id"] == "workflow-1" + assert captured["node_id"] == "node-1" + assert captured["account_id"] == "account-1" + assert captured["agent_soul"].prompt.system_prompt == "start from scratch" + assert fake_session.flushes == 1 + + +def test_node_job_only_rejects_start_from_scratch_with_existing_inline_binding_id(): + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=WorkflowNodeJobConfig(), + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "binding": { + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + "agent_id": "existing-inline-agent", + }, + } + ) + + with pytest.raises(ValueError, match="Start from Scratch"): + AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session)