fix(agent): create pipelines with selected runner defaults

This commit is contained in:
fdc310
2026-10-27 16:01:53 +08:00
parent cf9d8eee9d
commit 71b5b70633
8 changed files with 81 additions and 67 deletions
@@ -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}
+29 -12
View File
@@ -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
@@ -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,
@@ -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': {
@@ -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(
@@ -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<GetAgentMetadataResponseData>();
const [runner, setRunner] = useState('');
const [loading, setLoading] = useState(true);
const createdPipeline = useRef<string | null>(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 && (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
{form.formState.isSubmitting
? t('common.loading')
: t('common.submit')}
</Button>
</div>
+7
View File
@@ -259,6 +259,13 @@ export interface Agent {
updated_at?: string;
}
export type CreateAgentRequest = Pick<
Agent,
'name' | 'description' | 'emoji' | 'kind' | 'component_ref' | 'config'
> & {
runner_id?: string;
};
export interface ApiRespAgents {
agents: Agent[];
}
+4 -1
View File
@@ -28,6 +28,7 @@ import {
ApiRespAgents,
ApiRespAgent,
Agent,
CreateAgentRequest,
Pipeline,
ApiRespPlatformAdapters,
ApiRespPlatformAdapter,
@@ -380,7 +381,9 @@ export class BackendClient extends BaseHttpClient {
);
}
public createAgent(agent: Agent): Promise<{ uuid: string; kind: string }> {
public createAgent(
agent: CreateAgentRequest,
): Promise<{ uuid: string; kind: string }> {
return this.post('/api/v1/agents', agent);
}