Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions api/.importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 14 additions & 23 deletions api/controllers/console/workspace/agent_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")


Expand All @@ -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)
4 changes: 4 additions & 0 deletions api/extensions/ext_application_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions api/services/agent_provider_gateway.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions api/services/agent_provider_service.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 == []

Expand All @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading