perf: Filter plugins by component types in pipeline extensions (#1821)

* Initial plan

* Add component-kind filtering to list_plugins and filter pipeline extensions to only show plugins with Command, EventListener, or Tool components

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: testing path

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-30 20:55:48 +08:00
committed by GitHub
parent c368d828c9
commit 1ecb0735cb
5 changed files with 393 additions and 4 deletions

View File

@@ -26,7 +26,7 @@ markers =
# Coverage options (when using pytest-cov)
[coverage:run]
source = langbot.pkg
source = langbot
omit =
*/tests/*
*/test_*.py

View File

@@ -22,7 +22,7 @@ echo "Running all unit tests..."
# Run tests with coverage
pytest tests/unit_tests/ -v --tb=short \
--cov=pkg \
--cov=langbot \
--cov-report=xml \
"$@"

View File

@@ -59,7 +59,10 @@ class PipelinesRouterGroup(group.RouterGroup):
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
plugins = await self.ap.plugin_connector.list_plugins()
# Only include plugins with pipeline-related components (Command, EventListener, Tool)
# Plugins that only have KnowledgeRetriever components are not suitable for pipeline extensions
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
extensions_prefs = pipeline.get('extensions_preferences', {})

View File

@@ -284,12 +284,35 @@ class PluginRuntimeConnector:
task_context.trace('Cleaning up plugin configuration and storage...')
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
async def list_plugins(self) -> list[dict[str, Any]]:
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
Args:
component_kinds: Optional list of component kinds to filter by.
If provided, only plugins that contain at least one
component of the specified kinds will be returned.
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
"""
if not self.is_enable_plugin:
return []
plugins = await self.handler.list_plugins()
# Filter plugins by component kinds if specified
if component_kinds is not None:
filtered_plugins = []
for plugin in plugins:
components = plugin.get('components', [])
has_matching_component = False
for component in components:
component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '')
if component_kind in component_kinds:
has_matching_component = True
break
if has_matching_component:
filtered_plugins.append(plugin)
plugins = filtered_plugins
# Sort plugins: debug plugins first, then by installation time (newest first)
# Get installation timestamps from database in a single query
plugin_timestamps = {}

View File

@@ -0,0 +1,363 @@
"""Test plugin list filtering by component kinds."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.mark.asyncio
async def test_plugin_list_filter_by_component_kinds():
"""Test that plugins can be filtered by component kinds."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
# Mock the application
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
# Mock plugin data with different component kinds
mock_plugins = [
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author1',
'name': 'plugin_with_tool',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author2',
'name': 'plugin_with_knowledge_retriever_only',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author3',
'name': 'plugin_with_command',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Command',
'metadata': {'name': 'cmd1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author4',
'name': 'plugin_with_event_listener',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'EventListener',
'metadata': {'name': 'listener1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author5',
'name': 'plugin_with_mixed_components',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever2'}
}
}
},
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool2'}
}
}
}
]
},
]
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
# Test filtering by pipeline component kinds (Command, EventListener, Tool)
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
result = await connector.list_plugins(component_kinds=pipeline_component_kinds)
# Verify that only plugins with pipeline-related components are returned
assert len(result) == 4
plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]
assert 'plugin_with_tool' in plugin_names
assert 'plugin_with_command' in plugin_names
assert 'plugin_with_event_listener' in plugin_names
assert 'plugin_with_mixed_components' in plugin_names
# Plugin with only KnowledgeRetriever should NOT be included
assert 'plugin_with_knowledge_retriever_only' not in plugin_names
@pytest.mark.asyncio
async def test_plugin_list_filter_no_filter():
"""Test that all plugins are returned when no filter is specified."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
# Mock the application
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
# Mock plugin data with different component kinds
mock_plugins = [
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author1',
'name': 'plugin1',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author2',
'name': 'plugin2',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
},
]
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
# Test without filter - should return all plugins
result = await connector.list_plugins()
assert len(result) == 2
plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]
assert 'plugin1' in plugin_names
assert 'plugin2' in plugin_names
@pytest.mark.asyncio
async def test_plugin_list_filter_empty_result():
"""Test that empty list is returned when no plugins match the filter."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
# Mock the application
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
# Mock plugin data - only KnowledgeRetriever plugins
mock_plugins = [
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author1',
'name': 'plugin1',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
},
]
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
# Filter by Tool kind - should return empty list
result = await connector.list_plugins(component_kinds=['Tool'])
assert len(result) == 0
@pytest.mark.asyncio
async def test_plugin_list_filter_plugin_without_components():
"""Test that plugins without components are excluded when filtering."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
# Mock the application
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
# Mock plugin data - one with components, one without
mock_plugins = [
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author1',
'name': 'plugin_with_tool',
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
},
{
'debug': False,
'manifest': {
'manifest': {
'metadata': {
'author': 'author2',
'name': 'plugin_without_components',
}
}
},
'components': []
},
]
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
mock_result.__iter__ = lambda self: iter([])
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
# Filter by Tool kind - should return only plugin with Tool
result = await connector.list_plugins(component_kinds=['Tool'])
assert len(result) == 1
assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin_with_tool'