From 1eafbf9763e94c94db1c26b07dbfaa8c984c50d5 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 24 Jun 2026 11:35:12 +0800 Subject: [PATCH] fix(agent): save workflow agents as console roster apps (#37848) --- .../apps/agent_app/runtime_request_builder.py | 2 +- .../nodes/agent_v2/runtime_request_builder.py | 2 +- api/services/agent/composer_service.py | 61 +++++--- api/services/agent/roster_service.py | 1 + .../agent_app/test_runtime_request_builder.py | 16 +++ .../agent_v2/test_runtime_request_builder.py | 2 +- .../services/agent/test_agent_services.py | 133 +++++++++++++++++- 7 files changed, 195 insertions(+), 22 deletions(-) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index fc1fcb0b168..9790f2fbca0 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -197,7 +197,7 @@ class AgentAppRuntimeRequestBuilder: def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: """Return the transport plugin id expected by plugin-daemon headers.""" if plugin_id.count("/") == 1: - return plugin_id + return plugin_id.split(":", 1)[0].split("@", 1)[0] if plugin_id: return ModelProviderID(plugin_id).plugin_id return ModelProviderID(model_provider).plugin_id diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index e3c2dcee839..e5a541ed350 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -265,7 +265,7 @@ class WorkflowAgentRuntimeRequestBuilder: def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str: """Return the transport plugin id expected by plugin-daemon headers.""" if plugin_id.count("/") == 1: - return plugin_id + return plugin_id.split(":", 1)[0].split("@", 1)[0] if plugin_id: return ModelProviderID(plugin_id).plugin_id return ModelProviderID(model_provider).plugin_id diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 230475d5b2a..8c83ee80031 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -8,6 +8,7 @@ from sqlalchemy.sql.elements import ColumnElement from extensions.ext_database import db from libs.helper import to_timestamp +from models import Account from models.agent import ( Agent, AgentConfigRevision, @@ -36,6 +37,8 @@ from services.agent.errors import ( AgentVersionNotFoundError, InvalidComposerConfigError, ) +from services.agent.roster_service import AgentRosterService +from services.app_service import AppService, CreateAppParams from services.entities.agent_entities import ( AgentSoulConfig, ComposerCandidatesResponse, @@ -992,6 +995,14 @@ class AgentComposerService: operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, ) + cls._copy_agent_drive_rows( + tenant_id=tenant_id, + source_agent_id=source_agent.id, + target_agent_id=roster_agent.id, + account_id=account_id, + agent_soul=agent_soul, + node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + ) binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT binding.agent_id = roster_agent.id binding.current_snapshot_id = roster_agent.active_config_snapshot_id @@ -1157,30 +1168,36 @@ class AgentComposerService: icon: str | None = None, icon_background: str | None = None, ) -> Agent: - agent = Agent( - tenant_id=tenant_id, - name=name, - description=description, - role=role, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - agent_kind=AgentKind.DIFY_AGENT, - scope=AgentScope.ROSTER, - source=AgentSource.WORKFLOW, - status=AgentStatus.ACTIVE, - created_by=account_id, - updated_by=account_id, - ) - db.session.add(agent) + account = cls._require_account(account_id=account_id) try: - db.session.flush() + app = AppService().create_app( + tenant_id, + CreateAppParams( + name=name, + description=description, + mode="agent", + agent_role=role, + icon_type=icon_type.value if isinstance(icon_type, AgentIconType) else icon_type, + icon=icon, + icon_background=icon_background, + ), + account, + ) except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc - version = cls._create_config_version( + + agent = AgentRosterService(db.session).get_app_backing_agent(tenant_id=tenant_id, app_id=app.id) + if agent is None: + raise AgentNotFoundError() + + current_snapshot = cls._require_version( tenant_id=tenant_id, agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, account_id=account_id, agent_soul=agent_soul, operation=operation, @@ -1188,6 +1205,7 @@ class AgentComposerService: ) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.updated_by = account_id return agent @classmethod @@ -1316,6 +1334,13 @@ class AgentComposerService: raise AgentNotFoundError() return agent + @classmethod + def _require_account(cls, *, account_id: str) -> Account: + account = db.session.get(Account, account_id) + if not account: + raise ValueError("Account not found") + return account + @classmethod def _get_agent_if_present(cls, *, tenant_id: str, agent_id: str | None) -> Agent | None: if not agent_id: diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 6a9d5818647..97d91b50770 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -837,6 +837,7 @@ class AgentRosterService: if agent.source == AgentSource.AGENT_APP: return { AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } return { diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 0d1483e1b79..4f292d90bb4 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -144,6 +144,22 @@ class TestAgentAppRuntimeRequestBuilder: assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_normalizes_marketplace_model_plugin_id(self): + soul = _soul_with_model() + soul.model.plugin_id = ( + "langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468" + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + llm = next(layer for layer in result.request.composition.layers if layer.name == "llm") + assert llm.config.plugin_id == "langgenius/openai" + assert llm.config.model_provider == "openai" + def test_build_maps_agent_soul_knowledge_to_knowledge_layer(self): soul = AgentSoulConfig.model_validate( { diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 78e49769159..ffa7ccdbca7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -189,7 +189,7 @@ def test_normalizes_langgenius_model_provider_for_agent_backend_transport(): context.snapshot.config_snapshot = AgentSoulConfig( prompt={"system_prompt": "You are careful."}, model=AgentSoulModelConfig( - plugin_id="langgenius/openai/openai", + plugin_id="langgenius/openai:0.4.2@21195ee1321849e0a7d4b3f6b2fd8c2be23ea6c7182e1b444ecc4c1711b52468", model_provider="langgenius/openai/openai", model="gpt-test", ), 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 1d0d5ed42c6..1bac183c39f 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from types import SimpleNamespace import pytest +from sqlalchemy.exc import IntegrityError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.agent import ( @@ -32,7 +33,12 @@ from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError +from services.agent.errors import ( + AgentNameConflictError, + AgentNotFoundError, + AgentVersionConflictError, + InvalidComposerConfigError, +) from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.app_service import AppListParams, AppService @@ -427,6 +433,7 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk icon_background="#FFFFFF", ) create_roster_calls = [] + copy_drive_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) def fake_create_roster_agent_for_composer(**kwargs): @@ -438,6 +445,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk "_create_roster_agent_for_composer", fake_create_roster_agent_for_composer, ) + monkeypatch.setattr( + AgentComposerService, + "_copy_agent_drive_rows", + lambda **kwargs: copy_drive_calls.append(kwargs), + ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -533,6 +545,16 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk assert create_roster_calls[1]["role"] == "Copied role" assert create_roster_calls[1]["icon"] == "copied" assert create_roster_calls[1]["icon_background"] == "#E0F2FE" + assert copy_drive_calls == [ + { + "tenant_id": "tenant-1", + "source_agent_id": "roster-agent-1", + "target_agent_id": "roster-agent-1", + "account_id": "account-1", + "agent_soul": payload.agent_soul, + "node_job": payload.node_job, + } + ] def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): @@ -1173,6 +1195,39 @@ def test_drive_copy_scopes_include_declared_output_benchmark_files(): def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) + created_apps = [] + backing_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Ready Agent", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="app-agent-1", + active_config_snapshot_id="empty-version-1", + ) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + created_apps.append((tenant_id, params, account)) + return SimpleNamespace(id="app-agent-1") + + class FakeAgentRosterService: + def __init__(self, session): + self.session = session + + def get_app_backing_agent(self, *, tenant_id, app_id): + assert tenant_id == "tenant-1" + assert app_id == "app-agent-1" + return backing_agent + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + monkeypatch.setattr( + AgentComposerService, + "_require_version", + lambda **kwargs: SimpleNamespace(id="empty-version-1", tenant_id="tenant-1", agent_id="roster-agent-1"), + ) monkeypatch.setattr( AgentComposerService, "_create_config_version", @@ -1200,6 +1255,81 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytes assert workflow_agent.active_config_has_model is True assert roster_agent.active_config_snapshot_id == "version-with-model" assert roster_agent.active_config_has_model is True + assert roster_agent.source == AgentSource.AGENT_APP + assert roster_agent.app_id == "app-agent-1" + created_tenant_id, created_params, created_account = created_apps[0] + assert created_tenant_id == "tenant-1" + assert created_params.mode == "agent" + assert created_params.name == "Ready Agent" + assert created_account.id == "account-1" + + +def test_composer_require_account(monkeypatch: pytest.MonkeyPatch): + account = SimpleNamespace(id="account-1") + monkeypatch.setattr(composer_service.db, "session", SimpleNamespace(get=lambda model, account_id: account)) + + assert AgentComposerService._require_account(account_id="account-1") is account + + +def test_composer_require_account_raises_when_missing(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(composer_service.db, "session", SimpleNamespace(get=lambda model, account_id: None)) + + with pytest.raises(ValueError, match="Account not found"): + AgentComposerService._require_account(account_id="missing-account") + + +def test_composer_create_roster_agent_rolls_back_name_conflict(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + raise IntegrityError("insert apps", params, Exception("duplicate")) + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + + with pytest.raises(AgentNameConflictError): + AgentComposerService._create_roster_agent_for_composer( + tenant_id="tenant-1", + account_id="account-1", + name="Duplicate Agent", + agent_soul=_agent_soul_with_model(), + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) + + assert fake_session.rollbacks == 1 + + +def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + + class FakeAppService: + def create_app(self, tenant_id, params, account): + return SimpleNamespace(id="app-agent-1") + + class FakeAgentRosterService: + def __init__(self, session): + self.session = session + + def get_app_backing_agent(self, *, tenant_id, app_id): + return None + + monkeypatch.setattr(composer_service, "AppService", FakeAppService) + monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) + monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) + + with pytest.raises(AgentNotFoundError): + AgentComposerService._create_roster_agent_for_composer( + tenant_id="tenant-1", + account_id="account-1", + name="Missing Backing Agent", + agent_soul=_agent_soul_with_model(), + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch): @@ -1773,6 +1903,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): assert agent_app_operations == { AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations