fix(migration): improve runner migration and plugin installation feedback

This commit is contained in:
RockChinQ
2026-09-18 13:10:32 +08:00
parent e77acfa3ab
commit 60606e1997
28 changed files with 1197 additions and 131 deletions
@@ -7,11 +7,11 @@
"name": "model",
"description": {
"en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.",
"zh_Hans": "\u4e3b\u6a21\u578b/\u5907\u7528\u6a21\u578b UUID \u4e0e\u5404\u6a21\u578b\u63a8\u7406\u7b49\u7ea7\u3002\u7531 Host \u5bf9\u6bcf\u6b21\u8bf7\u6c42\u6821\u9a8c\u5e76\u5e94\u7528\uff0c\u5c5e\u4e8e\u6d41\u6c34\u7ebf\u72ec\u7acb\u914d\u7f6e\u3002"
"zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。"
},
"label": {
"en_US": "Model",
"zh_Hans": "\u6a21\u578b"
"zh_Hans": "模型"
},
"type": "model-fallback-selector",
"required": true,
@@ -25,7 +25,7 @@
"name": "prompt",
"label": {
"en_US": "Prompt",
"zh_Hans": "\u63d0\u793a\u8bcd"
"zh_Hans": "提示词"
},
"type": "prompt-editor",
"required": true,
@@ -40,21 +40,122 @@
"name": "knowledge-bases",
"label": {
"en_US": "Knowledge Bases",
"zh_Hans": "\u77e5\u8bc6\u5e93"
"zh_Hans": "知识库"
},
"type": "knowledge-base-multi-selector",
"required": false,
"default": []
},
{
"name": "box-enabled",
"label": {
"en_US": "Sandbox",
"zh_Hans": "沙箱"
},
"description": {
"en_US": "Use Box for code execution and file processing.",
"zh_Hans": "使用 Box 执行代码和处理文件。"
},
"type": "boolean",
"required": false,
"default": true
},
{
"name": "box-session-id-template",
"label": {
"en_US": "Sandbox reuse",
"zh_Hans": "沙箱复用范围"
},
"description": {
"en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.",
"zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。"
},
"type": "select",
"allow_custom": true,
"required": true,
"default": "{launcher_type}_{launcher_id}",
"options": [
{
"name": "{global}",
"label": {
"en_US": "Global (shared by all)",
"zh_Hans": "全局(所有人共享)",
"zh_Hant": "全域(所有人共用)",
"ja_JP": "グローバル(全員共有)",
"vi_VN": "Toàn cục (chia sẻ cho tất cả)",
"th_TH": "ทั่วไป (แชร์ทั้งหมด)",
"es_ES": "Global (compartido por todos)",
"ru_RU": "Глобальный (общий для всех)"
}
},
{
"name": "{launcher_type}_{launcher_id}",
"label": {
"en_US": "Per chat (Recommended)",
"zh_Hans": "每个会话(推荐)",
"zh_Hant": "每個會話(推薦)",
"ja_JP": "チャットごと(推奨)",
"vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)",
"th_TH": "ต่อแชท (แนะนำ)",
"es_ES": "Por chat (Recomendado)",
"ru_RU": "По чату (Рекомендуется)"
}
},
{
"name": "{launcher_type}_{launcher_id}_{sender_id}",
"label": {
"en_US": "Per user in chat",
"zh_Hans": "会话中每个用户",
"zh_Hant": "會話中每個用戶",
"ja_JP": "チャット内のユーザーごと",
"vi_VN": "Mỗi người dùng trong cuộc trò chuyện",
"th_TH": "ต่อผู้ใช้ในแชท",
"es_ES": "Por usuario en chat",
"ru_RU": "По пользователю в чате"
}
},
{
"name": "{launcher_type}_{launcher_id}_{conversation_id}",
"label": {
"en_US": "Per conversation context",
"zh_Hans": "每个对话上下文",
"zh_Hant": "每個對話上下文",
"ja_JP": "会話コンテキストごと",
"vi_VN": "Mỗi ngữ cảnh hội thoại",
"th_TH": "ต่อบริบทการสนทนา",
"es_ES": "Por contexto de conversación",
"ru_RU": "По контексту разговора"
}
},
{
"name": "{query_id}",
"label": {
"en_US": "Per message (isolated)",
"zh_Hans": "每条消息(完全隔离)",
"zh_Hant": "每條訊息(完全隔離)",
"ja_JP": "メッセージごと(隔離)",
"vi_VN": "Mỗi tin nhắn (cách ly)",
"th_TH": "ต่อข้อความ (แยกส่วน)",
"es_ES": "Por mensaje (aislado)",
"ru_RU": "По сообщению (изолированно)"
}
}
],
"show_if": {
"field": "box-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
"zh_Hans": "高级设置"
},
"description": {
"en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.",
"zh_Hans": "\u663e\u793a\u68c0\u7d22\u3001\u5de5\u5177\u3001\u8d85\u65f6\u548c\u4e0a\u4e0b\u6587\u7ba1\u7406\u7684\u8c03\u4f18\u9009\u9879\u3002"
"zh_Hans": "显示检索、工具、超时和上下文管理的调优选项。"
},
"type": "boolean",
"required": false,
@@ -64,11 +165,11 @@
"name": "date-grounding",
"label": {
"en_US": "Current Date Grounding",
"zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a"
"zh_Hans": "当前日期锚定"
},
"description": {
"en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.",
"zh_Hans": "\u6ce8\u5165\u5f53\u524d UTC \u65e5\u671f\uff0c\u5e76\u63d0\u9192\u4f7f\u7528\u53ef\u7528\u641c\u7d22\u5de5\u5177\u6838\u5b9e\u6709\u65f6\u6548\u6027\u7684\u4fe1\u606f\u3002"
"zh_Hans": "注入当前 UTC 日期,并提醒使用可用搜索工具核实有时效性的信息。"
},
"type": "boolean",
"required": false,
@@ -83,7 +184,7 @@
"name": "timeout",
"label": {
"en_US": "Timeout",
"zh_Hans": "\u6267\u884c\u8d85\u65f6"
"zh_Hans": "执行超时"
},
"type": "integer",
"required": false,
@@ -98,7 +199,7 @@
"name": "remove-think",
"label": {
"en_US": "Remove Thinking Output",
"zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9"
"zh_Hans": "移除思考内容"
},
"type": "boolean",
"required": false,
@@ -113,7 +214,7 @@
"name": "retrieval-top-k",
"label": {
"en_US": "Retrieval Top K",
"zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf"
"zh_Hans": "知识库检索数量"
},
"type": "integer",
"required": false,
@@ -128,7 +229,7 @@
"name": "rerank-model",
"label": {
"en_US": "Rerank Model",
"zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b"
"zh_Hans": "重排序模型"
},
"type": "rerank-model-selector",
"required": false,
@@ -143,7 +244,7 @@
"name": "rerank-top-k",
"label": {
"en_US": "Rerank Top K",
"zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf"
"zh_Hans": "重排序保留数量"
},
"type": "integer",
"required": false,
@@ -158,7 +259,7 @@
"name": "max-tool-iterations",
"label": {
"en_US": "Max Tool Iterations",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570"
"zh_Hans": "最大工具调用轮数"
},
"type": "integer",
"required": false,
@@ -173,11 +274,11 @@
"name": "tool-execution-mode",
"description": {
"en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.",
"zh_Hans": "\u5e76\u884c\u9002\u5408\u72ec\u7acb\u5de5\u5177\uff1b\u6709\u4f9d\u8d56\u6216\u526f\u4f5c\u7528\u7684\u64cd\u4f5c\u8bf7\u9009\u62e9\u4e32\u884c\u3002\u7ed3\u679c\u6392\u5217\u4e0d\u4fdd\u8bc1\u6267\u884c\u987a\u5e8f\u3002"
"zh_Hans": "并行适合独立工具;有依赖或副作用的操作请选择串行。结果排列不保证执行顺序。"
},
"label": {
"en_US": "Tool Execution Mode",
"zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f"
"zh_Hans": "工具执行模式"
},
"type": "select",
"required": false,
@@ -187,14 +288,14 @@
"name": "parallel",
"label": {
"en_US": "Parallel",
"zh_Hans": "\u5e76\u884c"
"zh_Hans": "并行"
}
},
{
"name": "serial",
"label": {
"en_US": "Serial",
"zh_Hans": "\u4e32\u884c"
"zh_Hans": "串行"
}
}
],
@@ -208,7 +309,7 @@
"name": "max-tool-result-chars",
"label": {
"en_US": "Max Tool Result Characters",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570"
"zh_Hans": "最大工具结果字符数"
},
"type": "integer",
"required": false,
@@ -223,7 +324,7 @@
"name": "context-history-fetch-limit",
"label": {
"en_US": "History Fetch Limit",
"zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf"
"zh_Hans": "历史消息拉取数量"
},
"type": "integer",
"required": false,
@@ -238,7 +339,7 @@
"name": "context-window-tokens",
"label": {
"en_US": "Context Window Tokens",
"zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570"
"zh_Hans": "上下文窗口 Token 数"
},
"type": "integer",
"required": false,
@@ -253,7 +354,7 @@
"name": "context-reserve-tokens",
"label": {
"en_US": "Reserved Output Tokens",
"zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570"
"zh_Hans": "输出保留 Token 数"
},
"type": "integer",
"required": false,
@@ -268,7 +369,7 @@
"name": "context-keep-recent-tokens",
"label": {
"en_US": "Recent Context Tokens",
"zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570"
"zh_Hans": "最近上下文 Token 数"
},
"type": "integer",
"required": false,
@@ -283,7 +384,7 @@
"name": "context-summary-tokens",
"label": {
"en_US": "Summary Tokens",
"zh_Hans": "\u6458\u8981 Token \u6570"
"zh_Hans": "摘要 Token 数"
},
"type": "integer",
"required": false,
@@ -864,3 +864,90 @@ async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env):
with pytest.raises(env.m.MigrationError, match='migration_running'):
await execute_all(env)
env.svc._all_tasks.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize('stage', ['discovery', 'install', 'validation'])
async def test_disconnected_runtime_has_specific_error_without_modifying_sources(env, stage):
from langbot.pkg.plugin.connector import PluginRuntimeNotConnectedError
error = PluginRuntimeNotConnectedError('private connection diagnostics')
if stage == 'discovery':
env.ap.runner_registry.list_runners.side_effect = error
elif stage == 'install':
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=error)
else:
env.svc._verify_runtime = AsyncMock(side_effect=error)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_runtime_unavailable' for r in metadata['results'])
assert 'private connection diagnostics' not in str(metadata)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and configs['two'] == SOURCE
assert not backups
def test_install_progress_records_stages_without_exposing_diagnostics():
from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext
ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1.0'})
ctx.trace('private-token', action='downloading plugin package')
ctx.metadata.update(download_current=120, download_total=240, progress_percent=15, url='private-token')
ctx.set_current_action('validating plugin package')
ctx.metadata['progress_percent'] = float('nan')
ctx.finish('plugin_install_failed')
assert ctx.progress['download_current'] == 120
assert ctx.progress['progress_percent'] == 15
assert [step['stage'] for step in ctx.progress['steps']] == ['checking', 'downloading', 'validating']
assert all(step['finished_at'] is not None for step in ctx.progress['steps'])
assert ctx.progress['status'] == 'failed'
assert 'private-token' not in str(ctx.progress)
@pytest.mark.asyncio
async def test_install_progress_visible_while_download_is_running(env):
import asyncio
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
started, release = asyncio.Event(), asyncio.Event()
async def install(source, info, task_context):
task_context.set_current_action('downloading plugin package')
task_context.metadata.update(download_current=123, download_total=456)
started.set()
await release.wait()
raise RuntimeError('private-token')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': True})
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
try:
await asyncio.wait_for(started.wait(), 3)
await asyncio.sleep(0.25)
progress = task.task_context.metadata['installations'][0]
assert progress['status'] == 'installing'
assert progress['download_current'] == 123
assert progress['stage'] == 'downloading'
finally:
release.set()
await task.task
assert len(task.task_context.metadata['installations']) == 1
assert progress['status'] == 'failed'
assert 'private-token' not in str(task.task_context.to_dict())
@pytest.mark.asyncio
async def test_missing_marketplace_release_has_specific_migration_result(env):
from langbot.pkg.plugin.connector import MarketplacePluginVersionNotFoundError
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(
side_effect=MarketplacePluginVersionNotFoundError('private-token')
)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results'])
assert metadata['installations'][0]['code'] == 'plugin_version_unavailable'
assert 'private-token' not in str(metadata)
@@ -168,3 +168,76 @@ async def test_local_descriptor_migrates_through_detached_task(local, monkeypatc
assert configs['one'] == plan['config']
assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'template',
[
None,
'{global}',
'{launcher_type}_{launcher_id}',
'{launcher_type}_{launcher_id}_{sender_id}',
'{launcher_type}_{launcher_id}_{conversation_id}',
'{query_id}',
'{bot_id}_{launcher_type}_{launcher_id}',
'project-{project}',
],
)
@pytest.mark.parametrize('install_plugins', [False, True])
async def test_box_templates_roundtrip_in_both_migration_modes(local, monkeypatch, template, install_plugins):
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
env, descriptor, _, _ = local
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
section = {'model': 'model', 'prompt': [], 'enable-all-tools': False, 'tools': []}
if template is not None:
section['box-session-id-template'] = template
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': section}}
plan = plan_legacy_pipeline(source)
descriptor.plugin_version = plan['target_plugin']['version']
async with env.engine.begin() as conn:
await conn.execute(
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
)
await conn.execute(sa.delete(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'two'))
if install_plugins:
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=WS,
plugin_author='langbot-team',
plugin_name='LocalAgent',
enabled=True,
)
)
if not install_plugins:
env.ap.runner_registry.list_runners.side_effect = AssertionError('Data-only must work offline')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('Data-only must not install'))
_, result = await base.execute_all(env, install_plugins=install_plugins)
assert result['results'] == [
{
'pipeline_uuid': 'one',
'state': 'migrated',
'code': None if install_plugins else 'data_only',
}
]
configs, snapshots = await base.rows(env)
saved = configs['one']['ai']['runner_config'][plan['target_runner_id']]
effective = {field['name']: field['default'] for field in descriptor.config_schema if 'default' in field}
effective.update(saved)
assert effective['box-enabled'] is True
assert effective['box-session-id-template'] == (template or '{launcher_type}_{launcher_id}')
assert snapshots[0]['source_snapshot']['config'] == source
assert 'box-session-id-template' not in configs['one']['ai']
@pytest.mark.asyncio
@pytest.mark.parametrize('allow_custom', [False, None, 'true'])
async def test_select_requires_explicit_custom_support(local, allow_custom):
env, descriptor, config, plan = local
field = next(f for f in descriptor.config_schema if f['name'] == 'box-session-id-template')
field['allow_custom'] = allow_custom
config['box-session-id-template'] = '{bot_id}_{launcher_id}'
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@@ -992,3 +992,13 @@ def test_local_box_reuse_templates_are_preserved_for_plugin(template):
result = plan(source)
assert result['state'] != 'blocked', result
assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template
@pytest.mark.parametrize(
'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}']
)
def test_invalid_box_templates_are_reported_before_migration(template):
source = source_for('local-agent')
source['ai']['local-agent']['box-session-id-template'] = template
result = plan(source)
assert_block(result, 'local.box_template_invalid', 'ai.local-agent.box-session-id-template')
@@ -62,4 +62,6 @@ def test_empty_box_template_requires_correction():
source['ai']['local-agent']['box-session-id-template'] = ''
result = plan_legacy_pipeline(source)
assert result['state'] == 'blocked'
assert {'code': 'invalid_type', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers']
assert {'code': 'local.box_template_invalid', 'field': 'ai.local-agent.box-session-id-template'} in result[
'blockers'
]
@@ -163,3 +163,32 @@ async def test_marketplace_response_reader_is_bounded():
with pytest.raises(ValueError, match='exceeds'):
await _read_httpx_response_limited(response, max_bytes=4)
@pytest.mark.asyncio
async def test_marketplace_missing_release_is_classified_without_upstream_message():
from langbot.pkg.plugin.connector import _marketplace_get, MarketplacePluginVersionNotFoundError
async with httpx.AsyncClient(
transport=httpx.MockTransport(
lambda r: httpx.Response(500, json={'msg': 'plugin version not found: record not found private-token'})
)
) as client:
with pytest.raises(MarketplacePluginVersionNotFoundError) as exc:
await _marketplace_get(client, 'https://market.test/download', max_bytes=4096)
assert 'private-token' not in str(exc.value)
@pytest.mark.asyncio
async def test_marketplace_download_reports_byte_progress():
from langbot.pkg.plugin.connector import _marketplace_get
from langbot.pkg.core.taskmgr import TaskContext
ctx = TaskContext.new()
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda r: httpx.Response(200, content=b'x' * 1024))
) as client:
status, body = await _marketplace_get(client, 'https://market.test/download', max_bytes=2048, task_context=ctx)
assert status == 200 and len(body) == 1024
assert ctx.metadata['download_current'] == ctx.metadata['download_total'] == 1024
assert ctx.metadata['download_speed'] > 0