diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 3f544e9438..16ab362792 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -115,7 +115,15 @@ class AgentComposerService: and binding is not None and binding.agent_id and payload.save_strategy - in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION) + in ( + ComposerSaveStrategy.NODE_JOB_ONLY, + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ) + and ( + payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + ) ): cls._require_drive_refs_resolved( tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul @@ -823,6 +831,26 @@ class AgentComposerService: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: 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( + tenant_id=tenant_id, + agent_id=binding.agent_id, + version_id=binding.current_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + if agent.scope != AgentScope.WORKFLOW_ONLY: + raise ValueError("Inline workflow agent binding must point to a workflow-only agent") + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.updated_by = account_id + binding.current_snapshot_id = version.id binding.updated_by = account_id return binding diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 47c255aae2..8554b5c1ab 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -18,6 +18,7 @@ from services.agent.prompt_mentions import ( from services.entities.agent_entities import ( AgentSoulConfig, ComposerSavePayload, + ComposerSaveStrategy, ComposerVariant, WorkflowNodeJobConfig, ) @@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = ( class ComposerConfigValidator: @classmethod def validate_save_payload(cls, payload: ComposerSavePayload) -> None: - if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None: + if ( + payload.variant == ComposerVariant.WORKFLOW + and payload.soul_lock.locked + and payload.agent_soul is not None + and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + ): raise AgentSoulLockedError() if payload.agent_soul is not None: diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index dbdf37a905..089a5c74f3 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes(): ComposerConfigValidator.validate_save_payload(payload) +def test_locked_workflow_node_job_only_allows_inline_soul_payload(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "soul_lock": {"locked": True}, + "agent_soul": {"prompt": {"system_prompt": "changed"}}, + } + ) + + ComposerConfigValidator.validate_save_payload(payload) + + def test_agent_app_soul_allows_app_features_and_variables(): payload = ComposerSavePayload.model_validate( { 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 5d6ba1d0c9..fc85719883 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -459,6 +459,125 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): assert new_version_binding.current_snapshot_id == "new-version-1" +def test_node_job_only_updates_inline_agent_soul(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + inline_agent = SimpleNamespace( + id="inline-agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="inline-version-1", + active_config_has_model=False, + updated_by=None, + ) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot( + id="inline-version-2", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=2, + ) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "new"}, + }, + "node_job": {"workflow_prompt": "use prior output"}, + } + ) + + 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.current_snapshot_id == "inline-version-2" + assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output" + assert updated_binding.updated_by == "account-1" + assert inline_agent.active_config_snapshot_id == "inline-version-2" + assert inline_agent.active_config_has_model is True + assert inline_agent.updated_by == "account-1" + + +def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2) + roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": {"prompt": {"system_prompt": "new"}}, + } + ) + + with pytest.raises(ValueError, match="workflow-only agent"): + 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_composer_create_agents_syncs_active_config_has_model(monkeypatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -2175,6 +2294,117 @@ def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies( assert guarded["agent_id"] == "agent-1" +def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["tenant_id"] = tenant_id + guarded["agent_id"] = agent_id + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} + + +def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + + def fail_guard(cls, *, tenant_id, agent_id, agent_soul): + raise AssertionError("roster node-job-only saves must not validate agent drive refs") + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + + def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch): soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}} _, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)