diff --git a/pyproject.toml b/pyproject.toml index 629a450e8..4488201b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,4 +232,4 @@ line-ending = "auto" [tool.uv.sources] # Development contract: update to the matching SDK release before publishing. -langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "c67e6c85a0cde8ae2b20cbd89e33805a68382563" } diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/runner/default.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/runner/default.yaml index 75b04f2e4..3e8e65057 100644 --- a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/runner/default.yaml +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/runner/default.yaml @@ -9,6 +9,7 @@ metadata: en_US: Deterministic runner fixture that returns stable QA sentinel output. zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。 spec: + usages: [agent] capabilities: streaming: true tool_calling: false diff --git a/src/langbot/pkg/agent/runner/descriptor.py b/src/langbot/pkg/agent/runner/descriptor.py index 5933f8ad9..aab6805a1 100644 --- a/src/langbot/pkg/agent/runner/descriptor.py +++ b/src/langbot/pkg/agent/runner/descriptor.py @@ -55,7 +55,7 @@ class RunnerDescriptor(pydantic.BaseModel): """Original manifest for reference""" component_kind: typing.Literal['Runner'] = 'Runner' - usages: list[typing.Literal['agent', 'event']] = pydantic.Field(default_factory=lambda: ['agent']) + usages: list[typing.Literal['agent', 'event']] = pydantic.Field(min_length=1) supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*']) model_config = pydantic.ConfigDict( diff --git a/tests/unit_tests/agent/test_context_builder_state.py b/tests/unit_tests/agent/test_context_builder_state.py index 746d9d5ea..0c38042ec 100644 --- a/tests/unit_tests/agent/test_context_builder_state.py +++ b/tests/unit_tests/agent/test_context_builder_state.py @@ -32,6 +32,7 @@ def make_descriptor( permissions: dict | None = None, ) -> RunnerDescriptor: return RunnerDescriptor( + usages=['agent'], id='plugin:test/runner/default', source='plugin', label={'en_US': 'Test Runner'}, diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py index dc01c743a..bb47a6e12 100644 --- a/tests/unit_tests/agent/test_context_validation.py +++ b/tests/unit_tests/agent/test_context_validation.py @@ -91,6 +91,7 @@ class TestContextValidation: def _make_descriptor(self): """Create a mock runner descriptor.""" return RunnerDescriptor( + usages=['agent'], id='plugin:test/plugin/runner', source='plugin', label={'en_US': 'Test Runner'}, diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 72a1638d7..60668414e 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -125,6 +125,7 @@ class MockApplication: class FakeRunnerRegistry: async def get(self, context, runner_id, bound_plugins=None): return RunnerDescriptor( + usages=['agent'], id=runner_id, source='plugin', label={'en_US': 'Test Runner'}, diff --git a/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py index 46225fe20..f7a354f6c 100644 --- a/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py +++ b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py @@ -23,6 +23,7 @@ SOURCE = {'source': 'native', 'source_id': None} async def _resources(tool_mgr, rag_mgr, binding): descriptor = RunnerDescriptor( + usages=['agent'], id=LOCAL_RUNNER_ID, source='plugin', label={'en_US': 'Local Agent'}, diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 3d4697eae..ad55fbd70 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -164,6 +164,7 @@ class FakeConversation: def make_descriptor() -> RunnerDescriptor: return RunnerDescriptor( + usages=['agent'], id=RUNNER_ID, source='plugin', label={'en_US': 'Local Agent'}, diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index 2cc0dc3af..da7780622 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -55,6 +55,7 @@ class FakeApplication: 'id': 'plugin:langbot-team/LocalAgent/default', 'name': 'default', 'label': {'en_US': 'Local Agent'}, + 'usages': ['agent'], 'capabilities': {'streaming': True}, 'permissions': {}, 'config_schema': [], @@ -68,6 +69,7 @@ class FakeApplication: 'id': 'plugin:alice/my-agent/custom', 'name': 'custom', 'label': {'en_US': 'Custom Agent'}, + 'usages': ['agent'], 'capabilities': {}, 'permissions': {}, 'config_schema': [{'name': 'param1', 'type': 'string'}], @@ -308,6 +310,7 @@ class TestDescriptorValidation: def test_validate_runner_descriptor(self): """Validate correctly built descriptor.""" descriptor = RunnerDescriptor( + usages=['agent'], id='plugin:test/my-runner/default', source='plugin', label={'en_US': 'Test Runner'}, @@ -323,6 +326,7 @@ class TestDescriptorValidation: def test_descriptor_capabilities(self): """Descriptor capability helper methods.""" descriptor = RunnerDescriptor( + usages=['agent'], id='plugin:test/my-runner/default', source='plugin', label={'en_US': 'Test Runner'}, @@ -365,3 +369,18 @@ async def test_registry_filters_usages_without_splitting_component_identity(): assert [item.runner_name for item in processors] == ['events', 'both'] assert agents[-1].id == processors[-1].id assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event'] + + +@pytest.mark.asyncio +async def test_discovery_rejects_runner_without_usage_declaration(): + ap = FakeApplication() + original = ap.plugin_connector.list_runners + + async def missing_usage(bound_plugins=None): + runners = await original(bound_plugins) + runners[0]['manifest'].pop('usages') + return runners + + ap.plugin_connector.list_runners = missing_usage + runners = await RunnerRegistry(ap).list_runners(TEST_CONTEXT, use_cache=False) + assert [runner.id for runner in runners] == ['plugin:alice/my-agent/custom'] diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index 67fa16295..03c2fc69a 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -39,6 +39,7 @@ def make_descriptor( permissions: dict | None = None, ) -> RunnerDescriptor: return RunnerDescriptor( + usages=['agent'], id=RUNNER_ID, source='plugin', label={'en_US': 'Test Runner'}, diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py index 6a332ceac..b4dbe0970 100644 --- a/tests/unit_tests/agent/test_result_normalizer.py +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -37,6 +37,7 @@ class FakeApplication: def make_descriptor(): """Create a test descriptor.""" return RunnerDescriptor( + usages=['agent'], id='plugin:langbot-team/LocalAgent/default', source='plugin', label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'}, diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py index 756904af3..4154cd840 100644 --- a/tests/unit_tests/agent/test_state_store.py +++ b/tests/unit_tests/agent/test_state_store.py @@ -25,6 +25,7 @@ from langbot.pkg.agent.runner.state_scope import ( def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor: """Create a test descriptor.""" return RunnerDescriptor( + usages=['agent'], id=runner_id, source='plugin', label={'en_US': 'Test Runner'}, diff --git a/tests/unit_tests/api/test_pipeline_service_defaults.py b/tests/unit_tests/api/test_pipeline_service_defaults.py index 710565503..dd7687d87 100644 --- a/tests/unit_tests/api/test_pipeline_service_defaults.py +++ b/tests/unit_tests/api/test_pipeline_service_defaults.py @@ -26,6 +26,7 @@ class FakeRegistry: def make_runner(runner_id: str, config_schema: list[dict]): parts = runner_id.removeprefix('plugin:').split('/') return RunnerDescriptor( + usages=['agent'], id=runner_id, source='plugin', label={'en_US': runner_id}, diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 9ebb6dd75..46cccadad 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -32,6 +32,7 @@ RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' def attach_runner_descriptor(app): descriptor = RunnerDescriptor( + usages=['agent'], id=RUNNER_ID, source='plugin', label={'en_US': 'Local Agent'}, diff --git a/tests/unit_tests/pipeline/test_preproc_media_fallback.py b/tests/unit_tests/pipeline/test_preproc_media_fallback.py index c596fc1cd..52da1210f 100644 --- a/tests/unit_tests/pipeline/test_preproc_media_fallback.py +++ b/tests/unit_tests/pipeline/test_preproc_media_fallback.py @@ -17,6 +17,7 @@ def _attach_runner_descriptor(app): from langbot.pkg.agent.runner.descriptor import RunnerDescriptor descriptor = RunnerDescriptor( + usages=['agent'], id=RUNNER_ID, source='plugin', label={'en_US': 'Local Agent'}, diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index 3b67547f1..13dd9d75b 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -80,6 +80,7 @@ def _make_app(*, skill_service) -> SimpleNamespace: model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'})) tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) descriptor = RunnerDescriptor( + usages=['agent'], id=_RUNNER_ID, source='plugin', label={'en_US': 'Local Agent'}, diff --git a/uv.lock b/uv.lock index 623f676c9..bc64d46a4 100644 --- a/uv.lock +++ b/uv.lock @@ -2119,7 +2119,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2186,7 +2186,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.5.5" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563#c67e6c85a0cde8ae2b20cbd89e33805a68382563" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, diff --git a/web/src/app/home/agents/components/PluginProcessorSettings.tsx b/web/src/app/home/agents/components/PluginProcessorSettings.tsx index 349f07463..34bc94ce8 100644 --- a/web/src/app/home/agents/components/PluginProcessorSettings.tsx +++ b/web/src/app/home/agents/components/PluginProcessorSettings.tsx @@ -115,7 +115,7 @@ export default function PluginProcessorSettings({
{t('agents.eventProcessor.noComponents')} diff --git a/web/src/app/home/agents/components/RunnerSelect.tsx b/web/src/app/home/agents/components/RunnerSelect.tsx index 110a8992e..1c0f2549e 100644 --- a/web/src/app/home/agents/components/RunnerSelect.tsx +++ b/web/src/app/home/agents/components/RunnerSelect.tsx @@ -227,7 +227,7 @@ export default function RunnerSelect({ setCatalogLoading(true); setCatalogError(false); try { - const catalog = await loadRunnerCatalog(); + const catalog = await loadRunnerCatalog('agent'); setMarketplaceRunners(catalog.marketplaceRunners); setInstalledPluginIds(catalog.installedPluginIds); setInstalledPluginDescriptions(catalog.installedPluginDescriptions); @@ -392,7 +392,7 @@ export default function RunnerSelect({ {t('agents.marketplaceRunners')} { +export async function loadRunnerCatalog( + usage: RunnerUsage, +): Promise { const cloudClient = await getCloudServiceClient(); const [firstSearchResult, recommendationResult, installedResult] = await Promise.all([ @@ -150,6 +156,7 @@ export async function loadRunnerCatalog(): Promise { page_size: RUNNER_CATALOG_PAGE_SIZE, type_filter: 'plugin', component_filter: RUNNER_COMPONENT_FILTER, + runner_usage: usage, }), cloudClient.getRecommendationLists().catch(() => ({ lists: [] })), httpClient.getPlugins().catch(() => ({ plugins: [] })), @@ -167,6 +174,7 @@ export async function loadRunnerCatalog(): Promise { page_size: RUNNER_CATALOG_PAGE_SIZE, type_filter: 'plugin', component_filter: RUNNER_COMPONENT_FILTER, + runner_usage: usage, }), ), ); @@ -189,7 +197,7 @@ export async function loadRunnerCatalog(): Promise { } const marketplaceRunners = catalogPlugins - .filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER]) + .filter((plugin) => supportsRunnerUsage(plugin, usage)) .sort((left, right) => { const leftOrder = recommendationOrder.get(marketplacePluginId(left)); const rightOrder = recommendationOrder.get(marketplacePluginId(right)); diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index 646813ec3..ea39dcb5f 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -126,6 +126,14 @@ function MarketPageContent({ loadMarketFilters().componentFilter ?? 'all', ); + const [runnerUsage, setRunnerUsage] = useState(() => { + const value = searchParams.get('runner_usage'); + return value === 'agent' || value === 'event' ? value : 'all'; + }); + const activeRunnerUsage = + componentFilter === 'Runner' && runnerUsage !== 'all' + ? (runnerUsage as 'agent' | 'event') + : undefined; const [typeFilter, setTypeFilter] = useState(() => { if (getComponentFilterFromQuery(searchParams)) { return 'plugin'; @@ -138,7 +146,9 @@ function MarketPageContent({ return saved && MARKET_TYPE_VALUES.includes(saved) ? saved : 'all'; }); const activeAdvancedFilters = - (typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1); + (typeFilter === 'all' ? 0 : 1) + + (componentFilter === 'all' ? 0 : 1) + + (activeRunnerUsage ? 1 : 0); const [selectedTags, setSelectedTags] = useState( () => loadMarketFilters().selectedTags ?? [], ); @@ -313,6 +323,7 @@ function MarketPageContent({ type_filter: typeFilter === 'all' ? undefined : typeFilter, component_filter: componentFilter === 'all' ? undefined : componentFilter, + runner_usage: activeRunnerUsage, tags_filter: selectedTags.length > 0 ? selectedTags : undefined, }); @@ -344,6 +355,7 @@ function MarketPageContent({ [ searchQuery, componentFilter, + activeRunnerUsage, selectedTags, pageSize, transformToVO, @@ -502,6 +514,10 @@ function MarketPageContent({ setPlugins([]); const params = new URLSearchParams(searchParams); + if (value !== 'Runner') { + setRunnerUsage('all'); + params.delete('runner_usage'); + } if (value === 'all') { params.delete('component'); } else { @@ -517,7 +533,7 @@ function MarketPageContent({ // 当排序选项或组件筛选或类型筛选变化时重新加载数据 useEffect(() => { fetchPlugins(1, !!searchQuery.trim(), true); - }, [sortOption, componentFilter, typeFilter]); + }, [sortOption, componentFilter, typeFilter, activeRunnerUsage]); // Tags 筛选变化时重新搜索 useEffect(() => { @@ -826,6 +842,38 @@ function MarketPageContent({ })}
+ {componentFilter === 'Runner' && ( +
+
+ {t('market.runnerUsage')} +
+ { + if (!value) return; + setRunnerUsage(value); + setCurrentPage(1); + setPlugins([]); + const params = new URLSearchParams(searchParams); + if (value === 'all') params.delete('runner_usage'); + else params.set('runner_usage', value); + setSearchParams(params, { replace: true }); + }} + > + + {t('market.runnerUsageAll')} + + + {t('market.runnerUsageAgent')} + + + {t('market.runnerUsageEvent')} + + +
+ )} diff --git a/web/src/app/infra/entities/plugin/index.ts b/web/src/app/infra/entities/plugin/index.ts index 5783588cb..64f19396e 100644 --- a/web/src/app/infra/entities/plugin/index.ts +++ b/web/src/app/infra/entities/plugin/index.ts @@ -52,8 +52,23 @@ export interface PluginV4 { hot_score?: number; latest_version: string; components: Record; + runner_usages?: RunnerUsage[]; status: PluginV4Status; type?: 'plugin' | 'mcp' | 'skill'; created_at: string; updated_at: string; } + +export type RunnerUsage = 'agent' | 'event'; + +/** Unknown usage metadata must not become an install recommendation. */ +export function supportsRunnerUsage( + plugin: PluginV4, + usage: RunnerUsage, +): boolean { + return Boolean( + plugin.components?.Runner && + Array.isArray(plugin.runner_usages) && + plugin.runner_usages.includes(usage), + ); +} diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index 2bb49bc68..0335f395c 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -3,7 +3,11 @@ import { ApiRespMarketplacePluginDetail, ApiRespMarketplacePlugins, } from '@/app/infra/entities/api'; -import { PluginV4 } from '@/app/infra/entities/plugin'; +import { + PluginV4, + RunnerUsage, + supportsRunnerUsage, +} from '@/app/infra/entities/plugin'; import { I18nObject } from '@/app/infra/entities/common'; /** @@ -39,6 +43,7 @@ export class CloudServiceClient extends BaseHttpClient { component_filter?: string, tags_filter?: string[], type_filter?: string, + runner_usage?: RunnerUsage, ): Promise { // Use different endpoints based on type_filter if (type_filter === 'mcp') { @@ -90,6 +95,7 @@ export class CloudServiceClient extends BaseHttpClient { sort_by, sort_order, component_filter, + runner_usage, tags_filter, type_filter, }, @@ -104,6 +110,7 @@ export class CloudServiceClient extends BaseHttpClient { sort_order?: string; type_filter?: string; component_filter?: string; + runner_usage?: RunnerUsage; tags_filter?: string[]; }): Promise { return this.post<{ extensions: PluginV4[]; total: number }>( @@ -125,7 +132,15 @@ export class CloudServiceClient extends BaseHttpClient { total: resp?.total || 0, }; }) - .catch(() => this.searchMarketplaceExtensionsLegacy(data)); + .catch(() => this.searchMarketplaceExtensionsLegacy(data)) + .then((result) => ({ + ...result, + plugins: data.runner_usage + ? result.plugins.filter((plugin) => + supportsRunnerUsage(plugin, data.runner_usage!), + ) + : result.plugins, + })); } public getMarketplaceLikedExtensions( @@ -162,6 +177,7 @@ export class CloudServiceClient extends BaseHttpClient { sort_order?: string; type_filter?: string; component_filter?: string; + runner_usage?: RunnerUsage; tags_filter?: string[]; }): Promise { const query = data.query || ''; @@ -183,6 +199,7 @@ export class CloudServiceClient extends BaseHttpClient { data.component_filter, data.tags_filter, data.component_filter ? 'plugin' : data.type_filter, + data.runner_usage, ).catch((error) => { if (data.type_filter === 'mcp' || data.type_filter === 'skill') { return { plugins: [], total: 0 }; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 7037d6f0e..b3f8bf2da 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -225,7 +225,7 @@ export default function WizardPage() { setIsRunnerCatalogLoading(true); setRunnerCatalogError(false); try { - const catalog = await fetchRunnerCatalog(); + const catalog = await fetchRunnerCatalog('agent'); setMarketplaceRunners(catalog.marketplaceRunners); setInstalledPluginIds(catalog.installedPluginIds); } catch (error) { @@ -2087,7 +2087,7 @@ function StepAIEngine({