From c0cfb5c18c57eb76e2ae8ebe348745850371e97b Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 14 Oct 2026 00:09:52 +0800 Subject: [PATCH] feat(pipeline-migration): relax legacy config migration and pin latest runner plugins - Coerce/default/drop invalid or unknown legacy fields with warnings instead of blocking - Never block on LocalAgent model; prune dangling KB/tool/MCP references - Mark custom (non-builtin) 4.10 runners as unsupported_runner instead of failing - Pin runner plugins to current marketplace versions (0.2.x); fall back to latest when a pin is withdrawn - Raise plugin install/upgrade timeout to 600s for slow dependency installs - Assistant configure_pipeline writes the 4.11 runner shape Co-Authored-By: Claude Opus 5.5 --- docs/pipeline-migration-config-map.zh-CN.md | 38 +- src/langbot/pkg/agent/runner/query_bridge.py | 4 +- .../pkg/api/http/service/assistant_tools.py | 31 +- .../api/http/service/pipeline_migration.py | 129 +++- .../pkg/pipeline/legacy_config_migration.py | 628 +++++++++++------- src/langbot/pkg/plugin/handler.py | 6 +- .../test_pipeline_migration_resources.py | 22 +- .../pipeline/test_legacy_config_migration.py | 416 ++++++++---- .../pipeline/test_migration_current_shape.py | 10 +- .../app/home/pipelines/PipelineMigration.tsx | 26 +- .../pipelines/pipeline-migration-issues.ts | 15 + .../infra/entities/api/pipeline-migration.ts | 1 + .../i18n/locales/pipeline-migration/en-US.ts | 14 + .../i18n/locales/pipeline-migration/es-ES.ts | 15 + .../i18n/locales/pipeline-migration/ja-JP.ts | 14 + .../i18n/locales/pipeline-migration/ru-RU.ts | 14 + .../i18n/locales/pipeline-migration/th-TH.ts | 14 + .../i18n/locales/pipeline-migration/vi-VN.ts | 13 + .../locales/pipeline-migration/zh-Hans.ts | 12 + .../locales/pipeline-migration/zh-Hant.ts | 12 + .../fixtures/runner-migration-contract.json | 167 +++-- 21 files changed, 1103 insertions(+), 498 deletions(-) diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md index 482a6bbeb..5b2fa7d38 100644 --- a/docs/pipeline-migration-config-map.zh-CN.md +++ b/docs/pipeline-migration-config-map.zh-CN.md @@ -18,7 +18,7 @@ - Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。 - Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。 - Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。 -- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。 +- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突时固定采用当前规范字段的值并给出提示,不靠字段出现顺序决定。 - N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。 - `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。 - 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。 @@ -28,19 +28,35 @@ 以下是迁移器要求的插件最低版本,不是认证版本清单。已发布的专属运行版本不等于已认证可跨工作区共享;旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。认证须由精确制品的有效签名及 `shared-runtime-v1`、`stateless-v1` 声明证明。 -- `local-agent` → `LocalAgent` **0.1.10**。 -- `dify-service-api` → `DifyAgent` **0.1.10**。 -- `coze-api` → `CozeAgent` **0.1.10**。 -- `dashscope-app-api` → `DashScopeAgent` **0.1.10**。 -- `n8n-service-api` → `N8nAgent` **0.1.10**。 -- `langflow-api` → `LangflowAgent` **0.1.10**。 -- `deerflow-api` → `DeerFlowAgent` **0.1.10**。 -- `tbox-app-api` → `TboxAgent` **0.1.8**。 -- `weknora-api` → `WeKnoraAgent` **0.1.10**。 +- `local-agent` → `LocalAgent` **0.2.1**。 +- `dify-service-api` → `DifyAgent` **0.2.3**。 +- `coze-api` → `CozeAgent` **0.2.0**。 +- `dashscope-app-api` → `DashScopeAgent` **0.2.2**。 +- `n8n-service-api` → `N8nAgent` **0.2.0**。 +- `langflow-api` → `LangflowAgent` **0.2.2**。 +- `deerflow-api` → `DeerFlowAgent` **0.2.1**。 +- `tbox-app-api` → `TboxAgent` **0.2.1**。 +- `weknora-api` → `WeKnoraAgent` **0.2.1**。 + +版本号是经过审阅的最低版本:安装时优先安装该版本;若扩展市场已下架该版本则回退安装最新版。已安装的更高版本不会被降级,迁移执行前仍以运行时 Runner schema 校验迁移结果。 + +## 宽松迁移规则(planner v5) + +迁移器尽量自动修复,只在无法安全运行时阻断: + +- 仍然阻断:配置不是对象/不是合法 JSON;必填凭据或端点缺失/为空(api-key、base-url、webhook-url、app-id、bot-id、flow-id、LocalAgent 缺少主模型);n8n header 名称非法或 basic 认证凭据不是 latin-1;Coze api-base 含凭据、查询串或片段。 +- 自动修复并提示: + - 字符串形式的布尔/数字(如 `"true"`、`"30"`)按字段类型转换;无法转换时使用默认值(`migration.value_coerced` / `migration.invalid_value_defaulted`)。 + - 缺失字段按 4.10 界面默认值补齐(`migration.missing_default_filled`)。 + - 未知字段与残留的其他 Runner 配置段从活动配置移除,原值保存在迁移备份(`migration.unknown_field_dropped` / `migration.unknown_section_archived`)。 + - 别名冲突固定取规范字段(`migration.alias_conflict_resolved`);同时存在 `runner.id` 与旧 `runner.runner` 时采用可用的一项(`migration.mixed_runner_selection`)。 + - 扩展设置非法时忽略;仅启用部分插件时自动加入目标 Runner 插件(`extensions.runner_added`)。 +- 旧版自定义 Runner(非内置 9 类)标记为 `unsupported_runner`:不迁移、不算失败,需改写为插件 Runner 后再绑定,参见 https://langbot.app/docs/zh/plugin/dev/tutor 。 +- 诊断只包含代码和字段路径,不包含任何值或凭据。 ## 逐字段完整清单 -共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。 +共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时从活动配置移除、原值保留在迁移备份中并给出提示。 目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。 diff --git a/src/langbot/pkg/agent/runner/query_bridge.py b/src/langbot/pkg/agent/runner/query_bridge.py index 9932bf785..84923ce17 100644 --- a/src/langbot/pkg/agent/runner/query_bridge.py +++ b/src/langbot/pkg/agent/runner/query_bridge.py @@ -36,7 +36,9 @@ class QueryRunBridge: """Build an event-first run plan from a Pipeline Query.""" runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) if not runner_id: - raise RunnerNotFoundError('no runner configured') + raise RunnerNotFoundError( + 'no runner configured; legacy (pre-4.11) pipelines must be migrated with the pipeline migration assistant' + ) event = QueryEntryAdapter.query_to_event(query) agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id) diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index a850f428e..fd9599f64 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -312,12 +312,35 @@ async def execute_tool(ap, context, name: str, arguments: dict): if await ap.knowledge_service.get_knowledge_base(context, kb_id) is None: raise ValueError('Knowledge base not found') config = copy.deepcopy(pipeline['config']) - config['ai']['runner']['runner'] = 'local-agent' - local = config['ai']['local-agent'] - local['model']['primary'] = args['model_uuid'] + runner_id = 'plugin:langbot-team/LocalAgent/default' + ai = config.get('ai') if isinstance(config.get('ai'), dict) else {} + runner = ai.get('runner') if isinstance(ai.get('runner'), dict) else {} + runner_configs = ai.get('runner_config') if isinstance(ai.get('runner_config'), dict) else {} + local = runner_configs.get(runner_id) if isinstance(runner_configs.get(runner_id), dict) else {} + model = local.get('model') if isinstance(local.get('model'), dict) else {} + local['model'] = { + 'primary': args['model_uuid'], + 'fallbacks': model.get('fallbacks') if isinstance(model.get('fallbacks'), list) else [], + 'reasoning': model.get('reasoning') if isinstance(model.get('reasoning'), dict) else {}, + } local['prompt'] = [{'role': 'system', 'content': args['system_prompt']}] local['knowledge-bases'] = args['knowledge_base_uuids'] - await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], {'config': config}) + # 4.11 shape: ai.runner.id + ai.runner_config[id]; legacy keys are dropped. + ai['runner'] = {'id': runner_id, 'expire-time': runner.get('expire-time', 0)} + ai['runner_config'] = {**runner_configs, runner_id: local} + ai.pop('local-agent', None) + config['ai'] = ai + update = {'config': config} + prefs = pipeline.get('extensions_preferences') + if isinstance(prefs, dict) and prefs.get('enable_all_plugins') is False: + plugins = prefs.get('plugins') if isinstance(prefs.get('plugins'), list) else [] + entry = {'author': 'langbot-team', 'name': 'LocalAgent'} + if not any( + isinstance(p, dict) and p.get('author') == entry['author'] and p.get('name') == entry['name'] + for p in plugins + ): + update['extensions_preferences'] = {**prefs, 'plugins': [*plugins, entry]} + await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], update) return {'uuid': args['pipeline_uuid'], 'url': f'/home/pipelines?id={args["pipeline_uuid"]}'} resource_id = await ap.knowledge_service.create_knowledge_base(context, args) return {'uuid': resource_id, 'url': f'/home/knowledge?id={resource_id}'} diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index f51e5b609..5a3dc296a 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -152,6 +152,28 @@ def _json(value): return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str) +_SKIPPED_STATES = ('already_current', 'not_legacy', 'unsupported_runner') + + +def _version_at_least(installed, minimum): + # Pins are reviewed minimums; never downgrade an installed newer plugin. + from packaging.version import InvalidVersion, Version + + if installed == minimum: + return True + try: + return Version(str(installed)) >= Version(str(minimum)) + except (InvalidVersion, TypeError): + return False + + +def _candidate(row, plan): + candidate = {**row, 'config': plan.get('config')} + if plan.get('extensions_preferences') is not None: + candidate['extensions_preferences'] = plan['extensions_preferences'] + return candidate + + def _fingerprint(row): # SQL timestamps are neither monotonic revisions nor portable CAS values. return hashlib.sha256( @@ -312,7 +334,7 @@ class PipelineMigrationService: pending = dict(snapshots[0]._mapping) source = pending['source_snapshot'] plan = plan_legacy_pipeline(source['config'], source['extensions_preferences']) - candidate = {**row, 'config': plan.get('config')} + candidate = _candidate(row, plan) if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row): raise MigrationError('planner_changed') facts = await self._plugin(ctx, plan) @@ -439,7 +461,7 @@ class PipelineMigrationService: sources = [] for row in await self._rows(ctx): _, _, _, state, _ = await self._plan(ctx, row) - if state not in ('already_current', 'not_legacy'): + if state not in _SKIPPED_STATES: sources.append(row) if not sources: raise MigrationError('nothing_to_migrate') @@ -482,8 +504,11 @@ class PipelineMigrationService: hard_blockers = [b for b in blockers if b['code'] not in ('plugin_missing', 'plugin_disabled')] if hard_blockers: raise MigrationError(hard_blockers[0]['code']) - if state in ('already_current', 'not_legacy'): - result.update(state='already_current', code=None) + if state in _SKIPPED_STATES: + result.update( + state='unsupported_runner' if state == 'unsupported_runner' else 'already_current', + code=None, + ) continue target = plan.get('target_plugin') if not target: @@ -505,7 +530,7 @@ class PipelineMigrationService: facts and facts['enabled'] and descriptor - and descriptor.plugin_version == target['version'] + and _version_at_least(descriptor.plugin_version, target['version']) ) if not ready: task_context.metadata['phase'] = 'installing' @@ -516,15 +541,22 @@ class PipelineMigrationService: try: await self._authorize(ctx) await self.ap.plugin_connector.require_workspace_context(execution) - await self.ap.plugin_connector.install_plugin( - PluginInstallSource.MARKETPLACE, - { - 'plugin_author': target['author'], - 'plugin_name': target['name'], - 'plugin_version': target['version'], - }, - task_context=install_context, - ) + spec = {'plugin_author': target['author'], 'plugin_name': target['name']} + try: + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, + {**spec, 'plugin_version': target['version']}, + task_context=install_context, + ) + except (MarketplacePluginVersionNotFoundError, httpx.HTTPStatusError) as exc: + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code != 404: + raise + # The reviewed pin was withdrawn: fall back to the + # marketplace latest; _verify_runtime still checks + # the schema against the planned config. + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, spec, task_context=install_context + ) install_code = None except PluginRuntimeNotConnectedError: install_code = 'plugin_runtime_unavailable' @@ -595,7 +627,7 @@ class PipelineMigrationService: descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None) if descriptor is None or 'agent' not in descriptor.usages: raise MigrationError('runner_unavailable') - if descriptor.plugin_version != plan['target_plugin']['version']: + if not _version_at_least(descriptor.plugin_version, plan['target_plugin']['version']): raise MigrationError('plugin_version_incompatible') config = plan['config'] RunnerConfigResolver.validate_pipeline_config(config) @@ -685,12 +717,23 @@ class PipelineMigrationService: ) async def _verify_resources(self, execution, descriptor, runner_config): - """Fail closed on references the scoped Host cannot authorize. + """Fail closed on unauthorized grants; prune dangling references. This runs only on explicit execution, outside the mutation transaction. Resource use is still reauthorized by the Host on every eventual run. """ + async def has_row(model, identifier, *, column=None): + column = model.uuid if column is None else column + return ( + await self.pm.execute_async( + sa.select(model.uuid).where( + model.workspace_uuid == execution.workspace_uuid, + column == identifier, + ) + ) + ).first() is not None + async def require_row(model, identifier, *, column=None): column = model.uuid if column is None else column found = ( @@ -715,20 +758,26 @@ class PipelineMigrationService: if value and value not in config_schema.NONE_SENTINELS: await require_row(EmbeddingModel, value) + # Dangling knowledge base / tool / MCP references (deleted in 4.10) + # are pruned rather than blocking the migration. kb_ids = runner_config.get('knowledge-bases', []) - if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids): - raise MigrationError('runner_resource_unavailable') + if not isinstance(kb_ids, list): + kb_ids = [] + kb_ids = [v for v in kb_ids if isinstance(v, str) and v and await has_row(KnowledgeBase, v)] + if 'knowledge-bases' in runner_config: + runner_config['knowledge-bases'] = kb_ids if kb_ids: if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr( permissions, 'knowledge_bases', [] ): raise MigrationError('runner_resource_unavailable') - for kb_id in kb_ids: - await require_row(KnowledgeBase, kb_id) tool_names = runner_config.get('tools', []) - if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names): - raise MigrationError('runner_resource_unavailable') + if not isinstance(tool_names, list): + tool_names = [] + tool_names = [v for v in tool_names if isinstance(v, str) and v] + if 'tools' in runner_config: + runner_config['tools'] = tool_names tool_grant = ( runner_config.get('enable-all-tools', False) or tool_names @@ -745,20 +794,22 @@ class PipelineMigrationService: descriptor, runner_config, ) - if not set(tool_names) <= {tool['tool_name'] for tool in tools}: - raise MigrationError('runner_resource_unavailable') + available = {tool['tool_name'] for tool in tools} + if 'tools' in runner_config: + runner_config['tools'] = [v for v in tool_names if v in available] - for attachment in runner_config.get('mcp-resources', []): - if not isinstance(attachment, dict): - raise MigrationError('runner_resource_unavailable') - identifier = attachment.get('server_uuid') or attachment.get('server_id') - name = attachment.get('server_name') - if identifier: - await require_row(MCPServer, identifier) - elif name: - await require_row(MCPServer, name, column=MCPServer.name) - else: - raise MigrationError('runner_resource_unavailable') + if 'mcp-resources' in runner_config: + kept = [] + for attachment in runner_config.get('mcp-resources') or []: + if not isinstance(attachment, dict): + continue + identifier = attachment.get('server_uuid') or attachment.get('server_id') + name = attachment.get('server_name') + if (identifier and await has_row(MCPServer, identifier)) or ( + not identifier and name and await has_row(MCPServer, name, column=MCPServer.name) + ): + kept.append(attachment) + runner_config['mcp-resources'] = kept async def _lock(self, ctx, pipeline_uuid, session): # Every pending producer shares this row and revalidates its captured @@ -792,11 +843,15 @@ class PipelineMigrationService: if pending: return pending['uuid'], pending['target_fingerprint'] target = {**row, 'config': candidate['config']} + values = {'config': candidate['config']} + if plan.get('extensions_preferences') is not None: + target['extensions_preferences'] = plan['extensions_preferences'] + values['extensions_preferences'] = plan['extensions_preferences'] t = LegacyPipeline.__table__ cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']] for key in ('config', 'stages', 'extensions_preferences'): cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key]) - result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config'])) + result = await self.pm.execute_async(sa.update(t).where(*cas).values(**values)) if result.rowcount != 1: raise MigrationError('preview_stale') snapshot_uuid = str(uuid.uuid4()) @@ -861,6 +916,8 @@ class PipelineMigrationService: RunnerConfigResolver.validate_pipeline_config(plan['config']) candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')} candidate_entity['config'] = copy.deepcopy(plan['config']) + if plan.get('extensions_preferences') is not None: + candidate_entity['extensions_preferences'] = copy.deepcopy(plan['extensions_preferences']) runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity)) if not data_only and await self._verify_runtime(execution, plan) != runtime_schema: raise MigrationError('runner_schema_changed') diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index a3df33af0..7c44d05c0 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -19,18 +19,21 @@ import re from urllib.parse import urlsplit from string import Formatter -PLANNER_VERSION = '4' +PLANNER_VERSION = '5' +# Minimum reviewed plugin versions. The marketplace delists old releases, so the +# installer falls back to the latest release when a pin is gone; the Host then +# validates the emitted config against the installed runner schema. _TARGETS = { - 'local-agent': ('LocalAgent', '0.1.10'), - 'dify-service-api': ('DifyAgent', '0.1.10'), - 'coze-api': ('CozeAgent', '0.1.10'), - 'dashscope-app-api': ('DashScopeAgent', '0.1.10'), - 'n8n-service-api': ('N8nAgent', '0.1.10'), - 'langflow-api': ('LangflowAgent', '0.1.10'), - 'deerflow-api': ('DeerFlowAgent', '0.1.10'), - 'tbox-app-api': ('TboxAgent', '0.1.8'), - 'weknora-api': ('WeKnoraAgent', '0.1.10'), + 'local-agent': ('LocalAgent', '0.2.1'), + 'dify-service-api': ('DifyAgent', '0.2.3'), + 'coze-api': ('CozeAgent', '0.2.0'), + 'dashscope-app-api': ('DashScopeAgent', '0.2.2'), + 'n8n-service-api': ('N8nAgent', '0.2.0'), + 'langflow-api': ('LangflowAgent', '0.2.2'), + 'deerflow-api': ('DeerFlowAgent', '0.2.1'), + 'tbox-app-api': ('TboxAgent', '0.2.1'), + 'weknora-api': ('WeKnoraAgent', '0.2.1'), } _DEERFLOW_FIELDS = { 'api-base', @@ -192,6 +195,51 @@ _DEFAULTS = { 'advanced-settings': False, }, } +# Missing values for these fields cannot be invented: they are credentials or +# endpoints. Everything else is repaired from reviewed 4.10 defaults. +_CRITICAL = { + 'local-agent': ('model',), + 'dify-service-api': ('base-url', 'api-key'), + 'coze-api': ('api-key', 'bot-id'), + 'dashscope-app-api': ('api-key', 'app-id'), + 'n8n-service-api': ('webhook-url',), + 'langflow-api': ('base-url', 'api-key', 'flow-id'), + 'deerflow-api': ('api-base',), + 'tbox-app-api': ('api-key', 'app-id'), + 'weknora-api': ('base-url', 'api-key'), +} +_LOCAL_PROMPT = [{'role': 'system', 'content': 'You are a helpful assistant.'}] +# 4.10 UI defaults for required, non-critical fields. +_FILL = { + 'local-agent': {'prompt': _LOCAL_PROMPT, 'box-session-id-template': '{launcher_type}_{launcher_id}'}, + 'dify-service-api': { + 'app-type': 'chat', + 'base-prompt': ( + 'When the file content is readable, please read the content of this file. ' + 'When the file is an image, describe the content of this image.' + ), + }, + 'coze-api': {'api-base': 'https://api.coze.cn', 'timeout': 120}, + 'dashscope-app-api': {'app-type': 'agent'}, + 'weknora-api': {'app-type': 'chat'}, +} +_INT_TIMEOUT = {'deerflow-api', 'n8n-service-api', 'dify-service-api'} +_TIMEOUT_DEFAULT = { + 'dify-service-api': 30, + 'coze-api': 120, + 'dashscope-app-api': 120, + 'n8n-service-api': 120, + 'deerflow-api': 300, + 'tbox-app-api': 120, + 'weknora-api': 120, + 'local-agent': 300, +} +_ALIASES = { + # legacy runner: (UI/hyphen key, alternate key, default when both missing) + 'coze-api': [('auto-save-history', 'auto_save_history')], + 'dashscope-app-api': [('references-quote', 'references_quote')], + 'langflow-api': [('input-type', 'input_type'), ('output-type', 'output_type')], +} _ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'} _REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'} _REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'} @@ -240,38 +288,6 @@ def _attachments(value): ) -def _validate_preferences(result, preferences, name): - if preferences is None: - return - if type(preferences) is not dict or not _strict_json(preferences): - _block(result, 'invalid_extension_preferences', 'extensions_preferences') - return - for field in ( - 'enable_all_plugins', - 'enable_all_mcp_servers', - 'enable_all_skills', - 'mcp_resource_agent_read_enabled', - ): - if field in preferences and type(preferences[field]) is not bool: - _block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') - for field in ('mcp_servers', 'skills'): - if field in preferences and not _names(preferences[field]): - _block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') - if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']): - _block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources') - plugins = preferences.get('plugins', []) - if type(plugins) is not list or not all( - type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins - ): - _block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins') - elif preferences.get('enable_all_plugins', True) is False and not any( - item['author'] == 'langbot-team' and item['name'] == name for item in plugins - ): - # The immutable result contract contains no extension-preference patch. - # Require explicit binding instead of silently flipping allow-all. - _block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins') - - def _warn(result, code, field): diagnostic = {'code': code, 'field': field} if diagnostic not in result['warnings']: @@ -321,42 +337,138 @@ def _valid_prompt(prompt): return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt) -def _validate_local(result, section): +_TRUE = {'true', '1', 'yes', 'on'} +_FALSE = {'false', '0', 'no', 'off'} +_CUSTOM_RUNNER_NAME = re.compile(r'[A-Za-z0-9_.\-]{1,64}') +_DEFAULT_BOX_TEMPLATE = '{launcher_type}_{launcher_id}' +_HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+") + + +def _coerce(kind, value): + """Best-effort, lossless coercion. Return (ok, value).""" + if kind == 'bool': + if type(value) is bool: + return True, value + if type(value) is int and value in (0, 1): + return True, bool(value) + if type(value) is str and value.strip().lower() in _TRUE: + return True, True + if type(value) is str and value.strip().lower() in _FALSE: + return True, False + return False, None + if kind in ('int', 'number'): + if type(value) is int: + return True, value + number = value + if type(value) is str and value.strip(): + text = value.strip() + try: + return True, int(text) + except ValueError: + pass + try: + number = float(text) + except ValueError: + return False, None + if type(number) is not float or not math.isfinite(number): + return False, None + if number.is_integer(): + return True, int(number) + return (kind == 'number'), (number if kind == 'number' else None) + if kind == 'names': + if type(value) is str: + return True, ([value.strip()] if value.strip() else []) + if type(value) is list: + return True, [v.strip() for v in value if type(v) is str and v.strip()] + return False, None + if kind == 'attachments': + if type(value) is list: + return True, [v for v in value if type(v) is dict and ('enabled' not in v or type(v['enabled']) is bool)] + return False, None + if type(value) is str: + return True, value + if type(value) in (int, float): + return True, str(value) + return False, None + + +def _kind(legacy, field): + if field in _BOOLEANS: + return 'bool' + if field in _INTEGERS: + return 'int' + if field == 'timeout': + return 'int' if legacy in _INT_TIMEOUT else 'number' + if field in _NAME_LISTS: + return 'names' + if field == 'mcp-resources': + return 'attachments' + return 'str' + + +def _normalize_local(result, section): + """Repair the LocalAgent model/prompt/template; never block on the model.""" prefix = 'ai.local-agent' model = section.get('model') if type(model) is dict: if set(model) - {'primary', 'fallbacks', 'reasoning'}: - _block(result, 'unknown_field', f'{prefix}.model') - if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])): - _block(result, 'invalid_type', f'{prefix}.model') + _warn(result, 'migration.unknown_field_dropped', f'{prefix}.model') + primary = model.get('primary') + if type(primary) is not str: + _warn(result, 'migration.missing_default_filled', f'{prefix}.model') + primary = '' + raw_fallbacks = model.get('fallbacks', []) + ok, fallbacks = _coerce('names', raw_fallbacks) + if not ok or fallbacks != raw_fallbacks: + _warn(result, 'migration.value_coerced', f'{prefix}.model.fallbacks') reasoning = model.get('reasoning', {}) - if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()): - _block(result, 'invalid_type', f'{prefix}.model.reasoning') - elif any(value not in _REASONING_LEVELS for value in reasoning.values()): - _block(result, 'local.reasoning_value', f'{prefix}.model') - elif reasoning: + if type(reasoning) is not dict: + _warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning') + reasoning = {} + valid = {k: v for k, v in reasoning.items() if type(v) is str and v in _REASONING_LEVELS} + if len(valid) != len(reasoning): + _warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning') + if valid: _warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning') - elif type(model) is not str or not model: - _block(result, 'invalid_type', f'{prefix}.model') - if not _valid_prompt(section.get('prompt')): - _block(result, 'local.prompt_shape', f'{prefix}.prompt') + section['model'] = {'primary': primary, 'fallbacks': fallbacks or [], 'reasoning': valid} + elif type(model) is str and model.strip(): + section['model'] = {'primary': model, 'fallbacks': [], 'reasoning': {}} + else: + # No usable model: migrate anyway; the user picks one after migration. + _warn(result, 'migration.missing_default_filled', f'{prefix}.model') + section['model'] = {'primary': '', 'fallbacks': [], 'reasoning': {}} + prompt = section.get('prompt') + if prompt is None: + section['prompt'] = copy.deepcopy(_LOCAL_PROMPT) + _warn(result, 'migration.missing_default_filled', f'{prefix}.prompt') + elif type(prompt) is str: + section['prompt'] = [{'role': 'system', 'content': prompt}] + _warn(result, 'local.prompt_normalized', f'{prefix}.prompt') + elif not _valid_prompt(prompt): + kept = [] + if type(prompt) is list: + for item in prompt: + if type(item) is dict and type(item.get('role')) is str and type(item.get('content')) is str: + kept.append({'role': item['role'], 'content': item['content']}) + section['prompt'] = kept if kept and _valid_prompt(kept) else copy.deepcopy(_LOCAL_PROMPT) + _warn(result, 'local.prompt_normalized', f'{prefix}.prompt') _warn(result, 'local.context_defaults', f'{prefix}.max-round') _warn(result, 'local.serial_tools_preserved', f'{prefix}.tools') _warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases') - template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}') - if type(template) is not str or not template.strip(): - _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') - else: + template = section.get('box-session-id-template', _DEFAULT_BOX_TEMPLATE) + valid_template = type(template) is str and bool(template.strip()) + if valid_template: try: - fields = list(Formatter().parse(template)) - if any( + valid_template = not any( name is not None and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion) - for _, name, spec, conversion in fields - ): - raise ValueError('Only named interpolation variables are supported') + for _, name, spec, conversion in Formatter().parse(template) + ) except ValueError: - _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') + valid_template = False + if not valid_template: + section['box-session-id-template'] = _DEFAULT_BOX_TEMPLATE + _warn(result, 'local.box_template_defaulted', f'{prefix}.box-session-id-template') _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') @@ -380,36 +492,28 @@ def _parse_tweaks(raw): return decoded -def _validate_external(result, legacy, section): +def _default_for(legacy, field): + for source in (_FILL.get(legacy, {}), _DEFAULTS[legacy]): + if field in source: + return True, copy.deepcopy(source[field]) + return False, None + + +def _normalize_external(result, legacy, section): prefix = f'ai.{legacy}' app_types = { 'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'), 'dashscope-app-api': ('agent', 'workflow'), 'weknora-api': ('chat', 'agent'), } - required_strings = { - 'dify-service-api': ('api-key', 'base-url'), - 'coze-api': ('api-key', 'bot-id'), - 'dashscope-app-api': ('api-key', 'app-id'), - 'n8n-service-api': ('webhook-url',), - 'langflow-api': ('base-url', 'api-key', 'flow-id'), - 'tbox-app-api': ('api-key', 'app-id'), - 'weknora-api': ('api-key', 'base-url'), - } - for field in required_strings.get(legacy, ()): + for field in _CRITICAL[legacy]: value = section.get(field) - if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())): + if type(value) is str and not value.strip(): _block(result, 'invalid_value', f'{prefix}.{field}') - if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]: - _block(result, 'invalid_value', f'{prefix}.app-type') + if legacy in app_types and section.get('app-type') not in app_types[legacy]: + section['app-type'] = _FILL[legacy]['app-type'] + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.app-type') if legacy == 'coze-api': - actual, alias = section.get('auto_save_history'), section.get('auto-save-history') - if type(actual) is bool and type(alias) is bool and actual != alias: - _block(result, 'coze.history_alias', f'{prefix}.auto_save_history') - if 'auto_save_history' in section or 'auto-save-history' in section: - _warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history') - if actual is None and alias is None: - _warn(result, 'migration.null_default', f'{prefix}.auto_save_history') base = section.get('api-base') try: parsed = urlsplit(base) if type(base) is str else None @@ -428,48 +532,35 @@ def _validate_external(result, legacy, section): except ValueError: _block(result, 'coze.custom_endpoint', f'{prefix}.api-base') _warn(result, 'coze.persistent_history', prefix) - elif legacy == 'dashscope-app-api': - if 'references-quote' in section: - if 'references_quote' in section and section['references_quote'] != section['references-quote']: - _block(result, 'dashscope.references_alias', f'{prefix}.references_quote') - else: - _warn(result, 'migration.alias_repaired', f'{prefix}.references-quote') elif legacy == 'langflow-api': - for axis in ('input', 'output'): - actual, ui = f'{axis}_type', f'{axis}-type' - if ui in section and section[ui] != section.get(actual, 'chat'): - _block(result, 'langflow.io_alias', f'{prefix}.{actual}') - if actual in section: - _warn(result, 'migration.alias_repaired', f'{prefix}.{actual}') - try: - _parse_tweaks(section.get('tweaks')) - except (ValueError, RecursionError): - _block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks') - if section.get('tweaks') is None or ( - type(section.get('tweaks')) is str - and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null') - ): + raw = section.get('tweaks') + if raw is None or (type(raw) is str and (not raw.strip() or raw.strip() == 'null')): _warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks') + try: + section['tweaks'] = copy.deepcopy(_parse_tweaks(raw)) + except (ValueError, RecursionError): + section['tweaks'] = {} + _warn(result, 'langflow.tweaks_invalid_defaulted', f'{prefix}.tweaks') _warn(result, 'langflow.persistent_history', prefix) elif legacy == 'dify-service-api': _warn(result, 'dify.timeout_default', f'{prefix}.timeout') elif legacy == 'n8n-service-api': - mode = section.get('auth-type', 'none') auth_fields = { 'none': (), 'basic': ('basic-username', 'basic-password'), 'jwt': ('jwt-secret',), 'header': ('header-name', 'header-value'), } - if type(mode) is not str or mode not in auth_fields: - _block(result, 'invalid_value', f'{prefix}.auth-type') - else: - for field in auth_fields[mode]: - if field not in section: - _block(result, 'missing_field', f'{prefix}.{field}') - response = section.get('response-handling', 'reply') - if response not in ('reply', 'ignore'): - _block(result, 'invalid_value', f'{prefix}.response-handling') + mode = section.get('auth-type', 'none') + if mode not in auth_fields: + mode = section['auth-type'] = 'none' + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.auth-type') + for field in auth_fields[mode]: + if field not in section: + _block(result, 'missing_field', f'{prefix}.{field}') + if section.get('response-handling', 'reply') not in ('reply', 'ignore'): + section['response-handling'] = 'reply' + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.response-handling') if mode == 'basic': for field in ('basic-username', 'basic-password'): value = section.get(field) @@ -481,75 +572,133 @@ def _validate_external(result, legacy, section): except (UnicodeEncodeError, ValueError): _block(result, 'n8n.basic_encoding', f'{prefix}.{field}') if mode == 'header' and type(section.get('header-name')) is str: - if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']): + if not _HEADER_NAME.fullmatch(section['header-name']): _block(result, 'n8n.header_name', f'{prefix}.header-name') _warn(result, 'n8n.session_ids_reset', prefix) - elif legacy == 'weknora-api': - knowledge = section.get('knowledge-base-ids') - if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge): - _block(result, 'invalid_value', f'{prefix}.knowledge-base-ids') - if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'): - timeout = section.get('timeout', 120) - if type(timeout) in (int, float) and timeout <= 0: - _block(result, 'invalid_value', f'{prefix}.timeout') - - -def _validate_section(result, legacy, section): - prefix = f'ai.{legacy}' - if set(section) - _FIELDS[legacy]: - _block(result, 'unknown_field', prefix) - for field in _REQUIRED[legacy]: - if field not in section: - _block(result, 'missing_field', f'{prefix}.{field}') - # Iterate only known keys, never put an arbitrary saved key in diagnostics. - for field in sorted(_FIELDS[legacy] & section.keys()): - value = section[field] - if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'): - continue - if value is None and ( - (legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history')) - or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids')) - ): - continue - if field in _BOOLEANS: - valid = type(value) is bool - elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'): - valid = type(value) is int - elif field == 'timeout': - valid = type(value) in (int, float) - elif field in _NAME_LISTS: - valid = _names(value) - elif field == 'mcp-resources': - valid = _attachments(value) - else: - valid = type(value) is str - if not valid: - _block(result, 'invalid_type', f'{prefix}.{field}') - if legacy == 'local-agent': - _validate_local(result, section) - else: - _validate_external(result, legacy, section) - if legacy == 'deerflow-api': + elif legacy == 'deerflow-api': base = section.get('api-base') if type(base) is str and not base.strip().startswith(('http://', 'https://')): _block(result, 'invalid_value', f'{prefix}.api-base') -def _validate_output(result, config, legacy): +def _normalize_section(result, legacy, section): + """Return a repaired copy of a legacy runner section (value-free diagnostics).""" + prefix = f'ai.{legacy}' + critical = _CRITICAL[legacy] + if set(section) - _FIELDS[legacy]: + _warn(result, 'migration.unknown_field_dropped', prefix) + # Iterate only known keys, never put an arbitrary saved key in diagnostics. + known = {k: copy.deepcopy(section[k]) for k in sorted(_FIELDS[legacy] & section.keys())} + for preferred, other in _ALIASES.get(legacy, []): + # The UI (hyphen) key wins; dashscope's plugin schema keeps the underscore. + output = 'references_quote' if legacy == 'dashscope-app-api' else preferred + drop = other if output == preferred else preferred + if drop not in known: + continue + a, b = known.get(preferred), known.get(other) + if a is not None and b is not None and a != b: + _warn(result, 'migration.alias_conflict_resolved', f'{prefix}.{drop}') + else: + _warn(result, 'migration.alias_repaired', f'{prefix}.{drop}') + known.pop(drop) + known[output] = a if a is not None else b + if legacy == 'coze-api' and known.get('auto-save-history') is None: + known['auto-save-history'] = True + _warn(result, 'migration.null_default', f'{prefix}.auto_save_history') + for field in _REQUIRED[legacy]: + if field not in known and field in critical: + _block(result, 'missing_field', f'{prefix}.{field}') + for field in sorted(known): + value = known[field] + if field in ('model', 'prompt', 'tweaks'): + continue + if value is None and legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'): + continue + ok, coerced = (False, None) if value is None else _coerce(_kind(legacy, field), value) + if ok and field == 'timeout' and coerced <= 0: + ok = False + if ok: + if coerced != value or type(coerced) is not type(value): + _warn(result, 'migration.value_coerced', f'{prefix}.{field}') + known[field] = coerced + elif field in critical: + _block(result, 'invalid_type', f'{prefix}.{field}') + else: + known.pop(field) + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.{field}') + for field in _REQUIRED[legacy]: + if field not in known and field not in critical: + has_default, default = _default_for(legacy, field) + if has_default: + known[field] = default + if field != 'prompt': + _warn(result, 'migration.missing_default_filled', f'{prefix}.{field}') + if legacy == 'local-agent': + _normalize_local(result, known) + else: + _normalize_external(result, legacy, known) + return known + + +def _normalize_preferences(result, preferences, name): + """Ignore malformed preference fields; return a patch only to bind the runner.""" + if preferences is None: + return None + if type(preferences) is not dict or not _strict_json(preferences): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences') + return None + for field in ( + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + 'mcp_resource_agent_read_enabled', + ): + if field in preferences and type(preferences[field]) is not bool: + _warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') + for field in ('mcp_servers', 'skills'): + if field in preferences and not _names(preferences[field]): + _warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') + if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources') + plugins = preferences.get('plugins', []) + + def valid(item): + return type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) + + kept = [item for item in plugins if valid(item)] if type(plugins) is list else [] + if type(plugins) is not list or len(kept) != len(plugins): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences.plugins') + if preferences.get('enable_all_plugins', True) is False and not any( + item['author'] == 'langbot-team' and item['name'] == name for item in kept + ): + # Bind exactly the migrated runner plugin; never flip allow-all. + patched = copy.deepcopy(preferences) + patched['plugins'] = [*copy.deepcopy(kept), {'author': 'langbot-team', 'name': name}] + _warn(result, 'extensions.runner_added', 'extensions_preferences.plugins') + return patched + return None + + +def _normalize_output(result, config): output = config.get('output', {}) if type(output) is not dict: - _block(result, 'invalid_type', 'output') - return + _warn(result, 'migration.invalid_value_defaulted', 'output') + return False misc = output.get('misc', {}) if type(misc) is not dict: - _block(result, 'invalid_type', 'output.misc') - return + _warn(result, 'migration.invalid_value_defaulted', 'output.misc') + return False value = misc.get('remove-think', False) + ok, coerced = _coerce('bool', value) + if not ok: + _warn(result, 'migration.invalid_value_defaulted', 'output.misc.remove-think') + return False if type(value) is not bool: - _block(result, 'invalid_type', 'output.misc.remove-think') + _warn(result, 'migration.value_coerced', 'output.misc.remove-think') + return coerced -def _assemble(result, legacy, section, config, preferences): +def _assemble(result, legacy, section, remove_think, preferences): selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)} prefix = f'ai.{legacy}' if legacy in _IDENTITY_SOURCES: @@ -557,18 +706,18 @@ def _assemble(result, legacy, section, config, preferences): if legacy == 'local-agent': selected.pop('max-round', None) singular = selected.pop('knowledge-base', '') - if not selected['knowledge-bases'] and singular and singular != '__none__': + if not selected['knowledge-bases'] and type(singular) is str and singular and singular != '__none__': selected['knowledge-bases'] = [singular] _warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base') - model = selected['model'] - selected['model'] = ( - {'primary': model, 'fallbacks': [], 'reasoning': {}} - if type(model) is str - else {'fallbacks': [], 'reasoning': {}, **model} + preferences = preferences if type(preferences) is dict else {} + resources = preferences.get('mcp_resources', []) + read = preferences.get('mcp_resource_agent_read_enabled', True) + selected['mcp-resources'] = copy.deepcopy( + section.get('mcp-resources', resources if _attachments(resources) else []) + ) + selected['mcp-resource-agent-read-enabled'] = section.get( + 'mcp-resource-agent-read-enabled', read if type(read) is bool else True ) - preferences = preferences or {} - for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)): - selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default))) else: selected.update( { @@ -582,26 +731,15 @@ def _assemble(result, legacy, section, config, preferences): if legacy in _ASSET_RUNNERS: selected['langbot-assets-enabled'] = False if legacy in _REMOVE_THINK_RUNNERS: - selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False) + selected['remove-think'] = remove_think _warn(result, 'migration.output_policy_copied', 'output.misc.remove-think') if legacy == 'dify-service-api': selected['timeout'] = 30 - elif legacy == 'coze-api': - actual = selected.pop('auto_save_history', None) - selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history') - if selected['auto-save-history'] is None: - selected['auto-save-history'] = True - elif legacy == 'dashscope-app-api': - if 'references-quote' in selected: - selected['references_quote'] = selected.pop('references-quote') - elif legacy == 'langflow-api': - for axis in ('input', 'output'): - selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type']) - selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks'))) elif legacy == 'weknora-api': - selected.setdefault( - 'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning' - ) + if selected.get('agent-id') is None: + selected['agent-id'] = ( + 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning' + ) if selected['knowledge-base-ids'] is None: selected['knowledge-base-ids'] = [] _warn(result, 'weknora.session_title_changed', prefix) @@ -616,6 +754,7 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: 'target_runner_id': None, 'target_plugin': None, 'config': None, + 'extensions_preferences': None, 'changed_paths': [], 'blockers': [], 'warnings': [], @@ -630,26 +769,43 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: selection = ai.get('runner', {}) if type(selection) is not dict: return _block(result, 'invalid_type', 'ai.runner') - if 'id' in selection and 'runner' in selection: - return _block(result, 'mixed_runner_selection', 'ai.runner') + legacy = selection.get('runner') + known_legacy = type(legacy) is str and legacy in _TARGETS if 'id' in selection: current = selection['id'] - # A blank runner id paired with no legacy runner section means the saved - # pipeline simply has no runner selected. There is nothing to migrate and - # nothing to convert, so report not_legacy instead of failing the whole - # batch with a malformed-id blocker. - if type(current) is str and not current.strip() and not any(legacy in ai for legacy in _TARGETS): + valid_id = type(current) is str and bool(re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current)) + if 'runner' in selection: + # Prefer a valid current id; otherwise migrate the legacy selection. + if valid_id: + _warn(result, 'migration.mixed_runner_selection', 'ai.runner') + result['state'] = 'already_current' + return result + if not known_legacy: + return _block(result, 'mixed_runner_selection', 'ai.runner') + _warn(result, 'migration.mixed_runner_selection', 'ai.runner') + else: + # A blank runner id paired with no legacy runner section means the + # saved pipeline simply has no runner selected: nothing to migrate. + if type(current) is str and not current.strip() and not any(name in ai for name in _TARGETS): + return result + if not valid_id: + return _block(result, 'invalid_runner_id', 'ai.runner.id') + result['state'] = 'already_current' return result - if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current): - return _block(result, 'invalid_runner_id', 'ai.runner.id') - result['state'] = 'already_current' - return result if 'runner' not in selection: return result - legacy = selection['runner'] if type(legacy) is not str: return _block(result, 'invalid_type', 'ai.runner.runner') - if legacy not in _TARGETS: + if not known_legacy: + if not legacy.strip(): + return result + # 4.10 allowed in-tree custom runners (@runner_class). They have no + # plugin equivalent; report them without failing other pipelines. + result.update( + state='unsupported_runner', + legacy_runner=legacy if _CUSTOM_RUNNER_NAME.fullmatch(legacy) else None, + ) + _warn(result, 'legacy.custom_runner_unsupported', 'ai.runner.runner') return result name, version = _TARGETS[legacy] target = f'plugin:langbot-team/{name}/default' @@ -658,27 +814,27 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: target_runner_id=target, target_plugin={'author': 'langbot-team', 'name': name, 'version': version}, ) - if 'runner_config' in ai: - if type(ai['runner_config']) is not dict: - return _block(result, 'invalid_type', 'ai.runner_config') - if ai['runner_config']: - return _block(result, 'mixed_runner_config', 'ai.runner_config') - if 'expire-time' in selection: - expiry = selection['expire-time'] - if type(expiry) is not int or expiry < 0: - _block(result, 'invalid_expiry', 'ai.runner.expire-time') + if 'runner_config' in ai and (type(ai['runner_config']) is not dict or ai['runner_config']): + _warn(result, 'migration.runner_config_replaced', 'ai.runner_config') + expiry = selection.get('expire-time', 0) + ok, expiry_value = _coerce('int', expiry) + if not ok or expiry_value < 0: + expiry_value = 0 + _warn(result, 'migration.invalid_value_defaulted', 'ai.runner.expire-time') + elif type(expiry) is not int: + _warn(result, 'migration.value_coerced', 'ai.runner.expire-time') if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}: - _block(result, 'unknown_field', 'ai') - if set(selection) - {'runner', 'expire-time'}: - _block(result, 'unknown_field', 'ai.runner') + _warn(result, 'migration.unknown_section_archived', 'ai') + if set(selection) - {'runner', 'expire-time', 'id'}: + _warn(result, 'migration.unknown_field_dropped', 'ai.runner') if legacy not in ai: return _block(result, 'missing_field', f'ai.{legacy}') section = ai[legacy] if type(section) is not dict: return _block(result, 'invalid_type', f'ai.{legacy}') - _validate_section(result, legacy, section) - _validate_preferences(result, extensions_preferences, name) - _validate_output(result, config, legacy) + section = _normalize_section(result, legacy, section) + patched_preferences = _normalize_preferences(result, extensions_preferences, name) + remove_think = _normalize_output(result, config) _warn(result, 'external.state_validation_required', 'ai.runner') _warn(result, 'migration.history_reset', 'ai.runner') _warn(result, 'migration.new_defaults', f'ai.{legacy}') @@ -689,22 +845,22 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: _warn(result, 'migration.identity_preserved', f'ai.{legacy}') if result['blockers']: return result + selected = _assemble(result, legacy, section, remove_think, extensions_preferences) candidate = copy.deepcopy(config) - candidate['ai']['runner'].pop('runner') - candidate['ai']['runner']['id'] = target - selected = _assemble(result, legacy, section, config, extensions_preferences) - candidate['ai']['runner_config'] = {target: selected} - # The durable transaction stores the full source, including inactive legacy - # credentials. The active configuration must be canonical so the guarded - # editor never needs to interpret mixed legacy/current containers. - archived_paths = [] - for old_runner in _TARGETS: - if old_runner in candidate['ai']: - candidate['ai'].pop(old_runner) - archived_paths.append(f'ai.{old_runner}') + # The durable snapshot stores the full source, including inactive legacy + # credentials and unknown leftovers. The active configuration is canonical. + candidate['ai'] = { + 'runner': {'id': target, 'expire-time': expiry_value}, + 'runner_config': {target: selected}, + } + archived_paths = [f'ai.{old}' for old in _TARGETS if old in ai] + changed = ['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths] + if patched_preferences is not None: + changed.append('extensions_preferences.plugins') result.update( state='ready', config=candidate, - changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths], + extensions_preferences=patched_preferences, + changed_paths=changed, ) return result diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 5b2356802..61137e0f2 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -2805,7 +2805,7 @@ class RuntimeConnectionHandler(handler.Handler): return await self.call_action( LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION, request.model_dump(exclude_none=True), - timeout=120, + timeout=600, # dependency install can be slow ) async def remove_plugin_installation( @@ -2829,7 +2829,7 @@ class RuntimeConnectionHandler(handler.Handler): 'install_source': install_source, 'install_info': install_info, }, - timeout=120, + timeout=600, # dependency install can be slow ) async for ret in gen: @@ -2843,7 +2843,7 @@ class RuntimeConnectionHandler(handler.Handler): 'plugin_author': plugin_author, 'plugin_name': plugin_name, }, - timeout=120, + timeout=600, # dependency install can be slow ) async for ret in gen: diff --git a/tests/unit_tests/api/service/test_pipeline_migration_resources.py b/tests/unit_tests/api/service/test_pipeline_migration_resources.py index aa169a569..c0b865ff6 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration_resources.py +++ b/tests/unit_tests/api/service/test_pipeline_migration_resources.py @@ -103,20 +103,28 @@ async def test_valid_local_resources_and_real_descriptor_options(local): @pytest.mark.asyncio -@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool']) -async def test_foreign_or_unresolved_resource_is_denied(local, resource): +async def test_foreign_or_unresolved_model_is_denied(local): env, _, config, plan = local - if resource == 'model': - config['model']['primary'] = 'model_foreign' - elif resource == 'kb': + config['model']['primary'] = 'model_foreign' + async with env.pm.tenant_scope(WS): + with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('resource', ['kb', 'mcp', 'tool']) +async def test_foreign_or_unresolved_reference_is_pruned(local, resource): + env, _, config, plan = local + if resource == 'kb': config['knowledge-bases'] = ['kb_foreign'] elif resource == 'mcp': config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign' else: config['tools'] = ['foreign_tool'] async with env.pm.tenant_scope(WS): - with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'): - await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + key = {'kb': 'knowledge-bases', 'mcp': 'mcp-resources', 'tool': 'tools'}[resource] + assert config[key] == [] @pytest.mark.asyncio diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index d054b58ee..b3ea09e17 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -1,4 +1,4 @@ -"""Behavioral tests for the pure, fail-closed legacy migration planner.""" +"""Behavioral tests for the pure legacy migration planner (relaxed v5 semantics).""" import copy import importlib @@ -11,15 +11,15 @@ import pytest FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text()) TARGETS = { - 'local-agent': ('LocalAgent', '0.1.10', None), - 'dify-service-api': ('DifyAgent', '0.1.10', None), - 'coze-api': ('CozeAgent', '0.1.10', None), - 'dashscope-app-api': ('DashScopeAgent', '0.1.10', None), - 'n8n-service-api': ('N8nAgent', '0.1.10', None), - 'langflow-api': ('LangflowAgent', '0.1.10', None), - 'deerflow-api': ('DeerFlowAgent', '0.1.10', None), - 'tbox-app-api': ('TboxAgent', '0.1.8', None), - 'weknora-api': ('WeKnoraAgent', '0.1.10', None), + 'local-agent': ('LocalAgent', '0.2.1', None), + 'dify-service-api': ('DifyAgent', '0.2.3', None), + 'coze-api': ('CozeAgent', '0.2.0', None), + 'dashscope-app-api': ('DashScopeAgent', '0.2.2', None), + 'n8n-service-api': ('N8nAgent', '0.2.0', None), + 'langflow-api': ('LangflowAgent', '0.2.2', None), + 'deerflow-api': ('DeerFlowAgent', '0.2.1', None), + 'tbox-app-api': ('TboxAgent', '0.2.1', None), + 'weknora-api': ('WeKnoraAgent', '0.2.1', None), } @@ -47,6 +47,21 @@ def assert_block(result, code, field=None): assert expected in result['blockers'] +def assert_repaired(result, code, field=None): + """Relaxed planner (v5): non-critical problems are repaired and reported as warnings.""" + assert result['state'] == 'ready', result['blockers'] + assert result['blockers'] == [] + assert result['config'] is not None + codes = {(w['code'], w.get('field')) for w in result['warnings']} + if field is None: + assert code in {c for c, _ in codes} + else: + assert (code, field) in codes + public = {key: value for key, value in result.items() if key != 'config'} + assert 'SECRET' not in json.dumps(public) + assert 'secret-' not in json.dumps(public) + + @pytest.mark.parametrize('runner', TARGETS) def test_recognizes_all_nine_exact_official_identities(runner): result = plan(source_for(runner)) @@ -140,11 +155,22 @@ def test_malformed_envelopes_are_safe_blockers(field, value): assert_block(plan(source), 'invalid_type', 'ai' if field == 'ai' else 'ai.runner') -@pytest.mark.parametrize('current', [None, '', False, {}, 'plugin:langbot-team/DeerFlowAgent/default']) -def test_mixed_selections_block_even_empty_current_id(current): +@pytest.mark.parametrize('current', [None, '', False, {}]) +def test_mixed_selection_with_unusable_current_id_migrates_legacy(current): source = source_for() source['ai']['runner']['id'] = current - assert_block(plan(source), 'mixed_runner_selection', 'ai.runner') + result = plan(source) + assert_repaired(result, 'migration.mixed_runner_selection', 'ai.runner') + assert result['config']['ai']['runner']['id'] == 'plugin:langbot-team/DeerFlowAgent/default' + assert 'runner' not in result['config']['ai']['runner'] + + +def test_mixed_selection_prefers_valid_current_id(): + source = source_for() + source['ai']['runner']['id'] = 'plugin:langbot-team/DeerFlowAgent/default' + result = plan(source) + assert result['state'] == 'already_current' + assert {'code': 'migration.mixed_runner_selection', 'field': 'ai.runner'} in result['warnings'] @pytest.mark.parametrize('value', [None, False, 0, '', [], 'secret']) @@ -154,33 +180,59 @@ def test_malformed_active_runner_section_is_not_defaulted(value): assert_block(plan(source), 'invalid_type', 'ai.deerflow-api') -def test_existing_runner_config_is_not_overwritten(): +def test_existing_runner_config_is_replaced_with_warning(): source = source_for() - source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'value'}} - assert_block(plan(source), 'mixed_runner_config', 'ai.runner_config') - - -def test_unknown_selection_is_not_echoed_as_public_legacy_id(): - source = source_for() - source['ai']['runner']['runner'] = 'secret-value' + source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'SECRET-value'}} result = plan(source) - assert result['state'] == 'not_legacy' - assert 'secret-value' not in json.dumps(result) + assert_repaired(result, 'migration.runner_config_replaced', 'ai.runner_config') + assert list(result['config']['ai']['runner_config']) == [result['target_runner_id']] + + +@pytest.mark.parametrize('name', ['my-runner', 'custom_runner']) +def test_custom_legacy_runner_is_unsupported_not_blocked(name): + source = source_for() + source['ai']['runner']['runner'] = name + result = plan(source) + assert result['state'] == 'unsupported_runner' + assert result['config'] is None + assert result['blockers'] == [] + assert result['changed_paths'] == [] + assert {'code': 'legacy.custom_runner_unsupported', 'field': 'ai.runner.runner'} in result['warnings'] + + +def test_custom_runner_diagnostics_never_echo_section_values(): + source = source_for() + source['ai']['runner']['runner'] = 'my-runner' + source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'} + result = plan(source) + assert result['state'] == 'unsupported_runner' + assert 'SECRET' not in json.dumps(result) + + +def test_leftover_custom_section_does_not_block_builtin_migration(): + source = source_for() + source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'} + result = plan(source) + assert_repaired(result, 'migration.unknown_section_archived', 'ai') + assert 'my-runner' not in result['config']['ai'] @pytest.mark.parametrize('value', [None, False, -1, 1.5, '0']) -def test_expiry_is_strict_nonnegative_integer(value): +def test_malformed_expiry_is_defaulted(value): source = source_for() source['ai']['runner']['expire-time'] = value - assert_block(plan(source), 'invalid_expiry', 'ai.runner.expire-time') + result = plan(source) + assert result['state'] == 'ready' + assert result['config']['ai']['runner']['expire-time'] == 0 -def test_unknown_active_field_name_and_value_never_leak(): +def test_unknown_active_field_is_dropped_without_leaking(): source = source_for() source['ai']['deerflow-api']['secret-field-name'] = 'secret-field-value' result = plan(source) - assert_block(result, 'unknown_field', 'ai.deerflow-api') - assert 'secret-field' not in json.dumps(result) + assert_repaired(result, 'migration.unknown_field_dropped', 'ai.deerflow-api') + assert 'secret-field' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) + assert 'secret-field-name' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize('value', [float('nan'), float('inf'), {1: 'secret'}, ('tuple',), b'secret']) @@ -198,10 +250,12 @@ def test_cyclic_input_blocks_without_recursion_error(): @pytest.mark.parametrize('runner', TARGETS) @pytest.mark.parametrize('value', [None, False, 0, '', [], {}]) -def test_unknown_active_keys_block_even_when_falsey(runner, value): +def test_unknown_active_keys_are_dropped_even_when_falsey(runner, value): source = source_for(runner) source['ai'][runner]['secret-unknown-key'] = value - assert_block(plan(source), 'unknown_field', f'ai.{runner}') + result = plan(source) + assert_repaired(result, 'migration.unknown_field_dropped', f'ai.{runner}') + assert 'secret-unknown-key' not in selected_config(result) @pytest.mark.parametrize('runner', TARGETS) @@ -217,8 +271,9 @@ def test_unknown_active_envelope_keys_are_not_echoed(container): target = source['ai'] if container == 'ai' else source['ai']['runner'] target['secret-unknown-key'] = 'secret-value' result = plan(source) - assert_block(result, 'unknown_field', 'ai' if container == 'ai' else 'ai.runner') - assert 'secret-' not in json.dumps(result) + code = 'migration.unknown_section_archived' if container == 'ai' else 'migration.unknown_field_dropped' + assert_repaired(result, code, 'ai' if container == 'ai' else 'ai.runner') + assert 'secret-' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize( @@ -237,17 +292,29 @@ def test_unknown_active_envelope_keys_are_not_echoed(container): {'mcp_resources': [False]}, ], ) -def test_malformed_extension_preferences_block_without_expanding_access(preferences): +def test_malformed_extension_preferences_are_ignored_without_expanding_access(preferences): result = plan(source_for(), preferences) - assert result['state'] == 'blocked' - assert any(item['code'] == 'invalid_extension_preferences' for item in result['blockers']) - assert 'secret' not in json.dumps(result) + assert result['state'] == 'ready' + assert any(item['code'] == 'invalid_extension_preferences' for item in result['warnings']) + assert result['extensions_preferences'] is None + assert 'secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) -def test_explicit_plugin_denylist_is_not_silently_broadened(): - preferences = {'enable_all_plugins': False, 'plugins': []} - assert_block(plan(source_for(), preferences), 'extensions.runner_excluded', 'extensions_preferences.plugins') - assert preferences == {'enable_all_plugins': False, 'plugins': []} +def test_explicit_plugin_allowlist_gets_only_the_target_runner_added(): + preferences = {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]} + result = plan(source_for(), preferences) + assert_repaired(result, 'extensions.runner_added', 'extensions_preferences.plugins') + assert result['extensions_preferences'] == { + 'enable_all_plugins': False, + 'plugins': [{'author': 'other', 'name': 'Plugin'}, {'author': 'langbot-team', 'name': 'DeerFlowAgent'}], + } + assert preferences == {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]} + + +def test_enable_all_plugins_needs_no_extension_patch(): + result = plan(source_for(), {'enable_all_plugins': True}) + assert result['state'] == 'ready' + assert result['extensions_preferences'] is None @pytest.mark.parametrize('field', ['api-key', 'auth-header']) @@ -274,29 +341,50 @@ def test_deerflow_option_trim_drift_is_not_repaired(field, value): @pytest.mark.parametrize('field', ['thinking-enabled', 'plan-mode', 'subagent-enabled']) -@pytest.mark.parametrize('value', [None, 0, 1, 'false', []]) -def test_deerflow_requires_real_booleans(field, value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, False, 'migration.invalid_value_defaulted'), + ([], False, 'migration.invalid_value_defaulted'), + (0, False, 'migration.value_coerced'), + (1, True, 'migration.value_coerced'), + ('false', False, 'migration.value_coerced'), + ('true', True, 'migration.value_coerced'), + ], +) +def test_deerflow_booleans_are_coerced_or_defaulted(field, value, expected, code): source = source_for() source['ai']['deerflow-api'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + result = plan(source) + assert_repaired(result, code, f'ai.deerflow-api.{field}') + assert selected_config(result)[field] is expected -@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) -@pytest.mark.parametrize('value', [None, False, 1.5, '3']) -def test_deerflow_requires_real_integers_without_coercion(field, value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, 300, 'migration.invalid_value_defaulted'), + (False, 300, 'migration.invalid_value_defaulted'), + (1.5, 300, 'migration.invalid_value_defaulted'), + ('3', 3, 'migration.value_coerced'), + (0, 300, 'migration.invalid_value_defaulted'), + (-1, 300, 'migration.invalid_value_defaulted'), + ], +) +def test_deerflow_timeout_is_coerced_or_defaulted(value, expected, code): source = source_for() - source['ai']['deerflow-api'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + source['ai']['deerflow-api']['timeout'] = value + result = plan(source) + assert_repaired(result, code, 'ai.deerflow-api.timeout') + assert selected_config(result)['timeout'] == expected -@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) +@pytest.mark.parametrize('field', ['max-concurrent-subagents', 'recursion-limit']) @pytest.mark.parametrize('value', [0, -1, 1]) def test_deerflow_does_not_invent_numeric_clamps(field, value): source = source_for() source['ai']['deerflow-api'][field] = value - result = plan(source) - assert result['state'] == 'ready' - assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value + assert selected_config(plan(source))[field] == value @pytest.mark.parametrize('value', [None, False, 0, [], '']) @@ -333,7 +421,7 @@ def test_deerflow_native_runtime_defaults_are_materialized_not_ui_defaults(): 'mcp-resources': [], 'mcp-resource-agent-read-enabled': False, } - assert 'expire-time' not in result['config']['ai']['runner'] + assert result['config']['ai']['runner']['expire-time'] == 0 def test_deerflow_missing_endpoint_blocks_instead_of_using_schema_localhost(): @@ -361,11 +449,21 @@ def test_ready_public_projection_contains_no_secrets_or_credentials_in_urls(): assert 'SECRET' not in json.dumps(public) -@pytest.mark.parametrize('value', [None, 0, 'false']) -def test_remove_think_flag_is_not_coerced(value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, False, 'migration.invalid_value_defaulted'), + (0, False, 'migration.value_coerced'), + ('false', False, 'migration.value_coerced'), + ('true', True, 'migration.value_coerced'), + ], +) +def test_remove_think_flag_is_coerced_or_defaulted(value, expected, code): source = source_for('tbox-app-api') source['output']['misc']['remove-think'] = value - assert_block(plan(source), 'invalid_type', 'output.misc.remove-think') + result = plan(source) + assert_repaired(result, code, 'output.misc.remove-think') + assert selected_config(result)['remove-think'] is expected @pytest.mark.parametrize( @@ -401,83 +499,130 @@ def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds): @pytest.mark.parametrize( - 'field,value,code', + 'field,value,code,expected', [ ( 'model', {'primary': 'model', 'fallbacks': [], 'reasoning': {'SECRET-model': 'invalid-level'}}, - 'local.reasoning_value', + 'local.reasoning_dropped', + {'primary': 'model', 'fallbacks': [], 'reasoning': {}}, + ), + ( + 'prompt', + [{'role': 'system', 'content': 42}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'You are a helpful assistant.'}], + ), + ( + 'prompt', + [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'text'}], + ), + ( + 'prompt', + [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'You are a helpful assistant.'}], ), - ('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'), - ('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'), - ('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'), ], ) -def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code): +def test_local_unsupported_behaviors_are_repaired_without_leaking(field, value, code, expected): source = source_for('local-agent') source['ai']['local-agent'][field] = value result = plan(source) - assert_block(result, code, f'ai.local-agent.{field}') - assert 'SECRET' not in json.dumps(result) + assert_repaired(result, code, f'ai.local-agent.{field}' + ('.reasoning' if field == 'model' else '')) + assert selected_config(result)[field] == expected + assert 'SECRET' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize( - 'field,value', + 'field,value,expected', [ - ('enable-all-tools', None), - ('enable-all-tools', 0), - ('tools', None), - ('tools', [42]), - ('mcp-resources', None), - ('mcp-resources', [False]), - ('mcp-resource-agent-read-enabled', 'false'), + ('enable-all-tools', None, True), + ('enable-all-tools', 0, False), + ('tools', None, []), + ('tools', [42], []), + ('mcp-resources', None, []), + ('mcp-resources', [False], []), + ('mcp-resource-agent-read-enabled', 'false', False), ], ) -def test_local_host_policy_malformed_values_block(field, value): +def test_local_host_policy_malformed_values_are_repaired(field, value, expected): source = source_for('local-agent') source['ai']['local-agent'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.local-agent.{field}') + result = plan(source) + assert result['state'] == 'ready' + assert selected_config(result)[field] == expected + codes = {(w['code'], w['field']) for w in result['warnings']} + assert codes & { + ('migration.value_coerced', f'ai.local-agent.{field}'), + ('migration.invalid_value_defaulted', f'ai.local-agent.{field}'), + } @pytest.mark.parametrize( 'runner,field', [ ('dify-service-api', 'api-key'), - ('dify-service-api', 'base-prompt'), - ('coze-api', 'timeout'), - ('coze-api', 'api-base'), ('langflow-api', 'flow-id'), ('n8n-service-api', 'webhook-url'), ('tbox-app-api', 'app-id'), - ('weknora-api', 'app-type'), ], ) -def test_defaultless_native_fields_are_not_filled_from_target_schema(runner, field): +def test_missing_required_credentials_and_endpoints_still_block(runner, field): source = source_for(runner) del source['ai'][runner][field] assert_block(plan(source), 'missing_field', f'ai.{runner}.{field}') -@pytest.mark.parametrize('value', [None, False, 0, [], {}]) -@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')]) +@pytest.mark.parametrize( + 'runner,field', + [ + ('dify-service-api', 'base-prompt'), + ('coze-api', 'timeout'), + ('coze-api', 'api-base'), + ('weknora-api', 'app-type'), + ], +) +def test_missing_optional_fields_are_filled_with_410_defaults(runner, field): + source = source_for(runner) + del source['ai'][runner][field] + result = plan(source) + assert_repaired(result, 'migration.missing_default_filled', f'ai.{runner}.{field}') + + +@pytest.mark.parametrize('value', [None, False, [], {}]) +@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api', 'deerflow-api')]) def test_external_api_keys_require_strings_even_if_target_coerces(runner, value): source = source_for(runner) source['ai'][runner]['api-key'] = value assert_block(plan(source), 'invalid_type', f'ai.{runner}.api-key') +@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')]) +def test_numeric_external_api_key_is_stringified(runner): + source = source_for(runner) + source['ai'][runner]['api-key'] = 0 + result = plan(source) + assert_repaired(result, 'migration.value_coerced', f'ai.{runner}.api-key') + assert selected_config(result)['api-key'] == '0' + + @pytest.mark.parametrize( - 'section', + 'section,expected', [ - {'auto_save_history': False, 'auto-save-history': True}, - {'auto_save_history': True, 'auto-save-history': False}, + ({'auto_save_history': False, 'auto-save-history': True}, True), + ({'auto_save_history': True, 'auto-save-history': False}, False), ], ) -def test_coze_actual_underscore_history_key_missing_null_or_conflict_blocks(section): +def test_coze_history_alias_conflict_uses_canonical_field(section, expected): source = source_for('coze-api') source['ai']['coze-api'].pop('auto_save_history') source['ai']['coze-api'].update(section) - assert_block(plan(source), 'coze.history_alias', 'ai.coze-api.auto_save_history') + result = plan(source) + assert_repaired(result, 'migration.alias_conflict_resolved', 'ai.coze-api.auto_save_history') + assert selected_config(result)['auto-save-history'] is expected @pytest.mark.parametrize('value', [False, True]) @@ -497,27 +642,36 @@ def test_coze_custom_endpoint_not_replaced_with_schema_region(): assert 'secret-' not in json.dumps(result) -@pytest.mark.parametrize('underscore,hyphen', [('one', 'two')]) -def test_dashscope_seed_hyphen_is_not_actual_native_runtime_key(underscore, hyphen): +def test_dashscope_seed_alias_conflict_is_resolved_with_warning(): source = source_for('dashscope-app-api') section = source['ai']['dashscope-app-api'] section.pop('references_quote') - if underscore is not None: - section['references_quote'] = underscore - section['references-quote'] = hyphen - assert_block(plan(source), 'dashscope.references_alias', 'ai.dashscope-app-api.references_quote') + section['references_quote'] = 'one' + section['references-quote'] = 'two' + assert_repaired(plan(source), 'migration.alias_conflict_resolved', 'ai.dashscope-app-api.references-quote') @pytest.mark.parametrize('axis', ['input', 'output']) -@pytest.mark.parametrize('underscore,hyphen', [(None, 'text'), ('chat', 'text'), ('text', 'chat')]) -def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscore, hyphen): +@pytest.mark.parametrize( + 'underscore,hyphen,code', + [ + (None, 'text', 'migration.alias_repaired'), + ('chat', 'text', 'migration.alias_conflict_resolved'), + ('text', 'chat', 'migration.alias_conflict_resolved'), + ], +) +def test_langflow_io_aliases_use_canonical_hyphen_field(axis, underscore, hyphen, code): source = source_for('langflow-api') section = source['ai']['langflow-api'] section.pop(f'{axis}_type') if underscore is not None: section[f'{axis}_type'] = underscore section[f'{axis}-type'] = hyphen - assert_block(plan(source), 'langflow.io_alias', f'ai.langflow-api.{axis}_type') + result = plan(source) + assert result['state'] == 'ready' + assert selected_config(result)[f'{axis}-type'] == hyphen + if underscore is not None: + assert {'code': code, 'field': f'ai.langflow-api.{axis}_type'} in result['warnings'] @pytest.mark.parametrize( @@ -537,12 +691,13 @@ def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscor 0, ], ) -def test_langflow_tweaks_require_strict_object_json_without_fallback_loss(raw): +def test_invalid_langflow_tweaks_are_defaulted_without_leaking(raw): source = source_for('langflow-api') source['ai']['langflow-api']['tweaks'] = raw result = plan(source) - assert_block(result, 'langflow.invalid_tweaks', 'ai.langflow-api.tweaks') - assert 'broken-secret' not in json.dumps(result) + assert_repaired(result, 'langflow.tweaks_invalid_defaulted', 'ai.langflow-api.tweaks') + assert selected_config(result)['tweaks'] == {} + assert 'broken-secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) @pytest.mark.parametrize('raw', [None, '', '{}', '{"nested":{"token":"SECRET","values":[null,false,0]}}']) @@ -616,19 +771,20 @@ def test_n8n_secrets_remain_raw_and_not_public_even_for_inactive_modes(mode): @pytest.mark.parametrize( - 'runner,field,value', + 'runner,field,value,expected', [ - ('dify-service-api', 'app-type', 'unknown'), - ('dashscope-app-api', 'app-type', 'chat'), - ('weknora-api', 'app-type', 'workflow'), - ('n8n-service-api', 'auth-type', 'SECRET-unknown'), + ('dify-service-api', 'app-type', 'unknown', 'chat'), + ('dashscope-app-api', 'app-type', 'chat', 'agent'), + ('weknora-api', 'app-type', 'workflow', 'chat'), + ('n8n-service-api', 'auth-type', 'SECRET-unknown', 'none'), ], ) -def test_invalid_enum_values_are_not_echoed(runner, field, value): +def test_invalid_enum_values_are_defaulted_and_not_echoed(runner, field, value, expected): source = source_for(runner) source['ai'][runner][field] = value result = plan(source) - assert_block(result, 'invalid_value', f'ai.{runner}.{field}') + assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.{field}') + assert selected_config(result)[field] == expected assert 'SECRET' not in json.dumps(result) @@ -818,7 +974,7 @@ def test_dashscope_unambiguous_seed_alias_is_repaired(value): @pytest.mark.parametrize('mode', ['agent', 'chat']) @pytest.mark.parametrize('value', ['ABSENT', None, '', ' ', 'saved-agent']) -def test_weknora_agent_absence_is_not_null_or_empty(mode, value): +def test_weknora_agent_absence_is_defaulted(mode, value): source = source_for('weknora-api') section = source['ai']['weknora-api'] section['app-type'] = mode @@ -826,9 +982,8 @@ def test_weknora_agent_absence_is_not_null_or_empty(mode, value): if value != 'ABSENT': section['agent-id'] = value selected = selected_config(plan(source)) - assert selected['agent-id'] == ( - ('builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning') if value == 'ABSENT' else value - ) + default = 'builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning' + assert selected['agent-id'] == (default if value in ('ABSENT', None) else value) assert selected['knowledge-base-ids'] == [] assert selected['knowledge-bases'] == [] @@ -914,18 +1069,22 @@ def test_n8n_ignore_and_native_latin1_are_selected(): assert selected['basic-encoding'] == 'latin1' -def test_weknora_blank_remote_kb_is_rejected(): +def test_weknora_blank_remote_kb_is_dropped(): source = source_for('weknora-api') source['ai']['weknora-api']['knowledge-base-ids'] = [' '] - assert_block(plan(source), 'invalid_value', 'ai.weknora-api.knowledge-base-ids') + result = plan(source) + assert_repaired(result, 'migration.value_coerced', 'ai.weknora-api.knowledge-base-ids') + assert selected_config(result)['knowledge-base-ids'] == [] @pytest.mark.parametrize('runner', ['coze-api', 'n8n-service-api', 'weknora-api']) @pytest.mark.parametrize('value', [0, -1]) -def test_external_target_requires_positive_timeout(runner, value): +def test_nonpositive_external_timeout_is_defaulted(runner, value): source = source_for(runner) source['ai'][runner]['timeout'] = value - assert_block(plan(source), 'invalid_value', f'ai.{runner}.timeout') + result = plan(source) + assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.timeout') + assert selected_config(result)['timeout'] == 120 @pytest.mark.parametrize( @@ -975,6 +1134,7 @@ def test_ready_all9_nested_candidates_are_detached_and_no_io(runner, monkeypatch 'target_runner_id', 'target_plugin', 'config', + 'extensions_preferences', 'changed_paths', 'blockers', 'warnings', @@ -989,13 +1149,14 @@ def test_empty_config_is_not_legacy(): 'target_runner_id': None, 'target_plugin': None, 'config': None, + 'extensions_preferences': None, 'changed_paths': [], 'blockers': [], 'warnings': [], } -def test_default_local_agent_blocks_instead_of_inventing_round_translation(): +def test_minimal_local_agent_is_completed_with_defaults(): source = { 'ai': { 'runner': {'runner': 'local-agent', 'expire-time': 0}, @@ -1004,15 +1165,25 @@ def test_default_local_agent_blocks_instead_of_inventing_round_translation(): } original = copy.deepcopy(source) result = planner().plan_legacy_pipeline(source) - assert planner().PLANNER_VERSION == '4' - assert result['state'] == 'blocked' + assert planner().PLANNER_VERSION == '5' assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default' - assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.10'} - assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers'] - assert result['config'] is None + assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.2.1'} + selected = selected_config(result) + assert selected['model'] == {'primary': 'model-id', 'fallbacks': [], 'reasoning': {}} + assert 'max-round' not in selected + assert selected['prompt'] == [{'role': 'system', 'content': 'You are a helpful assistant.'}] + assert {'code': 'migration.new_defaults', 'field': 'ai.local-agent'} in result['warnings'] assert source == original +def test_local_agent_without_primary_model_still_blocks(): + source = source_for('local-agent') + del source['ai']['local-agent']['model'] + result = plan(source) + assert result['state'] == 'blocked' + assert result['config'] is None + + @pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{sender_id}', '{project}']) def test_local_box_reuse_templates_are_preserved_for_plugin(template): source = source_for('local-agent') @@ -1025,8 +1196,11 @@ def test_local_box_reuse_templates_are_preserved_for_plugin(template): @pytest.mark.parametrize( 'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}'] ) -def test_invalid_box_templates_are_reported_before_migration(template): +def test_invalid_box_templates_are_defaulted(template): source = source_for('local-agent') source['ai']['local-agent']['box-session-id-template'] = template result = plan(source) - assert_block(result, 'local.box_template_invalid', 'ai.local-agent.box-session-id-template') + assert_repaired(result, 'local.box_template_defaulted', 'ai.local-agent.box-session-id-template') + assert selected_config(result)['box-session-id-template'] == '{launcher_type}_{launcher_id}' + + diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py index f6189633d..547149007 100644 --- a/tests/unit_tests/pipeline/test_migration_current_shape.py +++ b/tests/unit_tests/pipeline/test_migration_current_shape.py @@ -57,11 +57,13 @@ def test_box_reuse_template_is_preserved_for_runner(template): assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template -def test_empty_box_template_requires_correction(): +def test_empty_box_template_is_defaulted_with_warning(): source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} source['ai']['local-agent']['box-session-id-template'] = '' result = plan_legacy_pipeline(source) - assert result['state'] == 'blocked' - assert {'code': 'local.box_template_invalid', 'field': 'ai.local-agent.box-session-id-template'} in result[ - 'blockers' + assert result['state'] == 'ready' + selected = result['config']['ai']['runner_config'][result['target_runner_id']] + assert selected['box-session-id-template'] == '{launcher_type}_{launcher_id}' + assert {'code': 'local.box_template_defaulted', 'field': 'ai.local-agent.box-session-id-template'} in result[ + 'warnings' ] diff --git a/web/src/app/home/pipelines/PipelineMigration.tsx b/web/src/app/home/pipelines/PipelineMigration.tsx index d85b3931f..a578ff9d5 100644 --- a/web/src/app/home/pipelines/PipelineMigration.tsx +++ b/web/src/app/home/pipelines/PipelineMigration.tsx @@ -147,7 +147,15 @@ export default function PipelineMigration({ ['migrated', 'already_current'].includes(item.state), ); const rows = preview?.items ?? []; + // Custom 4.10 runners cannot be migrated automatically; they are listed + // for attention but never submitted. const count = rows.filter( + (item) => + !['already_current', 'not_legacy', 'unsupported_runner'].includes( + item.state, + ), + ).length; + const attention = rows.filter( (item) => !['already_current', 'not_legacy'].includes(item.state), ).length; @@ -189,7 +197,10 @@ export default function PipelineMigration({ ) return; let items = rows.filter( - (item) => !['already_current', 'not_legacy'].includes(item.state), + (item) => + !['already_current', 'not_legacy', 'unsupported_runner'].includes( + item.state, + ), ); setInstallations([]); setDataOnly(!installPlugins); @@ -340,12 +351,12 @@ export default function PipelineMigration({ return ( <> - {(count > 0 || results.length > 0) && ( + {(attention > 0 || results.length > 0) && (