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) && (
{t('pipelineMigration.title')}
- {t('pipelineMigration.detected', { count })}
+ {t('pipelineMigration.detected', { count: attention })}
)}
- {(count > 0 || results.length > 0) && (
+ {(attention > 0 || results.length > 0) && (