mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user