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 <[email protected]>
This commit is contained in:
Hyu
2026-10-14 00:09:52 +08:00
co-authored by Claude Opus 5.5
parent 0ed2a513f9
commit c0cfb5c18c
21 changed files with 1103 additions and 498 deletions
+27 -11
View File
@@ -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 扩展策略,不是插件全局配置。
+3 -1
View File
@@ -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)
@@ -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}'}
@@ -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')
@@ -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
+3 -3
View File
@@ -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:
@@ -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
@@ -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}'
@@ -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'
]
@@ -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) && (
<Alert className="mb-4 shrink-0 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
<AlertTriangle aria-hidden="true" />
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3 text-amber-800 dark:text-amber-200">
<span>{t('pipelineMigration.detected', { count })}</span>
<span>{t('pipelineMigration.detected', { count: attention })}</span>
<Button
variant="outline"
size="sm"
@@ -404,7 +415,7 @@ export default function PipelineMigration({
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('pipelineMigration.detected', { count })}
{t('pipelineMigration.detected', { count: attention })}
</p>
)}
<MigrationInstallProgress installations={installations} />
@@ -417,7 +428,7 @@ export default function PipelineMigration({
})}
</p>
)}
{(count > 0 || results.length > 0) && (
{(attention > 0 || results.length > 0) && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
@@ -470,6 +481,11 @@ export default function PipelineMigration({
{item.target_plugin.name}
</p>
)}
{item.state === 'unsupported_runner' && (
<p className="text-xs text-destructive break-words">
{t('pipelineMigration.notices.customRunner')}
</p>
)}
{item.warnings.some(
(issue) =>
issue.code === 'local.box_state_reset',
@@ -31,6 +31,21 @@ const issueKeys: Record<string, string> = {
runner_schema_incompatible: 'schemaChanged',
plugin_version_incompatible: 'pluginVersion',
'runtime.pending_interaction': 'pendingInteraction',
'legacy.custom_runner_unsupported': 'customRunner',
'migration.unknown_field_dropped': 'fieldDropped',
'migration.unknown_section_archived': 'fieldDropped',
'migration.value_coerced': 'valueRepaired',
'migration.invalid_value_defaulted': 'valueRepaired',
'migration.missing_default_filled': 'valueRepaired',
'migration.alias_conflict_resolved': 'aliasRepaired',
'local.prompt_normalized': 'valueRepaired',
'local.reasoning_dropped': 'valueRepaired',
'local.box_template_defaulted': 'valueRepaired',
'langflow.tweaks_invalid_defaulted': 'valueRepaired',
'migration.mixed_runner_selection': 'mixedRunner',
'migration.runner_config_replaced': 'runnerConfigReplaced',
'extensions.runner_added': 'runnerAdded',
invalid_extension_preferences: 'extensionPrefs',
};
/** Only locally reviewed codes may select localized copy. */
@@ -4,6 +4,7 @@ export type PipelineMigrationState =
| 'blocked'
| 'already_current'
| 'not_legacy'
| 'unsupported_runner'
| 'activation_pending';
export interface PipelineMigrationIssue {
@@ -37,6 +37,19 @@ export default {
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
details: 'Migration details',
notices: {
customRunner:
'Legacy custom runners must be rewritten as plugin runners before they can be bound. See the plugin docs: https://langbot.app/docs/en/plugin/dev/tutor',
fieldDropped:
'Unrecognized legacy fields or sections were removed from the active config; the original values are kept in the migration backup.',
valueRepaired:
'Malformed or missing values were repaired with the 4.10 defaults. Review them after migration.',
mixedRunner:
'Both a new and a legacy runner selection exist; the usable one was kept.',
runnerConfigReplaced:
'The existing new-style runner config is replaced by the migration result; the original is kept in the backup.',
runnerAdded:
'The required runner plugin was added to the extension list of this pipeline.',
extensionPrefs: 'Invalid entries in the extension settings were ignored.',
runtimeUnavailable:
'The plugin runtime is disconnected. Restore the connection and retry, or migrate data only.',
executionFailed: 'Migration failed. Check the server logs and retry.',
@@ -119,6 +132,7 @@ export default {
blocked: 'Blocked',
already_current: 'Already current',
not_legacy: 'Not legacy',
unsupported_runner: 'Unsupported runner',
activation_pending: 'Activation pending',
pending: 'Pending',
migrated: 'Migrated',
@@ -36,6 +36,20 @@ export default {
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
details: 'Detalles de migración',
notices: {
customRunner:
'Los runners personalizados antiguos deben reescribirse como runners de plugin antes de vincularlos. Consulta la documentación de plugins: https://langbot.app/docs/en/plugin/dev/tutor',
fieldDropped:
'Los campos o secciones antiguos no reconocidos se eliminaron de la configuración activa; los valores originales se conservan en la copia de seguridad.',
valueRepaired:
'Los valores mal formados o ausentes se repararon con los valores predeterminados de 4.10. Revísalos tras la migración.',
mixedRunner:
'Existen una selección de runner nueva y otra antigua; se conservó la utilizable.',
runnerConfigReplaced:
'La configuración de runner nueva existente se reemplaza por el resultado de la migración; la original se conserva en la copia de seguridad.',
runnerAdded:
'Se añadió el plugin de runner necesario a la lista de extensiones de esta pipeline.',
extensionPrefs:
'Se ignoraron entradas no válidas de la configuración de extensiones.',
runtimeUnavailable:
'El entorno de plugins está desconectado. Restablece la conexión y reintenta, o migra solo los datos.',
executionFailed:
@@ -124,6 +138,7 @@ export default {
blocked: 'Bloqueado',
already_current: 'Ya actualizado',
not_legacy: 'No es antiguo',
unsupported_runner: 'Runner no compatible',
activation_pending: 'Activación pendiente',
pending: 'Pendiente',
migrated: 'Migrado',
@@ -37,6 +37,19 @@ export default {
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
details: '移行の詳細',
notices: {
customRunner:
'旧形式のカスタム Runner は、プラグイン Runner として書き直してからバインドしてください。プラグイン開発ドキュメント:https://langbot.app/docs/ja/plugin/dev/tutor',
fieldDropped:
'認識できない旧フィールドやセクションは有効な設定から削除され、元の値は移行バックアップに保存されます。',
valueRepaired:
'不正または欠落した値は 4.10 の既定値で自動修復されました。移行後に確認してください。',
mixedRunner:
'新旧両方の Runner 指定があるため、利用可能な方を採用しました。',
runnerConfigReplaced:
'既存の新形式 Runner 設定は移行結果で置き換えられ、元の値はバックアップに保存されます。',
runnerAdded:
'必要な Runner プラグインをこのパイプラインの拡張リストに追加しました。',
extensionPrefs: '拡張設定の無効な項目は無視されました。',
runtimeUnavailable:
'プラグインランタイムが未接続です。接続を復旧して再試行するか、データのみ移行してください。',
executionFailed:
@@ -119,6 +132,7 @@ export default {
blocked: '移行不可',
already_current: '移行済みの形式',
not_legacy: '旧形式ではない',
unsupported_runner: '非対応の Runner',
activation_pending: '有効化待ち',
pending: '待機中',
migrated: '移行済み',
@@ -36,6 +36,19 @@ export default {
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
details: 'Подробности переноса',
notices: {
customRunner:
'Устаревшие пользовательские runner нужно переписать как runner-плагины перед привязкой. См. документацию по плагинам: https://langbot.app/docs/en/plugin/dev/tutor',
fieldDropped:
'Нераспознанные устаревшие поля или разделы удалены из активной конфигурации; исходные значения сохранены в резервной копии миграции.',
valueRepaired:
'Некорректные или отсутствующие значения исправлены на значения по умолчанию 4.10. Проверьте их после миграции.',
mixedRunner: 'Указан и новый, и устаревший runner; оставлен пригодный.',
runnerConfigReplaced:
'Существующая конфигурация runner нового формата заменяется результатом миграции; исходная сохранена в резервной копии.',
runnerAdded:
'Нужный runner-плагин добавлен в список расширений этого конвейера.',
extensionPrefs:
'Недопустимые записи в настройках расширений проигнорированы.',
runtimeUnavailable:
'Среда плагинов отключена. Восстановите подключение и повторите попытку или перенесите только данные.',
executionFailed:
@@ -121,6 +134,7 @@ export default {
blocked: 'Заблокирован',
already_current: 'Уже актуален',
not_legacy: 'Не устаревший',
unsupported_runner: 'Неподдерживаемый runner',
activation_pending: 'Ожидает активации',
pending: 'Ожидает',
migrated: 'Перенесён',
@@ -36,6 +36,19 @@ export default {
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
details: 'รายละเอียดการย้าย',
notices: {
customRunner:
'Runner แบบกำหนดเองรุ่นเก่าต้องเขียนใหม่เป็น Runner แบบปลั๊กอินก่อนจึงจะผูกได้ ดูเอกสารปลั๊กอิน: https://langbot.app/docs/en/plugin/dev/tutor',
fieldDropped:
'ฟิลด์หรือส่วนเก่าที่ไม่รู้จักถูกนำออกจากการตั้งค่าที่ใช้งาน ค่าเดิมถูกเก็บไว้ในข้อมูลสำรองการย้าย',
valueRepaired:
'ค่าที่ผิดรูปแบบหรือขาดหายถูกซ่อมด้วยค่าเริ่มต้นของ 4.10 โปรดตรวจสอบหลังการย้าย',
mixedRunner:
'มีการเลือก Runner ทั้งแบบใหม่และแบบเก่า ระบบเลือกอันที่ใช้งานได้',
runnerConfigReplaced:
'การตั้งค่า Runner แบบใหม่ที่มีอยู่จะถูกแทนที่ด้วยผลการย้าย ค่าเดิมถูกเก็บไว้ในข้อมูลสำรอง',
runnerAdded:
'เพิ่มปลั๊กอิน Runner ที่จำเป็นลงในรายการส่วนขยายของไปป์ไลน์นี้แล้ว',
extensionPrefs: 'รายการที่ไม่ถูกต้องในการตั้งค่าส่วนขยายถูกละเว้น',
runtimeUnavailable:
'ไม่ได้เชื่อมต่อรันไทม์ปลั๊กอิน โปรดเชื่อมต่อใหม่แล้วลองอีกครั้ง หรือย้ายเฉพาะข้อมูล',
executionFailed:
@@ -115,6 +128,7 @@ export default {
blocked: 'ถูกบล็อก',
already_current: 'เป็นรุ่นใหม่แล้ว',
not_legacy: 'ไม่ใช่รุ่นเก่า',
unsupported_runner: 'Runner ที่ไม่รองรับ',
activation_pending: 'รอเปิดใช้งาน',
pending: 'รอดำเนินการ',
migrated: 'ย้ายแล้ว',
@@ -36,6 +36,18 @@ export default {
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
details: 'Chi tiết chuyển đổi',
notices: {
customRunner:
'Runner tùy chỉnh kiểu cũ cần được viết lại thành runner dạng plugin trước khi liên kết. Xem tài liệu plugin: https://langbot.app/docs/en/plugin/dev/tutor',
fieldDropped:
'Các trường hoặc mục cũ không nhận dạng được đã bị loại khỏi cấu hình đang dùng; giá trị gốc được lưu trong bản sao lưu di chuyển.',
valueRepaired:
'Các giá trị sai định dạng hoặc thiếu đã được sửa bằng giá trị mặc định của 4.10. Hãy kiểm tra sau khi di chuyển.',
mixedRunner: 'Có cả lựa chọn runner mới và cũ; đã giữ lựa chọn dùng được.',
runnerConfigReplaced:
'Cấu hình runner kiểu mới hiện có được thay bằng kết quả di chuyển; bản gốc được lưu trong bản sao lưu.',
runnerAdded:
'Đã thêm plugin runner cần thiết vào danh sách tiện ích của pipeline này.',
extensionPrefs: 'Các mục không hợp lệ trong cài đặt tiện ích đã bị bỏ qua.',
runtimeUnavailable:
'Môi trường chạy plugin bị ngắt kết nối. Khôi phục kết nối rồi thử lại, hoặc chỉ di chuyển dữ liệu.',
executionFailed:
@@ -118,6 +130,7 @@ export default {
blocked: 'Bị chặn',
already_current: 'Đã cập nhật',
not_legacy: 'Không phải bản cũ',
unsupported_runner: 'Runner không được hỗ trợ',
activation_pending: 'Chờ kích hoạt',
pending: 'Đang chờ',
migrated: 'Đã di chuyển',
@@ -31,6 +31,17 @@ export default {
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
details: '迁移详情',
notices: {
customRunner:
'旧版自定义 Runner 需要改写为插件 Runner 后再绑定。参见插件开发文档:https://langbot.app/docs/zh/plugin/dev/tutor',
fieldDropped:
'未识别的旧字段或配置段已从活动配置中移除,原值保存在迁移备份中。',
valueRepaired:
'格式不正确或缺失的值已按 4.10 默认值自动修复,请在迁移后检查。',
mixedRunner: '同时存在新旧 Runner 选择,已采用可用的那一项。',
runnerConfigReplaced:
'已有的新版 Runner 配置会被迁移结果替换,原值保存在迁移备份中。',
runnerAdded: '已将所需 Runner 插件加入此流水线的扩展列表。',
extensionPrefs: '扩展设置中有无效项,已忽略。',
runtimeUnavailable:
'插件运行时未连接,请恢复连接后重试,或选择仅迁移数据。',
executionFailed: '迁移执行失败,请查看服务日志后重试。',
@@ -96,6 +107,7 @@ export default {
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非旧版',
unsupported_runner: '不支持的 Runner',
activation_pending: '等待激活',
pending: '等待中',
migrated: '已迁移',
@@ -32,6 +32,17 @@ export default {
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
details: '遷移詳情',
notices: {
customRunner:
'舊版自訂 Runner 需要改寫為外掛 Runner 後再綁定。參見外掛開發文件:https://langbot.app/docs/zh/plugin/dev/tutor',
fieldDropped:
'未識別的舊欄位或設定段已從使用中設定移除,原值保存在遷移備份中。',
valueRepaired:
'格式不正確或缺少的值已依 4.10 預設值自動修復,請在遷移後檢查。',
mixedRunner: '同時存在新舊 Runner 選擇,已採用可用的那一項。',
runnerConfigReplaced:
'既有的新版 Runner 設定會被遷移結果取代,原值保存在遷移備份中。',
runnerAdded: '已將所需 Runner 外掛加入此流水線的擴充列表。',
extensionPrefs: '擴充設定中有無效項目,已忽略。',
runtimeUnavailable:
'外掛執行環境未連線,請恢復連線後重試,或選擇僅遷移資料。',
executionFailed: '遷移執行失敗,請查看服務日誌後重試。',
@@ -97,6 +108,7 @@ export default {
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非舊版',
unsupported_runner: '不支援的 Runner',
activation_pending: '等待啟用',
pending: '等待中',
migrated: '已遷移',
@@ -1,5 +1,5 @@
{
"planner_version": "4",
"planner_version": "5",
"fixture_only": true,
"legacy_sections": {
"local-agent": {
@@ -86,7 +86,7 @@
"metadata": {
"author": "langbot-team",
"name": "LocalAgent",
"version": "0.1.10",
"version": "0.2.1",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/LocalAgent",
"label": {
"en_US": "Local Agent",
@@ -124,6 +124,8 @@
"pages": []
},
"execution": {
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1",
"python": {
"path": "main.py",
"attr": "LocalAgentPlugin"
@@ -152,8 +154,8 @@
{
"name": "model",
"description": {
"en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.",
"zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。"
"en_US": "Choose a primary model and optional fallback models, and set the reasoning level for each model.",
"zh_Hans": "选择主模型和备用模型,并设置各模型的推理等级。"
},
"label": {
"en_US": "Model",
@@ -217,14 +219,9 @@
"zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。"
},
"type": "select",
"allow_custom": true,
"required": true,
"default": "{launcher_type}_{launcher_id}",
"show_if": {
"field": "box-enabled",
"operator": "eq",
"value": true
},
"allow_custom": true,
"options": [
{
"name": "{global}",
@@ -291,7 +288,12 @@
"ru_RU": "По сообщению (изолированно)"
}
}
]
],
"show_if": {
"field": "box-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "advanced-settings",
@@ -578,8 +580,8 @@
}
}
},
"package_sha256": "52780785960722cbc0f2a9335c48a852cf631a32e4d04ce1e1ff601af7c0b599",
"schema_sha256": "cdb336b8655b53dec482cfe9e191d0ed1d409929423584e5848255aabe499338"
"package_sha256": "2d2ce17055d70dd7736e6902595be3262992fd1598a00bd9d79d19fef1c16329",
"schema_sha256": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47"
},
"dify-agent": {
"manifest": {
@@ -588,7 +590,7 @@
"metadata": {
"author": "langbot-team",
"name": "DifyAgent",
"version": "0.1.10",
"version": "0.2.3",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dify-agent",
"label": {
"en_US": "Dify Agent",
@@ -629,7 +631,9 @@
"python": {
"path": "main.py",
"attr": "DifyAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -948,7 +952,7 @@
}
}
},
"package_sha256": "6fc89a8d127b338b6f8d2deca78001b7dbaccfe9862c88c0d2ce95bd1df1ebb2",
"package_sha256": "6fb3c388e713449ff0db4c4491cf4bf89173b2a8de69d5c4c2e0c1fb21a3f4a8",
"schema_sha256": "dccc4b7df7730c4e5e2cfc2ca2e67db5ea9195ad9720a79a329bc11fdcaeb840"
},
"coze-agent": {
@@ -958,7 +962,7 @@
"metadata": {
"author": "langbot-team",
"name": "CozeAgent",
"version": "0.1.10",
"version": "0.2.0",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/coze-agent",
"label": {
"en_US": "Coze Agent",
@@ -999,7 +1003,9 @@
"python": {
"path": "main.py",
"attr": "CozeAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -1259,7 +1265,7 @@
}
}
},
"package_sha256": "c8e48659a0358bd8291137379f6159e61f2b1dbe72766772d81c9af49f7a8317",
"package_sha256": "d17917c8fcdbb5e044cdae7ba4c7c966835ade4350c97f9b38184a17de9d79b2",
"schema_sha256": "3f7c4d4e5c8a7ace3e804f0ca02739c48439abcc8f360bfc17394b9cf6660674"
},
"dashscope-agent": {
@@ -1269,7 +1275,7 @@
"metadata": {
"author": "langbot-team",
"name": "DashScopeAgent",
"version": "0.1.10",
"version": "0.2.2",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dashscope-agent",
"label": {
"en_US": "Dashscope Agent",
@@ -1310,7 +1316,9 @@
"python": {
"path": "main.py",
"attr": "DashScopeAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -1552,7 +1560,7 @@
}
}
},
"package_sha256": "cd4e28e53eb986b906d6aa92d26b49d8ad4a9514fcc764f359bbdb84ad1a3f00",
"package_sha256": "200450d94da2173acd58239e4565c3d4a9cd4830a09173ff35bb24aa31bdd8a4",
"schema_sha256": "aadfce3fe0157b124235437f251c24fbfea0e498ece4be8f9de6028b97573937"
},
"n8n-agent": {
@@ -1562,7 +1570,7 @@
"metadata": {
"author": "langbot-team",
"name": "N8nAgent",
"version": "0.1.10",
"version": "0.2.0",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/n8n-agent",
"label": {
"en_US": "N8n Agent",
@@ -1603,7 +1611,9 @@
"python": {
"path": "main.py",
"attr": "N8nAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -2028,7 +2038,7 @@
}
}
},
"package_sha256": "507af9773690ff90c8ce2cdf8c3660ca01050e195eeb954cb631ea0116fe680c",
"package_sha256": "a83807decf3f62d2bd9a8c5515849c867140e22b3377ba248cb1e594757a5961",
"schema_sha256": "9e492577b0e8d197fd792cd1456771e5ff1731ebf897a128c70f4850b4dfece0"
},
"langflow-agent": {
@@ -2038,7 +2048,7 @@
"metadata": {
"author": "langbot-team",
"name": "LangflowAgent",
"version": "0.1.10",
"version": "0.2.2",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/langflow-agent",
"label": {
"en_US": "Langflow Agent",
@@ -2079,7 +2089,9 @@
"python": {
"path": "main.py",
"attr": "LangflowAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -2306,7 +2318,7 @@
}
}
},
"package_sha256": "93fee3064505d0a69d8b16d8aceb4a171ffdffed63a36278eef635267681f5a1",
"package_sha256": "f82975125ec6916fdf88876e93a3020d23728f96fec36c4980dd0670cfaa6608",
"schema_sha256": "c7d940674bae9acab993801c6957da778bbdb3fa8734bfad9cda96dbb57f5fee"
},
"deerflow-agent": {
@@ -2316,7 +2328,7 @@
"metadata": {
"author": "langbot-team",
"name": "DeerFlowAgent",
"version": "0.1.10",
"version": "0.2.1",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/deerflow-agent",
"label": {
"en_US": "DeerFlow Agent",
@@ -2357,7 +2369,9 @@
"python": {
"path": "main.py",
"attr": "DeerFlowAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -2588,7 +2602,7 @@
}
}
},
"package_sha256": "10c93fec92e2d34c791baa541b71debf060e80aaa5fb64c386dc25fbc6b510a5",
"package_sha256": "6413462042008652f4a0fd4ed0514c4943ceefbbdcea36c9a58a9cbf080168d9",
"schema_sha256": "8a865320f37e367095a1f332bcc490cad8c0af273bc82baf768015242457c8f6"
},
"tbox-agent": {
@@ -2598,7 +2612,7 @@
"metadata": {
"author": "langbot-team",
"name": "TboxAgent",
"version": "0.1.8",
"version": "0.2.1",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/tbox-agent",
"label": {
"en_US": "Tbox Agent",
@@ -2639,7 +2653,9 @@
"python": {
"path": "main.py",
"attr": "TboxAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -2756,7 +2772,7 @@
}
}
},
"package_sha256": "ed9c7a3f7c904a1fc6a95e8e7de0253f85282bf159250e355480fdcc91b4f629",
"package_sha256": "d88a3d14f2996383fab27a6fa1adf089f3bd575c2aa7a2c76c09f4eef682a9ff",
"schema_sha256": "ac34c6a84859728328aa8d554aadbd1bef9b92c35ce4524812a541bac58c5088"
},
"weknora-agent": {
@@ -2766,7 +2782,7 @@
"metadata": {
"author": "langbot-team",
"name": "WeKnoraAgent",
"version": "0.1.10",
"version": "0.2.1",
"repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/weknora-agent",
"label": {
"en_US": "WeKnora Agent",
@@ -2807,7 +2823,9 @@
"python": {
"path": "main.py",
"attr": "WeKnoraAgentPlugin"
}
},
"sharedRuntime": "shared-runtime-v1",
"componentModel": "stateless-v1"
}
},
"component": {
@@ -3002,7 +3020,7 @@
}
}
},
"package_sha256": "63841513ad9a0272f1e9863ab70a280989651a26db421b3f0df634972978135b",
"package_sha256": "41cb9bd32b3f794fad95a7d20cf8d9f52f81da1e1e4b3049f4b5f1c51581b980",
"schema_sha256": "78d6d36d38f3991cc1a0bfc057d09a18a43c55e6af45b6ed61908d66bf475aa2"
}
},
@@ -3155,7 +3173,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "LocalAgent",
"version": "0.1.10"
"version": "0.2.1"
},
"config": {
"trigger": {
@@ -3197,8 +3215,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/LocalAgent/default"
"id": "plugin:langbot-team/LocalAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/LocalAgent/default": {
@@ -3220,9 +3238,9 @@
"tools": [],
"knowledge-bases": [],
"model": {
"primary": "synthetic-model",
"fallbacks": [],
"reasoning": {},
"primary": "synthetic-model"
"reasoning": {}
},
"prompt": [
{
@@ -3256,6 +3274,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -3459,7 +3478,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DifyAgent",
"version": "0.1.10"
"version": "0.2.3"
},
"config": {
"trigger": {
@@ -3501,17 +3520,17 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/DifyAgent/default"
"id": "plugin:langbot-team/DifyAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/DifyAgent/default": {
"timeout": 30,
"advanced-settings": false,
"base-url": "https://dify.invalid/v1",
"api-key": "synthetic-key",
"app-type": "chat",
"base-prompt": "Synthetic prompt",
"base-url": "https://dify.invalid/v1",
"user-id-source": "legacy-session",
"enable-all-tools": false,
"tools": [],
@@ -3543,6 +3562,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -3742,7 +3762,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "CozeAgent",
"version": "0.1.10"
"version": "0.2.0"
},
"config": {
"trigger": {
@@ -3784,16 +3804,16 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/CozeAgent/default"
"id": "plugin:langbot-team/CozeAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/CozeAgent/default": {
"auto-save-history": false,
"advanced-settings": false,
"api-base": "https://api.coze.cn",
"api-key": "synthetic-key",
"bot-id": "synthetic-bot",
"api-base": "https://api.coze.cn",
"timeout": 120,
"user-id-source": "legacy-session",
"enable-all-tools": false,
@@ -3826,6 +3846,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -4025,7 +4046,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DashScopeAgent",
"version": "0.1.10"
"version": "0.2.2"
},
"config": {
"trigger": {
@@ -4067,8 +4088,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/DashScopeAgent/default"
"id": "plugin:langbot-team/DashScopeAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/DashScopeAgent/default": {
@@ -4108,6 +4129,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -4299,7 +4321,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "N8nAgent",
"version": "0.1.10"
"version": "0.2.0"
},
"config": {
"trigger": {
@@ -4341,8 +4363,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/N8nAgent/default"
"id": "plugin:langbot-team/N8nAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/N8nAgent/default": {
@@ -4389,6 +4411,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -4584,7 +4607,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "LangflowAgent",
"version": "0.1.10"
"version": "0.2.2"
},
"config": {
"trigger": {
@@ -4626,8 +4649,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/LangflowAgent/default"
"id": "plugin:langbot-team/LangflowAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/LangflowAgent/default": {
@@ -4635,8 +4658,8 @@
"output-type": "chat",
"tweaks": {},
"advanced-settings": false,
"base-url": "https://langflow.invalid",
"api-key": "synthetic-key",
"base-url": "https://langflow.invalid",
"flow-id": "synthetic-flow",
"enable-all-tools": false,
"tools": [],
@@ -4667,6 +4690,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -4862,7 +4886,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "DeerFlowAgent",
"version": "0.1.10"
"version": "0.2.1"
},
"config": {
"trigger": {
@@ -4904,8 +4928,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/DeerFlowAgent/default"
"id": "plugin:langbot-team/DeerFlowAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/DeerFlowAgent/default": {
@@ -4948,6 +4972,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -5131,7 +5156,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "TboxAgent",
"version": "0.1.8"
"version": "0.2.1"
},
"config": {
"trigger": {
@@ -5173,8 +5198,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/TboxAgent/default"
"id": "plugin:langbot-team/TboxAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/TboxAgent/default": {
@@ -5211,6 +5236,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",
@@ -5402,7 +5428,7 @@
"target_plugin": {
"author": "langbot-team",
"name": "WeKnoraAgent",
"version": "0.1.10"
"version": "0.2.1"
},
"config": {
"trigger": {
@@ -5444,8 +5470,8 @@
},
"ai": {
"runner": {
"expire-time": 0,
"id": "plugin:langbot-team/WeKnoraAgent/default"
"id": "plugin:langbot-team/WeKnoraAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/WeKnoraAgent/default": {
@@ -5456,9 +5482,9 @@
"timeout": 120,
"base-prompt": "请回答用户的问题。",
"advanced-settings": false,
"base-url": "https://weknora.invalid/api/v1",
"api-key": "synthetic-key",
"app-type": "agent",
"base-url": "https://weknora.invalid/api/v1",
"enable-all-tools": false,
"tools": [],
"knowledge-bases": [],
@@ -5488,6 +5514,7 @@
}
}
},
"extensions_preferences": null,
"changed_paths": [
"ai.runner.runner",
"ai.runner.id",