fix(agent): save workflow agents as console roster apps (#37848)

This commit is contained in:
zyssyz123 2026-06-24 11:35:12 +08:00 committed by GitHub
parent d0b2239c60
commit 1eafbf9763
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 195 additions and 22 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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 {

View File

@ -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(
{

View File

@ -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",
),

View File

@ -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