From f1327d759f1983b8c9e44caf08195a0ae24ec14c Mon Sep 17 00:00:00 2001 From: Bear0117 <101653449+Bear0117@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:30:43 +0800 Subject: [PATCH 1/2] refactor(api): isolate console Agent provider discovery --- api/.importlinter | 32 ++++++++++ .../console/workspace/agent_providers.py | 37 +++++------ api/extensions/ext_application_services.py | 4 ++ api/services/agent_provider_gateway.py | 17 ++++++ api/services/agent_provider_service.py | 27 ++++++++ .../console/workspace/test_agent_providers.py | 61 +++++++++---------- .../test_ext_application_services.py | 15 +++++ .../services/test_agent_provider_service.py | 39 ++++++++++++ 8 files changed, 178 insertions(+), 54 deletions(-) create mode 100644 api/services/agent_provider_gateway.py create mode 100644 api/services/agent_provider_service.py create mode 100644 api/tests/unit_tests/services/test_agent_provider_service.py diff --git a/api/.importlinter b/api/.importlinter index 5f9ab46613b7d9..8f28e25abc499f 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -1181,3 +1181,35 @@ forbidden_modules = services.account.adapters sqlalchemy werkzeug + +[importlinter:contract:console-agent-provider-controller-boundary] +name = Console Agent provider controller reaches the runtime through application services +type = forbidden +source_modules = + controllers.console.workspace.agent_providers +forbidden_modules = + models + repositories + extensions.ext_database + services.agent_service + core.plugin.impl + sqlalchemy +allow_indirect_imports = True + +[importlinter:contract:agent-provider-application-boundary] +name = Agent provider application operations do not depend on runtime adapters +type = forbidden +source_modules = + services.agent_provider_service +forbidden_modules = + controllers + extensions + flask + models + repositories + services.agent_provider_gateway + services.agent_service + core.plugin.impl + sqlalchemy + werkzeug +allow_indirect_imports = True diff --git a/api/controllers/console/workspace/agent_providers.py b/api/controllers/console/workspace/agent_providers.py index 77bcbac19fb493..665b9276a00c5a 100644 --- a/api/controllers/console/workspace/agent_providers.py +++ b/api/controllers/console/workspace/agent_providers.py @@ -3,18 +3,13 @@ from controllers.common.schema import register_response_schema_models from controllers.console import console_ns -from controllers.console.wraps import ( - account_initialization_required, - setup_required, - with_current_tenant_id, - with_current_user, -) +from controllers.console.flask_admission import console_account_admission from core.plugin.entities.plugin_daemon import PluginAgentProviderEntity +from extensions.ext_application_services import application_services from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import login_required -from models import Account -from services.agent_service import AgentService +from machinery.context import RequestContext +from services.agent_provider_service import AgentProviderUnavailableError class AgentProviderResponse(PluginAgentProviderEntity, ResponseModel): @@ -37,13 +32,9 @@ class AgentProviderListApi(Resource): "Success", console_ns.models[AgentProviderListResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account): - providers = AgentService.list_agent_providers(current_user.id, current_tenant_id) + @console_account_admission() + def get(self, request_context: RequestContext): + providers = application_services().agent_providers.list_providers(request_context) return AgentProviderListResponse.model_validate(providers, from_attributes=True).model_dump(mode="json") @@ -57,11 +48,11 @@ class AgentProviderApi(Resource): "Success", console_ns.models[AgentProviderResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account, provider_name: str): - provider = AgentService.get_agent_provider(current_user.id, current_tenant_id, provider_name) + @console_account_admission() + def get(self, request_context: RequestContext, provider_name: str): + try: + provider = application_services().agent_providers.get_provider(request_context, provider_name) + except AgentProviderUnavailableError as error: + # Preserve the legacy ValueError -> 400 invalid_param contract. + raise ValueError(str(error)) from error return dump_response(AgentProviderResponse, provider) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index d4105d8db27a61..158d46f63a126b 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -99,6 +99,8 @@ from services.account.service import AccountSetupProvisioner from services.account_password_hasher import DefaultAccountPasswordHasher from services.agent.roster_package_exporter import RosterAgentPackageExporter +from services.agent_provider_gateway import PluginAgentProviderGateway +from services.agent_provider_service import AgentProviderService from services.api_based_extension_adapters import APIBasedExtensionPingProbe, WorkspaceTokenCipher from services.api_based_extension_application_service import APIBasedExtensionApplicationService from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService @@ -256,6 +258,7 @@ class AppScopedEndUserServices: @dataclass(frozen=True, slots=True) class ApplicationServices: + agent_providers: AgentProviderService rbac: RBACServices agent_apps: AgentAppServices advanced_prompt_templates: AdvancedPromptTemplateService @@ -523,6 +526,7 @@ def build_application_services( invitation_tokens=invitation_tokens, ) return ApplicationServices( + agent_providers=AgentProviderService(providers=PluginAgentProviderGateway()), rbac=rbac, accounts=account_services, apps=apps, diff --git a/api/services/agent_provider_gateway.py b/api/services/agent_provider_gateway.py new file mode 100644 index 00000000000000..f8fda21bc394a5 --- /dev/null +++ b/api/services/agent_provider_gateway.py @@ -0,0 +1,17 @@ +"""Plugin-runtime adapter for Console Agent provider discovery.""" + +from core.plugin.entities.plugin_daemon import PluginAgentProviderEntity +from core.plugin.impl.agent import PluginAgentClient +from core.plugin.impl.exc import PluginDaemonClientSideError +from services.agent_provider_service import AgentProviderUnavailableError + + +class PluginAgentProviderGateway: + def list_providers(self, tenant_id: str) -> list[PluginAgentProviderEntity]: + return PluginAgentClient().fetch_agent_strategy_providers(tenant_id) + + def get_provider(self, tenant_id: str, provider_name: str) -> PluginAgentProviderEntity: + try: + return PluginAgentClient().fetch_agent_strategy_provider(tenant_id, provider_name) + except PluginDaemonClientSideError as error: + raise AgentProviderUnavailableError(str(error)) from error diff --git a/api/services/agent_provider_service.py b/api/services/agent_provider_service.py new file mode 100644 index 00000000000000..7603dd34eb18fe --- /dev/null +++ b/api/services/agent_provider_service.py @@ -0,0 +1,27 @@ +"""Console access to installed Agent strategy providers.""" + +from typing import Protocol + +from core.plugin.entities.plugin_daemon import PluginAgentProviderEntity +from machinery.context import RequestContext + + +class AgentProviderUnavailableError(Exception): + """The plugin runtime rejected a provider lookup.""" + + +class AgentProviderGateway(Protocol): + def list_providers(self, tenant_id: str) -> list[PluginAgentProviderEntity]: ... + + def get_provider(self, tenant_id: str, provider_name: str) -> PluginAgentProviderEntity: ... + + +class AgentProviderService: + def __init__(self, *, providers: AgentProviderGateway) -> None: + self._providers = providers + + def list_providers(self, context: RequestContext) -> list[PluginAgentProviderEntity]: + return self._providers.list_providers(context.active_workspace_id) + + def get_provider(self, context: RequestContext, provider_name: str) -> PluginAgentProviderEntity: + return self._providers.get_provider(context.active_workspace_id, provider_name) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py index 47d7d00bab44fc..e1b3732f23c354 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py @@ -13,13 +13,12 @@ ) from core.agent.plugin_entities import AgentStrategyParameter from core.plugin.entities.plugin_daemon import PluginAgentProviderEntity -from core.plugin.impl.exc import PluginNotFoundError -from models.account import Account -from tests.unit_tests.model_factories import make_account +from machinery.context import RequestContext +from services.agent_provider_service import AgentProviderUnavailableError -def _account() -> Account: - return make_account(account_id="user1", name="Agent Provider Tester", email="agent-provider@example.com") +def _context() -> RequestContext: + return RequestContext("request", None, "user1", "tenant1") def _provider() -> PluginAgentProviderEntity: @@ -63,38 +62,37 @@ def test_get_success(self, app: Flask) -> None: api = AgentProviderListApi() method = unwrap(api.get) - user = _account() - tenant_id = "tenant1" + context = _context() provider = _provider() with ( app.test_request_context("/"), patch( - "controllers.console.workspace.agent_providers.AgentService.list_agent_providers", - return_value=[provider], - ) as list_providers, + "controllers.console.workspace.agent_providers.application_services", + ) as services, ): - result = method(api, tenant_id, user) + list_providers = services.return_value.agent_providers.list_providers + list_providers.return_value = [provider] + result = method(api, context) assert result == [provider.model_dump(mode="json")] assert result[0]["declaration"]["identity"]["name"] == "langgenius/agent/agent" - list_providers.assert_called_once_with(user.id, tenant_id) + list_providers.assert_called_once_with(context) def test_get_empty_list(self, app: Flask) -> None: api = AgentProviderListApi() method = unwrap(api.get) - user = _account() - tenant_id = "tenant1" + context = _context() with ( app.test_request_context("/"), patch( - "controllers.console.workspace.agent_providers.AgentService.list_agent_providers", - return_value=[], - ), + "controllers.console.workspace.agent_providers.application_services", + ) as services, ): - result = method(api, tenant_id, user) + services.return_value.agent_providers.list_providers.return_value = list[PluginAgentProviderEntity]() + result = method(api, context) assert result == [] @@ -104,19 +102,19 @@ def test_get_success(self, app: Flask) -> None: api = AgentProviderApi() method = unwrap(api.get) - user = _account() - tenant_id = "tenant1" + context = _context() provider_name = "langgenius/agent/agent" provider = _provider() with ( app.test_request_context("/"), patch( - "controllers.console.workspace.agent_providers.AgentService.get_agent_provider", - return_value=provider, - ) as get_provider, + "controllers.console.workspace.agent_providers.application_services", + ) as services, ): - result = method(api, tenant_id, user, provider_name) + get_provider = services.return_value.agent_providers.get_provider + get_provider.return_value = provider + result = method(api, context, provider_name) assert result == provider.model_dump(mode="json") assert result["meta"]["version"] is None @@ -128,25 +126,26 @@ def test_get_success(self, app: Flask) -> None: assert strategy["parameters"][0]["type"] == "array[tools]" assert strategy["parameters"][1]["default"] == 0 assert strategy["parameters"][2]["default"] is False - get_provider.assert_called_once_with(user.id, tenant_id, provider_name) + get_provider.assert_called_once_with(context, provider_name) def test_get_provider_not_found(self, app: Flask) -> None: api = AgentProviderApi() method = unwrap(api.get) - user = _account() - tenant_id = "tenant1" + context = _context() provider_name = "langgenius/missing/agent" with ( app.test_request_context("/"), patch( - "services.agent_service.PluginAgentClient.fetch_agent_strategy_provider", - side_effect=PluginNotFoundError("Agent provider is not installed"), - ), + "controllers.console.workspace.agent_providers.application_services", + ) as services, ): + services.return_value.agent_providers.get_provider.side_effect = AgentProviderUnavailableError( + "Agent provider is not installed" + ) with pytest.raises(ValueError, match="Agent provider is not installed"): - method(api, tenant_id, user, provider_name) + method(api, context, provider_name) class TestAgentProviderResponseContract: diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 32de236ce2290d..2388c555109b30 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -91,6 +91,8 @@ WorkspaceProvisioningOAuthGateway, ) from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway +from services.agent_provider_gateway import PluginAgentProviderGateway +from services.agent_provider_service import AgentProviderService from services.api_based_extension_adapters import APIBasedExtensionPingProbe, WorkspaceTokenCipher from services.api_based_extension_application_service import APIBasedExtensionApplicationService from services.app.api_key_service import AppApiKeyService @@ -406,6 +408,19 @@ def test_build_application_services_wires_api_based_extension_boundary( assert isinstance(services.api_based_extensions._probe, APIBasedExtensionPingProbe) +def test_build_application_services_wires_agent_provider_boundary( + sqlite_session_factory: sessionmaker[Session], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.COMMUNITY, + initialization_password="", + redis=_redis(), + ) + assert isinstance(services.agent_providers, AgentProviderService) + assert isinstance(services.agent_providers._providers, PluginAgentProviderGateway) + + def test_build_application_services_reuses_file_service( sqlite_session_factory: sessionmaker[Session], ) -> None: diff --git a/api/tests/unit_tests/services/test_agent_provider_service.py b/api/tests/unit_tests/services/test_agent_provider_service.py new file mode 100644 index 00000000000000..bcc649d28833d9 --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_provider_service.py @@ -0,0 +1,39 @@ +"""Workspace isolation and plugin-error compatibility for Agent provider reads.""" + +from unittest.mock import create_autospec, patch + +import pytest + +from core.plugin.impl.exc import PluginNotFoundError +from machinery.context import RequestContext +from services.agent_provider_gateway import PluginAgentProviderGateway +from services.agent_provider_service import AgentProviderGateway, AgentProviderService, AgentProviderUnavailableError + + +@pytest.mark.parametrize("workspace", ["workspace-a", "workspace-b"]) +def test_provider_queries_use_the_admitted_workspace(workspace: str) -> None: + gateway = create_autospec(AgentProviderGateway, instance=True) + service = AgentProviderService(providers=gateway) + context = RequestContext("request", None, "account", workspace) + gateway.list_providers.return_value = list[object]() + assert service.list_providers(context) == [] + gateway.list_providers.assert_called_once_with(workspace) + assert service.get_provider(context, "org/plugin/strategy") is gateway.get_provider.return_value + gateway.get_provider.assert_called_once_with(workspace, "org/plugin/strategy") + + +def test_gateway_preserves_provider_lookup_error_message() -> None: + gateway = PluginAgentProviderGateway() + with patch("services.agent_provider_gateway.PluginAgentClient") as client: + client.return_value.fetch_agent_strategy_provider.side_effect = PluginNotFoundError("not installed") + with pytest.raises(AgentProviderUnavailableError, match="not installed"): + gateway.get_provider("workspace", "org/plugin/strategy") + client.return_value.fetch_agent_strategy_provider.assert_called_once_with("workspace", "org/plugin/strategy") + + +def test_gateway_does_not_translate_unexpected_failures() -> None: + gateway = PluginAgentProviderGateway() + with patch("services.agent_provider_gateway.PluginAgentClient") as client: + client.return_value.fetch_agent_strategy_providers.side_effect = RuntimeError("unavailable") + with pytest.raises(RuntimeError, match="unavailable"): + gateway.list_providers("workspace") From 41d19a702efaa5e35f01dbcbbf616f89b231bcb2 Mon Sep 17 00:00:00 2001 From: Bear0117 <101653449+Bear0117@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:37:21 +0800 Subject: [PATCH 2/2] test(api): cover plugin-backed Agent provider discovery --- .../services/test_agent_provider_service.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/api/tests/unit_tests/services/test_agent_provider_service.py b/api/tests/unit_tests/services/test_agent_provider_service.py index bcc649d28833d9..4a7b0fbe4b94ef 100644 --- a/api/tests/unit_tests/services/test_agent_provider_service.py +++ b/api/tests/unit_tests/services/test_agent_provider_service.py @@ -4,12 +4,66 @@ import pytest +from core.plugin.entities.plugin_daemon import PluginAgentProviderEntity +from core.plugin.impl.agent import PluginAgentClient from core.plugin.impl.exc import PluginNotFoundError from machinery.context import RequestContext from services.agent_provider_gateway import PluginAgentProviderGateway from services.agent_provider_service import AgentProviderGateway, AgentProviderService, AgentProviderUnavailableError +@pytest.mark.parametrize("workspace", ["workspace-a", "workspace-b"]) +@pytest.mark.parametrize("detail", [False, True]) +def test_plugin_discovery_preserves_declarations_and_normalizes_provider_ids(workspace: str, detail: bool) -> None: + provider = PluginAgentProviderEntity.model_validate( + { + "provider": "agent", + "plugin_id": "langgenius/agent", + "plugin_unique_identifier": "langgenius/agent:0.0.2@checksum", + "meta": {}, + "declaration": { + "identity": { + "author": "langgenius", + "name": "agent", + "label": {"en_US": "Agent"}, + "description": {"en_US": "Agent strategies"}, + "icon": "icon.svg", + }, + "strategies": [ + { + "identity": { + "author": "langgenius", + "name": "reasoning", + "provider": "agent", + "label": {"en_US": "Reasoning"}, + }, + "description": {"en_US": "Reason with tools"}, + "parameters": [], + } + ], + }, + } + ) + service = AgentProviderService(providers=PluginAgentProviderGateway()) + context = RequestContext("request", None, "account", workspace) + with patch.object( + PluginAgentClient, "_request_with_plugin_daemon_response", return_value=provider if detail else [provider] + ) as request: + if detail: + result = service.get_provider(context, "langgenius/agent/agent") + assert request.call_args.args[1] == f"plugin/{workspace}/management/agent_strategy" + assert request.call_args.kwargs["params"] == {"provider": "agent", "plugin_id": "langgenius/agent"} + else: + [result] = service.list_providers(context) + assert request.call_args.args[1] == f"plugin/{workspace}/management/agent_strategies" + assert request.call_args.kwargs["params"] == {"page": 1, "page_size": 256} + assert result is provider + assert result.declaration.identity.name == "langgenius/agent/agent" + assert result.declaration.strategies[0].identity.provider == "langgenius/agent/agent" + assert result.declaration.strategies[0].identity.name == "reasoning" + assert result.declaration.identity.icon == "icon.svg" + + @pytest.mark.parametrize("workspace", ["workspace-a", "workspace-b"]) def test_provider_queries_use_the_admitted_workspace(workspace: str) -> None: gateway = create_autospec(AgentProviderGateway, instance=True)