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
+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: