mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -26,7 +26,7 @@ markers =
|
|||||||
|
|
||||||
# Coverage options (when using pytest-cov)
|
# Coverage options (when using pytest-cov)
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
source = langbot.pkg
|
source = langbot
|
||||||
omit =
|
omit =
|
||||||
*/tests/*
|
*/tests/*
|
||||||
*/test_*.py
|
*/test_*.py
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ echo "Running all unit tests..."
|
|||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
pytest tests/unit_tests/ -v --tb=short \
|
pytest tests/unit_tests/ -v --tb=short \
|
||||||
--cov=pkg \
|
--cov=langbot \
|
||||||
--cov-report=xml \
|
--cov-report=xml \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
return self.http_status(404, -1, 'pipeline not found')
|
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)
|
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||||
|
|
||||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||||
|
|||||||
@@ -284,12 +284,35 @@ class PluginRuntimeConnector:
|
|||||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
task_context.trace('Cleaning up plugin configuration and storage...')
|
||||||
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
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:
|
if not self.is_enable_plugin:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
plugins = await self.handler.list_plugins()
|
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)
|
# Sort plugins: debug plugins first, then by installation time (newest first)
|
||||||
# Get installation timestamps from database in a single query
|
# Get installation timestamps from database in a single query
|
||||||
plugin_timestamps = {}
|
plugin_timestamps = {}
|
||||||
|
|||||||
363
tests/unit_tests/plugin/test_plugin_component_filtering.py
Normal file
363
tests/unit_tests/plugin/test_plugin_component_filtering.py
Normal 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'
|
||||||
Reference in New Issue
Block a user