From 71b5b706339496cdd6e1118cbd6cd124160a79e2 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Tue, 27 Oct 2026 16:01:53 +0800 Subject: [PATCH] fix(agent): create pipelines with selected runner defaults --- src/langbot/pkg/api/http/service/agent.py | 1 + src/langbot/pkg/api/http/service/pipeline.py | 41 ++++++++---- .../api/service/test_agent_service.py | 2 + .../test_pipeline_create_description.py | 2 +- .../api/test_pipeline_service_defaults.py | 25 +++++++ .../agents/components/AgentCreateContent.tsx | 65 ++++--------------- web/src/app/infra/entities/api/index.ts | 7 ++ web/src/app/infra/http/BackendClient.ts | 5 +- 8 files changed, 81 insertions(+), 67 deletions(-) diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 442e1dcf7..9d5f76c82 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -438,6 +438,7 @@ class AgentService: 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or '⚙️', }, + runner_id=agent_data.get('runner_id'), ) return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE} diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 934cf97f9..5e449b68a 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -7,6 +7,7 @@ import typing from ....core import app from ....agent.runner.config_resolver import RunnerConfigResolver +from ....agent.runner.errors import RunnerNotFoundError from ....entity.persistence import pipeline as persistence_pipeline from ....pipeline.extension_preferences import ( normalize_extension_preferences, @@ -44,7 +45,11 @@ class PipelineService: ) -> dict[str, typing.Any]: return {item['name']: item['default'] for item in config_schema if item.get('name') and 'default' in item} - async def get_default_pipeline_config(self, context: TenantContext) -> dict[str, typing.Any]: + async def get_default_pipeline_config( + self, + context: TenantContext, + runner_id: str | None = None, + ) -> dict[str, typing.Any]: from ....utils import paths as path_utils template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') @@ -54,15 +59,20 @@ class PipelineService: registry = getattr(self.ap, 'runner_registry', None) if registry is None: return config - try: - runners = await registry.list_runners(context, bound_plugins=None) - except Exception as exc: - self.ap.logger.warning(f'Failed to load Runner defaults for pipeline config: {exc}') - return config - if not runners: - return config - - selected = runners[0] + if runner_id: + try: + selected = await registry.get(context, runner_id) + except RunnerNotFoundError as exc: + raise ValueError(f'Runner {runner_id} not found') from exc + else: + try: + runners = await registry.list_runners(context, bound_plugins=None) + except Exception as exc: + self.ap.logger.warning(f'Failed to load Runner defaults for pipeline config: {exc}') + return config + if not runners: + return config + selected = runners[0] ai_config = config.setdefault('ai', {}) runner_config = ai_config.setdefault('runner', {}) runner_config['id'] = selected.id @@ -166,7 +176,14 @@ class PipelineService: serialized = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) return serialized if include_secret else redact_secrets(serialized) - async def create_pipeline(self, context: TenantContext, pipeline_data: dict, default: bool = False) -> str: + async def create_pipeline( + self, + context: TenantContext, + pipeline_data: dict, + default: bool = False, + *, + runner_id: str | None = None, + ) -> str: workspace_uuid = require_workspace_uuid(context) if 'extensions_preferences' in pipeline_data: self._validate_extension_preferences(pipeline_data['extensions_preferences']) @@ -193,7 +210,7 @@ class PipelineService: pipeline_data['is_default'] = default if 'config' not in pipeline_data or pipeline_data['config'] == {}: - pipeline_data['config'] = await self.get_default_pipeline_config(context) + pipeline_data['config'] = await self.get_default_pipeline_config(context, runner_id) RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index bca8b3a9c..c4375885c 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -762,6 +762,7 @@ class TestAgentServiceCreateUpdateDelete: 'name': 'Pipeline Agent', 'description': 'Legacy pipeline', 'emoji': 'P', + 'runner_id': 'plugin:test/runner/default', }, ) await service.update_agent( @@ -779,6 +780,7 @@ class TestAgentServiceCreateUpdateDelete: 'description': 'Legacy pipeline', 'emoji': 'P', }, + runner_id='plugin:test/runner/default', ) app.pipeline_service.update_pipeline.assert_awaited_once_with( WORKSPACE_UUID, diff --git a/tests/unit_tests/api/service/test_pipeline_create_description.py b/tests/unit_tests/api/service/test_pipeline_create_description.py index 7fec6bca1..230e6d801 100644 --- a/tests/unit_tests/api/service/test_pipeline_create_description.py +++ b/tests/unit_tests/api/service/test_pipeline_create_description.py @@ -22,7 +22,7 @@ async def test_create_pipeline_defaults_missing_description(monkeypatch): service = PipelineService.__new__(PipelineService) service.ap = app - async def fake_get_default_config(context): + async def fake_get_default_config(context, runner_id=None): runner_id = 'plugin:langbot-team/LocalAgent/default' return { 'ai': { diff --git a/tests/unit_tests/api/test_pipeline_service_defaults.py b/tests/unit_tests/api/test_pipeline_service_defaults.py index 20fa685ac..348edfa51 100644 --- a/tests/unit_tests/api/test_pipeline_service_defaults.py +++ b/tests/unit_tests/api/test_pipeline_service_defaults.py @@ -23,6 +23,9 @@ class FakeRegistry: async def list_runners(self, context, bound_plugins=None): return self.runners + async def get(self, context, runner_id): + return next(runner for runner in self.runners if runner.id == runner_id) + def make_runner(runner_id: str, config_schema: list[dict]): parts = runner_id.removeprefix('plugin:').split('/') @@ -67,6 +70,28 @@ async def test_default_pipeline_config_uses_first_installed_runner_schema(): } +@pytest.mark.asyncio +async def test_default_pipeline_config_uses_requested_runner_schema(): + first = make_runner( + 'plugin:alice/custom-agent/default', + [{'name': 'api-key', 'type': 'string', 'default': ''}], + ) + selected = make_runner( + 'plugin:langbot-team/LocalAgent/default', + [{'name': 'temperature', 'type': 'number', 'default': 0.2}], + ) + ap = SimpleNamespace( + logger=FakeLogger(), + runner_registry=FakeRegistry([first, selected]), + ) + ap.llm_model_service = LLMModelsService(ap) + + config = await PipelineService(ap).get_default_pipeline_config('workspace-test', selected.id) + + assert config['ai']['runner']['id'] == selected.id + assert config['ai']['runner_config'] == {selected.id: {'temperature': 0.2}} + + @pytest.mark.asyncio async def test_default_pipeline_config_stays_neutral_without_installed_runners(): ap = SimpleNamespace( diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index c7d96248f..5daee54eb 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { Bot, Workflow, Puzzle } from 'lucide-react'; +import { Bot, Workflow, Puzzle, Loader2 } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { AgentKind } from '@/app/infra/entities/api'; import { Button } from '@/components/ui/button'; @@ -46,7 +46,6 @@ export default function AgentCreateContent({ const [metadata, setMetadata] = useState(); const [runner, setRunner] = useState(''); const [loading, setLoading] = useState(true); - const createdPipeline = useRef(null); const loadMetadata = useCallback(async () => { setLoading(true); try { @@ -135,58 +134,13 @@ export default function AgentCreateContent({ runner_config: { [runner]: parameters }, }; try { - if ( - kind === 'pipeline' && - runnerStage?.config.some((field) => - [ - 'llm-model-selector', - 'select-llm-model', - 'model-fallback-selector', - ].includes(field.type), - ) - ) { - const { uuid } = await httpClient.getDefaultModel(); - if (uuid) { - for (const field of runnerStage.config) { - if ( - ['llm-model-selector', 'select-llm-model'].includes(field.type) && - !parameters[field.name] - ) - parameters[field.name] = uuid; - if ( - field.type === 'model-fallback-selector' && - !parameters[field.name]?.primary - ) { - parameters[field.name] = { - ...parameters[field.name], - primary: uuid, - fallbacks: parameters[field.name]?.fallbacks ?? [], - }; - } - } - } - } if (kind === 'pipeline') { - // Keep the created ID if configuration fails, so retry does not create duplicates. - const uuid = - createdPipeline.current ?? - ( - await httpClient.createAgent({ - kind, - name: values.name, - description: '', - emoji: values.emoji, - }) - ).uuid; - createdPipeline.current = uuid; - const { pipeline } = await httpClient.getPipeline(uuid); - await httpClient.updatePipeline(uuid, { + const { uuid } = await httpClient.createAgent({ + kind, name: values.name, + description: '', emoji: values.emoji, - config: { - ...pipeline.config, - ai: { ...pipeline.config.ai, ...config }, - }, + runner_id: runner, }); toast.success(t('agents.createSuccess')); onCreated(uuid); @@ -248,7 +202,12 @@ export default function AgentCreateContent({ form="agent-create-form" disabled={form.formState.isSubmitting || loading || !runner} > - {t('common.submit')} + {form.formState.isSubmitting && ( +