From 8c119bc4b6dcae688f164ca652f65cb0e2fc14c5 Mon Sep 17 00:00:00 2001 From: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:13:11 +0000 Subject: [PATCH] feat: add verified manual pipeline migration for plugin runners --- docs/pipeline-migration-config-map.zh-CN.md | 504 ++ .../pkg/agent/runner/context_builder.py | 46 +- src/langbot/pkg/agent/runner/host_models.py | 15 + .../pkg/agent/runner/interaction_manager.py | 55 +- .../pkg/agent/runner/interaction_store.py | 83 +- .../pkg/agent/runner/model_reasoning.py | 85 + src/langbot/pkg/agent/runner/orchestrator.py | 14 + .../pkg/agent/runner/query_entry_adapter.py | 19 + .../pkg/agent/runner/session_registry.py | 4 + .../controller/groups/pipelines/migration.py | 33 + src/langbot/pkg/api/http/service/pipeline.py | 26 +- .../api/http/service/pipeline_migration.py | 685 ++ .../entity/persistence/pipeline_migration.py | 33 + .../0027_pipeline_migration_snapshots.py | 53 + src/langbot/pkg/persistence/mgr.py | 1 + .../pkg/persistence/pipeline_admission.py | 27 + src/langbot/pkg/persistence/tenant_uow.py | 17 +- .../pkg/pipeline/legacy_config_migration.py | 692 ++ src/langbot/pkg/pipeline/pipelinemgr.py | 26 +- src/langbot/pkg/plugin/handler.py | 41 +- .../pkg/provider/modelmgr/requesters/codex.py | 5 +- .../pipeline_migration/synthetic_legacy.json | 11 + .../test_pipeline_admission_postgres.py | 226 + .../agent/test_interaction_manager.py | 63 +- .../agent/test_interaction_store.py | 16 + .../agent/test_legacy_identity_boundary.py | 241 + .../agent/test_orchestrator_integration.py | 17 + .../agent/test_runner_model_reasoning.py | 268 + tests/unit_tests/agent/test_state_store.py | 16 +- .../agent/test_state_store_fixture.py | 31 + .../langflow-agent-artifact-schema.json | 176 + .../pipeline_migration_deerflow_schema.json | 94 + .../pipeline_migration_local_schema.json | 326 + .../weknora-agent-artifact-schema.json | 154 + .../api/service/test_pipeline_migration.py | 734 +++ ...pipeline_migration_all_runner_contracts.py | 119 + .../service/test_pipeline_migration_cloud.py | 121 + ...pipeline_migration_pending_interactions.py | 234 + .../test_pipeline_migration_resources.py | 170 + .../api/test_pipeline_migration_routes.py | 60 + .../test_pipeline_migration_snapshot.py | 47 + .../pipeline/test_legacy_config_migration.py | 986 +++ .../pipeline/test_migration_current_shape.py | 67 + .../test_pipeline_migration_runtime.py | 47 + .../plugin/test_runner_reasoning_override.py | 245 + .../provider/test_codex_reasoning_override.py | 208 + web/playwright.completion.config.ts | 15 + web/playwright.reasoning.config.ts | 17 + web/playwright.structured.config.ts | 15 + .../app/home/agents/AgentDetailContent.tsx | 16 +- web/src/app/home/agents/page.tsx | 40 +- .../dynamic-form/DynamicFormComponent.tsx | 31 +- .../dynamic-form/DynamicFormItemComponent.tsx | 20 +- .../dynamic-form/DynamicFormItemConfig.ts | 1 - .../dynamic-form/DynamicFormSaveValues.ts | 1 + .../dynamic-form/StructuredFieldEditor.tsx | 73 + .../dynamic-form/StructuredFieldValue.ts | 67 + .../reasoning/ReasoningLevelPicker.tsx | 40 +- .../home/pipelines/PipelineDetailContent.tsx | 22 + .../app/home/pipelines/PipelineMigration.tsx | 540 ++ .../pipeline-form/PipelineFormComponent.tsx | 129 +- .../pipeline-form/RunnerConfigPreservation.ts | 35 + web/src/app/home/pipelines/page.tsx | 37 +- .../home/pipelines/pipeline-config-safety.ts | 26 + .../pipelines/pipeline-migration-issues.ts | 35 + .../infra/entities/api/pipeline-migration.ts | 55 + web/src/app/infra/http/BackendClient.ts | 21 +- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/es-ES.ts | 4 + web/src/i18n/locales/ja-JP.ts | 4 + .../i18n/locales/pipeline-migration/en-US.ts | 88 + .../i18n/locales/pipeline-migration/es-ES.ts | 93 + .../i18n/locales/pipeline-migration/ja-JP.ts | 87 + .../i18n/locales/pipeline-migration/ru-RU.ts | 90 + .../i18n/locales/pipeline-migration/th-TH.ts | 83 + .../i18n/locales/pipeline-migration/vi-VN.ts | 87 + .../locales/pipeline-migration/zh-Hans.ts | 72 + .../locales/pipeline-migration/zh-Hant.ts | 72 + web/src/i18n/locales/ru-RU.ts | 4 + web/src/i18n/locales/th-TH.ts | 4 + web/src/i18n/locales/vi-VN.ts | 4 + web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + .../fixtures/runner-migration-contract.json | 5557 +++++++++++++++++ web/tests/e2e/pipeline-migration.spec.ts | 1010 +++ .../e2e/reasoning-edit-semantics.spec.ts | 287 + .../e2e/runner-config-preservation.spec.ts | 212 + .../e2e/structured-runner-editor.spec.ts | 233 + web/tests/unit/codex-subscription.test.mjs | 35 +- .../unit/dynamic-form-save-values.test.mjs | 1 + .../unit/pipeline-config-safety.test.mjs | 69 + .../unit/pipeline-migration-notices.test.mjs | 65 + .../unit/reasoning-edit-semantics.test.mjs | 42 + .../unit/runner-config-preservation.test.mjs | 103 + .../unit/structured-field-value.test.mjs | 74 + web/vite.reasoning.config.ts | 8 + web/vite.structured.config.ts | 8 + 97 files changed, 16662 insertions(+), 123 deletions(-) create mode 100644 docs/pipeline-migration-config-map.zh-CN.md create mode 100644 src/langbot/pkg/agent/runner/model_reasoning.py create mode 100644 src/langbot/pkg/api/http/controller/groups/pipelines/migration.py create mode 100644 src/langbot/pkg/api/http/service/pipeline_migration.py create mode 100644 src/langbot/pkg/entity/persistence/pipeline_migration.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py create mode 100644 src/langbot/pkg/persistence/pipeline_admission.py create mode 100644 src/langbot/pkg/pipeline/legacy_config_migration.py create mode 100644 tests/fixtures/pipeline_migration/synthetic_legacy.json create mode 100644 tests/integration/persistence/test_pipeline_admission_postgres.py create mode 100644 tests/unit_tests/agent/test_legacy_identity_boundary.py create mode 100644 tests/unit_tests/agent/test_runner_model_reasoning.py create mode 100644 tests/unit_tests/agent/test_state_store_fixture.py create mode 100644 tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json create mode 100644 tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json create mode 100644 tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json create mode 100644 tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json create mode 100644 tests/unit_tests/api/service/test_pipeline_migration.py create mode 100644 tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py create mode 100644 tests/unit_tests/api/service/test_pipeline_migration_cloud.py create mode 100644 tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py create mode 100644 tests/unit_tests/api/service/test_pipeline_migration_resources.py create mode 100644 tests/unit_tests/api/test_pipeline_migration_routes.py create mode 100644 tests/unit_tests/persistence/test_pipeline_migration_snapshot.py create mode 100644 tests/unit_tests/pipeline/test_legacy_config_migration.py create mode 100644 tests/unit_tests/pipeline/test_migration_current_shape.py create mode 100644 tests/unit_tests/pipeline/test_pipeline_migration_runtime.py create mode 100644 tests/unit_tests/plugin/test_runner_reasoning_override.py create mode 100644 tests/unit_tests/provider/test_codex_reasoning_override.py create mode 100644 web/playwright.completion.config.ts create mode 100644 web/playwright.reasoning.config.ts create mode 100644 web/playwright.structured.config.ts create mode 100644 web/src/app/home/components/dynamic-form/StructuredFieldEditor.tsx create mode 100644 web/src/app/home/components/dynamic-form/StructuredFieldValue.ts create mode 100644 web/src/app/home/pipelines/PipelineMigration.tsx create mode 100644 web/src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts create mode 100644 web/src/app/home/pipelines/pipeline-config-safety.ts create mode 100644 web/src/app/home/pipelines/pipeline-migration-issues.ts create mode 100644 web/src/app/infra/entities/api/pipeline-migration.ts create mode 100644 web/src/i18n/locales/pipeline-migration/en-US.ts create mode 100644 web/src/i18n/locales/pipeline-migration/es-ES.ts create mode 100644 web/src/i18n/locales/pipeline-migration/ja-JP.ts create mode 100644 web/src/i18n/locales/pipeline-migration/ru-RU.ts create mode 100644 web/src/i18n/locales/pipeline-migration/th-TH.ts create mode 100644 web/src/i18n/locales/pipeline-migration/vi-VN.ts create mode 100644 web/src/i18n/locales/pipeline-migration/zh-Hans.ts create mode 100644 web/src/i18n/locales/pipeline-migration/zh-Hant.ts create mode 100644 web/tests/e2e/fixtures/runner-migration-contract.json create mode 100644 web/tests/e2e/pipeline-migration.spec.ts create mode 100644 web/tests/e2e/reasoning-edit-semantics.spec.ts create mode 100644 web/tests/e2e/runner-config-preservation.spec.ts create mode 100644 web/tests/e2e/structured-runner-editor.spec.ts create mode 100644 web/tests/unit/pipeline-config-safety.test.mjs create mode 100644 web/tests/unit/pipeline-migration-notices.test.mjs create mode 100644 web/tests/unit/reasoning-edit-semantics.test.mjs create mode 100644 web/tests/unit/runner-config-preservation.test.mjs create mode 100644 web/tests/unit/structured-field-value.test.mjs create mode 100644 web/vite.reasoning.config.ts create mode 100644 web/vite.structured.config.ts diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md new file mode 100644 index 000000000..6f3b4bbe5 --- /dev/null +++ b/docs/pipeline-migration-config-map.zh-CN.md @@ -0,0 +1,504 @@ +# 旧流水线迁移:配置去向与语义变更 + +本文对应迁移规划器 v3。原生行为基线:`9b7ba0d64708496ace30a82866f6dbc185f089dc`。 + +## 迁移边界 + +- 迁移只由用户在 UI 选择流水线并确认后执行。打开页面、预览、刷新、安装插件都不触发自动迁移。 +- 流水线 ID、名称、Bot 绑定以及非 AI 配置保持不变。插件凭证、应用 ID、端点和模型配置仍属于本流水线,不迁入共享插件全局设置。 +- 活动 AI 配置规范化为 `ai.runner` 与 `ai.runner_config`,避免编辑器把迁移结果判为混合旧配置。所有原始 AI 段(包括未选中的 Runner 配置)与完整原始配置一起保存到同一事务的持久化备份;预览会列出被归档的字段。 +- 备份不是公开下载,也不意味着已有一键恢复 UI。未知提交结果不显示为已回滚;激活待重试只在新预览、重新选择并确认后执行,且不重复转换配置。 +- “配置迁移”不等于“运行状态无损迁移”。原有远端会话、历史、Box 文件、待处理表单令牌不自动导入;UI 必须在确认前说明新会话/状态重置。自定义 Box 共享作用域与未结束的交互不能被静默清除。 + +## 功能与默认值的取舍 + +- LocalAgent 保留日期提示、结构化提示词、模型回退、模型专属推理配置、工具/知识库/沙箱能力;保留现代上下文预算、检查点和摘要机制,不恢复旧的 `max-round` 截断算法。 +- `max-round` 退役,不把“10 轮”伪装成“50 条消息”等价换算;新上下文参数使用下面列明的默认值。工具执行仍显式采用 `serial`,不偷偷改变有副作用工具的执行顺序。 +- Dify 旧保存项 `timeout` 并非原生 Runner 实际读取的超时控制。迁移采用新插件 30 秒默认值,并提示用户;旧任意数值只保存在备份中。 +- Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。 +- Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。 +- Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。 +- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。 +- N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。 +- `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。 +- 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。 +- 工具、KB、MCP 的授权由 Host 校验;表单未显示的授权字段仍必须保留,不能因保存而删除或扩大权限。 + +## 插件版本要求 + +以下是本次本地源码的目标版本;本表不代表插件已经发布。旧版本即使名称相同,也不能作为新迁移能力的证明。 + +- `local-agent` → `LocalAgent` **0.1.6**。 +- `dify-service-api` → `DifyAgent` **0.1.7**。 +- `coze-api` → `CozeAgent` **0.1.7**。 +- `dashscope-app-api` → `DashScopeAgent` **0.1.7**。 +- `n8n-service-api` → `N8nAgent` **0.1.7**。 +- `langflow-api` → `LangflowAgent` **0.1.7**。 +- `deerflow-api` → `DeerFlowAgent` **0.1.7**。 +- `tbox-app-api` → `TboxAgent` **0.1.5**。 +- `weknora-api` → `WeKnoraAgent` **0.1.7**。 + +## 逐字段完整清单 + +共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。 + +目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。 + +### LocalAgent(28 项) + +- **`ai.local-agent.box-session-id-template`** → `Host execution_context.build_host_box_scope + Box service`。 + - 缺失、空值或原生默认 {launcher_type}_{launcher_id} 不写入新 Runner;用户确认明确的 Box 状态重置警告后创建新隔离会话。任何其他自定义共享模板仍阻断,不自动复制文件或跨作用域授权。 + - 所有权:`host_tenancy_and_existing_file_state`;原生依据:`src/langbot/pkg/box/service.py:641`。 +- **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。 +- **`ai.local-agent.knowledge-base`** → `R.knowledge-bases`。 + - 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:280`。 +- **`ai.local-agent.knowledge-bases`** → `R.knowledge-bases`。 + - 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:278`。 +- **`ai.local-agent.max-round`** → `退役;不生成同名活动参数`。 + - 活动目标省略 max-round;保留回滚备份和弃用通知;不把10轮映射成50条。 + - 所有权:`obsolete_native_context_control`;原生依据:`src/langbot/pkg/pipeline/msgtrun/truncators/round.py:13`。 +- **`ai.local-agent.mcp-resource-agent-read-enabled`** → `R.mcp-resource-agent-read-enabled`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:133`。 +- **`ai.local-agent.mcp-resources`** → `R.mcp-resources`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:129`。 +- **`ai.local-agent.model`** → `R.model`。 + - 旧字符串UUID→{primary:原UUID,fallbacks:[],reasoning:{}};对象深拷贝 primary/fallbacks/reasoning;不替换失效UUID、不扩大授权。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:283`。 +- **`ai.local-agent.model.fallbacks`** → `R.model.fallbacks`。 + - 保留有序UUID列表,缺失=[];runtime 去重不是迁移重排。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:93`。 +- **`ai.local-agent.model.primary`** → `R.model.primary`。 + - 逐字复制非空UUID;Host 所有权交集校验,缺失不得选无关模型。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:92`。 +- **`ai.local-agent.model.reasoning`** → `R.model.reasoning`。 + - 复制 UUID→level 映射,缺失={};缺项保留模型持久默认;显式 provider_default 覆盖持久级别。 + - 所有权:`pipeline_selector_with_host_provider_policy`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:284`。 +- **`ai.local-agent.prompt`** → `R.prompt`。 + - 保留有序消息、合法SDK结构化内容、空消息、name/provider metadata;缺失不拿UI短提示词覆盖seed长提示词;空Host提示列表有权威性。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:122`。 +- **`ai.local-agent.prompt[0].content`** → `R.prompt[0].content`。 + - 保留所有prompt记录的content,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:121`。 +- **`ai.local-agent.prompt[0].role`** → `R.prompt[0].role`。 + - 保留所有prompt记录的role,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:120`。 +- **`ai.local-agent.rerank-model`** → `R.rerank-model`。 + - 复制UUID;空字符串或__none__代表禁用;Host验证 rerank 授权。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:442`。 +- **`ai.local-agent.rerank-top-k`** → `R.rerank-top-k`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:456`。 +- **`ai.local-agent.tools`** → `R.tools`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:37`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`extensions_preferences.enable_all_mcp_servers`** → `extensions_preferences.enable_all_mcp_servers`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:127`。 +- **`extensions_preferences.enable_all_plugins`** → `extensions_preferences.enable_all_plugins`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:126`。 +- **`extensions_preferences.enable_all_skills`** → `extensions_preferences.enable_all_skills`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:339`。 +- **`extensions_preferences.mcp_resource_agent_read_enabled`** → `extensions_preferences.mcp_resource_agent_read_enabled`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:135`。 +- **`extensions_preferences.mcp_resources`** → `extensions_preferences.mcp_resources`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:131`。 +- **`extensions_preferences.mcp_servers`** → `extensions_preferences.mcp_servers`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:149`。 +- **`extensions_preferences.plugins`** → `extensions_preferences.plugins`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:142`。 +- **`extensions_preferences.skills`** → `extensions_preferences.skills`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:344`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:539`。 + +### DifyAgent(8 项) + +- **`ai.dify-service-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:838`。 +- **`ai.dify-service-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:833`。 +- **`ai.dify-service-api.base-prompt`** → `R.base-prompt`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:984`。 +- **`ai.dify-service-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:842`。 +- **`ai.dify-service-api.timeout`** → `R.timeout`。 + - 旧seed timeout=30没有被native Runner读取;native请求显式120。采用新插件30秒默认,旧任意保存值只留备份,不悄悄激活;用户可显式配置新timeout。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:65`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:859`。 + +### CozeAgent(9 项) + +- **`ai.coze-api.api-base`** → `R.api-base`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:35`。 +- **`ai.coze-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:31`。 +- **`ai.coze-api.auto-save-history`** → `R.auto-save-history`。 + - 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:566`。 +- **`ai.coze-api.auto_save_history`** → `R.auto-save-history`。 + - 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:34`。 +- **`ai.coze-api.bot-id`** → `R.bot-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:32`。 +- **`ai.coze-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:33`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:50`。 + +### DashScopeAgent(8 项) + +- **`ai.dashscope-app-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:58`。 +- **`ai.dashscope-app-api.app-id`** → `R.app-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:57`。 +- **`ai.dashscope-app-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:51`。 +- **`ai.dashscope-app-api.references-quote`** → `R.references_quote`。 + - 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:71`。 +- **`ai.dashscope-app-api.references_quote`** → `R.references_quote`。 + - 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:59`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:121`。 + +### N8nAgent(13 项) + +- **`ai.n8n-service-api.auth-type`** → `R.auth-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:47`。 +- **`ai.n8n-service-api.basic-password`** → `R.basic-password`。 + - 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:52`。 +- **`ai.n8n-service-api.basic-username`** → `R.basic-username`。 + - 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:51`。 +- **`ai.n8n-service-api.header-name`** → `R.header-name`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:57`。 +- **`ai.n8n-service-api.header-value`** → `R.header-value`。 + - 原样复制包括空字符串;active header必须有合法header-name,不省略合法空value;隐藏/inactive字段仍保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:58`。 +- **`ai.n8n-service-api.jwt-algorithm`** → `R.jwt-algorithm`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:55`。 +- **`ai.n8n-service-api.jwt-secret`** → `R.jwt-secret`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:54`。 +- **`ai.n8n-service-api.output-key`** → `R.output-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:41`。 +- **`ai.n8n-service-api.response-handling`** → `R.response-handling`。 + - reply/ignore逐字复制;缺失reply;ignore现已实现2xx只读header、不读body、不输出;非2xx仍失败;不得留旧缺特性blocker。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:42`。 +- **`ai.n8n-service-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:38`。 +- **`ai.n8n-service-api.webhook-url`** → `R.webhook-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:35`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### LangflowAgent(10 项) + +- **`ai.langflow-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:119`。 +- **`ai.langflow-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:118`。 +- **`ai.langflow-api.flow-id`** → `R.flow-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:120`。 +- **`ai.langflow-api.input-type`** → `R.input-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:692`。 +- **`ai.langflow-api.input_type`** → `R.input-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:81`。 +- **`ai.langflow-api.output-type`** → `R.output-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:702`。 +- **`ai.langflow-api.output_type`** → `R.output-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:82`。 +- **`ai.langflow-api.tweaks`** → `R.tweaks`。 + - 目标接受dict或严格JSON object(递归保留null/false/0);缺失/null/空串/JSON null→{};空白串按新便利默认{}并记录;false/0/list拒绝;JSON重复键/NaN/Infinity/过深结构拒绝;坏JSON绝不降级{}。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:93`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### DeerFlowAgent(13 项) + +- **`ai.deerflow-api.api-base`** → `R.api-base`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:61`。 +- **`ai.deerflow-api.api-key`** → `R.api-key`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:68`。 +- **`ai.deerflow-api.assistant-id`** → `R.assistant-id`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:70`。 +- **`ai.deerflow-api.auth-header`** → `R.auth-header`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:69`。 +- **`ai.deerflow-api.max-concurrent-subagents`** → `R.max-concurrent-subagents`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:75`。 +- **`ai.deerflow-api.model-name`** → `R.model-name`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:71`。 +- **`ai.deerflow-api.plan-mode`** → `R.plan-mode`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:73`。 +- **`ai.deerflow-api.recursion-limit`** → `R.recursion-limit`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:77`。 +- **`ai.deerflow-api.subagent-enabled`** → `R.subagent-enabled`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:74`。 +- **`ai.deerflow-api.thinking-enabled`** → `R.thinking-enabled`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:72`。 +- **`ai.deerflow-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:76`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### TboxAgent(5 项) + +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`ai.tbox-app-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:59`。 +- **`ai.tbox-app-api.app-id`** → `R.app-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:58`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:114`。 + +### WeKnoraAgent(10 项) + +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`ai.weknora-api.agent-id`** → `R.agent-id`。 + - 原键缺失:明确写入按旧app-type的默认(chat quick-answer,agent smart-reasoning);显式null/空串保持(线端省略agent_id);其余字符串含空白逐字保留,不能因chat而覆盖历史smart ID。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:89`。 +- **`ai.weknora-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:38`。 +- **`ai.weknora-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:336`。 +- **`ai.weknora-api.base-prompt`** → `R.base-prompt`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:65`。 +- **`ai.weknora-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:45`。 +- **`ai.weknora-api.knowledge-base-ids`** → `R.knowledge-base-ids`。 + - 保留远端ID顺序/重复/字节;缺失或null→[];无效元素/空白ID拒绝,不静默丢弃。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:90`。 +- **`ai.weknora-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:92`。 +- **`ai.weknora-api.web-search-enabled`** → `R.web-search-enabled`。 + - 复制严格bool,缺失false;仅agent线端使用,chat保存true仍不激活。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:91`。 + +## 新增参数与明确采用的默认值 + +这些值是新机制的默认值,不声称与旧行为一一等价。已存在且受支持的有效值按映射规则保留;必填凭证/端点不能由模板伪造。 + +### LocalAgent + +```json +{ + "advanced-settings": false, + "date-grounding": true, + "timeout": 300, + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 100, + "tool-execution-mode": "serial", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 50, + "context-window-tokens": 200000, + "context-reserve-tokens": 16384, + "context-keep-recent-tokens": 20000, + "context-summary-tokens": 8000, + "enable-all-tools": true, + "tools": [], + "knowledge-bases": [] +} +``` + +### DifyAgent + +```json +{ + "timeout": 30, + "advanced-settings": false +} +``` + +### CozeAgent + +```json +{ + "auto-save-history": true, + "advanced-settings": false +} +``` + +### DashScopeAgent + +```json +{ + "references_quote": "参考资料来自:", + "timeout": 120, + "advanced-settings": false +} +``` + +### N8nAgent + +```json +{ + "auth-type": "none", + "basic-username": "", + "basic-password": "", + "jwt-secret": "", + "jwt-algorithm": "HS256", + "header-name": "", + "header-value": "", + "timeout": 120, + "output-key": "response", + "response-handling": "reply", + "basic-encoding": "latin1", + "advanced-settings": false +} +``` + +### LangflowAgent + +```json +{ + "input-type": "chat", + "output-type": "chat", + "tweaks": {}, + "advanced-settings": false +} +``` + +### DeerFlowAgent + +```json +{ + "api-key": "", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 +} +``` + +### TboxAgent + +```json +{ + "timeout": 120 +} +``` + +### WeKnoraAgent + +```json +{ + "knowledge-base-ids": [], + "web-search-enabled": false, + "timeout": 120, + "base-prompt": "请回答用户的问题。", + "advanced-settings": false +} +``` + +## 验证与交付状态 + +本清单负责说明所有已识别旧字段的去向和语义;不替代最终的集成验收。源码修改、插件打包、OSS/Cloud 持久化与权限、真实 SDK 调用、浏览器测试、独立审查和发布状态必须分别记录,不能以其中一项通过宣称全部完成。 + +源码库存与每条字段的多处原生证据、默认值/表达式及哈希见工作区的 `411-completion-config-audit.json` 和其标注的原始库存;最终验证以 `411-completion-*` 的实际日志和源码哈希为准。 diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 628d64f07..db8036a58 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -249,6 +249,20 @@ class RunnerContextBuilder: 'timestamp': event.event_time or int(time.time()), } + # A legacy Tbox resume must use the persisted original bot, not a + # newly reconstructed callback envelope. Ordinary sender mode is unchanged. + bot_id = event.bot_id + if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot': + identity = event.legacy_identity + bot_id = ( + identity.bot_id + if identity is not None + and identity.workspace_id == event.workspace_id + and identity.bot_id == event.bot_id + and identity.pipeline_id == (binding.processor_id or binding.agent_id) + else None + ) + # Build conversation context from event conversation: ConversationContext | None = None if event.conversation_id: @@ -256,13 +270,39 @@ class RunnerContextBuilder: 'session_id': None, 'conversation_id': event.conversation_id, 'thread_id': event.thread_id, - 'launcher_type': None, # Will be filled from actor/subject if needed + 'launcher_type': None, 'launcher_id': None, 'sender_id': event.actor.actor_id if event.actor else None, - 'bot_id': event.bot_id, + 'bot_id': bot_id, 'workspace_id': event.workspace_id, } + identity = event.legacy_identity + if ( + binding.runner_config.get('user-id-source') == 'legacy-session' + and identity is not None + and identity.workspace_id == event.workspace_id + and identity.bot_id == event.bot_id + and identity.pipeline_id == (binding.processor_id or binding.agent_id) + and binding.processor_type == 'pipeline' + ): + # Coze used Query itself; Dify/n8n used Query.session, which may + # deliberately differ (e.g. group per-member session isolation). + if binding.runner_id == 'plugin:langbot-team/CozeAgent/default': + launcher_type = identity.query_launcher_type + launcher_id = identity.query_launcher_id + elif binding.runner_id in { + 'plugin:langbot-team/DifyAgent/default', + 'plugin:langbot-team/N8nAgent/default', + }: + launcher_type = identity.session_launcher_type + launcher_id = identity.session_launcher_id + else: + launcher_type = launcher_id = None + if launcher_type in ('group', 'person') and launcher_id: + conversation['launcher_type'] = launcher_type + conversation['launcher_id'] = launcher_id + # Build event context (Protocol v1 event-first) event_context = { 'event_id': event.event_id, @@ -322,7 +362,7 @@ class RunnerContextBuilder: 'trace_id': run_id, 'deadline_at': self._build_deadline_from_binding(binding), 'metadata': { - 'bot_id': event.bot_id, + 'bot_id': bot_id, 'workspace_id': event.workspace_id, 'streaming_supported': event.delivery.supports_streaming, 'model_context_window_tokens': model_context_window_tokens, diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py index dc50a4d48..2fb855e29 100644 --- a/src/langbot/pkg/agent/runner/host_models.py +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -17,6 +17,18 @@ from langbot_plugin.api.entities.builtin.runner.input import AgentInput from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext +class LegacyRunnerIdentity(pydantic.BaseModel): + """Host-captured native Query identity; never populated from event data/params.""" + + workspace_id: str | None = None + bot_id: str | None = None + pipeline_id: str | None = None + query_launcher_type: str | None = None + query_launcher_id: str | None = None + session_launcher_type: str | None = None + session_launcher_id: str | None = None + + class AgentEventEnvelope(pydantic.BaseModel): """Event envelope for LangBot Host event gateway. @@ -69,6 +81,9 @@ class AgentEventEnvelope(pydantic.BaseModel): data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) """Small structured event payload. Large payloads should be referenced via raw_ref.""" + legacy_identity: LegacyRunnerIdentity | None = None + """Host-only provenance, separate from untrusted event payload and adapter params.""" + # Binding scope types class BindingScope(pydantic.BaseModel): diff --git a/src/langbot/pkg/agent/runner/interaction_manager.py b/src/langbot/pkg/agent/runner/interaction_manager.py index 85f19a3e3..99efb0fad 100644 --- a/src/langbot/pkg/agent/runner/interaction_manager.py +++ b/src/langbot/pkg/agent/runner/interaction_manager.py @@ -4,6 +4,7 @@ from __future__ import annotations from langbot.pkg.telemetry import diagnostics +import copy import json import time import typing @@ -14,7 +15,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes from .descriptor import RunnerDescriptor from .errors import RunnerProtocolError -from .host_models import AgentBinding, AgentEventEnvelope +from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity from .interaction_store import InteractionStore @@ -152,7 +153,12 @@ class InteractionManager: runner_id=descriptor.id, processor_type=binding.processor_type, processor_id=processor_id, - request=request_data, + request={ + **request_data, + '_host_legacy_identity': event.legacy_identity.model_dump(mode='json') + if event.legacy_identity is not None + else None, + }, delivery_target=reply_target, replaces_interaction_id=(update_target or {}).get('interaction_id'), bot_id=event.bot_id, @@ -161,6 +167,7 @@ class InteractionManager: thread_id=event.thread_id, actor_id=event.actor.actor_id if event.actor else None, expires_at=expires_at, + **self._pipeline_admission(binding, descriptor, adapter_context), ) try: @@ -204,6 +211,50 @@ class InteractionManager: diagnostics.set_outcome('waiting', reason_code='waiting') return True + @staticmethod + def _pipeline_admission(binding, descriptor, adapter_context): + if binding.processor_type != 'pipeline': + return {} + query = (adapter_context or {}).get('_query') + expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config')) + session = getattr(query, 'session', None) + conversation = (adapter_context or {}).get('_pipeline_conversation') + + def authority(): + from .config_resolver import RunnerConfigResolver + + return ( + query is not None + and session is not None + and conversation is not None + and isinstance(expected, dict) + and session.using_conversation is conversation + and getattr(query, 'pipeline_uuid', None) == binding.processor_id + and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id + and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config + ) + + return {'expected_config': expected, 'authority_check': authority} + + async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None: + """Resume only from the scoped, consumed Host record, never callback fields.""" + event.legacy_identity = None + submission = getattr(event.input, 'interaction', None) + if submission is None: + return + if hasattr(submission, 'model_dump'): + submission = submission.model_dump(mode='json') + record = await self.store.find_resume_request(event=event, binding=binding, submission=submission) + if record is None: + return + identity = record['request'].get('_host_legacy_identity') + if not isinstance(identity, dict): + return + try: + event.legacy_identity = LegacyRunnerIdentity.model_validate(identity) + except pydantic.ValidationError: + return + @diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack') async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None: """Best-effort transition of submitted controls into a read-only state.""" diff --git a/src/langbot/pkg/agent/runner/interaction_store.py b/src/langbot/pkg/agent/runner/interaction_store.py index 15613376d..ccd8178b9 100644 --- a/src/langbot/pkg/agent/runner/interaction_store.py +++ b/src/langbot/pkg/agent/runner/interaction_store.py @@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker from ...entity.persistence.agent_interaction import AgentInteraction +from ...persistence.tenant_uow import TenantUnitOfWork +from ...persistence.pipeline_admission import lock_pipeline_admission UTC = datetime.timezone.utc @@ -49,6 +51,12 @@ def _utc_now() -> datetime.datetime: return datetime.datetime.now(UTC) +def _db_datetime(value): + # Existing schema is TIMESTAMP WITHOUT TIME ZONE; persist UTC naive on + # both backends and normalize to aware UTC only for application comparisons. + return _as_utc(value).replace(tzinfo=None) if value is not None else None + + def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None: if value is None: return None @@ -113,6 +121,8 @@ class InteractionStore: thread_id: str | None = None, actor_id: str | None = None, expires_at: int | float | None = None, + expected_config: dict | None = None, + authority_check: typing.Callable[[], bool] | None = None, ) -> tuple[dict[str, typing.Any], str]: """Persist a request and return its record plus one-time callback token.""" if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id: @@ -145,11 +155,32 @@ class InteractionStore: delivery_target_json=delivery_target_json, replaces_interaction_id=replaces_interaction_id, callback_token_hash=_token_hash(callback_token), - expires_at=expires_at_dt, - created_at=now, - updated_at=now, + expires_at=_db_datetime(expires_at_dt), + created_at=_db_datetime(now), + updated_at=_db_datetime(now), ) + if processor_type == 'pipeline': + # Require Host-captured configuration and live conversation authority; + # runner output must never supply either of these admission inputs. + if not workspace_id or not isinstance(expected_config, dict) or authority_check is None: + raise InteractionScopeError('Pipeline interaction authority unavailable') + try: + async with TenantUnitOfWork(self.engine, workspace_id) as uow: + current = await lock_pipeline_admission(uow.session, workspace_id, processor_id) + if current != expected_config or authority_check() is not True: + raise InteractionScopeError('Pipeline interaction authority changed') + existing = await self._get_by_run_interaction(uow.session, run_id, interaction_id) + if existing is not None: + raise DuplicateInteractionError('Interaction already exists') + uow.session.add(row) + await uow.session.flush() + return self._to_dict(row), callback_token + except ValueError as exc: + raise InteractionScopeError('Pipeline interaction authority unavailable') from exc + except IntegrityError as exc: + raise DuplicateInteractionError('Interaction already exists') from exc + async with self._session_factory() as session: existing = await self._get_by_run_interaction(session, run_id, interaction_id) if existing is not None: @@ -182,7 +213,7 @@ class InteractionStore: AgentInteraction.interaction_id == interaction_id, AgentInteraction.status.in_(['pending', 'submitted']), ) - .values(delivery_result_json=payload, updated_at=_utc_now()) + .values(delivery_result_json=payload, updated_at=_db_datetime(_utc_now())) ) await session.commit() return result.rowcount == 1 @@ -228,6 +259,38 @@ class InteractionStore: row = result.scalar_one_or_none() return self._to_dict(row) if row is not None else None + async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None: + """Resolve persisted provenance with exact tenant/processor/callback scope. + + Ambiguous identities fail closed instead of picking the newest run. + """ + if not isinstance(submission, dict) or not submission.get('interaction_id'): + return None + target = event.delivery.reply_target or {} + target_type, target_id = target.get('target_type'), target.get('target_id') + conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id + conditions = [ + AgentInteraction.interaction_id == submission['interaction_id'], + AgentInteraction.status == 'submitted', + AgentInteraction.binding_id == binding.binding_id, + AgentInteraction.runner_id == binding.runner_id, + AgentInteraction.processor_type == binding.processor_type, + AgentInteraction.processor_id == (binding.processor_id or binding.agent_id), + ] + for column, value in ( + (AgentInteraction.workspace_id, event.workspace_id), + (AgentInteraction.bot_id, event.bot_id), + (AgentInteraction.conversation_id, conversation_id), + (AgentInteraction.thread_id, event.thread_id), + (AgentInteraction.actor_id, event.actor.actor_id if event.actor else None), + ): + conditions.append(column.is_(None) if value is None else column == value) + async with self._session_factory() as session: + result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions)) + records = [self._to_dict(row) for row in result.scalars()] + matches = [record for record in records if record['submission'] == submission] + return matches[0] if len(matches) == 1 else None + async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None: """Return a request by the runner-visible identity.""" async with self._session_factory() as session: @@ -283,7 +346,7 @@ class InteractionStore: if expires_at is not None and expires_at <= now: row.status = 'expired' row.status_reason = 'interaction expired before submission' - row.updated_at = now + row.updated_at = _db_datetime(now) await session.commit() raise InteractionExpiredError('Interaction has expired') @@ -297,9 +360,9 @@ class InteractionStore: ) .values( status='submitted', - submitted_at=submission_time, + submitted_at=_db_datetime(submission_time), submission_json=_json_dumps(submission), - updated_at=now, + updated_at=_db_datetime(now), ) ) if update_result.rowcount != 1: @@ -323,7 +386,7 @@ class InteractionStore: AgentInteraction.interaction_id == interaction_id, AgentInteraction.status == 'pending', ) - .values(status='delivery_failed', status_reason=reason, updated_at=now) + .values(status='delivery_failed', status_reason=reason, updated_at=_db_datetime(now)) ) await session.commit() return result.rowcount == 1 @@ -337,12 +400,12 @@ class InteractionStore: .where( AgentInteraction.status == 'pending', AgentInteraction.expires_at.is_not(None), - AgentInteraction.expires_at <= cutoff, + AgentInteraction.expires_at <= _db_datetime(cutoff), ) .values( status='expired', status_reason='interaction expired', - updated_at=cutoff, + updated_at=_db_datetime(cutoff), ) ) await session.commit() diff --git a/src/langbot/pkg/agent/runner/model_reasoning.py b/src/langbot/pkg/agent/runner/model_reasoning.py new file mode 100644 index 000000000..1e4e83ee1 --- /dev/null +++ b/src/langbot/pkg/agent/runner/model_reasoning.py @@ -0,0 +1,85 @@ +"""Host-owned, run-scoped reasoning policy for schema-declared model selectors. + +This policy is not an SDK resource or a provider kwarg. Only the Host binding +assembler supplies it; model actions consult it after resource authorization. +""" + +from __future__ import annotations + +import copy +import typing + +from ...provider.modelmgr.reasoning import normalize_reasoning_config, validate_reasoning_config +from .config_schema import NONE_SENTINELS, iter_schema_items +from .descriptor import RunnerDescriptor + +if typing.TYPE_CHECKING: + from ...provider.modelmgr.requester import RuntimeLLMModel + + +ModelReasoningOverrides = dict[str, dict[str, str]] + + +def extract_model_reasoning_overrides( + descriptor: RunnerDescriptor, + runner_config: dict[str, typing.Any], + resources: typing.Mapping[str, typing.Any], +) -> ModelReasoningOverrides: + """Normalize explicit UUID-to-level mappings, intersecting selection and grants. + + An absent entry preserves persisted model defaults. Explicit provider_default + overrides them using the native requester's semantics. Descriptor defaults and + undeclared config fields cannot silently introduce reasoning overrides. + """ + authorized = {model.get('model_id') for model in resources.get('models', [])} + overrides: ModelReasoningOverrides = {} + for item in iter_schema_items(descriptor, {'model-fallback-selector'}): + field_name = item.get('name') + if not isinstance(field_name, str): + continue + selection = runner_config.get(field_name) + if not isinstance(selection, dict) or 'reasoning' not in selection: + continue + configured = selection['reasoning'] + if not isinstance(configured, dict): + raise ValueError('Invalid runner model reasoning configuration') + candidates = [selection.get('primary')] + fallbacks = selection.get('fallbacks') + if isinstance(fallbacks, list): + candidates.extend(fallbacks) + selected = {value for value in candidates if isinstance(value, str) and value not in NONE_SENTINELS} + for model_id, level in configured.items(): + # Validate with Core, but do not echo arbitrary config/secret values. + try: + if not isinstance(model_id, str) or not isinstance(level, str): + raise ValueError + config = normalize_reasoning_config({'level': level}) + except (TypeError, ValueError): + raise ValueError('Invalid runner model reasoning configuration') from None + if model_id not in selected or model_id not in authorized: + continue + if model_id in overrides and overrides[model_id] != config: + raise ValueError('Conflicting runner model reasoning overrides') + overrides[model_id] = config + return overrides + + +def model_with_reasoning_override( + model: RuntimeLLMModel, + model_id: str, + session: typing.Mapping[str, typing.Any] | None, +) -> RuntimeLLMModel: + """Clone only the runtime wrapper, after the caller has authorized the model. + + Do not mutate shared model entities or providers. Requesters retain ownership + of ability/capability validation and provider-specific argument translation. + """ + if session is None: + return model + overrides = session.get('authorization', {}).get('model_reasoning_overrides', {}) + if model_id not in overrides: + return model + config = validate_reasoning_config(overrides[model_id], model.model_entity.abilities, model.model_entity.extra_args) + scoped_model = copy.copy(model) + scoped_model.reasoning_config_override = copy.deepcopy(config) + return scoped_model diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 76348c25d..7e37b4afa 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -29,6 +29,7 @@ from .execution_context import ( ) from .host_models import AgentBinding, AgentEventEnvelope from .invoker import RunnerInvoker +from .model_reasoning import extract_model_reasoning_overrides from .interaction_manager import InteractionManager from .query_bridge import QueryRunBridge from .registry import RunnerRegistry @@ -121,6 +122,12 @@ class AgentRunOrchestrator: project_mcp_resource_config(execution_query, binding.runner_config) object.__setattr__(execution_query, '_execution_context', execution_context) + if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in ( + 'legacy-session', + 'legacy-bot', + ): + await self.interaction_manager.restore_legacy_identity(event, binding) + execution_event = event resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query) if resource_addition: @@ -133,6 +140,7 @@ class AgentRunOrchestrator: binding=binding, descriptor=descriptor, ) + model_reasoning_overrides = extract_model_reasoning_overrides(descriptor, binding.runner_config, resources) context = await self.context_builder.build_context_from_event( event=execution_event, @@ -192,6 +200,7 @@ class AgentRunOrchestrator: 'state_scopes': list(binding.state_policy.state_scopes), }, 'state_context': state_context, + 'model_reasoning_overrides': model_reasoning_overrides, } seen_sequences: set[int] = set() @@ -228,6 +237,7 @@ class AgentRunOrchestrator: execution_query=execution_query, platform_context=freeze_platform_context(event), reply_streams=reply_streams, + model_reasoning_overrides=model_reasoning_overrides, ) event_log_id = await self.journal.write_event_log( @@ -412,6 +422,10 @@ class AgentRunOrchestrator: plan = self.query_bridge.build_plan(query) adapter_context = dict(plan.adapter_context) adapter_context['_query'] = query + import copy + + adapter_context['_pipeline_expected_config'] = copy.deepcopy(query.pipeline_config) + adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None) adapter_context['_execution_context'] = get_query_execution_context(query) # Inbound files and subsequent runner tools must share one Host scope. diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index 21d2e1f87..7999f9efd 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -25,6 +25,7 @@ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext from .host_models import ( AgentConfig, AgentEventEnvelope, + LegacyRunnerIdentity, StatePolicy, DeliveryPolicy, ) @@ -100,6 +101,24 @@ class QueryEntryAdapter: delivery=delivery, raw_ref=raw_ref, data=event.data, + legacy_identity=cls._build_legacy_identity(query, execution_context.workspace_uuid), + ) + + @staticmethod + def _build_legacy_identity(query, workspace_id: str) -> LegacyRunnerIdentity: + def exact(value): + value = getattr(value, 'value', value) + return value if isinstance(value, str) and value else None + + session = getattr(query, 'session', None) + return LegacyRunnerIdentity( + workspace_id=workspace_id, + bot_id=exact(getattr(query, 'bot_uuid', None)), + pipeline_id=exact(getattr(query, 'pipeline_uuid', None)), + query_launcher_type=exact(getattr(query, 'launcher_type', None)), + query_launcher_id=exact(getattr(query, 'launcher_id', None)), + session_launcher_type=exact(getattr(session, 'launcher_type', None)), + session_launcher_id=exact(getattr(session, 'launcher_id', None)), ) @classmethod diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index d7e2910d8..fc12b9b40 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -13,6 +13,7 @@ import threading from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from .context_builder import AgentResources +from .model_reasoning import ModelReasoningOverrides from ...provider.tools.toolmgr import ToolSourceRef @@ -52,6 +53,7 @@ class RunAuthorizationSnapshot(typing.TypedDict): platform_context: dict[str, typing.Any] authorized_ids: dict[str, set[str]] authorized_operations: dict[str, dict[str, set[str]]] + model_reasoning_overrides: ModelReasoningOverrides SteeringQueueItem = dict[str, typing.Any] @@ -119,6 +121,7 @@ class AgentRunSessionRegistry: execution_query: pipeline_query.Query | None = None, platform_context: dict[str, typing.Any] | None = None, reply_streams: typing.Any = None, + model_reasoning_overrides: ModelReasoningOverrides | None = None, ) -> None: """Register a new agent run session. @@ -164,6 +167,7 @@ class AgentRunSessionRegistry: 'platform_context': copy.deepcopy(platform_context or {}), 'authorized_ids': self._build_authorized_ids(resources_snapshot), 'authorized_operations': self._build_authorized_operations(resources_snapshot), + 'model_reasoning_overrides': copy.deepcopy(model_reasoning_overrides or {}), } session: AgentRunSession = { diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py new file mode 100644 index 000000000..238a1fa89 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py @@ -0,0 +1,33 @@ +"""User-confirmed migration only; deliberately not API-key/MCP accessible.""" + +import quart + +from ... import group +from ....authz import Permission +from ....context import RequestContext +from ....service.pipeline_migration import PipelineMigrationService, MigrationError + + +@group.group_class('pipeline_migration', '/api/v1/pipelines/_/migration') +class PipelineMigrationRouterGroup(group.RouterGroup): + async def initialize(self): + self.service = PipelineMigrationService(self.ap) + + @self.route( + '/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_VIEW + ) + async def preview(request_context: RequestContext): + try: + return self.success(data=await self.service.preview(request_context)) + except MigrationError as exc: + return self.http_status(exc.status_code, -1, exc.code) + + @self.route( + '/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_MANAGE + ) + async def execute(request_context: RequestContext): + try: + body = await quart.request.get_json(silent=True) + return self.success(data=await self.service.execute(request_context, body)) + except MigrationError as exc: + return self.http_status(exc.status_code, -1, exc.code) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index f0042cfe3..1935e3a1f 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -13,7 +13,7 @@ from ....pipeline.extension_preferences import ( validate_extension_preferences, ) from ....workspace.errors import WorkspaceNotFoundError -from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders +from .secrets import redact_secrets, restore_secret_placeholders from .tenant import TenantContext, require_workspace_uuid, scope_statement @@ -213,12 +213,24 @@ class PipelineService: pipeline_data.pop(protected_field, None) if 'config' in pipeline_data: - current_config = None - if contains_secret_placeholder(pipeline_data['config']): - current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True) - if current_pipeline is None: - raise WorkspaceNotFoundError('Pipeline not found') - current_config = current_pipeline.get('config', {}) + current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True) + if current_pipeline is None: + raise WorkspaceNotFoundError('Pipeline not found') + current_config = current_pipeline.get('config', {}) + old_ai = current_config.get('ai', {}) if isinstance(current_config, dict) else {} + old_runner = old_ai.get('runner') if isinstance(old_ai, dict) else None + legacy_binding = ( + isinstance(old_runner, str) + and bool(old_runner) + or isinstance(old_runner, dict) + and bool(old_runner.get('runner')) + and not old_runner.get('id') + ) + new_config = pipeline_data['config'] + new_ai = new_config.get('ai', {}) if isinstance(new_config, dict) else {} + new_runner = new_ai.get('runner') if isinstance(new_ai, dict) else None + if legacy_binding and isinstance(new_runner, dict) and new_runner.get('id'): + raise ValueError('manual_migration_required') pipeline_data['config'] = restore_secret_placeholders( pipeline_data['config'], current_config if current_config is not None else {}, diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py new file mode 100644 index 000000000..f713ab52a --- /dev/null +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -0,0 +1,685 @@ +"""Explicit, tenant-scoped migration. Preview never contacts plugin runtimes. + +The task handle is process-local. Original snapshots and pending activation are +persistent; a refreshed preview is required after observation loss or restart. +""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import hmac +import json +import secrets +import sys +import uuid + +import sqlalchemy as sa + +from ..authz import Permission, permissions_for_role, require_permission +from ..context import ExecutionContext, PrincipalType, RequestContext +from ....agent.runner.config_resolver import RunnerConfigResolver +from ....agent.runner import config_schema +from ....agent.runner.resource_builder import AgentResourceBuilder +from ....agent.runner.resource_policy import ResourcePolicyProjector +from ....agent.runner.model_reasoning import extract_model_reasoning_overrides +from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel +from ....entity.persistence.rag import KnowledgeBase +from ....entity.persistence.mcp import MCPServer +from ....core.taskmgr import TaskContext +from ....entity.persistence.pipeline import LegacyPipeline +from ....entity.persistence.agent_interaction import AgentInteraction +from ....persistence.pipeline_admission import lock_pipeline_admission +from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot +from ....entity.persistence.plugin import PluginSetting +from ....entity.persistence.user import User +from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState +from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline + + +class MigrationError(ValueError): + """Only constant, credential-free codes cross the API/task boundary.""" + + def __init__(self, code: str, status_code: int = 409): + super().__init__(code) + self.code = code + self.status_code = status_code + + +def validate_execute_request(body) -> list[dict]: + if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True: + raise MigrationError('confirmation_required', 400) + items = body['items'] + if not isinstance(items, list) or not 1 <= len(items) <= 50: + raise MigrationError('invalid_selection', 400) + seen = set() + for item in items: + if not isinstance(item, dict) or set(item) != {'pipeline_uuid', 'preview_token'}: + raise MigrationError('invalid_selection', 400) + for key, maximum in [('pipeline_uuid', 255), ('preview_token', 256)]: + value = item[key] + if not isinstance(value, str) or not value.strip() or len(value) > maximum or value != value.strip(): + raise MigrationError('invalid_selection', 400) + if item['pipeline_uuid'] in seen: + raise MigrationError('invalid_selection', 400) + seen.add(item['pipeline_uuid']) + return copy.deepcopy(items) + + +def _json(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str) + + +def _fingerprint(row): + # SQL timestamps are neither monotonic revisions nor portable CAS values. + return hashlib.sha256( + _json( + {k: v for k, v in row.items() if not k.startswith('_') and k not in ('created_at', 'updated_at')} + ).encode() + ).hexdigest() + + +class PipelineMigrationService: + def __init__(self, ap): + self.ap = ap + self.pm = ap.persistence_mgr + # Loss of this process key only invalidates previews, never snapshots. + self._token_key = secrets.token_bytes(32) + + async def _binding(self, ctx, session=None): + binding = await self.ap.workspace_service.get_execution_binding( + ctx.workspace_uuid, expected_generation=ctx.placement_generation, session=session + ) + if binding.instance_uuid != ctx.instance_uuid: + raise MigrationError('placement_changed') + + async def _authorize(self, ctx, session=None): + require_permission(ctx, Permission.RESOURCE_MANAGE) + if ctx.principal.principal_type != PrincipalType.ACCOUNT or not ctx.account_uuid: + raise MigrationError('authorization_changed', 403) + status = (await self.pm.execute_async(sa.select(User.status).where(User.uuid == ctx.account_uuid))).scalar() + if status != 'active': + raise MigrationError('authorization_changed', 403) + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + ctx.account_uuid, ctx.workspace_uuid, session=session + ) + if ( + access.workspace.uuid != ctx.workspace_uuid + or access.membership.uuid != ctx.workspace.membership_uuid + or access.membership.projection_revision != ctx.workspace.membership_revision + or Permission.RESOURCE_MANAGE.value not in permissions_for_role(access.membership.role) + or access.execution.instance_uuid != ctx.instance_uuid + or access.execution.placement_generation != ctx.placement_generation + ): + raise MigrationError('authorization_changed', 403) + await self._binding(ctx, session) + + async def _rows(self, ctx, pipeline_uuid=None): + # Read DB-native text for JSON CAS: never compare PostgreSQL JSON using =, + # and never assume Python's JSON whitespace/key order matches stored text. + table = LegacyPipeline.__table__ + stmt = sa.select( + table, + *[sa.cast(table.c[k], sa.Text).label('_raw_' + k) for k in ('config', 'stages', 'extensions_preferences')], + ) + stmt = stmt.where(table.c.workspace_uuid == ctx.workspace_uuid) + if pipeline_uuid is not None: + stmt = stmt.where(table.c.uuid == pipeline_uuid) + return [dict(r._mapping) for r in (await self.pm.execute_async(stmt.order_by(table.c.uuid))).all()] + + async def _plugin(self, ctx, plan): + target = plan.get('target_plugin') + if not target: + return None + t = PluginSetting.__table__ + row = ( + await self.pm.execute_async( + sa.select( + t.c.enabled, t.c.installation_uuid, t.c.artifact_digest, t.c.runtime_revision, t.c.install_info + ).where( + t.c.workspace_uuid == ctx.workspace_uuid, + t.c.plugin_author == target['author'], + t.c.plugin_name == target['name'], + ) + ) + ).first() + return dict(row._mapping) if row else None + + def _native_interactions(self, ctx, pipeline_uuid): + """Inspect the old in-process cache without importing/pruning it. + + Native forms have no durable handoff to the plugin runner. Operators + must finish them in the original runtime before migrating; never infer + completion from the cache TTL or submit/cancel a form here. + """ + module = sys.modules.get('langbot.pkg.provider.runners.difysvapi') + if module is None: + return [] + cache = getattr(module, '_PENDING_FORMS', None) + if not isinstance(cache, dict): + return ['unavailable'] + observed = [] + for key, forms in cache.items(): + if not isinstance(key, tuple) or len(key) != 8: + if forms: + observed.append('unscoped') + continue + instance, workspace, _, _, pipeline, *_ = key + # Inspect ownership before touching any form contents. Empty scope + # cannot prove this is another tenant; block without disclosing it. + if instance and instance != ctx.instance_uuid or workspace and workspace != ctx.workspace_uuid: + continue + if pipeline and pipeline != pipeline_uuid: + continue + if forms: + observed.append(hashlib.sha256(_json(key).encode()).hexdigest()) + return sorted(observed) + + async def _interaction_state(self, ctx, pipeline_uuid): + """Read only correlation/status, never request/submission/token data. + + The real table has workspace_id (not workspace_uuid), and no instance + column. Execution placement is verified by the caller; processor_id is + the globally unique pipeline UUID. NULL ownership on that exact UUID + is uncertainty, not permission to query other Workspaces. + """ + if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}: + return {'boundary': 'unavailable'}, True + t = AgentInteraction.__table__ + try: + records = ( + await self.pm.execute_async( + sa.select(t.c.id, t.c.status, t.c.updated_at, t.c.workspace_id) + .where( + t.c.processor_type == 'pipeline', + t.c.processor_id == pipeline_uuid, + sa.or_( + t.c.workspace_id == ctx.workspace_uuid, t.c.workspace_id.is_(None), t.c.workspace_id == '' + ), + ) + .order_by(t.c.id) + ) + ).all() + durable = [tuple(r) for r in records] + blocked = any(r.status not in {'submitted', 'cancelled', 'expired', 'delivery_failed'} for r in records) + except Exception: + # Missing schema, permissions, or a failed store must not be treated + # as an empty store. No original diagnostic crosses the API. + durable, blocked = ['unavailable'], True + native = self._native_interactions(ctx, pipeline_uuid) + return {'durable': durable, 'native': native}, blocked or bool(native) + + async def _plan(self, ctx, row): + plan = plan_legacy_pipeline(row['config'], row['extensions_preferences']) + pending = None + if plan['state'] == 'already_current': + t = PipelineMigrationSnapshot.__table__ + snapshots = ( + await self.pm.execute_async( + sa.select(t).where( + t.c.workspace_uuid == ctx.workspace_uuid, + t.c.pipeline_uuid == row['uuid'], + t.c.state == 'activation_pending', + t.c.target_fingerprint == _fingerprint(row), + t.c.planner_version == str(PLANNER_VERSION), + ) + ) + ).all() + if snapshots: + pending = dict(snapshots[0]._mapping) + source = pending['source_snapshot'] + plan = plan_legacy_pipeline(source['config'], source['extensions_preferences']) + candidate = {**row, 'config': plan.get('config')} + if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row): + raise MigrationError('planner_changed') + facts = await self._plugin(ctx, plan) + state = 'activation_pending' if pending else plan['state'] + blockers = list(plan['blockers']) + if state in ('ready', 'activation_pending'): + if facts is None: + state = 'needs_plugin' + blockers.append({'code': 'plugin_missing'}) + elif facts['enabled'] is not True: + state = 'blocked' + blockers.append({'code': 'plugin_disabled'}) + if plan['state'] == 'ready': + observation, interaction_blocked = await self._interaction_state(ctx, row['uuid']) + plan['_interaction_state'] = observation + if interaction_blocked: + state = 'blocked' + blockers.append({'code': 'runtime.pending_interaction'}) + return plan, facts, pending, state, blockers + + def _token(self, ctx, row, plan, facts, pending): + claim = [ + ctx.instance_uuid, + ctx.workspace_uuid, + ctx.placement_generation, + ctx.account_uuid, + ctx.workspace.membership_uuid, + ctx.workspace.membership_revision, + str(PLANNER_VERSION), + _fingerprint(row), + plan, + facts, + pending['uuid'] if pending else None, + ] + # An authenticated opaque digest discloses neither credentials nor configs. + return hmac.new(self._token_key, _json(claim).encode(), hashlib.sha256).hexdigest() + + async def preview(self, ctx: RequestContext): + require_permission(ctx, Permission.RESOURCE_VIEW) + async with self.pm.tenant_scope(ctx.workspace_uuid): + await self._binding(ctx) + items = [] + for row in await self._rows(ctx): + plan, facts, pending, state, blockers = await self._plan(ctx, row) + item = { + k: copy.deepcopy(plan[k]) + for k in ('legacy_runner', 'target_runner_id', 'target_plugin', 'changed_paths', 'warnings') + } + item.update( + pipeline_uuid=row['uuid'], + name=row['name'], + state=state, + blockers=blockers, + preview_token=self._token(ctx, row, plan, facts, pending) + if state in ('ready', 'activation_pending') + else None, + ) + items.append(item) + return { + 'planner_version': str(PLANNER_VERSION), + 'workspace_uuid': ctx.workspace_uuid, + 'items': items, + 'total': len(items), + } + + async def _selected(self, ctx, selection): + rows = await self._rows(ctx, selection['pipeline_uuid']) + if not rows: + raise MigrationError('pipeline_not_found', 404) + row = rows[0] + plan, facts, pending, state, _ = await self._plan(ctx, row) + token = self._token(ctx, row, plan, facts, pending) + if not hmac.compare_digest(token, selection['preview_token']): + raise MigrationError('preview_stale') + if state not in ('ready', 'activation_pending'): + raise MigrationError('migration_blocked') + return row, plan, facts, pending + + async def execute(self, ctx: RequestContext, body): + require_permission(ctx, Permission.RESOURCE_MANAGE) + items = validate_execute_request(body) + async with self.pm.tenant_scope(ctx.workspace_uuid): + await self._authorize(ctx) + # Preflight the entire selection before creating tasks or contacting runtimes. + for selection in items: + await self._selected(ctx, selection) + task_context = TaskContext.new() + task_context.metadata = { + 'kind': 'pipeline_migration', + 'results': [{'pipeline_uuid': i['pipeline_uuid'], 'state': 'pending', 'code': None} for i in items], + } + task = self.ap.task_mgr.create_user_task( + self._run(ctx, ExecutionContext.from_request(ctx), items, task_context), + kind='pipeline_migration', + name='pipeline_migration', + context=task_context, + instance_uuid=ctx.instance_uuid, + workspace_uuid=ctx.workspace_uuid, + placement_generation=ctx.placement_generation, + ) + return {'task_id': task.id} + + async def _verify_runtime(self, execution, plan): + runners = await self.ap.runner_registry.list_runners(execution, use_cache=False, usage='agent') + 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']: + raise MigrationError('plugin_version_incompatible') + config = plan['config'] + RunnerConfigResolver.validate_pipeline_config(config) + runner_config = config['ai']['runner_config'][plan['target_runner_id']] + schema = {item['name']: item for item in descriptor.config_schema} + # Host fields have their own scope/capability checks below; plugin + # schema defaults must never implicitly authorize a foreign resource. + host_fields = { + 'enable-all-tools': 'boolean', + 'tools': 'array', + 'knowledge-bases': 'knowledge-base-multi-selector', + 'mcp-resources': 'array', + 'mcp-resource-agent-read-enabled': 'boolean', + } + for name, value in runner_config.items(): + field = schema.get(name) + if field is None and name in host_fields: + field = {'type': host_fields[name]} + if field is None: + raise MigrationError('runner_schema_incompatible') + if value is None and field.get('nullable') is True: + continue + kind = config_schema.normalize_schema_item_type(field.get('type')) + valid = ( + ( + kind + in ( + 'string', + 'text', + 'password', + 'secret', + 'select', + 'llm-model-selector', + 'embedding-model-selector', + 'rerank-model-selector', + ) + and isinstance(value, str) + ) + or (kind == 'boolean' and type(value) is bool) + or (kind in ('integer', 'int') and type(value) is int) + or (kind in ('float', 'number') and type(value) in (int, float)) + or ( + kind + in ( + 'array', + 'multi-select', + 'knowledge-base-selector', + 'knowledge-base-multi-selector', + 'prompt-editor', + ) + and isinstance(value, list) + ) + or (kind in ('object', 'json') and type(value) is dict) + or (kind == 'array[string]' and type(value) is list and all(type(v) is str for v in value)) + or ( + kind == 'model-fallback-selector' + and ( + isinstance(value, str) + or isinstance(value, dict) + and set(value) <= {'primary', 'fallbacks', 'reasoning'} + and isinstance(value.get('primary'), str) + and isinstance(value.get('fallbacks', []), list) + and all(isinstance(v, str) for v in value.get('fallbacks', [])) + and isinstance(value.get('reasoning', {}), dict) + ) + ) + ) + if not valid: + raise MigrationError('runner_schema_incompatible') + if kind == 'select' and field.get('options'): + if value not in [o.get('value', o.get('name')) for o in field['options']]: + raise MigrationError('runner_schema_incompatible') + if any( + f.get('required') and name not in runner_config and f.get('default') is None for name, f in schema.items() + ): + raise MigrationError('runner_schema_incompatible') + await self._verify_resources(execution, descriptor, runner_config) + return _json( + [ + descriptor.id, + descriptor.plugin_version, + descriptor.usages, + descriptor.config_schema, + getattr(descriptor, 'capabilities', None), + getattr(descriptor, 'permissions', None), + ] + ) + + async def _verify_resources(self, execution, descriptor, runner_config): + """Fail closed on references the scoped Host cannot authorize. + + This runs only on explicit execution, outside the mutation transaction. + Resource use is still reauthorized by the Host on every eventual run. + """ + + async def require_row(model, identifier, *, column=None): + column = model.uuid if column is None else column + found = ( + await self.pm.execute_async( + sa.select(model.uuid).where( + model.workspace_uuid == execution.workspace_uuid, + column == identifier, + ) + ) + ).first() + if found is None: + raise MigrationError('runner_resource_unavailable') + + permissions = getattr(descriptor, 'permissions', None) + model_resources = [] + for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config): + allowed = set(getattr(permissions, 'models', [])) + if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})): + raise MigrationError('runner_resource_unavailable') + await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid) + model_resources.append({'model_id': model_uuid}) + for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}): + value = runner_config.get(field['name']) + if value and value not in config_schema.NONE_SENTINELS: + await require_row(EmbeddingModel, value) + extract_model_reasoning_overrides(descriptor, runner_config, {'models': model_resources}) + + 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 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') + tool_grant = ( + runner_config.get('enable-all-tools', False) + or tool_names + or runner_config.get('mcp-resource-agent-read-enabled', False) + ) + if tool_grant: + if not config_schema.uses_host_tools(descriptor) or 'call' not in getattr(permissions, 'tools', []): + raise MigrationError('runner_resource_unavailable') + policy = ResourcePolicyProjector.from_runner_config(runner_config) + tools = await AgentResourceBuilder(self.ap)._build_tools_from_binding( + execution, + permissions, + policy, + descriptor, + runner_config, + ) + if not set(tool_names) <= {tool['tool_name'] for tool in tools}: + raise MigrationError('runner_resource_unavailable') + + 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') + + async def _lock(self, ctx, pipeline_uuid, session): + # Every pending producer shares this row and revalidates its captured + # configuration/conversation authority after acquiring admission. + if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}: + raise MigrationError('runtime.pending_interaction') + await lock_pipeline_admission(session, ctx.workspace_uuid, pipeline_uuid) + # Hold revocable directory rows through commit (no network in this UoW). + await session.execute(sa.select(User.uuid).where(User.uuid == ctx.account_uuid).with_for_update()) + for model, condition in [ + (Workspace, Workspace.uuid == ctx.workspace_uuid), + (WorkspaceExecutionState, WorkspaceExecutionState.workspace_uuid == ctx.workspace_uuid), + ( + WorkspaceMembership, + sa.and_( + WorkspaceMembership.workspace_uuid == ctx.workspace_uuid, + WorkspaceMembership.account_uuid == ctx.account_uuid, + ), + ), + (PluginSetting, PluginSetting.workspace_uuid == ctx.workspace_uuid), + ]: + await session.execute(sa.select(model.__table__).where(condition).with_for_update()) + + async def _commit(self, ctx, selection, expected_facts, candidate): + async with self.pm.tenant_uow(ctx.workspace_uuid) as uow: + await self._lock(ctx, selection['pipeline_uuid'], uow.session) + await self._authorize(ctx, uow.session) + row, plan, facts, pending = await self._selected(ctx, selection) + if facts != expected_facts: + raise MigrationError('plugin_changed') + if pending: + return pending['uuid'], pending['target_fingerprint'] + target = {**row, 'config': candidate['config']} + 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'])) + if result.rowcount != 1: + raise MigrationError('preview_stale') + snapshot_uuid = str(uuid.uuid4()) + target_fingerprint = _fingerprint(target) + await self.pm.execute_async( + sa.insert(PipelineMigrationSnapshot).values( + uuid=snapshot_uuid, + workspace_uuid=ctx.workspace_uuid, + pipeline_uuid=row['uuid'], + source_fingerprint=_fingerprint(row), + target_fingerprint=target_fingerprint, + source_snapshot=json.loads(_json({k: v for k, v in row.items() if not k.startswith('_')})), + planner_version=str(PLANNER_VERSION), + state='activation_pending', + ) + ) + if self._native_interactions(ctx, row['uuid']): + raise MigrationError('runtime.pending_interaction') + return snapshot_uuid, target_fingerprint + + async def _activate(self, ctx, selection, snapshot_uuid, target_fingerprint, candidate, plan, expected_facts): + # Re-read the committed target under a short lock before synchronous + # publication. A later editor must never be replaced by our old candidate. + async with self.pm.tenant_uow(ctx.workspace_uuid) as uow: + await self._lock(ctx, selection['pipeline_uuid'], uow.session) + await self._authorize(ctx, uow.session) + rows = await self._rows(ctx, selection['pipeline_uuid']) + if not rows or _fingerprint(rows[0]) != target_fingerprint: + raise MigrationError('activation_source_changed') + if await self._plugin(ctx, plan) != expected_facts: + raise MigrationError('plugin_changed') + _, interaction_blocked = await self._interaction_state(ctx, selection['pipeline_uuid']) + if interaction_blocked: + raise MigrationError('runtime.pending_interaction') + # Native cache inspection above is the last await before publication + # and session invalidation; no native producer can interleave here. + self.ap.pipeline_mgr.publish_pipeline(candidate) + for session in self.ap.sess_mgr.session_list: + conversation = session.using_conversation + if ( + conversation is not None + and conversation.pipeline_uuid == selection['pipeline_uuid'] + and getattr(session, 'workspace_uuid', None) == ctx.workspace_uuid + ): + session.using_conversation = None + t = PipelineMigrationSnapshot.__table__ + await self.pm.execute_async( + sa.update(t) + .where(t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == snapshot_uuid) + .values(state='active') + ) + + async def _run(self, ctx, execution, items, task_context): + async with self.pm.tenant_scope(execution.workspace_uuid): + for selection, result in zip(items, task_context.metadata['results']): + committed = False + commit_attempted = False + try: + await self._authorize(ctx) + row, plan, facts, pending = await self._selected(ctx, selection) + runtime_schema = await self._verify_runtime(execution, plan) + candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')} + candidate_entity['config'] = copy.deepcopy(plan['config']) + runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity)) + if await self._verify_runtime(execution, plan) != runtime_schema: + raise MigrationError('runner_schema_changed') + # Runtime awaits are over. Recheck authorization/source/plugin + # facts under database locks, then atomically journal and CAS. + commit_attempted = True + snapshot_uuid, target_fingerprint = await self._commit(ctx, selection, facts, candidate_entity) + committed = True + await self._activate(ctx, selection, snapshot_uuid, target_fingerprint, runtime, plan, facts) + result.update(state='migrated', code=None) + except (Exception, asyncio.CancelledError) as exc: + cancelled = isinstance(exc, asyncio.CancelledError) + reconciliation_cancel = None + code = ( + 'operation_cancelled' + if cancelled + else exc.code + if isinstance(exc, MigrationError) + else 'migration_failed' + ) + if commit_attempted and not committed and not isinstance(exc, MigrationError): + # A commit can succeed while acknowledgement/connection + # cleanup fails, including cancellation. Make one read + # after the UoW has unwound, in this task's tenant scope; + # never shield/retry through another cancellation. + try: + t = PipelineMigrationSnapshot.__table__ + conditions = [ + t.c.workspace_uuid == ctx.workspace_uuid, + t.c.pipeline_uuid == selection['pipeline_uuid'], + t.c.source_fingerprint + == (pending['source_fingerprint'] if pending else _fingerprint(row)), + t.c.target_fingerprint == _fingerprint(candidate_entity), + t.c.planner_version == str(PLANNER_VERSION), + ] + if pending: + # A retry starts from the target, not the legacy + # source. Only the original snapshot proves this + # durable migration; an unrelated journal cannot. + conditions.extend( + [ + t.c.uuid == pending['uuid'], + t.c.target_fingerprint == pending['target_fingerprint'], + ] + ) + found = (await self.pm.execute_async(sa.select(t.c.uuid).where(*conditions))).first() + committed = found is not None + except asyncio.CancelledError as cancel_exc: + reconciliation_cancel = cancel_exc + cancelled = True + code = 'commit_outcome_unknown' + except Exception: + code = 'commit_outcome_unknown' + state = ( + # Unknown is deliberately recovery-needed, not a claim + # of rollback or proof that a snapshot was committed. + 'activation_pending' + if committed or code == 'commit_outcome_unknown' + else 'stale' + if code == 'preview_stale' + else 'blocked' + if isinstance(exc, MigrationError) + else 'failed' + ) + result.update(state=state, code=code) + if cancelled: + for unstarted in task_context.metadata['results']: + if unstarted['state'] == 'pending': + unstarted.update(state='failed', code='operation_cancelled') + if reconciliation_cancel is not None: + # Preserve cancellation propagation during recovery, + # but record safe outcomes before leaving the task. + raise reconciliation_cancel + return task_context.metadata + # Never attach the original exception, traceback or plugin + # diagnostic to TaskContext: upstream errors can contain keys. + return task_context.metadata diff --git a/src/langbot/pkg/entity/persistence/pipeline_migration.py b/src/langbot/pkg/entity/persistence/pipeline_migration.py new file mode 100644 index 000000000..44d42ff4d --- /dev/null +++ b/src/langbot/pkg/entity/persistence/pipeline_migration.py @@ -0,0 +1,33 @@ +"""Tenant-owned original configurations; never expose through public serialization.""" + +import sqlalchemy as sa + +from .base import Base + + +class PipelineMigrationSnapshot(Base): + __tablename__ = 'pipeline_migration_snapshots' + + uuid = sa.Column(sa.String(36), primary_key=True) + workspace_uuid = sa.Column(sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False) + pipeline_uuid = sa.Column(sa.String(255), nullable=False) + source_fingerprint = sa.Column(sa.String(64), nullable=False) + target_fingerprint = sa.Column(sa.String(64), nullable=False) + planner_version = sa.Column(sa.String(64), nullable=False) + source_snapshot = sa.Column(sa.JSON, nullable=False) + state = sa.Column(sa.String(32), nullable=False) + created_at = sa.Column(sa.DateTime, nullable=False, server_default=sa.func.now()) + + __table_args__ = ( + sa.ForeignKeyConstraint( + ['workspace_uuid', 'pipeline_uuid'], + ['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'], + name='fk_pipeline_migration_workspace_pipeline', + ondelete='CASCADE', + ), + sa.UniqueConstraint( + 'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source' + ), + sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'), + sa.Index('ix_pipeline_migration_workspace_pipeline', 'workspace_uuid', 'pipeline_uuid'), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py b/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py new file mode 100644 index 000000000..e589b2828 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py @@ -0,0 +1,53 @@ +"""Create the manual migration journal only; never convert pipeline JSON.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0027_pipeline_migration' +down_revision = '0026_merge_master_beta' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + table = 'pipeline_migration_snapshots' + if not sa.inspect(bind).has_table(table): + op.create_table( + table, + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column( + 'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False + ), + sa.Column('pipeline_uuid', sa.String(255), nullable=False), + sa.Column('source_fingerprint', sa.String(64), nullable=False), + sa.Column('target_fingerprint', sa.String(64), nullable=False), + sa.Column('planner_version', sa.String(64), nullable=False), + sa.Column('source_snapshot', sa.JSON(), nullable=False), + sa.Column('state', sa.String(32), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint( + ['workspace_uuid', 'pipeline_uuid'], + ['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'], + name='fk_pipeline_migration_workspace_pipeline', + ondelete='CASCADE', + ), + sa.UniqueConstraint( + 'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source' + ), + sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'), + ) + op.create_index('ix_pipeline_migration_workspace_pipeline', table, ['workspace_uuid', 'pipeline_uuid']) + if bind.dialect.name == 'postgresql': + op.execute(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY') + op.execute(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY') + op.execute(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {table}') + expression = "workspace_uuid = NULLIF(current_setting('langbot.workspace_uuid', true), '')" + op.execute( + f'CREATE POLICY langbot_workspace_isolation ON {table} FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + + +def downgrade(): + op.drop_table('pipeline_migration_snapshots') diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 87b9f1635..7f491bea5 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -71,6 +71,7 @@ _ALEMBIC_TENANT_TABLES = { 'rerank_models', 'legacy_pipelines', 'pipeline_run_records', + 'pipeline_migration_snapshots', 'plugin_settings', 'knowledge_bases', 'knowledge_base_files', diff --git a/src/langbot/pkg/persistence/pipeline_admission.py b/src/langbot/pkg/persistence/pipeline_admission.py new file mode 100644 index 000000000..3f7a1093c --- /dev/null +++ b/src/langbot/pkg/persistence/pipeline_admission.py @@ -0,0 +1,27 @@ +"""Transaction-held, tenant/pipeline-scoped admission for form producers. + +Every pending producer must hold this row until its INSERT commits. Migration +holds the same row for both CAS and activation. No table/process/advisory lock. +The no-op UPDATE also acquires SQLite's write reservation before any reads. +""" + +import sqlalchemy as sa +from ..entity.persistence.pipeline import LegacyPipeline + + +async def lock_pipeline_admission(session, workspace_uuid: str, pipeline_uuid: str): + if not workspace_uuid or not pipeline_uuid: + raise ValueError('Pipeline admission requires exact ownership') + t = LegacyPipeline.__table__ + result = await session.execute( + sa.update(t) + .where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid) + .values(updated_at=t.c.updated_at) + ) + if result.rowcount != 1: + raise ValueError('Pipeline admission authority unavailable') + return ( + await session.execute( + sa.select(t.c.config).where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid) + ) + ).scalar_one() diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 47a705c45..6d954f82f 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -58,6 +58,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { 'rerank_models': 'workspace_uuid', 'legacy_pipelines': 'workspace_uuid', 'pipeline_run_records': 'workspace_uuid', + 'pipeline_migration_snapshots': 'workspace_uuid', 'plugin_settings': 'workspace_uuid', 'knowledge_bases': 'workspace_uuid', 'knowledge_base_files': 'workspace_uuid', @@ -467,9 +468,21 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters') if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}: - raise ScopedSessionTransactionError( - 'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search' + # Manual Pipeline migration compares DB-native JSON text, because + # PostgreSQL JSON has no equality operator. Permit only these fixed + # mapped columns and a built-in Text target, never arbitrary casts. + source = element.clause + pipeline_json_cas = ( + type(element.type) is sqlalchemy.Text + and isinstance(source, sqlalchemy.Column) + and type(source.type) is sqlalchemy.JSON + and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines' + and source.name in {'config', 'stages', 'extensions_preferences'} ) + if not pipeline_json_cas: + raise ScopedSessionTransactionError( + 'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts' + ) if isinstance(element, sqlalchemy.sql.functions.FunctionElement): function_name = str(getattr(element, 'name', '')).casefold() diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py new file mode 100644 index 000000000..da9f32a8f --- /dev/null +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -0,0 +1,692 @@ +"""Pure planning for explicitly requested legacy Pipeline migrations. + +The candidate config is internal-only: never serialize it in a preview or log +it. ``ready`` means a config candidate, NOT permission to execute: installation, +resource ownership and existing conversation-state checks belong to the caller. +No IO, registry access, defaults from UI schemas, or runtime mutation occurs here. + +Reviewed native source: 9b7ba0d64708496ace30a82866f6dbc185f089dc. +Reviewed official plugins: local parity patches over 678b18fd65af98230806c3b97b649514c83acea9. +Patch manifest versions below are required; version alone is not artifact proof. +""" + +from __future__ import annotations + +import copy +import json +import math +import re +from urllib.parse import urlsplit + +PLANNER_VERSION = '3' + +_TARGETS = { + 'local-agent': ('LocalAgent', '0.1.6'), + 'dify-service-api': ('DifyAgent', '0.1.7'), + 'coze-api': ('CozeAgent', '0.1.7'), + 'dashscope-app-api': ('DashScopeAgent', '0.1.7'), + 'n8n-service-api': ('N8nAgent', '0.1.7'), + 'langflow-api': ('LangflowAgent', '0.1.7'), + 'deerflow-api': ('DeerFlowAgent', '0.1.7'), + 'tbox-app-api': ('TboxAgent', '0.1.5'), + 'weknora-api': ('WeKnoraAgent', '0.1.7'), +} +_DEERFLOW_FIELDS = { + 'api-base', + 'api-key', + 'auth-header', + 'assistant-id', + 'model-name', + 'thinking-enabled', + 'plan-mode', + 'subagent-enabled', + 'max-concurrent-subagents', + 'timeout', + 'recursion-limit', +} +_FIELDS = { + 'local-agent': { + 'model', + 'max-round', + 'prompt', + 'box-session-id-template', + 'rerank-model', + 'rerank-top-k', + 'enable-all-tools', + 'tools', + 'knowledge-bases', + 'knowledge-base', + 'mcp-resources', + 'mcp-resource-agent-read-enabled', + }, + 'dify-service-api': {'base-url', 'api-key', 'app-type', 'base-prompt', 'timeout'}, + 'coze-api': {'api-key', 'bot-id', 'api-base', 'timeout', 'auto-save-history', 'auto_save_history'}, + 'dashscope-app-api': {'app-type', 'api-key', 'app-id', 'references_quote', 'references-quote'}, + 'n8n-service-api': { + 'webhook-url', + 'auth-type', + 'basic-username', + 'basic-password', + 'jwt-secret', + 'jwt-algorithm', + 'header-name', + 'header-value', + 'timeout', + 'output-key', + 'response-handling', + }, + 'langflow-api': { + 'base-url', + 'api-key', + 'flow-id', + 'input-type', + 'output-type', + 'input_type', + 'output_type', + 'tweaks', + }, + 'deerflow-api': _DEERFLOW_FIELDS, + 'tbox-app-api': {'api-key', 'app-id'}, + 'weknora-api': { + 'base-url', + 'api-key', + 'app-type', + 'agent-id', + 'knowledge-base-ids', + 'web-search-enabled', + 'timeout', + 'base-prompt', + }, +} +_REQUIRED = { + 'local-agent': ('model', 'prompt'), + 'dify-service-api': ('base-url', 'api-key', 'app-type', 'base-prompt'), + 'coze-api': ('api-key', 'bot-id', 'api-base', 'timeout'), + 'dashscope-app-api': ('app-type', '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', 'app-type'), +} +_BOOLEANS = { + 'enable-all-tools', + 'mcp-resource-agent-read-enabled', + 'auto-save-history', + 'auto_save_history', + 'thinking-enabled', + 'plan-mode', + 'subagent-enabled', + 'web-search-enabled', +} +_INTEGERS = {'max-round', 'rerank-top-k', 'max-concurrent-subagents', 'recursion-limit'} +_NAME_LISTS = {'tools', 'knowledge-bases', 'knowledge-base-ids'} +_DEERFLOW_DEFAULTS = { + 'api-key': '', + 'auth-header': '', + 'assistant-id': 'lead_agent', + 'model-name': '', + 'thinking-enabled': False, + 'plan-mode': False, + 'subagent-enabled': False, + 'max-concurrent-subagents': 3, + 'timeout': 300, + 'recursion-limit': 1000, +} +# Reviewed local patch manifests and implementations explicitly preserve native +# provider identity using trusted Host conversation fields; absent context fails +# closed in the plugin. Caller must verify the installed artifact/Host contract. +_IDENTITY_SOURCES = { + 'dify-service-api': 'legacy-session', + 'coze-api': 'legacy-session', + 'n8n-service-api': 'legacy-session', + 'tbox-app-api': 'legacy-bot', +} +_LOCAL_DEFAULTS = { + 'advanced-settings': False, + 'date-grounding': True, + 'timeout': 300, + 'retrieval-top-k': 5, + 'rerank-model': '', + 'rerank-top-k': 5, + 'max-tool-iterations': 100, + 'tool-execution-mode': 'serial', + 'max-tool-result-chars': 20000, + 'context-history-fetch-limit': 50, + 'context-window-tokens': 200000, + 'context-reserve-tokens': 16384, + 'context-keep-recent-tokens': 20000, + 'context-summary-tokens': 8000, + 'enable-all-tools': True, + 'tools': [], + 'knowledge-bases': [], +} +_DEFAULTS = { + 'local-agent': _LOCAL_DEFAULTS, + 'dify-service-api': {'timeout': 30, 'advanced-settings': False}, + 'coze-api': {'auto-save-history': True, 'advanced-settings': False}, + 'dashscope-app-api': {'references_quote': '参考资料来自:', 'timeout': 120, 'advanced-settings': False}, + 'n8n-service-api': { + 'auth-type': 'none', + 'basic-username': '', + 'basic-password': '', + 'jwt-secret': '', + 'jwt-algorithm': 'HS256', + 'header-name': '', + 'header-value': '', + 'timeout': 120, + 'output-key': 'response', + 'response-handling': 'reply', + 'basic-encoding': 'latin1', + 'advanced-settings': False, + }, + 'langflow-api': {'input-type': 'chat', 'output-type': 'chat', 'tweaks': {}, 'advanced-settings': False}, + 'deerflow-api': _DEERFLOW_DEFAULTS, + 'tbox-app-api': {'timeout': 120}, + 'weknora-api': { + 'knowledge-base-ids': [], + 'web-search-enabled': False, + 'timeout': 120, + 'base-prompt': '请回答用户的问题。', + 'advanced-settings': False, + }, +} +_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'} + + +def _strict_json(value, ancestors=None, depth=0): + """Reject cycles, custom objects, nonfinite numbers and non-string keys.""" + if depth > 100: + return False + kind = type(value) + if value is None or kind in (str, bool, int): + return True + if kind is float: + return math.isfinite(value) + if kind not in (dict, list): + return False + ancestors = set() if ancestors is None else ancestors + identity = id(value) + if identity in ancestors: + return False + ancestors.add(identity) + try: + if kind is dict: + return all(type(k) is str and _strict_json(v, ancestors, depth + 1) for k, v in value.items()) + return all(_strict_json(v, ancestors, depth + 1) for v in value) + finally: + ancestors.remove(identity) + + +def _block(result, code, field): + """Only developer-owned codes and paths may reach the public projection.""" + diagnostic = {'code': code, 'field': field} + if diagnostic not in result['blockers']: + result['blockers'].append(diagnostic) + result['state'] = 'blocked' + return result + + +def _names(value): + return type(value) is list and all(type(item) is str and bool(item) for item in value) + + +def _attachments(value): + return type(value) is list and all( + type(item) is dict and ('enabled' not in item or type(item['enabled']) is bool) for item in 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']: + result['warnings'].append(diagnostic) + + +def _message_shape(value, shape, required=()): + # SDK Message schemas, without importing runtime services or coercing values. + return ( + type(value) is dict + and not (set(value) - set(shape)) + and all(key in value for key in required) + and all(check(value[key]) for key, check in shape.items() if key in value) + ) + + +def _valid_prompt(prompt): + def string(value): + return type(value) is str + + def optional_string(value): + return value is None or string(value) + + def metadata(value): + return value is None or type(value) is dict + + def content(value): + if value is None or string(value): + return True + fields = {key: optional_string for key in ('text', 'image_base64', 'file_url', 'file_base64', 'file_name')} + fields.update(type=string, image_url=lambda v: v is None or _message_shape(v, {'url': string}, ('url',))) + return type(value) is list and all(_message_shape(v, fields, ('type',)) for v in value) + + def tools(value): + fields = { + 'id': string, + 'type': string, + 'function': lambda v: _message_shape(v, {'name': string, 'arguments': string}, ('name', 'arguments')), + 'provider_specific_fields': metadata, + } + return value is None or ( + type(value) is list and all(_message_shape(v, fields, ('id', 'type', 'function')) for v in value) + ) + + fields = {key: optional_string for key in ('name', 'tool_call_id', 'resp_message_id')} + fields.update(role=string, content=content, tool_calls=tools, provider_specific_fields=metadata) + return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt) + + +def _validate_local(result, section): + 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') + 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: + _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') + if section.get('box-session-id-template') not in (None, '', '{launcher_type}_{launcher_id}'): + _block(result, 'local.box_scope', f'{prefix}.box-session-id-template') + _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') + _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') + + +def _unique_json_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('duplicate_key') + result[key] = value + return result + + +def _parse_tweaks(raw): + if raw is None or (type(raw) is str and not raw.strip()): + return {} + decoded = json.loads(raw, object_pairs_hook=_unique_json_object) if type(raw) is str else raw + if decoded is None: + return {} + if type(decoded) is not dict or not _strict_json(decoded): + raise ValueError('invalid_tweaks') + return decoded + + +def _validate_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, ()): + value = section.get(field) + if type(value) is str and (not value or (legacy == 'weknora-api' 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 == '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 + if ( + parsed is None + or parsed.scheme not in ('http', 'https') + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or any(c.isspace() or ord(c) < 32 for c in base) + ): + raise ValueError + _ = parsed.port + 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') + ): + _warn(result, 'langflow.tweaks_default', 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') + if mode == 'basic': + for field in ('basic-username', 'basic-password'): + value = section.get(field) + if type(value) is str: + try: + value.encode('latin1') + if field == 'basic-username' and ':' in value: + raise ValueError + 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']): + _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': + 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): + output = config.get('output', {}) + if type(output) is not dict: + _block(result, 'invalid_type', 'output') + return + misc = output.get('misc', {}) + if type(misc) is not dict: + _block(result, 'invalid_type', 'output.misc') + return + value = misc.get('remove-think', False) + if type(value) is not bool: + _block(result, 'invalid_type', 'output.misc.remove-think') + + +def _assemble(result, legacy, section, config, preferences): + selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)} + prefix = f'ai.{legacy}' + if legacy in _IDENTITY_SOURCES: + selected['user-id-source'] = _IDENTITY_SOURCES[legacy] + if legacy == 'local-agent': + selected.pop('max-round', None) + selected.pop('box-session-id-template', None) + singular = selected.pop('knowledge-base', '') + if not selected['knowledge-bases'] 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 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( + { + 'enable-all-tools': False, + 'tools': [], + 'knowledge-bases': [], + 'mcp-resources': [], + 'mcp-resource-agent-read-enabled': False, + } + ) + 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) + _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['knowledge-base-ids'] is None: + selected['knowledge-base-ids'] = [] + _warn(result, 'weknora.session_title_changed', prefix) + return selected + + +def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: + """Return a detached config candidate or safe, value-free diagnostics.""" + result = { + 'state': 'not_legacy', + 'legacy_runner': None, + 'target_runner_id': None, + 'target_plugin': None, + 'config': None, + 'changed_paths': [], + 'blockers': [], + 'warnings': [], + } + if type(config) is not dict: + return _block(result, 'invalid_type', 'config') + if not _strict_json(config): + return _block(result, 'invalid_json_value', 'config') + ai = config.get('ai', {}) + if type(ai) is not dict: + return _block(result, 'invalid_type', 'ai') + 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') + if 'id' in selection: + current = selection['id'] + 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: + return result + name, version = _TARGETS[legacy] + target = f'plugin:langbot-team/{name}/default' + result.update( + legacy_runner=legacy, + 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 set(ai) - set(_TARGETS) - {'runner', 'runner_config'}: + _block(result, 'unknown_field', 'ai') + if set(selection) - {'runner', 'expire-time'}: + _block(result, 'unknown_field', '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) + _warn(result, 'external.state_validation_required', 'ai.runner') + _warn(result, 'migration.history_reset', 'ai.runner') + _warn(result, 'migration.new_defaults', f'ai.{legacy}') + _warn(result, 'migration.legacy_sections_archived', 'ai') + if legacy in ('dify-service-api', 'dashscope-app-api', 'n8n-service-api'): + _warn(result, 'migration.filtered_variables', f'ai.{legacy}') + if legacy in _IDENTITY_SOURCES: + _warn(result, 'migration.identity_preserved', f'ai.{legacy}') + if result['blockers']: + return result + 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}') + result.update( + state='ready', + config=candidate, + changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths], + ) + return result diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index b069eea7e..45287a34c 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -636,6 +636,16 @@ class PipelineManager: return dataclasses.replace(context, pipeline_uuid=pipeline_uuid) async def load_pipeline( + self, + context: ExecutionContext | RequestContext, + pipeline_entity, + *, + _binding_validated: bool = False, + ): + candidate = await self.prepare_pipeline(context, pipeline_entity, _binding_validated=_binding_validated) + self.publish_pipeline(candidate) + + async def prepare_pipeline( self, context: ExecutionContext | RequestContext, pipeline_entity: persistence_pipeline.LegacyPipeline @@ -657,8 +667,6 @@ class PipelineManager: execution_context.workspace_uuid, expected_generation=execution_context.placement_generation, ) - self._observe_execution_context(execution_context) - coerce_pipeline_config( pipeline_entity.config, getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}), @@ -685,17 +693,25 @@ class PipelineManager: execution_context.workspace_uuid, expected_generation=execution_context.placement_generation, ) - self._observe_execution_context(execution_context) - runtime_pipeline = RuntimePipeline( + return RuntimePipeline( self.ap, pipeline_entity, stage_containers, execution_context, ) + + def publish_pipeline(self, runtime_pipeline: RuntimePipeline) -> None: + """Publish a prepared candidate without yielding. + + Callers preparing before a database commit must recheck the committed + source and execution binding before invoking this synchronous seam. + """ + execution_context = runtime_pipeline.execution_context + self._observe_execution_context(execution_context) key = ( execution_context.instance_uuid, execution_context.workspace_uuid, - pipeline_entity.uuid, + runtime_pipeline.pipeline_entity.uuid, ) self._pipelines_by_key[key] = runtime_pipeline self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index eee532746..03ec789fd 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -52,6 +52,7 @@ from ..entity.persistence import model as persistence_model from ..core import app from ..utils import constants from ..agent.runner.session_registry import get_session_registry +from ..agent.runner.model_reasoning import model_with_reasoning_override from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner import config_schema from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call @@ -204,6 +205,7 @@ async def _validate_run_authorization( ap: app.Application, caller_plugin_identity: str | None = None, operation: str | None = None, + workspace_uuid: str | None = None, ) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]: """Validate run_id authorization for a resource access. @@ -250,6 +252,9 @@ async def _validate_run_authorization( message=f'Plugin identity mismatch: caller {caller_plugin_identity} is not authorized for run_id {run_id}', ) + if workspace_uuid is not None and session['authorization'].get('workspace_id') not in (None, workspace_uuid): + return None, handler.ActionResponse.error(message='Run session belongs to another Workspace') + if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation): ap.logger.warning( f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}' @@ -1341,11 +1346,19 @@ class RuntimeConnectionHandler(handler.Handler): caller_plugin_identity = data.get('caller_plugin_identity') if run_id: - _session, error = await _validate_run_authorization( - run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens' + session, error = await _validate_run_authorization( + run_id, + 'model', + llm_model_uuid, + self.ap, + caller_plugin_identity, + operation='count_tokens', + workspace_uuid=action_context.workspace_uuid, ) if error: return error + else: + session = None if not await self._resource_exists( persistence_model.LLMModel, @@ -1366,6 +1379,9 @@ class RuntimeConnectionHandler(handler.Handler): message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', ) + if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid): + return handler.ActionResponse.error(message='LLM model belongs to another Workspace') + llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) messages_obj = [provider_message.Message.model_validate(message) for message in messages] async def _placeholder_func(**kwargs): @@ -1407,7 +1423,13 @@ class RuntimeConnectionHandler(handler.Handler): # Permission validation for Runner calls if run_id: session, error = await _validate_run_authorization( - run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke' + run_id, + 'model', + llm_model_uuid, + self.ap, + caller_plugin_identity, + operation='invoke', + workspace_uuid=action_context.workspace_uuid, ) if error: return error @@ -1441,6 +1463,7 @@ class RuntimeConnectionHandler(handler.Handler): message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', ) + llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) messages_obj = [provider_message.Message.model_validate(message) for message in messages] # The func field is excluded during model_dump() in plugin side (marked as exclude=True), @@ -1500,7 +1523,13 @@ class RuntimeConnectionHandler(handler.Handler): # Permission validation for Runner calls if run_id: session, error = await _validate_run_authorization( - run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream' + run_id, + 'model', + llm_model_uuid, + self.ap, + caller_plugin_identity, + operation='stream', + workspace_uuid=action_context.workspace_uuid, ) if error: yield error @@ -1528,6 +1557,10 @@ class RuntimeConnectionHandler(handler.Handler): ) return + if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid): + yield handler.ActionResponse.error(message='LLM model belongs to another Workspace') + return + llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) messages_obj = [provider_message.Message.model_validate(message) for message in messages] # The func field is excluded during model_dump() in plugin side diff --git a/src/langbot/pkg/provider/modelmgr/requesters/codex.py b/src/langbot/pkg/provider/modelmgr/requesters/codex.py index 0d19928b8..5b793ef75 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/codex.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/codex.py @@ -235,7 +235,10 @@ class CodexRequester(requester.ProviderAPIRequester): 'stream': True, 'include': ['reasoning.encrypted_content'], } - level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['level'] + reasoning_config = getattr(model, 'reasoning_config_override', None) + if reasoning_config is None: + reasoning_config = getattr(model.model_entity, 'reasoning_config', None) + level = reasoning.normalize_reasoning_config(reasoning_config)['level'] if level != 'provider_default': reasoning.validate_reasoning_capabilities( {'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name diff --git a/tests/fixtures/pipeline_migration/synthetic_legacy.json b/tests/fixtures/pipeline_migration/synthetic_legacy.json new file mode 100644 index 000000000..ea3a1b9e8 --- /dev/null +++ b/tests/fixtures/pipeline_migration/synthetic_legacy.json @@ -0,0 +1,11 @@ +{ + "local-agent": {"model": {"primary": "synthetic-model", "fallbacks": []}, "max-round": 10, "prompt": [{"role": "system", "content": "Synthetic prompt"}], "enable-all-tools": false, "tools": []}, + "dify-service-api": {"base-url": "https://dify.invalid/v1", "api-key": "synthetic-key", "app-type": "chat", "base-prompt": "Synthetic prompt"}, + "coze-api": {"api-key": "synthetic-key", "bot-id": "synthetic-bot", "api-base": "https://api.coze.cn", "auto_save_history": false, "timeout": 120}, + "dashscope-app-api": {"api-key": "synthetic-key", "app-id": "synthetic-app", "app-type": "agent", "references_quote": "Synthetic references"}, + "n8n-service-api": {"webhook-url": "https://n8n.invalid/hook?token=synthetic", "auth-type": "none", "timeout": 120, "output-key": "response", "response-handling": "reply"}, + "langflow-api": {"base-url": "https://langflow.invalid", "api-key": "synthetic-key", "flow-id": "synthetic-flow", "input_type": "chat", "output_type": "chat", "tweaks": "{}"}, + "deerflow-api": {"api-base": "https://deerflow.invalid", "api-key": "synthetic-key", "auth-header": "", "assistant-id": "lead_agent", "model-name": "synthetic-model", "thinking-enabled": false, "plan-mode": false, "subagent-enabled": false, "max-concurrent-subagents": 3, "timeout": 300, "recursion-limit": 1000}, + "tbox-app-api": {"api-key": "synthetic-key", "app-id": "synthetic-app"}, + "weknora-api": {"base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", "knowledge-base-ids": ["remote-kb"], "web-search-enabled": false} +} diff --git a/tests/integration/persistence/test_pipeline_admission_postgres.py b/tests/integration/persistence/test_pipeline_admission_postgres.py new file mode 100644 index 000000000..6c95eae63 --- /dev/null +++ b/tests/integration/persistence/test_pipeline_admission_postgres.py @@ -0,0 +1,226 @@ +"""Disposable PostgreSQL only: real row-lock admission, rollback, and FORCE RLS.""" + +import asyncio +import os +import copy +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine +from tests.unit_tests.api.service import test_pipeline_migration as base +from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError +from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission +from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction + +URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL') +pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')] + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + admin = create_async_engine(URL) + async with admin.begin() as conn: + await conn.execute(sa.text('DROP SCHEMA public CASCADE')) + await conn.execute(sa.text('CREATE SCHEMA public')) + await conn.execute( + sa.text( + 'DO $$ BEGIN CREATE ROLE admission_runtime LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$' + ) + ) + monkeypatch.setattr(base, 'create_async_engine', lambda *a, **kw: admin) + async for env in base.env.__wrapped__(tmp_path, monkeypatch): + async with admin.begin() as conn: + for table in ['legacy_pipelines', 'pipeline_migration_snapshots']: + await conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + await conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + await conn.execute( + sa.text( + f"CREATE POLICY isolation ON {table} USING (workspace_uuid = current_setting('langbot.workspace_uuid', true)) WITH CHECK (workspace_uuid = current_setting('langbot.workspace_uuid', true))" + ) + ) + await conn.execute(sa.text('GRANT USAGE ON SCHEMA public TO admission_runtime')) + await conn.execute( + sa.text('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO admission_runtime') + ) + await conn.execute(sa.text('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO admission_runtime')) + runtime = create_async_engine(URL.replace('postgres@', 'admission_runtime@')) + env.pm.db = base.NS(get_engine=lambda: runtime) + env.runtime = runtime + yield env + await runtime.dispose() + + +async def write(env, workspace=base.WS, pipeline='one', expected=None, check=lambda: True): + return await InteractionStore(env.runtime).create_request( + interaction_id='form', + run_id='run', + binding_id='binding', + runner_id=base.RID, + processor_type='pipeline', + processor_id=pipeline, + workspace_id=workspace, + request={}, + delivery_target={}, + expected_config=copy.deepcopy(base.SOURCE if expected is None else expected), + authority_check=check, + ) + + +async def blocked(env): + # Observe PostgreSQL's lock waiter, not an arbitrary scheduling sleep. + async with asyncio.timeout(5): + while True: + async with env.engine.connect() as conn: + n = ( + await conn.execute( + sa.text( + "SELECT count(*) FROM pg_stat_activity WHERE usename='admission_runtime' AND wait_event_type='Lock'" + ) + ) + ).scalar() + if n: + return + await asyncio.sleep(0) + + +@pytest.mark.parametrize('outcome', ['commit', 'rollback', 'cancel']) +async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome): + writer = None + try: + async with env.pm.tenant_uow(base.WS) as uow: + await lock_pipeline_admission(uow.session, base.WS, 'one') + writer = asyncio.create_task(write(env)) + await blocked(env) + assert not writer.done() + async with env.engine.connect() as conn: + assert not (await conn.execute(sa.select(AgentInteraction))).all() + await uow.session.execute( + sa.update(base.LegacyPipeline) + .where(base.LegacyPipeline.uuid == 'one') + .values(config=base.planner(base.SOURCE)['config']) + ) + if outcome == 'rollback': + raise RuntimeError('rollback') + if outcome == 'cancel': + raise asyncio.CancelledError() + except (RuntimeError, asyncio.CancelledError): + assert outcome != 'commit' + if outcome == 'commit': + with pytest.raises(InteractionScopeError): + await writer + else: + assert (await writer)[0]['status'] == 'pending' + + +async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env): + await write(env) + async with env.pm.tenant_uow(base.WS) as uow: + await lock_pipeline_admission(uow.session, base.WS, 'one') + assert ( + await uow.session.execute( + sa.select(AgentInteraction.status).where(AgentInteraction.workspace_id == base.WS) + ) + ).scalar_one() == 'pending' + with pytest.raises(InteractionScopeError): + await write(env, workspace=base.OTHER) + async with env.runtime.connect() as conn: + assert not (await conn.execute(sa.select(base.LegacyPipeline))).all() + async with env.pm.tenant_uow(base.OTHER) as uow: + assert (await uow.session.execute(sa.select(base.LegacyPipeline.uuid))).scalars().all() == ['foreign'] + + +async def test_real_migration_commit_activation_and_stale_writer(env): + original = env.svc._activate + + async def activate(*args): + with pytest.raises(InteractionScopeError): + await write(env) + return await original(*args) + + env.svc._activate = activate + task = await base.execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + with pytest.raises(InteractionScopeError): + await write(env) + _, snapshots = await base.rows(env) + assert snapshots[0]['state'] == 'active' + + +@pytest.mark.parametrize('boundary', ['cas', 'activation']) +async def test_real_no_phantom_between_check_and_publication(env, monkeypatch, boundary): + writer = None + sql = env.pm.execute_async + state = env.svc._interaction_state + entered_activation = False + activate = env.svc._activate + + async def activation(*args): + nonlocal entered_activation + entered_activation = True + return await activate(*args) + + async def start_waiter(): + nonlocal writer + writer = asyncio.create_task(write(env)) + await blocked(env) + assert not writer.done() + + async def execute_sql(statement, *args, **kwargs): + result = await sql(statement, *args, **kwargs) + if ( + boundary == 'cas' + and isinstance(statement, sa.sql.dml.Insert) + and statement.table.name == 'pipeline_migration_snapshots' + ): + await start_waiter() + return result + + async def interaction_state(*args): + result = await state(*args) + if boundary == 'activation' and entered_activation: + await start_waiter() + return result + + monkeypatch.setattr(env.pm, 'execute_async', execute_sql) + monkeypatch.setattr(env.svc, '_activate', activation) + monkeypatch.setattr(env.svc, '_interaction_state', interaction_state) + task = await base.execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + with pytest.raises(InteractionScopeError): + await writer + async with env.engine.connect() as conn: + assert not (await conn.execute(sa.select(AgentInteraction))).all() + + +async def test_real_conversation_revoked_while_waiting(env): + live = True + async with env.pm.tenant_uow(base.WS) as uow: + await lock_pipeline_admission(uow.session, base.WS, 'one') + writer = asyncio.create_task(write(env, check=lambda: live)) + await blocked(env) + live = False + with pytest.raises(InteractionScopeError): + await writer + + +async def test_real_other_tenant_writer_is_not_serialized(env): + async with env.pm.tenant_uow(base.WS) as uow: + await lock_pipeline_admission(uow.session, base.WS, 'one') + record, _ = await asyncio.wait_for(write(env, workspace=base.OTHER, pipeline='foreign'), 3) + assert record['workspace_id'] == base.OTHER + + +async def test_real_pending_before_commit_prevents_migration(env): + body = await base.selection(env) + await write(env) + with pytest.raises(env.m.MigrationError, match='preview_stale'): + await base.execute(env, body) + configs, snapshots = await base.rows(env) + assert configs['one'] == base.SOURCE and not snapshots + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +# Run the unchanged cancellation/ambiguous-acknowledgement contracts on real PG. +test_real_cancel_commit_outcome = base.test_cancel_commit_reports_durable_outcome_and_stops_batch +test_real_cancel_activation_retry = base.test_cancel_activation_retry_reconciles_original_snapshot +test_real_unavailable_reconciliation = base.test_unavailable_commit_reconciliation_is_conservative +test_real_cancel_prepare = base.test_cancel_during_prepare_keeps_original_and_stops_batch diff --git a/tests/unit_tests/agent/test_interaction_manager.py b/tests/unit_tests/agent/test_interaction_manager.py index bc09a26fc..821c24fc7 100644 --- a/tests/unit_tests/agent/test_interaction_manager.py +++ b/tests/unit_tests/agent/test_interaction_manager.py @@ -6,6 +6,7 @@ from types import SimpleNamespace import time import pytest +import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext @@ -26,6 +27,27 @@ from langbot.pkg.agent.runner.host_models import ( from langbot.pkg.agent.runner.interaction_manager import InteractionManager from langbot.pkg.agent.runner.interaction_store import InteractionStore from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.pipeline import LegacyPipeline + +CONFIG = { + 'ai': { + 'runner': {'id': 'plugin:test/ApprovalRunner/default'}, + 'runner_config': {'plugin:test/ApprovalRunner/default': {}}, + } +} + + +def _context(adapter): + conversation = SimpleNamespace(pipeline_uuid='pipeline-1') + query = SimpleNamespace( + pipeline_uuid='pipeline-1', pipeline_config=CONFIG, session=SimpleNamespace(using_conversation=conversation) + ) + return { + '_delivery_adapter': adapter, + '_query': query, + '_pipeline_expected_config': CONFIG, + '_pipeline_conversation': conversation, + } class FakeAdapter: @@ -63,6 +85,18 @@ async def store(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager.db"}', echo=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute( + sa.insert(LegacyPipeline).values( + uuid='pipeline-1', + workspace_uuid='workspace-1', + name='test', + description='', + for_version='4.11', + stages=[], + config=CONFIG, + extensions_preferences={}, + ) + ) yield InteractionStore(engine) await engine.dispose() @@ -74,6 +108,7 @@ def event(): event_type='message.received', source='platform', bot_id='bot-1', + workspace_id='workspace-1', conversation_id='group_chat-1', actor=ActorContext(actor_type='user', actor_id='user-1'), input=AgentInput(text='start'), @@ -135,7 +170,7 @@ async def test_structured_delivery_uses_frozen_target_and_persists_request(store binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) assert consumed is True @@ -167,7 +202,7 @@ async def test_continuous_interaction_reuses_submitted_platform_presentation(sto binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) first_token = adapter.actions[0][1]['callback_token'] submitted = await manager.consume_callback( @@ -192,7 +227,7 @@ async def test_continuous_interaction_reuses_submitted_platform_presentation(sto binding=binding, descriptor=_descriptor(), run_id='run-2', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) assert [action for action, _ in adapter.actions] == [ @@ -221,7 +256,7 @@ async def test_interaction_update_failure_falls_back_to_new_presentation(store, binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) first_token = adapter.actions[0][1]['callback_token'] await manager.consume_callback( @@ -241,7 +276,7 @@ async def test_interaction_update_failure_falls_back_to_new_presentation(store, binding=binding, descriptor=_descriptor(), run_id='run-2', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) update_attempt = adapter.actions[-2][1] @@ -263,7 +298,7 @@ async def test_callback_scope_uses_frozen_delivery_conversation(store, event, bi binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) record = await store.get_request('run-1', 'form-1') @@ -281,7 +316,7 @@ async def test_adapter_without_interactions_receives_fallback_text(store, event, binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) assert adapter.actions == [] @@ -301,7 +336,7 @@ async def test_runner_without_interaction_permission_is_rejected(store, event, b binding=binding, descriptor=_descriptor(permitted=False), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) assert await store.get_request('run-1', 'form-1') is None @@ -319,7 +354,7 @@ async def test_binding_policy_can_disable_interactions(store, event, binding): binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) @@ -344,7 +379,7 @@ async def test_interaction_expiry_is_bounded(store, event, binding, expires_at, binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) @@ -361,7 +396,7 @@ async def test_interaction_rejects_duplicate_protocol_ids(store, event, binding) binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) @@ -392,7 +427,7 @@ async def test_interaction_request_payload_is_bounded(store, event, binding): binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) @@ -408,7 +443,7 @@ async def test_non_interaction_action_is_not_consumed(store, event, binding): binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + adapter_context=_context(FakeAdapter(supports_interactions=True)), ) @@ -421,7 +456,7 @@ async def _deliver_interaction(store, event, binding, result=None): binding=binding, descriptor=_descriptor(), run_id='run-1', - adapter_context={'_delivery_adapter': adapter}, + adapter_context=_context(adapter), ) return manager, adapter.actions[0][1]['callback_token'] diff --git a/tests/unit_tests/agent/test_interaction_store.py b/tests/unit_tests/agent/test_interaction_store.py index 4872be4b1..c3dcb6f93 100644 --- a/tests/unit_tests/agent/test_interaction_store.py +++ b/tests/unit_tests/agent/test_interaction_store.py @@ -18,6 +18,7 @@ from langbot.pkg.agent.runner.interaction_store import ( ) from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.pipeline import LegacyPipeline UTC = datetime.timezone.utc @@ -28,6 +29,18 @@ async def db_engine(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute( + sqlalchemy.insert(LegacyPipeline).values( + uuid='pipeline-1', + workspace_uuid='workspace-1', + name='test', + description='', + for_version='4.11', + stages=[], + config={}, + extensions_preferences={}, + ) + ) yield engine await engine.dispose() @@ -45,6 +58,9 @@ async def _create(store: InteractionStore, **overrides): 'runner_id': 'plugin:test/ApprovalRunner/default', 'processor_type': 'pipeline', 'processor_id': 'pipeline-1', + 'workspace_id': 'workspace-1', + 'expected_config': {}, + 'authority_check': lambda: True, 'request': {'interaction_id': 'form-1', 'title': 'Approve?', 'fallback_text': 'Reply yes or no.'}, 'delivery_target': {'chat_id': 'chat-1'}, 'bot_id': 'bot-1', diff --git a/tests/unit_tests/agent/test_legacy_identity_boundary.py b/tests/unit_tests/agent/test_legacy_identity_boundary.py new file mode 100644 index 000000000..241b818b4 --- /dev/null +++ b/tests/unit_tests/agent/test_legacy_identity_boundary.py @@ -0,0 +1,241 @@ +"""Native identity provenance across the Host/SDK and persisted resume boundary.""" + +import ast +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from langbot_plugin.api.entities.builtin.runner.context import RunnerContext +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope +from langbot.pkg.agent.runner.interaction_manager import InteractionManager +from langbot.pkg.agent.runner.interaction_store import InteractionStore +from langbot.pkg.entity.persistence.base import Base +from sqlalchemy.ext.asyncio import create_async_engine +from tests.unit_tests.agent import test_event_first_protocol as event_fixtures +from tests.unit_tests.agent.test_context_validation import TestContextValidation as Helpers + + +@pytest.fixture +def mock_query(): + return event_fixtures.mock_query.__wrapped__() + + +async def build(event, runner, source='legacy-session'): + helper = Helpers() + binding = helper._make_binding() + binding.runner_id = f'plugin:langbot-team/{runner}/default' + binding.processor_type = 'pipeline' + binding.processor_id = 'pipeline-uuid-456' + binding.runner_config = {'user-id-source': source} + builder = RunnerContextBuilder(helper._make_mock_app()) + builder._build_context_access = AsyncMock(return_value={}) + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as store: + store.return_value.build_snapshot_from_event = AsyncMock(return_value={}) + context = await builder.build_context_from_event( + event, binding, helper._make_descriptor(), helper._make_resources() + ) + return context, binding + + +@pytest.mark.asyncio +@pytest.mark.parametrize('runner', ['DifyAgent', 'N8nAgent', 'CozeAgent']) +@pytest.mark.parametrize('kind', ['group', 'person']) +async def test_exact_native_identity_survives_serialization(mock_query, runner, kind): + mock_query.launcher_type.value = kind + mock_query.launcher_id = ' Event_007 ' + mock_query.session.launcher_type.value = 'person' if kind == 'group' else 'group' + mock_query.session.launcher_id = ' Session_008 ' + event = AgentEventEnvelope.model_validate_json(QueryEntryAdapter.query_to_event(mock_query).model_dump_json()) + context, _ = await build(event, runner) + expected = ( + (kind, ' Event_007 ') if runner == 'CozeAgent' else (mock_query.session.launcher_type.value, ' Session_008 ') + ) + assert (context['conversation']['launcher_type'], context['conversation']['launcher_id']) == expected + assert context['actor']['actor_id'] == 'sender-123' + assert context['conversation']['sender_id'] == 'sender-123' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('runner', ['DifyAgent', 'N8nAgent', 'CozeAgent']) +async def test_missing_authoritative_fields_never_fall_back_to_raw_data(mock_query, runner): + mock_query.session.launcher_id = None + mock_query.launcher_id = None + mock_query.variables.update({'launcher_id': 'spoof', 'session_id': 'group_spoof'}) + event = QueryEntryAdapter.query_to_event(mock_query) + event.data.update({'launcher_type': 'group', 'launcher_id': 'spoof'}) + context, _ = await build(event, runner) + assert context['conversation']['launcher_id'] is None + + +@pytest.mark.asyncio +async def test_tbox_and_sender_defaults_unchanged(mock_query): + event = QueryEntryAdapter.query_to_event(mock_query) + context, _ = await build(event, 'TboxAgent', 'legacy-bot') + assert context['conversation']['bot_id'] == mock_query.bot_uuid + context, _ = await build(event, 'DifyAgent', 'sender') + assert context['actor']['actor_id'] == 'sender-123' + assert context['conversation']['launcher_id'] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field', ['workspace_id', 'bot_id']) +async def test_provenance_cannot_cross_scope(mock_query, field): + event = QueryEntryAdapter.query_to_event(mock_query) + setattr(event, field, 'different') + context, _ = await build(event, 'DifyAgent') + assert context['conversation']['launcher_id'] is None + + +@pytest.mark.asyncio +async def test_provenance_cannot_cross_pipeline(mock_query): + event = QueryEntryAdapter.query_to_event(mock_query) + event.legacy_identity.pipeline_id = 'other-pipeline' + context, _ = await build(event, 'DifyAgent') + assert context['conversation']['launcher_id'] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('changed', [None, 'workspace_id', 'bot_id', 'processor_id', 'actor_id']) +async def test_resume_uses_persisted_identity_not_new_session(tmp_path, mock_query, changed): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + try: + store = InteractionStore(engine) + event = QueryEntryAdapter.query_to_event(mock_query) + context, binding = await build(event, 'DifyAgent') + manager = InteractionManager(SimpleNamespace(), store) + descriptor = Helpers()._make_descriptor() + descriptor.id = binding.runner_id + descriptor.capabilities.interactions = True + descriptor.permissions.interactions = ['request'] + binding.delivery_policy.enable_interactions = True + import sqlalchemy as sa + from langbot.pkg.entity.persistence.pipeline import LegacyPipeline + + config = { + 'ai': {'runner': {'id': binding.runner_id}, 'runner_config': {binding.runner_id: binding.runner_config}} + } + mock_query.pipeline_config = config + conversation = mock_query.session.using_conversation + async with engine.begin() as conn: + await conn.execute( + sa.insert(LegacyPipeline).values( + uuid=binding.processor_id, + workspace_uuid=event.workspace_id, + name='test', + description='', + for_version='4.11', + stages=[], + config=config, + extensions_preferences={}, + ) + ) + adapter = SimpleNamespace( + get_supported_apis=lambda: ['interaction.request'], call_platform_api=AsyncMock(return_value={}) + ) + mock_query.adapter = adapter + await manager.handle_result( + result_dict={ + 'data': { + 'action': 'interaction.requested', + 'payload': {'interaction_id': 'form', 'title': 'Approve', 'fallback_text': 'Approve'}, + } + }, + event=event, + binding=binding, + descriptor=descriptor, + run_id='original', + adapter_context={ + '_delivery_adapter': adapter, + '_query': mock_query, + '_pipeline_expected_config': config, + '_pipeline_conversation': conversation, + }, + ) + token = adapter.call_platform_api.call_args.args[1]['callback_token'] + record = await manager.consume_callback( + callback_token=token, + submission={'interaction_id': 'form', 'values': {}}, + bot_id=event.bot_id, + conversation_id='person_launcher-123', + actor_id='sender-123', + ) + mock_query.variables['_interaction_submission'] = record['submission'] + mock_query.session.launcher_id = 'wrong-new-session' + resumed = QueryEntryAdapter.query_to_event(mock_query) + if changed in ('workspace_id', 'bot_id'): + setattr(resumed, changed, 'other') + elif changed == 'processor_id': + binding.processor_id = 'other' + elif changed == 'actor_id': + resumed.actor.actor_id = 'other' + # A fresh manager/store represents a process restart, not an in-memory cache. + manager = InteractionManager(SimpleNamespace(), InteractionStore(engine)) + await manager.restore_legacy_identity(resumed, binding) + result, _ = await build(resumed, 'DifyAgent') + assert result['conversation']['launcher_id'] == (None if changed else 'launcher-123') + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_tbox_resume_without_persisted_identity_fails_closed(mock_query): + event = QueryEntryAdapter.query_to_event(mock_query) + event.event_type = 'interaction.submitted' + event.legacy_identity = None + context, _ = await build(event, 'TboxAgent', 'legacy-bot') + assert context['conversation']['bot_id'] is None + assert context['runtime']['metadata']['bot_id'] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'runner,folder,method', + [ + ('DifyAgent', 'dify-agent', '_get_user_tag'), + ('N8nAgent', 'n8n-agent', '_get_user_tag'), + ('CozeAgent', 'coze-agent', '_get_user_id'), + ('TboxAgent', 'tbox-agent', '_get_user_id'), + ], +) +@pytest.mark.parametrize('missing', [False, True]) +async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner, folder, method, missing): + # Execute the actual pure helper AST without loading vendor clients or making network calls. + root = Path(__file__).resolve().parents[3].parent / 'langbot-plugins-411-migration' + path = root / 'Runner' / folder / 'components/runner/default.py' + if not path.exists(): + pytest.skip('sibling plugin source checkout is required for cross-repository contract gate') + tree = ast.parse(path.read_text()) + function = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == method) + + class ConfigError(Exception): + def __init__(self, message, **kwargs): + super().__init__(message) + + namespace = { + 'RunnerContext': RunnerContext, + **{name: ConfigError for name in ('DifyConfigError', 'N8nConfigError', 'CozeConfigError', 'TboxConfigError')}, + } + exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), 'exec'), namespace) + mock_query.session.launcher_id = 'Session_007' + event = QueryEntryAdapter.query_to_event(mock_query) + if missing: + event.legacy_identity = None + event.bot_id = None + source = 'legacy-bot' if runner == 'TboxAgent' else 'legacy-session' + context, _ = await build(event, runner, source) + sdk = RunnerContext.model_validate(context) + if missing: + with pytest.raises(ConfigError, match='trusted Host identity'): + namespace[method](None, sdk) + else: + expected = ( + mock_query.bot_uuid + if runner == 'TboxAgent' + else ('person_launcher-123' if runner == 'CozeAgent' else 'person_Session_007') + ) + assert namespace[method](None, sdk) == expected diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 2bac63dbb..d634d9151 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -469,6 +469,23 @@ async def test_orchestrator_consumes_interaction_request_before_message_output(c call_platform_api=AsyncMock(return_value={'ok': True}), ) + import sqlalchemy as sa + from langbot.pkg.entity.persistence.pipeline import LegacyPipeline + + async with db_engine.begin() as conn: + await conn.execute( + sa.insert(LegacyPipeline).values( + uuid=query.pipeline_uuid, + workspace_uuid=TEST_CONTEXT.workspace_uuid, + name='test', + description='', + for_version='4.11', + stages=[], + config=query.pipeline_config, + extensions_preferences={}, + ) + ) + messages = [message async for message in orchestrator.run_from_query(query)] assert [message.content for message in messages] == ['Waiting for approval'] diff --git a/tests/unit_tests/agent/test_runner_model_reasoning.py b/tests/unit_tests/agent/test_runner_model_reasoning.py new file mode 100644 index 000000000..ff365834f --- /dev/null +++ b/tests/unit_tests/agent/test_runner_model_reasoning.py @@ -0,0 +1,268 @@ +"""Host-only, descriptor-driven Runner reasoning policy and durable snapshot tests.""" + +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import pytest + +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.provider.modelmgr import errors, reasoning +from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model + + +PRIMARY = '00000000-0000-4000-8000-000000000011' +FALLBACK = '00000000-0000-4000-8000-000000000012' +OTHER = '00000000-0000-4000-8000-000000000013' + + +def policy(): + return importlib.import_module('langbot.pkg.agent.runner.model_reasoning') + + +def descriptor(*names): + return SimpleNamespace(config_schema=[{'name': name, 'type': 'model-fallback-selector'} for name in names]) + + +def resources(*ids): + return {'models': [{'model_id': model_id} for model_id in ids]} + + +def selection(level='high'): + return {'primary': PRIMARY, 'fallbacks': [FALLBACK], 'reasoning': {PRIMARY: level, FALLBACK: 'low'}} + + +def test_generic_descriptor_extracts_only_selected_authorized_models(): + value = selection() + value['reasoning'][OTHER] = 'max' + result = policy().extract_model_reasoning_overrides( + descriptor('arbitrary'), {'arbitrary': value}, resources(PRIMARY, FALLBACK, OTHER) + ) + assert result == {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}} + assert policy().extract_model_reasoning_overrides( + descriptor('arbitrary'), {'arbitrary': value}, resources(FALLBACK) + ) == {FALLBACK: {'level': 'low'}} + value['reasoning'][PRIMARY] = 'disabled' + assert result[PRIMARY] == {'level': 'high'} + + +@pytest.mark.parametrize('level', reasoning.REASONING_LEVELS) +def test_all_canonical_levels_use_core_normalization(level): + result = policy().extract_model_reasoning_overrides( + descriptor('models'), {'models': selection(level)}, resources(PRIMARY) + ) + assert result == {PRIMARY: reasoning.normalize_reasoning_config({'level': level})} + + +@pytest.mark.parametrize('value', ['plain-model', {}, {'primary': PRIMARY}, {'primary': PRIMARY, 'reasoning': {}}]) +def test_absent_map_does_not_create_default_override(value): + assert policy().extract_model_reasoning_overrides(descriptor('models'), {'models': value}, resources(PRIMARY)) == {} + + +def test_undeclared_fields_and_descriptor_defaults_do_not_supply_overrides(): + desc = descriptor('declared') + desc.config_schema[0]['default'] = selection() + assert policy().extract_model_reasoning_overrides(desc, {'model': selection()}, resources(PRIMARY)) == {} + + +@pytest.mark.parametrize( + 'value', [None, [], 'high', {PRIMARY: None}, {PRIMARY: {}}, {PRIMARY: 'secret-invalid-level'}, {PRIMARY: ['high']}] +) +def test_invalid_explicit_maps_fail_with_safe_error(value): + with pytest.raises(ValueError, match='Invalid runner model reasoning configuration') as exc: + policy().extract_model_reasoning_overrides( + descriptor('models'), {'models': {'primary': PRIMARY, 'reasoning': value}}, resources(PRIMARY) + ) + assert 'secret-invalid-level' not in str(exc.value) + assert PRIMARY not in str(exc.value) + + +@pytest.mark.parametrize('reverse', [False, True]) +def test_multiple_selectors_reject_conflicting_overrides_deterministically(reverse): + names = ['one', 'two'] + if reverse: + names.reverse() + config = {'one': selection('high'), 'two': selection('provider_default')} + with pytest.raises(ValueError, match='Conflicting runner model reasoning overrides'): + policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) + config['two'] = selection('high') + assert policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) == { + PRIMARY: {'level': 'high'} + } + + +@pytest.mark.asyncio +async def test_session_deepcopies_reasoning_without_granting_models(): + registry = AgentRunSessionRegistry() + overrides = {PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}} + await registry.register( + run_id='frozen', + runner_id='plugin:test/runner/main', + query_id=None, + plugin_identity='test/runner', + resources=resources(PRIMARY), + model_reasoning_overrides=overrides, + ) + overrides[PRIMARY]['level'] = 'low' + session = await registry.get('frozen') + assert session['authorization']['model_reasoning_overrides'][PRIMARY] == {'level': 'high'} + assert not registry.is_resource_allowed(session, 'model', OTHER, 'invoke') + + +def test_request_local_clone_preserves_shared_model_and_absent_default(): + model = _runtime_model(_requester('openai'), 'high', name='gpt-5') + assert policy().model_with_reasoning_override(model, PRIMARY, None) is model + assert policy().model_with_reasoning_override(model, PRIMARY, {'authorization': {}}) is model + overrides = {PRIMARY: {'level': 'provider_default'}} + clone = policy().model_with_reasoning_override( + model, PRIMARY, {'authorization': {'model_reasoning_overrides': overrides}} + ) + assert clone is not model + assert clone.provider is model.provider + assert clone.model_entity is model.model_entity + assert clone.reasoning_config_override == {'level': 'provider_default'} + clone.reasoning_config_override['level'] = 'disabled' + assert overrides[PRIMARY]['level'] == 'provider_default' + assert model.reasoning_config_override is None + assert model.model_entity.reasoning_config == {'level': 'high'} + + +@pytest.mark.parametrize( + ('provider', 'name', 'expected'), + [ + ('openai', 'gpt-5', {'reasoning_effort': 'high'}), + ('anthropic', 'claude-sonnet-4-6', {'reasoning_effort': 'high'}), + ('gemini', 'gemini-3-pro', {'reasoning_effort': 'high'}), + ], +) +def test_real_requester_reasoning_boundary(provider, name, expected, monkeypatch): + request = _requester(provider) + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, 'low', name=name) + clone = policy().model_with_reasoning_override( + model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}} + ) + assert request._build_reasoning_args(clone) == expected + clone.reasoning_config_override = {'level': 'provider_default'} + assert request._build_reasoning_args(clone) == {} + assert model.model_entity.reasoning_config == {'level': 'low'} + + +@pytest.mark.parametrize( + ('name', 'abilities', 'level'), [('gpt-5', [], 'high'), ('gemini-3-pro', ['reasoning'], 'disabled')] +) +def test_real_requester_still_rejects_ability_and_capability_mismatches(name, abilities, level, monkeypatch): + request = _requester('gemini' if name.startswith('gemini') else 'openai') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, name=name, abilities=abilities) + session = {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}} + if not abilities: + with pytest.raises(ValueError, match='reasoning ability'): + policy().model_with_reasoning_override(model, PRIMARY, session) + else: + clone = policy().model_with_reasoning_override(model, PRIMARY, session) + with pytest.raises(errors.RequesterError): + request._build_reasoning_args(clone) + + +@pytest.mark.asyncio +async def test_orchestrator_freezes_host_policy_and_persistent_reload(tmp_path): + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator + from langbot.pkg.entity.persistence.base import Base + from langbot.pkg.plugin.agent_run_support import _load_persistent_agent_run_session + from tests.unit_tests.agent.test_orchestrator_integration import ( + FakeApplication, + FakePluginConnector, + FakeRegistry, + make_descriptor, + make_query, + ) + + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "reasoning.db"}') + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + connector = FakePluginConnector(results=[{'type': 'run.completed', 'data': {}}]) + ap = FakeApplication(connector, engine) + desc = make_descriptor() + query = make_query() + query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning'] = { + 'model_primary': 'high', + 'model_fallback': 'low', + } + expected = {'model_primary': {'level': 'high'}, 'model_fallback': {'level': 'low'}} + try: + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(desc)) + _ = [value async for value in orchestrator.run_from_query(query)] + session = connector.sessions_during_run[0] + assert session['authorization']['model_reasoning_overrides'] == expected + wire = connector.contexts[0] + assert 'model_reasoning_overrides' not in wire + assert 'model_reasoning_overrides' not in wire['resources'] + query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning']['model_primary'] = 'disabled' + assert session['authorization']['model_reasoning_overrides'] == expected + restored = await _load_persistent_agent_run_session(wire['run_id'], ap, 'test') + assert restored['authorization']['model_reasoning_overrides'] == expected + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('provider', 'name', 'level', 'expected'), + [ + ('openai', 'gpt-5', 'high', {'reasoning_effort': 'high'}), + ('anthropic', 'claude-sonnet-4-6', 'disabled', {'thinking': {'type': 'disabled'}}), + ('gemini', 'gemini-3-pro', 'low', {'reasoning_effort': 'low'}), + ], +) +async def test_real_completion_and_count_tokens_build_boundary(provider, name, level, expected, monkeypatch): + from unittest.mock import AsyncMock + from langbot_plugin.api.entities.builtin.provider import message as provider_message + from langbot.pkg.provider.modelmgr.requesters import litellmchat + + request = _requester(provider) + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, name=name) + model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret' + clone = policy().model_with_reasoning_override( + model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}} + ) + messages = [provider_message.Message(role='user', content='hello')] + for stream in (False, True): + built = await request._build_completion_args(clone, messages, extra_args={'temperature': 0.7}, stream=stream) + for key, value in expected.items(): + assert built[key] == value + assert 'model_reasoning_overrides' not in built + assert 'reasoning_config_override' not in built + assert built['temperature'] == 0.7 + assert built.get('stream', False) is stream + build = AsyncMock(wraps=request._build_completion_args) + monkeypatch.setattr(request, '_build_completion_args', build) + # Only the tokenizer is stubbed; count_tokens and completion construction are real. + monkeypatch.setattr(litellmchat.litellm, 'token_counter', lambda **kwargs: 37) + assert await request.count_tokens(clone, messages) == 37 + assert build.await_args.args[0] is clone + assert model.reasoning_config_override is None + + +@pytest.mark.asyncio +async def test_real_requester_rejects_caller_reasoning_conflicts(monkeypatch): + from langbot_plugin.api.entities.builtin.provider import message as provider_message + + request = _requester('openai') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, name='gpt-5') + model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret' + clone = policy().model_with_reasoning_override( + model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}} + ) + with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'): + await request._build_completion_args( + clone, [provider_message.Message(role='user', content='hello')], extra_args={'reasoning_effort': 'low'} + ) diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py index 4154cd840..a92c6a4af 100644 --- a/tests/unit_tests/agent/test_state_store.py +++ b/tests/unit_tests/agent/test_state_store.py @@ -171,14 +171,18 @@ class TestPersistentStateStore: engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False) from langbot.pkg.entity.persistence.base import Base + from langbot.pkg.entity.persistence.runner_state import RunnerState - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + try: + async with engine.begin() as conn: + # This unit fixture owns one table, not the application's entire + # schema. Unrelated DDL/fsync work must not scale every test. + await conn.run_sync(Base.metadata.create_all, tables=[RunnerState.__table__]) - yield engine - - await engine.dispose() - os.unlink(db_path) + yield engine + finally: + await engine.dispose() + os.unlink(db_path) @pytest.fixture async def persistent_store(self, db_engine): diff --git a/tests/unit_tests/agent/test_state_store_fixture.py b/tests/unit_tests/agent/test_state_store_fixture.py new file mode 100644 index 000000000..3fc563869 --- /dev/null +++ b/tests/unit_tests/agent/test_state_store_fixture.py @@ -0,0 +1,31 @@ +"""Regression guard against unrelated schema setup in state-store tests.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from unit_tests.agent import test_state_store as state_tests +from langbot.pkg.entity.persistence.runner_state import RunnerState + + +@pytest.mark.asyncio +async def test_state_store_fixture_creates_only_its_owned_table(monkeypatch): + created_tables = [] + + async def run_sync(create_all, **kwargs): + created_tables.extend(kwargs.get('tables', create_all.__self__.sorted_tables)) + + @asynccontextmanager + async def begin(): + yield SimpleNamespace(run_sync=run_sync) + + engine = SimpleNamespace(begin=begin, dispose=AsyncMock()) + monkeypatch.setattr(state_tests, 'create_async_engine', lambda *args, **kwargs: engine) + fixture = state_tests.TestPersistentStateStore.db_engine.__wrapped__(state_tests.TestPersistentStateStore()) + try: + assert await anext(fixture) is engine + assert created_tables == [RunnerState.__table__] + finally: + await fixture.aclose() diff --git a/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json b/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json new file mode 100644 index 000000000..69f7be97c --- /dev/null +++ b/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json @@ -0,0 +1,176 @@ +[ + { + "name": "base-url", + "label": { + "en_US": "Base URL", + "zh_Hans": "\u57fa\u7840 URL" + }, + "type": "string", + "required": true, + "default": "http://localhost:7860" + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API \u5bc6\u94a5" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "flow-id", + "label": { + "en_US": "Flow ID", + "zh_Hans": "\u6d41\u7a0b ID" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e" + }, + "description": { + "en_US": "Show input, output, and flow tweak controls.", + "zh_Hans": "\u663e\u793a\u8f93\u5165\u3001\u8f93\u51fa\u548c\u6d41\u7a0b tweak \u8c03\u4f18\u9009\u9879\u3002" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "input-type", + "label": { + "en_US": "Input Type", + "zh_Hans": "\u8f93\u5165\u7c7b\u578b" + }, + "type": "string", + "required": false, + "default": "chat", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "output-type", + "label": { + "en_US": "Output Type", + "zh_Hans": "\u8f93\u51fa\u7c7b\u578b" + }, + "type": "string", + "required": false, + "default": "chat", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "tweaks", + "label": { + "en_US": "Tweaks", + "zh_Hans": "\u8c03\u6574\u53c2\u6570" + }, + "type": "json", + "required": false, + "default": "{}", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "\u542f\u7528 LangBot \u8d44\u4ea7" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u76d1\u542c\u5730\u5740" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u7aef\u53e3" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u8bf7\u6c42\u8d85\u65f6" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u6709\u6548\u671f" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u8f93\u5165\u7ec4\u4ef6\u540d" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } +] diff --git a/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json b/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json new file mode 100644 index 000000000..5c11f6006 --- /dev/null +++ b/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json @@ -0,0 +1,94 @@ +[ + { + "name": "api-base", + "type": "string", + "required": true, + "default": "http://127.0.0.1:2026" + }, + { + "name": "api-key", + "type": "secret", + "required": false, + "default": "" + }, + { + "name": "auth-header", + "type": "secret", + "required": false, + "default": "" + }, + { + "name": "assistant-id", + "type": "string", + "required": true, + "default": "lead_agent" + }, + { + "name": "advanced-settings", + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "model-name", + "type": "string", + "required": false, + "default": "", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "thinking-enabled", + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "plan-mode", + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "subagent-enabled", + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "max-concurrent-subagents", + "type": "integer", + "required": false, + "default": 3, + "show_if": { + "field": "subagent-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "type": "integer", + "required": false, + "default": 300, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "recursion-limit", + "type": "integer", + "required": false, + "default": 1000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } +] diff --git a/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json new file mode 100644 index 000000000..c3b98c5ae --- /dev/null +++ b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json @@ -0,0 +1,326 @@ +{ + "usages": [ + "agent" + ], + "config": [ + { + "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" + }, + "label": { + "en_US": "Model", + "zh_Hans": "\u6a21\u578b" + }, + "type": "model-fallback-selector", + "required": true, + "default": { + "primary": "", + "fallbacks": [], + "reasoning": {} + } + }, + { + "name": "prompt", + "label": { + "en_US": "Prompt", + "zh_Hans": "\u63d0\u793a\u8bcd" + }, + "type": "prompt-editor", + "required": true, + "default": [ + { + "role": "system", + "content": "You are a helpful assistant." + } + ] + }, + { + "name": "knowledge-bases", + "label": { + "en_US": "Knowledge Bases", + "zh_Hans": "\u77e5\u8bc6\u5e93" + }, + "type": "knowledge-base-multi-selector", + "required": false, + "default": [] + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e" + }, + "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" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "date-grounding", + "label": { + "en_US": "Current Date Grounding", + "zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a" + }, + "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" + }, + "type": "boolean", + "required": false, + "default": true, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "\u6267\u884c\u8d85\u65f6" + }, + "type": "integer", + "required": false, + "default": 300, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "remove-think", + "label": { + "en_US": "Remove Thinking Output", + "zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9" + }, + "type": "boolean", + "required": false, + "default": false, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "retrieval-top-k", + "label": { + "en_US": "Retrieval Top K", + "zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf" + }, + "type": "integer", + "required": false, + "default": 5, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "rerank-model", + "label": { + "en_US": "Rerank Model", + "zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b" + }, + "type": "rerank-model-selector", + "required": false, + "default": "", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "rerank-top-k", + "label": { + "en_US": "Rerank Top K", + "zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf" + }, + "type": "integer", + "required": false, + "default": 5, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "max-tool-iterations", + "label": { + "en_US": "Max Tool Iterations", + "zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570" + }, + "type": "integer", + "required": false, + "default": 100, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "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" + }, + "label": { + "en_US": "Tool Execution Mode", + "zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f" + }, + "type": "select", + "required": false, + "default": "parallel", + "options": [ + { + "name": "parallel", + "label": { + "en_US": "Parallel", + "zh_Hans": "\u5e76\u884c" + } + }, + { + "name": "serial", + "label": { + "en_US": "Serial", + "zh_Hans": "\u4e32\u884c" + } + } + ], + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "max-tool-result-chars", + "label": { + "en_US": "Max Tool Result Characters", + "zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570" + }, + "type": "integer", + "required": false, + "default": 20000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-history-fetch-limit", + "label": { + "en_US": "History Fetch Limit", + "zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf" + }, + "type": "integer", + "required": false, + "default": 50, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-window-tokens", + "label": { + "en_US": "Context Window Tokens", + "zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570" + }, + "type": "integer", + "required": false, + "default": 200000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-reserve-tokens", + "label": { + "en_US": "Reserved Output Tokens", + "zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570" + }, + "type": "integer", + "required": false, + "default": 16384, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-keep-recent-tokens", + "label": { + "en_US": "Recent Context Tokens", + "zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570" + }, + "type": "integer", + "required": false, + "default": 20000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-summary-tokens", + "label": { + "en_US": "Summary Tokens", + "zh_Hans": "\u6458\u8981 Token \u6570" + }, + "type": "integer", + "required": false, + "default": 8000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": true, + "skill_authoring": true, + "interrupt": true, + "steering": true + }, + "permissions": { + "models": [ + "count_tokens", + "invoke", + "stream", + "rerank" + ], + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "list", + "retrieve" + ], + "history": [ + "page" + ] + } +} diff --git a/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json b/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json new file mode 100644 index 000000000..3725e7cd1 --- /dev/null +++ b/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json @@ -0,0 +1,154 @@ +[ + { + "name": "base-url", + "label": { + "en_US": "Base URL", + "zh_Hans": "\u57fa\u7840 URL" + }, + "description": { + "en_US": "The WeKnora API base URL, including /api/v1.", + "zh_Hans": "WeKnora API \u57fa\u7840 URL\uff0c\u5305\u542b /api/v1\u3002" + }, + "type": "string", + "required": true, + "default": "http://localhost:8080/api/v1" + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API \u5bc6\u94a5" + }, + "description": { + "en_US": "API key generated from WeKnora Settings -> API Keys.", + "zh_Hans": "\u4ece WeKnora \u8bbe\u7f6e -> API Keys \u751f\u6210\u7684 API \u5bc6\u94a5\u3002" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "app-type", + "label": { + "en_US": "App Type", + "zh_Hans": "\u5e94\u7528\u7c7b\u578b" + }, + "type": "select", + "required": true, + "default": "agent", + "options": [ + { + "name": "agent", + "label": { + "en_US": "Agent (Smart Reasoning)", + "zh_Hans": "Agent\uff08\u667a\u80fd\u63a8\u7406\uff09" + } + }, + { + "name": "chat", + "label": { + "en_US": "Chat (Knowledge Base RAG)", + "zh_Hans": "\u804a\u5929\uff08\u77e5\u8bc6\u5e93 RAG\uff09" + } + } + ] + }, + { + "name": "agent-id", + "label": { + "en_US": "Agent ID", + "zh_Hans": "\u667a\u80fd\u4f53 ID" + }, + "description": { + "en_US": "When omitted, chat uses builtin-quick-answer and agent uses builtin-smart-reasoning. An explicit empty value lets the server choose. Saved IDs apply in both modes.", + "zh_Hans": "\u672a\u8bbe\u7f6e\u65f6\uff0c\u804a\u5929\u4f7f\u7528 builtin-quick-answer\uff0c\u667a\u80fd\u4f53\u4f7f\u7528 builtin-smart-reasoning\u3002\u663e\u5f0f\u7559\u7a7a\u5219\u7531\u670d\u52a1\u7aef\u9009\u62e9\u3002\u5df2\u4fdd\u5b58\u7684 ID \u5728\u4e24\u79cd\u6a21\u5f0f\u4e0b\u5747\u751f\u6548\u3002" + }, + "type": "string", + "required": false + }, + { + "name": "knowledge-base-ids", + "label": { + "en_US": "Knowledge Base IDs", + "zh_Hans": "\u77e5\u8bc6\u5e93 ID \u5217\u8868" + }, + "description": { + "en_US": "Remote WeKnora knowledge base IDs used in both modes, not LangBot knowledge base UUIDs. Values and order are preserved.", + "zh_Hans": "\u4e24\u79cd\u6a21\u5f0f\u5747\u53ef\u4f7f\u7528\u7684\u8fdc\u7aef WeKnora \u77e5\u8bc6\u5e93 ID\uff0c\u4e0d\u662f LangBot \u77e5\u8bc6\u5e93 UUID\u3002\u4fdd\u7559\u539f\u503c\u548c\u987a\u5e8f\u3002" + }, + "type": "array[string]", + "required": false, + "default": [] + }, + { + "name": "web-search-enabled", + "label": { + "en_US": "Enable Web Search", + "zh_Hans": "\u542f\u7528\u7f51\u7edc\u641c\u7d22" + }, + "description": { + "en_US": "Whether to enable web search in agent mode.", + "zh_Hans": "\u662f\u5426\u5728 Agent \u6a21\u5f0f\u4e0b\u542f\u7528\u7f51\u7edc\u641c\u7d22\u3002" + }, + "type": "boolean", + "required": false, + "default": false, + "show_if": { + "field": "app-type", + "operator": "eq", + "value": "agent" + } + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e" + }, + "description": { + "en_US": "Show timeout and fallback prompt controls.", + "zh_Hans": "\u663e\u793a\u8d85\u65f6\u548c\u56de\u9000\u63d0\u793a\u8bcd\u8c03\u4f18\u9009\u9879\u3002" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "\u8d85\u65f6\u65f6\u95f4" + }, + "description": { + "en_US": "Request timeout in seconds.", + "zh_Hans": "\u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\uff08\u79d2\uff09\u3002" + }, + "type": "integer", + "required": false, + "default": 120, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "base-prompt", + "label": { + "en_US": "Base Prompt", + "zh_Hans": "\u57fa\u7840\u63d0\u793a\u8bcd" + }, + "description": { + "en_US": "Default prompt when the user message is empty.", + "zh_Hans": "\u7528\u6237\u6d88\u606f\u4e3a\u7a7a\u65f6\u4f7f\u7528\u7684\u9ed8\u8ba4\u63d0\u793a\u8bcd\u3002" + }, + "type": "string", + "required": false, + "default": "\u8bf7\u56de\u7b54\u7528\u6237\u7684\u95ee\u9898\u3002", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } +] diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py new file mode 100644 index 000000000..b9f3080b6 --- /dev/null +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -0,0 +1,734 @@ +"""Manual migration boundary tests; all configs are synthetic.""" + +import asyncio +import copy + +import importlib +import importlib.util +from types import SimpleNamespace as NS +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.authz import PermissionDeniedError, permissions_for_role +from langbot.pkg.api.http.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.pipeline import LegacyPipeline +from langbot.pkg.entity.persistence.plugin import PluginSetting +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace +from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.core.taskmgr import AsyncTaskManager + +MODULE = 'langbot.pkg.api.http.service.pipeline_migration' +RID = 'plugin:langbot-team/TestAgent/default' +WS = '00000000-0000-0000-0000-000000000001' +OTHER = '00000000-0000-0000-0000-000000000002' +SOURCE = {'ai': {'runner': 'test', 'secret': 'synthetic-secret'}, 'output': {'keep': True}} + + +def module(): + assert importlib.util.find_spec(MODULE) is not None, 'manual migration service is missing' + return importlib.import_module(MODULE) + + +def context(role='developer'): + return RequestContext( + 'instance', + 1, + 'request', + 'user_token', + PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'), + WorkspaceContext(WS, 'membership', role, permissions_for_role(role)), + ) + + +def planner(config, extensions_preferences=None): + base = dict( + state='already_current', + legacy_runner=None, + target_runner_id=None, + target_plugin=None, + changed_paths=[], + blockers=[], + warnings=[], + ) + if isinstance(config.get('ai', {}).get('runner'), str): + target = copy.deepcopy(config) + target['ai'] = {'runner': {'id': RID}, 'runner_config': {RID: {'api_key': config['ai']['secret']}}} + base.update( + state='ready', + legacy_runner='test', + target_runner_id=RID, + target_plugin={'author': 'langbot-team', 'name': 'TestAgent', 'version': '1.0'}, + changed_paths=['ai.runner', 'ai.runner_config'], + config=target, + ) + return base + + +class PM(PersistenceManager): + def __init__(self, engine): + super().__init__(NS()) + self.db = NS(get_engine=lambda: engine) + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + m = module() + monkeypatch.setattr(m, 'plan_legacy_pipeline', planner) + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "migration.db"}') + async with engine.begin() as conn: + names = { + 'users', + 'workspaces', + 'workspace_memberships', + 'workspace_execution_states', + 'legacy_pipelines', + 'plugin_settings', + 'pipeline_migration_snapshots', + 'agent_interaction', + } + await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Base.metadata.tables[n] for n in names])) + await conn.execute( + sa.insert(User).values( + uuid='account', + user='test@example.invalid', + normalized_email='test@example.invalid', + password='synthetic-not-a-password', + ) + ) + await conn.execute( + sa.insert(Workspace), + [ + dict(uuid=ws, instance_uuid='instance', name=ws, slug=ws, source='cloud_projection') + for ws in (WS, OTHER) + ], + ) + await conn.execute( + sa.insert(LegacyPipeline), + [ + dict( + uuid=pid, + workspace_uuid=ws, + name=pid, + description='kept', + for_version='4.10', + stages=[], + config=SOURCE, + extensions_preferences={'enable_all_plugins': True}, + ) + for pid, ws in [('one', WS), ('two', WS), ('foreign', OTHER)] + ], + ) + await conn.execute( + sa.insert(PluginSetting).values( + workspace_uuid=WS, + plugin_author='langbot-team', + plugin_name='TestAgent', + enabled=True, + install_info={'version': '1.0'}, + ) + ) + pm = PM(engine) + binding = NS(instance_uuid='instance', workspace_uuid=WS, placement_generation=1) + access = NS( + workspace=NS(uuid=WS), + membership=NS(uuid='membership', role='developer', projection_revision=0), + execution=binding, + ) + ap = NS( + persistence_mgr=pm, + event_loop=asyncio.get_running_loop(), + instance_config=NS(data={}), + workspace_service=NS(get_execution_binding=AsyncMock(return_value=binding)), + workspace_collaboration_service=NS(resolve_account_workspace=AsyncMock(return_value=access)), + pipeline_mgr=NS(prepare_pipeline=AsyncMock(return_value='candidate'), publish_pipeline=Mock()), + sess_mgr=NS(session_list=[]), + runner_registry=NS( + list_runners=AsyncMock( + return_value=[ + NS( + id=RID, + usages=['agent'], + plugin_version='1.0', + config_schema=[{'name': 'api_key', 'type': 'string', 'required': True}], + ) + ] + ) + ), + plugin_connector=NS(require_workspace_context=AsyncMock()), + ) + ap.task_mgr = AsyncTaskManager(ap) + svc = m.PipelineMigrationService(ap) + yield NS(m=m, ap=ap, svc=svc, engine=engine, pm=pm, access=access) + await ap.task_mgr.wait_all() + await engine.dispose() + + +async def selection(env, ids=('one',)): + preview = await env.svc.preview(context()) + return { + 'confirmed': True, + 'items': [ + {k: item[k] for k in ('pipeline_uuid', 'preview_token')} + for item in preview['items'] + if item['pipeline_uuid'] in ids + ], + } + + +async def execute(env, body=None): + result = await env.svc.execute(context(), body or await selection(env)) + task = env.ap.task_mgr.get_task_by_id(result['task_id']) + await task.task + return task + + +async def rows(env): + async with env.engine.connect() as conn: + configs = dict((await conn.execute(sa.select(LegacyPipeline.uuid, LegacyPipeline.config))).all()) + backups = (await conn.execute(sa.select(env.m.PipelineMigrationSnapshot))).mappings().all() + return configs, backups + + +def test_strict_confirmation_and_selection(): + m = module() + valid = {'confirmed': True, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'token'}]} + assert m.validate_execute_request(valid) == valid['items'] + for body in [ + None, + {}, + {**valid, 'confirmed': 'true'}, + {**valid, 'confirmed': 1}, + {**valid, 'confirmed': False}, + {**valid, 'workspace_uuid': WS}, + {**valid, 'items': []}, + {**valid, 'items': valid['items'] * 2}, + {**valid, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'x', 'config': {}}]}, + {**valid, 'items': [{'pipeline_uuid': str(i), 'preview_token': 'x'} for i in range(51)]}, + ]: + with pytest.raises(m.MigrationError): + m.validate_execute_request(body) + + +@pytest.mark.asyncio +async def test_preview_is_scoped_secret_free_and_read_only(env): + writes = [] + + def observe(_conn, _cursor, statement, *_args): + if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')): + writes.append(statement) + + sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe) + result = await env.svc.preview(context('viewer')) + assert result['total'] == 2 + assert {i['pipeline_uuid'] for i in result['items']} == {'one', 'two'} + assert all(i['state'] == 'ready' and i['preview_token'] for i in result['items']) + assert 'synthetic-secret' not in str(result) + assert writes == [] + env.ap.runner_registry.list_runners.assert_not_awaited() + env.ap.plugin_connector.require_workspace_context.assert_not_awaited() + env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited() + assert not env.ap.task_mgr.tasks + + +@pytest.mark.asyncio +async def test_viewer_and_foreign_ids_rejected_before_runtime(env): + body = await selection(env) + with pytest.raises(PermissionDeniedError): + await env.svc.execute(context('viewer'), body) + body['items'].append({'pipeline_uuid': 'foreign', 'preview_token': 'x'}) + with pytest.raises(env.m.MigrationError) as err: + await env.svc.execute(context(), body) + assert err.value.status_code == 404 + assert not env.ap.task_mgr.tasks + env.ap.runner_registry.list_runners.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_success_snapshot_atomic_preserves_source_and_task_scope(env): + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] == planner(SOURCE)['config'] + assert configs['two'] == SOURCE + assert len(backups) == 1 and backups[0]['source_snapshot']['config'] == SOURCE + assert backups[0]['state'] == 'active' + assert (task.instance_uuid, task.workspace_uuid, task.placement_generation) == ('instance', WS, 1) + assert task.task_context.metadata == { + 'kind': 'pipeline_migration', + 'results': [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}], + } + assert 'synthetic-secret' not in str(task.to_dict()) + env.ap.pipeline_mgr.publish_pipeline.assert_called_once() + + +@pytest.mark.asyncio +async def test_stale_preview_and_plugin_change_are_rejected(env): + body = await selection(env) + async with env.engine.begin() as conn: + await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config={'edit': True})) + with pytest.raises(env.m.MigrationError) as err: + await env.svc.execute(context(), body) + assert err.value.status_code == 409 + body = await selection(env, ('two',)) + async with env.engine.begin() as conn: + await conn.execute(sa.update(PluginSetting).values(enabled=False)) + with pytest.raises(env.m.MigrationError): + await env.svc.execute(context(), body) + assert not env.ap.task_mgr.tasks + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['prepare', 'schema', 'membership', 'generation', 'edit']) +async def test_precommit_failures_keep_original_without_backup(env, failure): + body = await selection(env) + if failure == 'prepare': + env.ap.pipeline_mgr.prepare_pipeline.side_effect = RuntimeError('synthetic-secret') + elif failure == 'schema': + env.ap.runner_registry.list_runners.return_value[0].config_schema = [] + + async def prepare(*args, **kwargs): + assert env.pm.current_session() is None + if failure == 'membership': + env.access.membership.role = 'viewer' + if failure == 'generation': + env.ap.workspace_service.get_execution_binding.side_effect = RuntimeError('synthetic-secret') + if failure == 'edit': + async with env.engine.begin() as conn: + await conn.execute( + sa.update(LegacyPipeline) + .where(LegacyPipeline.uuid == 'one') + .values(extensions_preferences={'enable_all_plugins': False}) + ) + return 'candidate' + + if failure not in ('prepare', 'schema'): + env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare + task = await execute(env, body) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + assert task.task_context.metadata['results'][0]['state'] in ('blocked', 'failed', 'stale') + assert 'synthetic-secret' not in str(task.to_dict()) + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +async def test_activation_pending_explicit_retry_without_second_backup(env): + env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret') + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'activation_pending' + configs, backups = await rows(env) + assert configs['one'] != SOURCE and len(backups) == 1 + preview = await env.svc.preview(context()) + assert preview['items'][0]['state'] == 'activation_pending' + env.ap.pipeline_mgr.publish_pipeline.side_effect = None + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + _, backups = await rows(env) + assert len(backups) == 1 and backups[0]['state'] == 'active' + + +@pytest.mark.asyncio +async def test_double_execute_commits_once_and_partial_results(env): + body = await selection(env, ('one', 'two')) + original_prepare = env.ap.pipeline_mgr.prepare_pipeline + + async def prepare(_ctx, entity): + assert env.pm.current_session() is None + if entity['uuid'] == 'two': + raise RuntimeError('synthetic-secret') + await asyncio.sleep(0) + return 'candidate' + + original_prepare.side_effect = prepare + first, second = await asyncio.gather(env.svc.execute(context(), body), env.svc.execute(context(), body)) + await env.ap.task_mgr.wait_all() + configs, backups = await rows(env) + assert len(backups) == 1 + assert configs['two'] == SOURCE + results = [env.ap.task_mgr.get_task_by_id(r['task_id']).task_context.metadata['results'] for r in (first, second)] + assert sum(item['state'] == 'migrated' for batch in results for item in batch) == 1 + assert all(batch[1]['state'] == 'failed' for batch in results) + + +@pytest.mark.asyncio +async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env): + from langbot.pkg.api.http.service.pipeline import PipelineService + + service = PipelineService(env.ap) + env.ap.pipeline_mgr.remove_pipeline = AsyncMock() + env.ap.pipeline_mgr.load_pipeline = AsyncMock() + async with env.pm.tenant_scope(WS): + with pytest.raises(ValueError, match='manual_migration_required'): + await service.update_pipeline(context(), 'one', {'config': planner(SOURCE)['config']}) + await service.update_pipeline(context(), 'one', {'name': 'renamed'}) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + + +@pytest.mark.asyncio +async def test_snapshot_commit_failure_rolls_back_config_and_backup(env, monkeypatch): + from sqlalchemy.ext.asyncio import AsyncSessionTransaction + + original = AsyncSessionTransaction.commit + + async def fail_write_commit(transaction): + # Fail the actual transaction after both statements have run. + result = await transaction.session.execute( + sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot) + ) + if result.scalar() > 0: + raise RuntimeError('synthetic-secret') + await original(transaction) + + monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_write_commit) + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + assert task.task_context.metadata['results'][0]['state'] == 'failed' + assert 'synthetic-secret' not in str(task.to_dict()) + + +@pytest.mark.asyncio +async def test_plugin_changes_while_preparing_prevent_commit(env): + async def prepare(*args): + async with env.engine.begin() as conn: + await conn.execute(sa.update(PluginSetting).values(runtime_revision=2)) + return 'candidate' + + env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + assert task.task_context.metadata['results'][0]['state'] == 'stale' + + +@pytest.mark.asyncio +async def test_scope_token_cannot_be_reused_by_other_identity_or_generation(env): + import dataclasses + + body = await selection(env) + ctx = dataclasses.replace(context(), placement_generation=2) + env.access.execution.placement_generation = 2 + with pytest.raises(env.m.MigrationError): + await env.svc.execute(ctx, body) + assert not env.ap.task_mgr.tasks + + +@pytest.mark.asyncio +async def test_conversation_invalidation_is_workspace_scoped(env): + good = NS(workspace_uuid=WS, using_conversation=NS(pipeline_uuid='one')) + foreign = NS(workspace_uuid=OTHER, using_conversation=NS(pipeline_uuid='one')) + env.ap.sess_mgr.session_list = [good, foreign] + await execute(env) + assert good.using_conversation is None + assert foreign.using_conversation is not None + + +@pytest.mark.asyncio +async def test_runtime_schema_changes_during_prepare_prevent_commit(env): + async def prepare(*args): + env.ap.runner_registry.list_runners.return_value[0].config_schema = [] + return 'candidate' + + env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + assert task.task_context.metadata['results'][0]['state'] == 'blocked' + + +@pytest.mark.asyncio +async def test_real_planner_deerflow_schema_integration(env, monkeypatch): + import json + from pathlib import Path + from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline + + monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline) + source = { + 'ai': { + 'runner': {'runner': 'deerflow-api', 'expire-time': 60}, + 'deerflow-api': {'api-base': 'https://synthetic.invalid', 'api-key': 'synthetic-secret'}, + }, + 'output': {'misc': {'remove-think': False}}, + } + plan = plan_legacy_pipeline(source, {'enable_all_plugins': True}) + assert plan['state'] == 'ready' + async with env.engine.begin() as conn: + await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config=source)) + await conn.execute( + sa.insert(PluginSetting).values( + workspace_uuid=WS, plugin_author='langbot-team', plugin_name='DeerFlowAgent', enabled=True + ) + ) + schema = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_deerflow_schema.json').read_text()) + env.ap.runner_registry.list_runners.return_value = [ + NS( + id=plan['target_runner_id'], + usages=['agent'], + plugin_version=plan['target_plugin']['version'], + config_schema=schema, + ) + ] + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + configs, backups = await rows(env) + assert configs['one'] == plan['config'] and backups[0]['source_snapshot']['config'] == source + + +@pytest.mark.asyncio +async def test_account_disabled_during_prepare_blocks_commit(env): + async def prepare(*args): + async with env.engine.begin() as conn: + await conn.execute(sa.update(User).where(User.uuid == 'account').values(status='disabled')) + return 'candidate' + + env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and not backups + assert task.task_context.metadata['results'][0]['state'] == 'blocked' + + +@pytest.mark.asyncio +async def test_plugin_disabled_after_commit_defers_activation(env, monkeypatch): + original = env.svc._commit + + async def commit(*args): + result = await original(*args) + async with env.engine.begin() as conn: + await conn.execute(sa.update(PluginSetting).values(enabled=False)) + return result + + monkeypatch.setattr(env.svc, '_commit', commit) + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'activation_pending' + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +async def test_ambiguous_commit_acknowledgement_reports_pending(env, monkeypatch): + from sqlalchemy.ext.asyncio import AsyncSessionTransaction + + original = AsyncSessionTransaction.commit + failed = False + + async def fail_ack(transaction): + nonlocal failed + count = ( + await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)) + ).scalar() + await original(transaction) + if count and not failed: + failed = True + raise RuntimeError('synthetic-secret') + + monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_ack) + task = await execute(env) + configs, backups = await rows(env) + assert configs['one'] != SOURCE and len(backups) == 1 + assert task.task_context.metadata['results'][0]['state'] == 'activation_pending' + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('durable', [False, True], ids=['before_commit', 'after_durable_commit']) +async def test_cancel_commit_reports_durable_outcome_and_stops_batch(env, monkeypatch, durable): + from sqlalchemy.ext.asyncio import AsyncSessionTransaction + + original = AsyncSessionTransaction.commit + cancelled = False + + async def cancel_commit(transaction): + nonlocal cancelled + count = ( + await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)) + ).scalar() + if count and not cancelled: + cancelled = True + if durable: + await original(transaction) + asyncio.current_task().cancel() + await asyncio.sleep(0) + await original(transaction) + + monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_commit) + task = await execute(env, await selection(env, ('one', 'two'))) + configs, backups = await rows(env) + assert cancelled + assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE) + assert len(backups) == int(durable) + if durable: + assert backups[0]['state'] == 'activation_pending' + assert configs['two'] == SOURCE + assert task.task_context.metadata['results'] == [ + {'pipeline_uuid': 'one', 'state': 'activation_pending' if durable else 'failed', 'code': 'operation_cancelled'}, + {'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'}, + ] + env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once() + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('durable', [False, True]) +@pytest.mark.parametrize('initial_cancel', [False, True]) +@pytest.mark.parametrize('reconcile_cancel', [False, True]) +async def test_unavailable_commit_reconciliation_is_conservative( + env, monkeypatch, durable, initial_cancel, reconcile_cancel +): + from sqlalchemy.ext.asyncio import AsyncSessionTransaction + + original_commit = AsyncSessionTransaction.commit + original_execute = env.pm.execute_async + interrupted = False + reconciliation_attempts = 0 + + async def interrupt_commit(transaction): + nonlocal interrupted + count = ( + await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)) + ).scalar() + if count and not interrupted: + interrupted = True + if durable: + await original_commit(transaction) + if initial_cancel: + asyncio.current_task().cancel() + await asyncio.sleep(0) + raise RuntimeError('synthetic-secret') + await original_commit(transaction) + + async def unavailable_reconciliation(statement, *args, **kwargs): + nonlocal reconciliation_attempts + if interrupted: + reconciliation_attempts += 1 + if reconcile_cancel: + # Repeated real cancellation must stop the batch, not start a + # shield/retry loop or leave the current result as pending. + asyncio.current_task().cancel() + asyncio.current_task().cancel() + await asyncio.sleep(0) + raise RuntimeError('synthetic-secret') + return await original_execute(statement, *args, **kwargs) + + monkeypatch.setattr(AsyncSessionTransaction, 'commit', interrupt_commit) + monkeypatch.setattr(env.pm, 'execute_async', unavailable_reconciliation) + task_id = (await env.svc.execute(context(), await selection(env, ('one', 'two'))))['task_id'] + task = env.ap.task_mgr.get_task_by_id(task_id) + await asyncio.gather(task.task, return_exceptions=True) + assert task.task.cancelled() is reconcile_cancel + configs, backups = await rows(env) + assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE) + assert len(backups) == int(durable) + assert task.task_context.metadata['results'][0] == { + 'pipeline_uuid': 'one', + 'state': 'activation_pending', + 'code': 'commit_outcome_unknown', + } + if initial_cancel or reconcile_cancel: + assert reconciliation_attempts == 1 + assert task.task_context.metadata['results'][1] == { + 'pipeline_uuid': 'two', + 'state': 'failed', + 'code': 'operation_cancelled', + } + env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once() + assert configs['two'] == SOURCE + assert 'synthetic-secret' not in str(task.task_context.metadata) + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('durable_retry_ack', [False, True]) +@pytest.mark.parametrize('reconcile_failure', [None, 'error', 'cancel']) +async def test_cancel_activation_retry_reconciles_original_snapshot( + env, monkeypatch, durable_retry_ack, reconcile_failure +): + from sqlalchemy.ext.asyncio import AsyncSessionTransaction + + env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret') + await execute(env) + before_configs, before_backups = await rows(env) + body = await selection(env, ('one', 'two')) + env.ap.pipeline_mgr.publish_pipeline.reset_mock(side_effect=True) + env.ap.pipeline_mgr.prepare_pipeline.reset_mock() + original_commit = AsyncSessionTransaction.commit + original_service_commit = env.svc._commit + original_execute = env.pm.execute_async + interrupted = False + in_commit = False + reconciliation_attempts = 0 + + async def service_commit(*args): + nonlocal in_commit + in_commit = True + try: + return await original_service_commit(*args) + finally: + in_commit = False + + async def cancel_ack(transaction): + nonlocal interrupted + if in_commit and not interrupted: + interrupted = True + if durable_retry_ack: + await original_commit(transaction) + asyncio.current_task().cancel() + await asyncio.sleep(0) + await original_commit(transaction) + + async def reconcile(statement, *args, **kwargs): + nonlocal reconciliation_attempts + if interrupted and not in_commit: + reconciliation_attempts += 1 + if reconcile_failure == 'cancel': + asyncio.current_task().cancel() + await asyncio.sleep(0) + if reconcile_failure == 'error': + raise RuntimeError('synthetic-secret') + return await original_execute(statement, *args, **kwargs) + + monkeypatch.setattr(env.svc, '_commit', service_commit) + monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_ack) + monkeypatch.setattr(env.pm, 'execute_async', reconcile) + task_id = (await env.svc.execute(context(), body))['task_id'] + task = env.ap.task_mgr.get_task_by_id(task_id) + await asyncio.gather(task.task, return_exceptions=True) + configs, backups = await rows(env) + assert interrupted and configs == before_configs + assert backups == before_backups + assert backups[0]['state'] == 'activation_pending' + assert reconciliation_attempts == 1 + assert task.task.cancelled() is (reconcile_failure == 'cancel') + assert task.task_context.metadata['results'] == [ + { + 'pipeline_uuid': 'one', + 'state': 'activation_pending', + 'code': 'commit_outcome_unknown' if reconcile_failure else 'operation_cancelled', + }, + {'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'}, + ] + env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once() + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + assert 'synthetic-secret' not in str(task.task_context.metadata) + + +@pytest.mark.asyncio +async def test_cancel_during_prepare_keeps_original_and_stops_batch(env): + async def cancel_prepare(*args): + asyncio.current_task().cancel() + await asyncio.sleep(0) + + env.ap.pipeline_mgr.prepare_pipeline.side_effect = cancel_prepare + task = await execute(env, await selection(env, ('one', 'two'))) + configs, backups = await rows(env) + assert configs['one'] == configs['two'] == SOURCE and not backups + assert all( + r['state'] == 'failed' and r['code'] == 'operation_cancelled' for r in task.task_context.metadata['results'] + ) + env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once() + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() diff --git a/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py b/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py new file mode 100644 index 000000000..c68d7193d --- /dev/null +++ b/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py @@ -0,0 +1,119 @@ +"""Actual planner + artifact descriptors + migration service + SQLite snapshots. + +Vendor calls and runtime publication are fixtures. Database writes, snapshots, +scoped service validation, and converter output are real. Browser tests consume +this same synthetic contract, without requiring Python or a sibling checkout. +""" + +import copy +import json +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy as sa +import yaml + +from langbot.pkg.agent.runner.descriptor import RunnerDescriptor +from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider +from langbot.pkg.pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline +from tests.unit_tests.api.service import test_pipeline_migration as base + +ROOT = Path(__file__).parents[4] +CONTRACT = json.loads((ROOT / 'web/tests/e2e/fixtures/runner-migration-contract.json').read_text()) +env = base.env + + +@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner']) +def test_portable_browser_contract_matches_actual_converter(case): + assert CONTRACT['planner_version'] == PLANNER_VERSION + assert plan_legacy_pipeline(case['source']) == case['plan'] + assert len(CONTRACT['cases']) == len(CONTRACT['plugins']) == 9 + for name in ('ai', 'output'): + schema = yaml.safe_load((ROOT / f'src/langbot/templates/metadata/pipeline/{name}.yaml').read_text()) + assert CONTRACT[f'{name}_schema'] == schema + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source_mode', ['local', 'cloud_projection']) +@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner']) +async def test_all_runner_service_roundtrip_retains_complete_backup(env, monkeypatch, case, source_mode): + monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline) + source = copy.deepcopy(case['source']) + plan = plan_legacy_pipeline(source) + target = plan['target_plugin'] + artifact = next( + item for item in CONTRACT['plugins'].values() if item['manifest']['metadata']['name'] == target['name'] + ) + schema = artifact['component']['spec'] + env.ap.runner_registry.list_runners.return_value = [ + RunnerDescriptor( + id=plan['target_runner_id'], + source='plugin', + label={}, + plugin_author=target['author'], + plugin_name=target['name'], + plugin_version=target['version'], + runner_name='default', + usages=schema['usages'], + config_schema=schema['config'], + capabilities=schema.get('capabilities', {}), + permissions=schema.get('permissions', {}), + ) + ] + env.ap.logger = Mock() + env.ap.tool_mgr = NS( + get_resolved_tool_catalog=AsyncMock(return_value=[]), get_tool_schema=AsyncMock(return_value=('', {})) + ) + async with env.engine.begin() as conn: + await conn.run_sync( + lambda c: ModelProvider.metadata.create_all(c, tables=[ModelProvider.__table__, LLMModel.__table__]) + ) + await conn.execute( + sa.insert(ModelProvider).values( + uuid='fixture-provider', + workspace_uuid=base.WS, + name='fixture', + requester='test', + base_url='https://synthetic.invalid', + ) + ) + await conn.execute( + sa.insert(LLMModel).values( + uuid='synthetic-model', workspace_uuid=base.WS, name='fixture', provider_uuid='fixture-provider' + ) + ) + await conn.execute(sa.update(base.Workspace).where(base.Workspace.uuid == base.WS).values(source=source_mode)) + await conn.execute( + sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source) + ) + await conn.execute( + sa.insert(base.PluginSetting).values( + workspace_uuid=base.WS, + plugin_author=target['author'], + plugin_name=target['name'], + enabled=True, + install_info={'version': target['version']}, + ) + ) + before, snapshots = await base.rows(env) + assert not snapshots + preview = await env.svc.preview(base.context()) + selected = next(row for row in preview['items'] if row['pipeline_uuid'] == 'one') + assert selected['state'] == 'ready', selected['blockers'] + assert selected['preview_token'] + assert await base.rows(env) == (before, snapshots) + task = await base.execute( + env, {'confirmed': True, 'items': [{key: selected[key] for key in ('pipeline_uuid', 'preview_token')}]} + ) + assert task.task_context.metadata['results'] == [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}] + configs, backups = await base.rows(env) + assert configs['one'] == plan['config'] + assert set(configs['one']['ai']) == {'runner', 'runner_config'} + assert configs['two'] == before['two'] and configs['foreign'] == before['foreign'] + assert len(backups) == 1 + assert backups[0]['source_snapshot']['config'] == source + assert set(backups[0]['source_snapshot']['config']['ai']) == {'runner', *CONTRACT['legacy_sections']} + assert backups[0]['state'] == 'active' + env.ap.pipeline_mgr.publish_pipeline.assert_called_once() diff --git a/tests/unit_tests/api/service/test_pipeline_migration_cloud.py b/tests/unit_tests/api/service/test_pipeline_migration_cloud.py new file mode 100644 index 000000000..09fc71ae7 --- /dev/null +++ b/tests/unit_tests/api/service/test_pipeline_migration_cloud.py @@ -0,0 +1,121 @@ +"""Shared admission and real embedded artifact schema regressions.""" + +import copy +import json +from pathlib import Path +from types import SimpleNamespace as NS + +import pytest +import sqlalchemy as sa +from tests.unit_tests.api.service.test_pipeline_migration import ( + env as migration_env, + execute, + WS, + SOURCE, + RID, + planner, +) +from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError +from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + async for value in migration_env.__wrapped__(tmp_path, monkeypatch): + yield value + + +async def request(env, **kwargs): + return await InteractionStore(env.pm.get_db_engine()).create_request( + interaction_id='form', + run_id='run', + binding_id='binding', + runner_id=RID, + processor_type='pipeline', + processor_id='one', + workspace_id=WS, + request={}, + delivery_target={}, + expected_config=copy.deepcopy(SOURCE), + authority_check=lambda: True, + **kwargs, + ) + + +@pytest.mark.asyncio +async def test_stale_writer_after_commit_rejected(env): + original = env.svc._activate + + async def activate(*args): + with pytest.raises(InteractionScopeError): + await request(env) + return await original(*args) + + env.svc._activate = activate + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + with pytest.raises(InteractionScopeError): + await request(env) + async with env.engine.connect() as conn: + assert not (await conn.execute(sa.select(AgentInteraction))).all() + + +@pytest.mark.asyncio +async def test_writer_requires_pipeline_authority(env): + with pytest.raises(InteractionScopeError): + await InteractionStore(env.engine).create_request( + interaction_id='form', + run_id='run', + binding_id='binding', + runner_id=RID, + processor_type='pipeline', + processor_id='one', + workspace_id=WS, + request={}, + delivery_target={}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'folder,field,good,bad', + [ + ('LangflowAgent', 'tweaks', {'node': {'value': 1}}, ['not-an-object']), + ('WeKnoraAgent', 'knowledge-base-ids', ['kb-a'], [1]), + ], +) +async def test_embedded_artifact_schema_types(env, folder, field, good, bad): + root = Path(__file__).parent / 'fixtures' + artifact = 'langflow-agent' if folder == 'LangflowAgent' else 'weknora-agent' + spec = {'config': json.loads((root / (artifact + '-artifact-schema.json')).read_text())} + kind = 'json' if folder == 'LangflowAgent' else 'array[string]' + schema = next(s for s in spec['config'] if s['type'] == kind) + descriptor = env.ap.runner_registry.list_runners.return_value[0] + descriptor.config_schema = [schema] + plan = planner(SOURCE) + plan['config']['ai']['runner_config'][RID] = {schema['name']: good} + await env.svc._verify_runtime(NS(workspace_uuid=WS), plan) + plan['config']['ai']['runner_config'][RID][schema['name']] = bad + with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'): + await env.svc._verify_runtime(NS(workspace_uuid=WS), plan) + + +def test_conversation_authority_is_captured_before_runner_returns(): + from langbot.pkg.agent.runner.interaction_manager import InteractionManager + + old, new = NS(), NS() + config = planner(SOURCE)['config'] + query = NS(pipeline_config=config, pipeline_uuid='one', session=NS(using_conversation=new)) + binding = NS( + processor_type='pipeline', processor_id='one', runner_id=RID, runner_config=config['ai']['runner_config'][RID] + ) + authority = InteractionManager._pipeline_admission( + binding, + NS(id=RID), + { + '_query': query, + '_pipeline_expected_config': copy.deepcopy(config), + '_pipeline_conversation': old, + }, + ) + assert authority['authority_check']() is False diff --git a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py new file mode 100644 index 000000000..b9a9801d9 --- /dev/null +++ b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py @@ -0,0 +1,234 @@ +"""Migration must not abandon native or durable human-input requests.""" + +import copy +import sys +from types import SimpleNamespace as NS + +import pytest +import sqlalchemy as sa + +from tests.unit_tests.api.service import test_pipeline_migration as existing +from tests.unit_tests.api.service.test_pipeline_migration import ( + context, + selection, + execute, + rows, + WS, + OTHER, + SOURCE, +) +from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction + +env = existing.env +NATIVE = 'langbot.pkg.provider.runners.difysvapi' + + +@pytest.fixture(autouse=True) +async def interaction_schema(env): + async with env.engine.begin() as conn: + await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True)) + + +async def add_request(env, status='pending', workspace=WS, pipeline='one'): + async with env.engine.begin() as conn: + await conn.execute( + sa.insert(AgentInteraction).values( + interaction_id='form', + run_id=f'{workspace}-{pipeline}-{status}', + binding_id='binding', + runner_id='plugin:langbot-team/DifyAgent/default', + processor_type='pipeline', + processor_id=pipeline, + workspace_id=workspace, + status=status, + request_json='{"secret":"private-form"}', + callback_token_hash=f'{workspace}-{pipeline}-{status}', + ) + ) + + +def native(monkeypatch, workspace=WS, pipeline='one', instance='instance'): + forms = { + (instance, workspace, 1, 'bot', pipeline, 'adapter', 'person', 'actor'): { + 'private-token': {'inputs': {'secret': 'private-form'}} + } + } + monkeypatch.setitem(sys.modules, NATIVE, NS(_PENDING_FORMS=forms)) + return forms + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['native', 'durable']) +async def test_pending_preview_readonly_secret_free(env, monkeypatch, source): + cache = native(monkeypatch) if source == 'native' else None + if source == 'durable': + await add_request(env) + before = copy.deepcopy(cache) + writes = [] + + def observe(_conn, _cursor, statement, *_args): + if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')): + writes.append(statement) + + sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe) + result = await env.svc.preview(context()) + item = result['items'][0] + assert item['state'] == 'blocked' + assert {'code': 'runtime.pending_interaction'} in item['blockers'] + assert item['preview_token'] is None + assert result['items'][1]['state'] == 'ready' + assert 'private-' not in str(result) + assert cache == before and not writes + env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status', ['submitted', 'cancelled', 'expired', 'delivery_failed']) +async def test_terminal_requests_allow_migration(env, status): + await add_request(env, status) + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['native', 'durable']) +async def test_other_scope_does_not_block(env, monkeypatch, source): + if source == 'native': + native(monkeypatch, workspace=OTHER) + else: + await add_request(env, workspace=OTHER) + await add_request(env, pipeline='two') + task = await execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['native', 'durable']) +async def test_request_after_preview_stales_token(env, monkeypatch, source): + body = await selection(env) + if source == 'native': + native(monkeypatch) + else: + await add_request(env) + with pytest.raises(env.m.MigrationError, match='preview_stale'): + await env.svc.execute(context(), body) + assert not env.ap.task_mgr.tasks + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['native', 'durable']) +async def test_new_request_during_prepare_prevents_snapshot_and_reset(env, monkeypatch, source): + conversation = NS(pipeline_uuid='one') + session = NS(workspace_uuid=WS, instance_uuid='instance', using_conversation=conversation) + env.ap.sess_mgr.session_list = [session] + + async def prepare(*args): + if source == 'native': + native(monkeypatch) + else: + await add_request(env) + return 'candidate' + + env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare + task = await execute(env) + configs, snapshots = await rows(env) + assert configs['one'] == SOURCE and not snapshots + assert session.using_conversation is conversation + assert task.task_context.metadata['results'][0]['state'] == 'stale' + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +@pytest.mark.asyncio +async def test_activation_boundary_catches_new_request(env, monkeypatch): + original = env.svc._commit + + async def commit(*args): + result = await original(*args) + await add_request(env) + return result + + monkeypatch.setattr(env.svc, '_commit', commit) + task = await execute(env) + assert task.task_context.metadata['results'][0] == { + 'pipeline_uuid': 'one', + 'state': 'activation_pending', + 'code': 'runtime.pending_interaction', + } + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + _, snapshots = await rows(env) + assert snapshots[0]['state'] == 'activation_pending' + + +@pytest.mark.asyncio +async def test_activation_retry_cannot_erase_pending_form(env): + env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('activation failed') + await execute(env) + body = await selection(env) + before = await rows(env) + await add_request(env) + with pytest.raises(env.m.MigrationError, match='preview_stale'): + await env.svc.execute(context(), body) + assert await rows(env) == before + assert (await env.svc.preview(context()))['items'][0]['state'] == 'blocked' + + +@pytest.mark.asyncio +async def test_missing_interaction_table_fails_closed(env): + async with env.engine.begin() as conn: + await conn.run_sync(lambda c: AgentInteraction.__table__.drop(c)) + result = await env.svc.preview(context()) + assert result['items'][0]['state'] == 'blocked' + assert {'code': 'runtime.pending_interaction'} in result['items'][0]['blockers'] + + +@pytest.mark.asyncio +async def test_unknown_native_scope_fails_closed_without_payload(env, monkeypatch): + native(monkeypatch, workspace='') + result = await env.svc.preview(context()) + assert result['items'][0]['state'] == 'blocked' + assert 'private-' not in str(result) + + +@pytest.mark.asyncio +async def test_terminal_state_change_requires_fresh_preview(env): + await add_request(env, 'submitted') + body = await selection(env) + async with env.engine.begin() as conn: + await conn.execute(sa.update(AgentInteraction).values(status='cancelled')) + with pytest.raises(env.m.MigrationError, match='preview_stale'): + await env.svc.execute(context(), body) + + +@pytest.mark.asyncio +async def test_unsupported_atomic_boundary_not_advertised_ready(env, monkeypatch): + from unittest.mock import AsyncMock + + monkeypatch.setattr(env.pm, 'get_db_engine', lambda: NS(dialect=NS(name='unsupported'))) + monkeypatch.setattr(env.pm, 'execute_async', AsyncMock(return_value=NS(all=lambda: []))) + _, blocked = await env.svc._interaction_state(context(), 'one') + assert blocked + + +@pytest.mark.asyncio +async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch): + original = env.pm.execute_async + + async def execute_sql(statement, *args, **kwargs): + result = await original(statement, *args, **kwargs) + if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'pipeline_migration_snapshots': + native(monkeypatch) + return result + + monkeypatch.setattr(env.pm, 'execute_async', execute_sql) + await execute(env) + configs, snapshots = await rows(env) + assert configs['one'] == SOURCE and not snapshots + env.ap.pipeline_mgr.publish_pipeline.assert_not_called() + + +# Also exercise the unchanged regression assertions with this module's +# interaction-schema fixture. The original tests remain in their own module. + +for _name, _test in vars(existing).items(): + if _name.startswith('test_') and callable(_test): + globals()['test_existing_' + _name[5:]] = _test diff --git a/tests/unit_tests/api/service/test_pipeline_migration_resources.py b/tests/unit_tests/api/service/test_pipeline_migration_resources.py new file mode 100644 index 000000000..d70adbfdb --- /dev/null +++ b/tests/unit_tests/api/service/test_pipeline_migration_resources.py @@ -0,0 +1,170 @@ +"""Real scoped database checks against the reviewed LocalAgent descriptor.""" + +import copy +import json +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy as sa + +from langbot.pkg.agent.runner.descriptor import RunnerDescriptor +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel +from langbot.pkg.entity.persistence.rag import KnowledgeBase +from langbot.pkg.entity.persistence.mcp import MCPServer +from tests.unit_tests.api.service import test_pipeline_migration as base +from tests.unit_tests.api.service.test_pipeline_migration import context, WS, OTHER + +env = base.env + + +@pytest.fixture +async def local(env): + spec = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_local_schema.json').read_text()) + rid = 'plugin:langbot-team/LocalAgent/default' + descriptor = RunnerDescriptor( + id=rid, + source='plugin', + label={}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + plugin_version='reviewed', + usages=['agent'], + config_schema=spec['config'], + capabilities=spec['capabilities'], + permissions=spec['permissions'], + ) + env.ap.runner_registry.list_runners.return_value = [descriptor] + env.ap.logger = Mock() + env.ap.tool_mgr = NS( + get_resolved_tool_catalog=AsyncMock( + return_value=[{'name': 'scoped_tool', 'source': 'mcp', 'source_id': 'mcp'}] + ), + get_tool_schema=AsyncMock(return_value=('', {})), + ) + async with env.engine.begin() as conn: + await conn.run_sync( + lambda c: ModelProvider.metadata.create_all( + c, tables=[ModelProvider.__table__, LLMModel.__table__, KnowledgeBase.__table__, MCPServer.__table__] + ) + ) + for ws, suffix in [(WS, ''), (OTHER, '_foreign')]: + await conn.execute( + sa.insert(ModelProvider).values( + uuid='provider' + suffix, + workspace_uuid=ws, + name='provider', + requester='test', + base_url='https://synthetic.invalid', + ) + ) + await conn.execute( + sa.insert(LLMModel).values( + uuid='model' + suffix, workspace_uuid=ws, name='model', provider_uuid='provider' + suffix + ) + ) + await conn.execute(sa.insert(KnowledgeBase).values(uuid='kb' + suffix, workspace_uuid=ws, name='kb')) + await conn.execute( + sa.insert(MCPServer).values( + uuid='mcp' + suffix, workspace_uuid=ws, name='mcp' + suffix, mode='remote', enable=True + ) + ) + config = {item['name']: copy.deepcopy(item['default']) for item in spec['config'] if 'default' in item} + config.update( + { + 'model': {'primary': 'model', 'fallbacks': [], 'reasoning': {}}, + 'knowledge-bases': ['kb'], + 'tools': ['scoped_tool'], + 'enable-all-tools': False, + 'mcp-resources': [{'server_uuid': 'mcp', 'uri': 'test://document', 'enabled': True}], + 'mcp-resource-agent-read-enabled': True, + } + ) + plan = { + 'target_runner_id': rid, + 'target_plugin': {'version': 'reviewed'}, + 'config': {'ai': {'runner': {'id': rid}, 'runner_config': {rid: config}}}, + } + return env, descriptor, config, plan + + +@pytest.mark.asyncio +async def test_valid_local_resources_and_real_descriptor_options(local): + env, _, _, plan = local + async with env.pm.tenant_scope(WS): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + env.ap.tool_mgr.get_resolved_tool_catalog.assert_awaited_once() + ctx = env.ap.tool_mgr.get_resolved_tool_catalog.call_args.args[0] + assert ctx.workspace_uuid == WS + assert env.pm.current_session() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool']) +async def test_foreign_or_unresolved_resource_is_denied(local, resource): + env, _, config, plan = local + if resource == 'model': + config['model']['primary'] = 'model_foreign' + elif resource == 'kb': + config['knowledge-bases'] = ['kb_foreign'] + elif resource == 'mcp': + config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign' + else: + config['tools'] = ['foreign_tool'] + async with env.pm.tenant_scope(WS): + with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('declared', [False, True]) +async def test_null_requires_explicit_descriptor_nullable(local, declared): + env, descriptor, config, plan = local + config['timeout'] = None + field = next(f for f in descriptor.config_schema if f['name'] == 'timeout') + field['nullable'] = declared + async with env.pm.tenant_scope(WS): + if declared: + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + else: + with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + + +@pytest.mark.asyncio +async def test_old_plugin_version_is_rejected_before_resource_resolution(local): + env, descriptor, _, plan = local + descriptor.plugin_version = 'old' + async with env.pm.tenant_scope(WS): + with pytest.raises(env.m.MigrationError, match='plugin_version_incompatible'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + env.ap.tool_mgr.get_resolved_tool_catalog.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_local_descriptor_migrates_through_detached_task(local, monkeypatch): + env, _, _, plan = local + + def planner(config, extensions_preferences=None): + result = base.planner(config, extensions_preferences) + if result['state'] == 'ready': + result.update(copy.deepcopy(plan)) + result['target_plugin'].update(author='langbot-team', name='LocalAgent') + return result + + monkeypatch.setattr(env.m, 'plan_legacy_pipeline', planner) + async with env.engine.begin() as conn: + await conn.execute( + sa.insert(base.PluginSetting).values( + workspace_uuid=WS, plugin_author='langbot-team', plugin_name='LocalAgent', enabled=True + ) + ) + task = await base.execute(env) + assert task.task_context.metadata['results'][0]['state'] == 'migrated' + configs, snapshots = await base.rows(env) + 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() diff --git a/tests/unit_tests/api/test_pipeline_migration_routes.py b/tests/unit_tests/api/test_pipeline_migration_routes.py new file mode 100644 index 000000000..6e0ff4b0c --- /dev/null +++ b/tests/unit_tests/api/test_pipeline_migration_routes.py @@ -0,0 +1,60 @@ +import importlib +import importlib.util +from types import SimpleNamespace as NS +from unittest.mock import AsyncMock + +import pytest +import quart + +from langbot.pkg.api.http.context import RequestContext, WorkspaceContext, PrincipalContext, PrincipalType +from langbot.pkg.api.http.authz import permissions_for_role + + +@pytest.mark.asyncio +async def test_manual_routes_user_token_permissions_and_strict_request(): + path = 'langbot.pkg.api.http.controller.groups.pipelines.migration' + assert importlib.util.find_spec(path), 'manual migration routes missing' + cls = importlib.import_module(path).PipelineMigrationRouterGroup + from langbot.pkg.api.http.service.pipeline_migration import MigrationError + + app = quart.Quart(__name__) + router = cls(NS(persistence_mgr=NS()), app) + router._authenticate_support_admin = AsyncMock(return_value=None) + router._authenticate_account = AsyncMock(return_value=(NS(uuid='account'), 'test@example.invalid')) + viewer = RequestContext( + 'instance', + 1, + 'request', + 'user_token', + PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'), + WorkspaceContext('workspace', 'membership', 'viewer', permissions_for_role('viewer')), + ) + router._resolve_account_context = AsyncMock(return_value=viewer) + await router.initialize() + router.service = NS( + preview=AsyncMock(return_value={'items': [], 'total': 0}), execute=AsyncMock(return_value={'task_id': 1}) + ) + client = app.test_client() + base = '/api/v1/pipelines/_/migration' + assert (await client.get(base + '/preview')).status_code == 401 + assert (await client.get(base + '/preview', headers={'X-API-Key': 'synthetic'})).status_code == 401 + headers = {'Authorization': 'Bearer synthetic'} + assert (await client.get(base + '/preview', headers=headers)).status_code == 200 + assert (await client.post(base + '/execute', headers=headers, json={})).status_code == 403 + router.service.execute.assert_not_awaited() + manager = RequestContext( + 'instance', + 1, + 'request', + 'user_token', + viewer.principal, + WorkspaceContext('workspace', 'membership', 'developer', permissions_for_role('developer')), + ) + router._resolve_account_context.return_value = manager + router.service.execute.side_effect = MigrationError('confirmation_required', 400) + response = await client.post(base + '/execute', headers=headers, json={'confirmed': 'true'}) + assert response.status_code == 400 + assert 'confirmation_required' in await response.get_data(as_text=True) + router.service.execute.side_effect = None + response = await client.post(base + '/execute', headers=headers, json={'confirmed': True, 'items': []}) + assert (await response.get_json())['data']['task_id'] == 1 diff --git a/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py b/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py new file mode 100644 index 000000000..3b8aabe80 --- /dev/null +++ b/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py @@ -0,0 +1,47 @@ +import importlib.util +from pathlib import Path + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.pipeline import LegacyPipeline +from langbot.pkg.persistence.mgr import _ALEMBIC_TENANT_TABLES +from langbot.pkg.persistence.tenant_uow import TENANT_TABLE_COLUMNS + + +def test_snapshot_schema_registered_composite_fk_and_schema_only_migration(tmp_path): + assert 'pipeline_migration_snapshots' in Base.metadata.tables, 'durable snapshot table missing' + table = Base.metadata.tables['pipeline_migration_snapshots'] + assert isinstance(table.c.source_snapshot.type, sa.JSON) + assert TENANT_TABLE_COLUMNS[table.name] == 'workspace_uuid' + assert table.name in _ALEMBIC_TENANT_TABLES + assert any( + tuple(f.column.name for f in c.elements) == ('workspace_uuid', 'uuid') for c in table.foreign_key_constraints + ) + assert any( + isinstance(c, sa.UniqueConstraint) + and [col.name for col in c.columns] == ['workspace_uuid', 'pipeline_uuid', 'source_fingerprint'] + for c in table.constraints + ) + path = ( + Path(__file__).parents[3] / 'src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py' + ) + spec = importlib.util.spec_from_file_location('migration0027', path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + assert migration.down_revision == '0026_merge_master_beta' + assert len(migration.revision) <= 32, 'Alembic version_num is VARCHAR(32)' + engine = sa.create_engine(f'sqlite:///{tmp_path / "schema.db"}') + with engine.begin() as conn: + # Upgrade an existing schema that has not yet created the journal. + Base.metadata.create_all(conn, tables=[LegacyPipeline.__table__]) + statements = [] + sa.event.listen(conn, 'before_cursor_execute', lambda _c, _u, sql, *_a: statements.append(sql)) + with Operations.context(MigrationContext.configure(conn)): + migration.upgrade() + migration.upgrade() # fresh-metadata boot and retry are idempotent + assert table.name in sa.inspect(conn).get_table_names() + assert not any(s.lstrip().upper().startswith(('UPDATE ', 'INSERT ', 'DELETE ')) for s in statements) + engine.dispose() diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py new file mode 100644 index 000000000..5f4319dfc --- /dev/null +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -0,0 +1,986 @@ +"""Behavioral tests for the pure, fail-closed legacy migration planner.""" + +import copy +import importlib +import importlib.util +import json +from pathlib import Path + +import pytest + + +FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text()) +TARGETS = { + 'local-agent': ('LocalAgent', '0.1.6', None), + 'dify-service-api': ('DifyAgent', '0.1.7', None), + 'coze-api': ('CozeAgent', '0.1.7', None), + 'dashscope-app-api': ('DashScopeAgent', '0.1.7', None), + 'n8n-service-api': ('N8nAgent', '0.1.7', None), + 'langflow-api': ('LangflowAgent', '0.1.7', None), + 'deerflow-api': ('DeerFlowAgent', '0.1.7', None), + 'tbox-app-api': ('TboxAgent', '0.1.5', None), + 'weknora-api': ('WeKnoraAgent', '0.1.7', None), +} + + +def source_for(runner='deerflow-api'): + return { + 'uuid': 'synthetic-pipeline', + 'bot_uuid': 'synthetic-bot', + 'stages': [{'stage': 'synthetic', 'config': {'nested': [False, 0, None]}}], + 'output': {'misc': {'remove-think': False}}, + 'ai': {'runner': {'runner': runner, 'expire-time': 0}, runner: copy.deepcopy(FIXTURES[runner])}, + } + + +def plan(source, preferences=None): + return planner().plan_legacy_pipeline(source, preferences) + + +def assert_block(result, code, field=None): + assert result['state'] == 'blocked' + assert result['config'] is None + assert result['changed_paths'] == [] + expected = {'code': code} + if field is not None: + expected['field'] = field + assert expected in result['blockers'] + + +@pytest.mark.parametrize('runner', TARGETS) +def test_recognizes_all_nine_exact_official_identities(runner): + result = plan(source_for(runner)) + name, version, blocker = TARGETS[runner] + assert result['legacy_runner'] == runner + assert result['target_runner_id'] == f'plugin:langbot-team/{name}/default' + assert result['target_plugin'] == {'author': 'langbot-team', 'name': name, 'version': version} + if blocker: + assert result['state'] == 'blocked' + assert blocker in {item['code'] for item in result['blockers']} + else: + assert result['state'] == 'ready' + + +def test_supported_deerflow_candidate_preserves_source_and_denies_new_resources(): + source = source_for() + source['ai']['local-agent'] = { + 'enable-all-tools': True, + 'tools': ['private-tool'], + 'knowledge-bases': ['private-kb'], + 'mcp-resources': [{'uri': 'private'}], + } + preferences = { + 'enable_all_plugins': False, + 'plugins': [{'author': 'langbot-team', 'name': 'DeerFlowAgent'}], + 'enable_all_mcp_servers': False, + 'mcp_servers': ['restricted-server'], + 'enable_all_skills': False, + 'skills': [], + 'mcp_resource_agent_read_enabled': True, + 'mcp_resources': [{'uri': 'private'}], + } + before = copy.deepcopy((source, preferences)) + result = plan(source, preferences) + assert result['state'] == 'ready' + candidate = result['config'] + assert candidate is not source + assert candidate['ai']['runner'] == {'id': result['target_runner_id'], 'expire-time': 0} + selected = candidate['ai']['runner_config'][result['target_runner_id']] + assert selected == { + **FIXTURES['deerflow-api'], + 'enable-all-tools': False, + 'tools': [], + 'knowledge-bases': [], + 'mcp-resources': [], + 'mcp-resource-agent-read-enabled': False, + } + for key in ('uuid', 'bot_uuid', 'stages', 'output'): + assert candidate[key] == source[key] + assert set(candidate['ai']) == {'runner', 'runner_config'} + assert source['ai']['local-agent'] == before[0]['ai']['local-agent'] + assert source['ai']['deerflow-api'] == before[0]['ai']['deerflow-api'] + assert (source, preferences) == before + candidate['stages'][0]['config']['nested'].append('mutated') + selected['api-key'] = 'mutated' + assert (source, preferences) == before + assert result['changed_paths'] == [ + 'ai.runner.runner', + 'ai.runner.id', + 'ai.runner_config', + 'ai.local-agent', + 'ai.deerflow-api', + ] + assert {'code': 'external.state_validation_required', 'field': 'ai.runner'} in result['warnings'] + + +@pytest.mark.parametrize('runner', TARGETS) +def test_current_identifiers_are_not_migrated_twice(runner): + name = TARGETS[runner][0] + source = source_for(runner) + source['ai']['runner'] = {'id': f'plugin:langbot-team/{name}/default', 'expire-time': 0} + result = plan(source) + assert result['state'] == 'already_current' + assert result['config'] is None + assert result['changed_paths'] == [] + + +@pytest.mark.parametrize('value', [None, [], False, 0, 'secret']) +def test_malformed_roots_are_safe_blockers(value): + assert_block(plan(value), 'invalid_type', 'config') + + +@pytest.mark.parametrize('field', ['ai', 'runner']) +@pytest.mark.parametrize('value', [None, [], False, 0, 'secret']) +def test_malformed_envelopes_are_safe_blockers(field, value): + source = source_for() + if field == 'ai': + source['ai'] = value + else: + source['ai']['runner'] = value + assert_block(plan(source), 'invalid_type', 'ai' if field == 'ai' else 'ai.runner') + + +@pytest.mark.parametrize('current', [None, '', False, {}, 'plugin:langbot-team/DeerFlowAgent/default']) +def test_mixed_selections_block_even_empty_current_id(current): + source = source_for() + source['ai']['runner']['id'] = current + assert_block(plan(source), 'mixed_runner_selection', 'ai.runner') + + +@pytest.mark.parametrize('value', [None, False, 0, '', [], 'secret']) +def test_malformed_active_runner_section_is_not_defaulted(value): + source = source_for() + source['ai']['deerflow-api'] = value + assert_block(plan(source), 'invalid_type', 'ai.deerflow-api') + + +def test_existing_runner_config_is_not_overwritten(): + source = source_for() + source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'value'}} + assert_block(plan(source), 'mixed_runner_config', 'ai.runner_config') + + +def test_unknown_selection_is_not_echoed_as_public_legacy_id(): + source = source_for() + source['ai']['runner']['runner'] = 'secret-value' + result = plan(source) + assert result['state'] == 'not_legacy' + assert 'secret-value' not in json.dumps(result) + + +@pytest.mark.parametrize('value', [None, False, -1, 1.5, '0']) +def test_expiry_is_strict_nonnegative_integer(value): + source = source_for() + source['ai']['runner']['expire-time'] = value + assert_block(plan(source), 'invalid_expiry', 'ai.runner.expire-time') + + +def test_unknown_active_field_name_and_value_never_leak(): + source = source_for() + source['ai']['deerflow-api']['secret-field-name'] = 'secret-field-value' + result = plan(source) + assert_block(result, 'unknown_field', 'ai.deerflow-api') + assert 'secret-field' not in json.dumps(result) + + +@pytest.mark.parametrize('value', [float('nan'), float('inf'), {1: 'secret'}, ('tuple',), b'secret']) +def test_non_json_values_block_without_serialization_or_coercion(value): + source = source_for() + source['opaque'] = value + assert_block(plan(source), 'invalid_json_value', 'config') + + +def test_cyclic_input_blocks_without_recursion_error(): + source = source_for() + source['opaque'] = source + assert_block(plan(source), 'invalid_json_value', 'config') + + +@pytest.mark.parametrize('runner', TARGETS) +@pytest.mark.parametrize('value', [None, False, 0, '', [], {}]) +def test_unknown_active_keys_block_even_when_falsey(runner, value): + source = source_for(runner) + source['ai'][runner]['secret-unknown-key'] = value + assert_block(plan(source), 'unknown_field', f'ai.{runner}') + + +@pytest.mark.parametrize('runner', TARGETS) +def test_missing_active_section_is_not_seeded(runner): + source = source_for(runner) + del source['ai'][runner] + assert_block(plan(source), 'missing_field', f'ai.{runner}') + + +@pytest.mark.parametrize('container', ['ai', 'selection']) +def test_unknown_active_envelope_keys_are_not_echoed(container): + source = source_for() + target = source['ai'] if container == 'ai' else source['ai']['runner'] + target['secret-unknown-key'] = 'secret-value' + result = plan(source) + assert_block(result, 'unknown_field', 'ai' if container == 'ai' else 'ai.runner') + assert 'secret-' not in json.dumps(result) + + +@pytest.mark.parametrize( + 'preferences', + [ + False, + [], + 'secret', + {'enable_all_plugins': 1}, + {'enable_all_mcp_servers': None}, + {'enable_all_skills': 'false'}, + {'plugins': ['secret']}, + {'mcp_servers': [1]}, + {'skills': [None]}, + {'mcp_resource_agent_read_enabled': 0}, + {'mcp_resources': [False]}, + ], +) +def test_malformed_extension_preferences_block_without_expanding_access(preferences): + result = plan(source_for(), preferences) + assert result['state'] == 'blocked' + assert any(item['code'] == 'invalid_extension_preferences' for item in result['blockers']) + assert 'secret' not in json.dumps(result) + + +def test_explicit_plugin_denylist_is_not_silently_broadened(): + preferences = {'enable_all_plugins': False, 'plugins': []} + assert_block(plan(source_for(), preferences), 'extensions.runner_excluded', 'extensions_preferences.plugins') + assert preferences == {'enable_all_plugins': False, 'plugins': []} + + +@pytest.mark.parametrize('field', ['api-key', 'auth-header']) +@pytest.mark.parametrize('secret', [' synthetic ', '\tsynthetic', 'synthetic\n']) +def test_deerflow_secret_trim_drift_blocks_without_leaking(field, secret): + source = source_for() + source['ai']['deerflow-api'][field] = secret + result = plan(source) + assert result['state'] == 'ready' + assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == secret + assert secret not in json.dumps({k: v for k, v in result.items() if k != 'config'}) + assert source['ai']['deerflow-api'][field] == secret + + +@pytest.mark.parametrize( + 'field,value', [('assistant-id', ''), ('assistant-id', ' padded '), ('model-name', ' padded ')] +) +def test_deerflow_option_trim_drift_is_not_repaired(field, value): + source = source_for() + source['ai']['deerflow-api'][field] = value + result = plan(source) + assert result['state'] == 'ready' + assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value + + +@pytest.mark.parametrize('field', ['thinking-enabled', 'plan-mode', 'subagent-enabled']) +@pytest.mark.parametrize('value', [None, 0, 1, 'false', []]) +def test_deerflow_requires_real_booleans(field, value): + source = source_for() + source['ai']['deerflow-api'][field] = value + assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + + +@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) +@pytest.mark.parametrize('value', [None, False, 1.5, '3']) +def test_deerflow_requires_real_integers_without_coercion(field, value): + source = source_for() + source['ai']['deerflow-api'][field] = value + assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + + +@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) +@pytest.mark.parametrize('value', [0, -1, 1]) +def test_deerflow_does_not_invent_numeric_clamps(field, value): + source = source_for() + source['ai']['deerflow-api'][field] = value + result = plan(source) + assert result['state'] == 'ready' + assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value + + +@pytest.mark.parametrize('value', [None, False, 0, [], '']) +def test_deerflow_requires_url_not_ui_default(value): + source = source_for() + source['ai']['deerflow-api']['api-base'] = value + result = plan(source) + assert result['state'] == 'blocked' + assert result['blockers'][0]['field'] == 'ai.deerflow-api.api-base' + + +def test_deerflow_native_runtime_defaults_are_materialized_not_ui_defaults(): + source = source_for() + source['ai']['deerflow-api'] = {'api-base': ' https://example.invalid/path/?signed=synthetic '} + del source['ai']['runner']['expire-time'] + result = plan(source) + assert result['state'] == 'ready' + selected = result['config']['ai']['runner_config'][result['target_runner_id']] + assert selected == { + 'api-base': ' https://example.invalid/path/?signed=synthetic ', + 'api-key': '', + 'auth-header': '', + 'assistant-id': 'lead_agent', + 'model-name': '', + 'thinking-enabled': False, + 'plan-mode': False, + 'subagent-enabled': False, + 'max-concurrent-subagents': 3, + 'timeout': 300, + 'recursion-limit': 1000, + 'enable-all-tools': False, + 'tools': [], + 'knowledge-bases': [], + 'mcp-resources': [], + 'mcp-resource-agent-read-enabled': False, + } + assert 'expire-time' not in result['config']['ai']['runner'] + + +def test_deerflow_missing_endpoint_blocks_instead_of_using_schema_localhost(): + source = source_for() + del source['ai']['deerflow-api']['api-base'] + assert_block(plan(source), 'missing_field', 'ai.deerflow-api.api-base') + + +def test_ready_public_projection_contains_no_secrets_or_credentials_in_urls(): + source = source_for() + section = source['ai']['deerflow-api'] + section.update( + { + 'api-key': 'SECRET-KEY', + 'auth-header': 'Bearer SECRET-HEADER', + 'api-base': 'https://name:SECRET-PASSWORD@example.invalid/?token=SECRET-TOKEN', + } + ) + result = plan(source) + assert result['state'] == 'ready' + selected = result['config']['ai']['runner_config'][result['target_runner_id']] + for key, value in section.items(): + assert selected[key] == value + public = {key: value for key, value in result.items() if key != 'config'} + assert 'SECRET' not in json.dumps(public) + + +@pytest.mark.parametrize('value', [None, 0, 'false']) +def test_remove_think_flag_is_not_coerced(value): + source = source_for('tbox-app-api') + source['output']['misc']['remove-think'] = value + assert_block(plan(source), 'invalid_type', 'output.misc.remove-think') + + +@pytest.mark.parametrize( + 'runner,code', + [ + ('dify-service-api', 'dify.remove_think_schema'), + ('coze-api', 'coze.remove_think'), + ('dashscope-app-api', 'dashscope.remove_think'), + ('tbox-app-api', 'tbox.remove_think'), + ], +) +def test_unsupported_thinking_suppression_has_precise_blocker(runner, code): + source = source_for(runner) + source['output']['misc']['remove-think'] = True + result = plan(source) + assert code not in {b['code'] for b in result['blockers']} + if result['state'] == 'ready': + assert result['config']['ai']['runner_config'][result['target_runner_id']]['remove-think'] is True + + +@pytest.mark.parametrize('rounds', [0, 1, 10, -1]) +def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds): + source = source_for('local-agent') + source['ai']['local-agent']['max-round'] = rounds + result = plan(source) + assert result['state'] == 'ready' + selected = result['config']['ai']['runner_config'][result['target_runner_id']] + assert 'max-round' not in selected + assert selected['context-history-fetch-limit'] == 50 + assert selected['context-window-tokens'] == 200000 + assert selected['tool-execution-mode'] == 'serial' + assert {'code': 'local.context_defaults', 'field': 'ai.local-agent.max-round'} in result['warnings'] + + +@pytest.mark.parametrize( + 'field,value,code', + [ + ( + 'model', + {'primary': 'model', 'fallbacks': [], 'reasoning': {'SECRET-model': 'invalid-level'}}, + 'local.reasoning_value', + ), + ('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'), + ('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'), + ('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'), + ('box-session-id-template', '{global}', 'local.box_scope'), + ], +) +def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code): + source = source_for('local-agent') + source['ai']['local-agent'][field] = value + result = plan(source) + assert_block(result, code, f'ai.local-agent.{field}') + assert 'SECRET' not in json.dumps(result) + + +@pytest.mark.parametrize( + 'field,value', + [ + ('enable-all-tools', None), + ('enable-all-tools', 0), + ('tools', None), + ('tools', [42]), + ('mcp-resources', None), + ('mcp-resources', [False]), + ('mcp-resource-agent-read-enabled', 'false'), + ], +) +def test_local_host_policy_malformed_values_block(field, value): + source = source_for('local-agent') + source['ai']['local-agent'][field] = value + assert_block(plan(source), 'invalid_type', f'ai.local-agent.{field}') + + +@pytest.mark.parametrize( + 'runner,field', + [ + ('dify-service-api', 'api-key'), + ('dify-service-api', 'base-prompt'), + ('coze-api', 'timeout'), + ('coze-api', 'api-base'), + ('langflow-api', 'flow-id'), + ('n8n-service-api', 'webhook-url'), + ('tbox-app-api', 'app-id'), + ('weknora-api', 'app-type'), + ], +) +def test_defaultless_native_fields_are_not_filled_from_target_schema(runner, field): + source = source_for(runner) + del source['ai'][runner][field] + assert_block(plan(source), 'missing_field', f'ai.{runner}.{field}') + + +@pytest.mark.parametrize('value', [None, False, 0, [], {}]) +@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')]) +def test_external_api_keys_require_strings_even_if_target_coerces(runner, value): + source = source_for(runner) + source['ai'][runner]['api-key'] = value + assert_block(plan(source), 'invalid_type', f'ai.{runner}.api-key') + + +@pytest.mark.parametrize( + 'section', + [ + {'auto_save_history': False, 'auto-save-history': True}, + {'auto_save_history': True, 'auto-save-history': False}, + ], +) +def test_coze_actual_underscore_history_key_missing_null_or_conflict_blocks(section): + source = source_for('coze-api') + source['ai']['coze-api'].pop('auto_save_history') + source['ai']['coze-api'].update(section) + assert_block(plan(source), 'coze.history_alias', 'ai.coze-api.auto_save_history') + + +@pytest.mark.parametrize('value', [False, True]) +def test_coze_equal_aliases_are_unambiguous_but_statefulness_still_blocks(value): + source = source_for('coze-api') + source['ai']['coze-api'].update({'auto_save_history': value, 'auto-save-history': value}) + result = plan(source) + assert selected_config(result)['auto-save-history'] is value + assert 'coze.history_alias' not in {b['code'] for b in result['blockers']} + + +def test_coze_custom_endpoint_not_replaced_with_schema_region(): + source = source_for('coze-api') + source['ai']['coze-api']['api-base'] = 'https://secret-user:secret-pass@proxy.invalid' + result = plan(source) + assert_block(result, 'coze.custom_endpoint', 'ai.coze-api.api-base') + assert 'secret-' not in json.dumps(result) + + +@pytest.mark.parametrize('underscore,hyphen', [('one', 'two')]) +def test_dashscope_seed_hyphen_is_not_actual_native_runtime_key(underscore, hyphen): + source = source_for('dashscope-app-api') + section = source['ai']['dashscope-app-api'] + section.pop('references_quote') + if underscore is not None: + section['references_quote'] = underscore + section['references-quote'] = hyphen + assert_block(plan(source), 'dashscope.references_alias', 'ai.dashscope-app-api.references_quote') + + +@pytest.mark.parametrize('axis', ['input', 'output']) +@pytest.mark.parametrize('underscore,hyphen', [(None, 'text'), ('chat', 'text'), ('text', 'chat')]) +def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscore, hyphen): + source = source_for('langflow-api') + section = source['ai']['langflow-api'] + section.pop(f'{axis}_type') + if underscore is not None: + section[f'{axis}_type'] = underscore + section[f'{axis}-type'] = hyphen + assert_block(plan(source), 'langflow.io_alias', f'ai.langflow-api.{axis}_type') + + +@pytest.mark.parametrize( + 'raw', + [ + '{broken-secret', + '[]', + '0', + 'false', + '{"key": NaN}', + '{"key": Infinity}', + '{"key": 1e999}', + '{"key":1,"key":2}', + '{"nested":{"key":1,"key":2}}', + [], + False, + 0, + ], +) +def test_langflow_tweaks_require_strict_object_json_without_fallback_loss(raw): + source = source_for('langflow-api') + source['ai']['langflow-api']['tweaks'] = raw + result = plan(source) + assert_block(result, 'langflow.invalid_tweaks', 'ai.langflow-api.tweaks') + assert 'broken-secret' not in json.dumps(result) + + +@pytest.mark.parametrize('raw', [None, '', '{}', '{"nested":{"token":"SECRET","values":[null,false,0]}}']) +def test_langflow_native_empty_and_valid_tweaks_not_misreported(raw): + source = source_for('langflow-api') + source['ai']['langflow-api']['tweaks'] = raw + original = copy.deepcopy(source) + result = plan(source) + assert result['state'] == 'ready' + assert 'langflow.invalid_tweaks' not in {item['code'] for item in result['blockers']} + assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) + assert source == original + + +def test_n8n_ignore_cannot_be_encoded_as_ignored_plugin_field(): + source = source_for('n8n-service-api') + source['ai']['n8n-service-api']['response-handling'] = 'ignore' + result = plan(source) + assert 'n8n.ignore_missing_0_1_6' not in {b['code'] for b in result['blockers']} + + +@pytest.mark.parametrize( + 'mode,missing', + [ + ('basic', 'basic-username'), + ('basic', 'basic-password'), + ('jwt', 'jwt-secret'), + ('header', 'header-name'), + ('header', 'header-value'), + ], +) +def test_n8n_missing_active_auth_material_has_precise_blocker(mode, missing): + source = source_for('n8n-service-api') + source['ai']['n8n-service-api'].update( + { + 'auth-type': mode, + 'basic-username': 'synthetic-user', + 'basic-password': 'SECRET-pass', + 'jwt-secret': 'SECRET-jwt', + 'header-name': 'X-Synthetic', + 'header-value': 'SECRET-header', + } + ) + source['ai']['n8n-service-api'].pop(missing) + result = plan(source) + assert_block(result, 'missing_field', f'ai.n8n-service-api.{missing}') + assert 'SECRET' not in json.dumps(result) + + +@pytest.mark.parametrize('mode', ['none', 'basic', 'jwt', 'header']) +def test_n8n_secrets_remain_raw_and_not_public_even_for_inactive_modes(mode): + source = source_for('n8n-service-api') + section = source['ai']['n8n-service-api'] + section.update( + { + 'auth-type': mode, + 'basic-username': 'user', + 'basic-password': ' SECRET-pass ', + 'jwt-secret': '\tSECRET-jwt', + 'header-name': 'Authorization', + 'header-value': 'Bearer SECRET-header ', + } + ) + original = copy.deepcopy(source) + result = plan(source) + selected = selected_config(result) + for field in ('basic-password', 'jwt-secret', 'header-value'): + assert selected[field] == section[field] + assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) + assert source == original + + +@pytest.mark.parametrize( + 'runner,field,value', + [ + ('dify-service-api', 'app-type', 'unknown'), + ('dashscope-app-api', 'app-type', 'chat'), + ('weknora-api', 'app-type', 'workflow'), + ('n8n-service-api', 'auth-type', 'SECRET-unknown'), + ], +) +def test_invalid_enum_values_are_not_echoed(runner, field, value): + source = source_for(runner) + source['ai'][runner][field] = value + result = plan(source) + assert_block(result, 'invalid_value', f'ai.{runner}.{field}') + assert 'SECRET' not in json.dumps(result) + + +@pytest.mark.parametrize('timeout', [None, 0, 30, 301]) +def test_dify_saved_timeout_is_not_blindly_activated(timeout): + source = source_for('dify-service-api') + source['ai']['dify-service-api']['timeout'] = timeout + result = plan(source) + assert 'dify.timeout_semantics' not in {b['code'] for b in result['blockers']} + assert {'code': 'dify.timeout_default', 'field': 'ai.dify-service-api.timeout'} in result['warnings'] + + +@pytest.mark.parametrize('current', [None, '', False, [], 'plugin:bad', ' plugin:a/b/c', 'plugin:a/b/c/extra']) +def test_malformed_current_id_is_not_already_current(current): + source = {'ai': {'runner': {'id': current}}} + assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id') + + +@pytest.mark.parametrize('runner', TARGETS) +def test_deterministic_results_and_input_nonmutation_for_all_nine(runner): + source = source_for(runner) + original = copy.deepcopy(source) + first = plan(source) + second = plan(source) + assert first == second + assert source == original + assert all(set(item) <= {'code', 'field'} for item in first['blockers'] + first['warnings']) + + +def test_ready_candidate_is_idempotently_recognized_current(): + first = plan(source_for()) + second = plan(first['config']) + assert second['state'] == 'already_current' + assert second['config'] is None + + +def planner(): + name = 'langbot.pkg.pipeline.legacy_config_migration' + assert importlib.util.find_spec(name) is not None, 'Pure migration planner is not implemented' + return importlib.import_module(name) + + +def selected_config(result): + assert result['state'] == 'ready', result['blockers'] + return result['config']['ai']['runner_config'][result['target_runner_id']] + + +@pytest.mark.parametrize( + 'prompt', + [ + [], + [{'role': 'system', 'content': ''}], + [{'role': 'tool', 'content': None, 'tool_call_id': 'call'}], + [ + { + 'role': 'user', + 'name': 'name', + 'content': [ + {'type': 'text', 'text': ''}, + {'type': 'image_url', 'image_url': {'url': 'https://image.invalid'}}, + ], + 'provider_specific_fields': {'opaque': [False, None]}, + } + ], + [ + { + 'role': 'assistant', + 'tool_calls': [ + { + 'id': 'call', + 'type': 'function', + 'function': {'name': 'tool', 'arguments': '{}'}, + 'provider_specific_fields': {'signature': 'opaque'}, + } + ], + } + ], + ], +) +def test_sdk_valid_prompts_are_preserved(prompt): + from langbot_plugin.api.entities.builtin.provider.message import Message + + for message in prompt: + Message.model_validate(message, strict=True) + source = source_for('local-agent') + source['ai']['local-agent']['prompt'] = prompt + assert selected_config(plan(source))['prompt'] == prompt + + +@pytest.mark.parametrize( + 'level', ['provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] +) +def test_reasoning_and_local_authorizations_survive(level): + source = source_for('local-agent') + section = source['ai']['local-agent'] + section.update( + { + 'model': {'primary': 'm', 'fallbacks': ['f'], 'reasoning': {'m': level}}, + 'knowledge-base': 'kb', + 'enable-all-tools': False, + 'tools': ['tool'], + 'mcp-resources': [], + 'mcp-resource-agent-read-enabled': False, + } + ) + preferences = {'mcp_resources': [{'uri': 'not-granted'}], 'mcp_resource_agent_read_enabled': True} + selected = selected_config(plan(source, preferences)) + assert selected['model'] == section['model'] + assert selected['knowledge-bases'] == ['kb'] + assert selected['tools'] == ['tool'] + assert selected['enable-all-tools'] is False + assert selected['mcp-resources'] == [] + assert selected['mcp-resource-agent-read-enabled'] is False + assert selected['tool-execution-mode'] == 'serial' + assert 'knowledge-base' not in selected + assert 'assistant-id' not in selected + + +def test_missing_local_host_policy_uses_native_defaults_and_preferences(): + source = source_for('local-agent') + section = source['ai']['local-agent'] + del section['enable-all-tools'] + del section['tools'] + section['model'] = 'm' + selected = selected_config( + plan(source, {'mcp_resources': [{'uri': 'granted'}], 'mcp_resource_agent_read_enabled': False}) + ) + assert selected['enable-all-tools'] is True + assert selected['tools'] == [] + assert selected['mcp-resources'] == [{'uri': 'granted'}] + assert selected['mcp-resource-agent-read-enabled'] is False + assert selected['model'] == {'primary': 'm', 'fallbacks': [], 'reasoning': {}} + + +@pytest.mark.parametrize('raw', [None, '', ' ', 'null', {}, '{"nested":[null,false,0]}']) +def test_langflow_convenience_inputs_are_explicitly_normalized(raw): + source = source_for('langflow-api') + source['ai']['langflow-api']['tweaks'] = raw + selected = selected_config(plan(source)) + assert selected['tweaks'] == ({'nested': [None, False, 0]} if isinstance(raw, str) and raw.startswith('{') else {}) + assert selected['input-type'] == 'chat' + assert 'input_type' not in selected + assert selected['langbot-assets-enabled'] is False + + +@pytest.mark.parametrize('value', ['', ' ', 'references']) +def test_dashscope_unambiguous_seed_alias_is_repaired(value): + source = source_for('dashscope-app-api') + section = source['ai']['dashscope-app-api'] + del section['references_quote'] + section['references-quote'] = value + result = plan(source) + selected = selected_config(result) + assert selected['references_quote'] == value + assert 'references-quote' not in selected + assert selected['timeout'] == 120 + assert {'code': 'migration.alias_repaired', 'field': 'ai.dashscope-app-api.references-quote'} in result['warnings'] + + +@pytest.mark.parametrize('mode', ['agent', 'chat']) +@pytest.mark.parametrize('value', ['ABSENT', None, '', ' ', 'saved-agent']) +def test_weknora_agent_absence_is_not_null_or_empty(mode, value): + source = source_for('weknora-api') + section = source['ai']['weknora-api'] + section['app-type'] = mode + section['knowledge-base-ids'] = None + if value != 'ABSENT': + section['agent-id'] = value + selected = selected_config(plan(source)) + assert selected['agent-id'] == ( + ('builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning') if value == 'ABSENT' else value + ) + assert selected['knowledge-base-ids'] == [] + assert selected['knowledge-bases'] == [] + + +@pytest.mark.parametrize('runner', ['dashscope-app-api', 'langflow-api', 'deerflow-api', 'weknora-api']) +def test_every_ready_external_denies_dormant_host_resources(runner): + source = source_for(runner) + source['ai']['local-agent'] = {'tools': ['forbidden'], 'knowledge-bases': ['forbidden'], 'enable-all-tools': True} + selected = selected_config(plan(source, {'enable_all_mcp_servers': True, 'mcp_resources': [{'uri': 'forbidden'}]})) + assert selected['enable-all-tools'] is False + assert selected['tools'] == selected['knowledge-bases'] == selected['mcp-resources'] == [] + assert selected['mcp-resource-agent-read-enabled'] is False + if runner != 'deerflow-api': + assert 'assistant-id' not in selected + + +@pytest.mark.parametrize( + 'runner,identity', + [ + ('dify-service-api', 'legacy-session'), + ('coze-api', 'legacy-session'), + ('n8n-service-api', 'legacy-session'), + ('tbox-app-api', 'legacy-bot'), + ], +) +def test_verified_plugin_identity_modes_are_explicit(runner, identity): + result = plan(source_for(runner)) + assert selected_config(result)['user-id-source'] == identity + assert {'code': 'migration.identity_preserved', 'field': f'ai.{runner}'} in result['warnings'] + + +@pytest.mark.parametrize( + 'values,expected', + [ + ({}, True), + ({'auto_save_history': None}, True), + ({'auto-save-history': False}, False), + ({'auto_save_history': False}, False), + ({'auto_save_history': None, 'auto-save-history': False}, False), + ], +) +def test_coze_history_aliases_and_null_defaults(values, expected): + source = source_for('coze-api') + section = source['ai']['coze-api'] + section.pop('auto_save_history') + section.update(values) + section['api-base'] = 'https://custom.invalid/prefix' + selected = selected_config(plan(source)) + assert selected['auto-save-history'] is expected + assert 'auto_save_history' not in selected + assert selected['api-base'] == section['api-base'] + + +@pytest.mark.parametrize( + 'values,code,field', + [ + ( + {'basic-username': '姓名', 'basic-password': 'p', 'auth-type': 'basic'}, + 'n8n.basic_encoding', + 'basic-username', + ), + ( + {'basic-username': 'a:b', 'basic-password': 'p', 'auth-type': 'basic'}, + 'n8n.basic_encoding', + 'basic-username', + ), + ({'header-name': '', 'header-value': '', 'auth-type': 'header'}, 'n8n.header_name', 'header-name'), + ], +) +def test_n8n_invalid_auth_is_not_reencoded_or_silently_dropped(values, code, field): + source = source_for('n8n-service-api') + source['ai']['n8n-service-api'].update(values) + assert_block(plan(source), code, f'ai.n8n-service-api.{field}') + + +def test_n8n_ignore_and_native_latin1_are_selected(): + source = source_for('n8n-service-api') + source['ai']['n8n-service-api'].update( + {'auth-type': 'basic', 'basic-username': 'café', 'basic-password': 'p', 'response-handling': 'ignore'} + ) + selected = selected_config(plan(source)) + assert selected['response-handling'] == 'ignore' + assert selected['basic-encoding'] == 'latin1' + + +def test_weknora_blank_remote_kb_is_rejected(): + source = source_for('weknora-api') + source['ai']['weknora-api']['knowledge-base-ids'] = [' '] + assert_block(plan(source), 'invalid_value', 'ai.weknora-api.knowledge-base-ids') + + +@pytest.mark.parametrize('runner', ['coze-api', 'n8n-service-api', 'weknora-api']) +@pytest.mark.parametrize('value', [0, -1]) +def test_external_target_requires_positive_timeout(runner, value): + source = source_for(runner) + source['ai'][runner]['timeout'] = value + assert_block(plan(source), 'invalid_value', f'ai.{runner}.timeout') + + +@pytest.mark.parametrize( + 'runner,field', + [ + ('dify-service-api', 'api-key'), + ('dify-service-api', 'base-url'), + ('coze-api', 'bot-id'), + ('coze-api', 'api-key'), + ('dashscope-app-api', 'api-key'), + ('dashscope-app-api', 'app-id'), + ('n8n-service-api', 'webhook-url'), + ('langflow-api', 'base-url'), + ('langflow-api', 'api-key'), + ('langflow-api', 'flow-id'), + ('tbox-app-api', 'api-key'), + ('tbox-app-api', 'app-id'), + ('weknora-api', 'api-key'), + ('weknora-api', 'base-url'), + ], +) +def test_required_target_strings_cannot_be_empty(runner, field): + source = source_for(runner) + source['ai'][runner][field] = '' + assert_block(plan(source), 'invalid_value', f'ai.{runner}.{field}') + + +@pytest.mark.parametrize('runner', TARGETS) +def test_ready_all9_nested_candidates_are_detached_and_no_io(runner, monkeypatch): + module = planner() + source = source_for(runner) + before = copy.deepcopy(source) + + def forbidden(*args, **kwargs): + raise AssertionError('planner performed IO') + + monkeypatch.setattr('builtins.open', forbidden) + result = module.plan_legacy_pipeline(source) + selected = selected_config(result) + selected['nested-test'] = ['changed'] + result['config']['stages'][0]['config']['nested'].append('changed') + result['config']['ai']['runner']['expire-time'] = 999 + assert source == before + assert set(result) == { + 'state', + 'legacy_runner', + 'target_runner_id', + 'target_plugin', + 'config', + 'changed_paths', + 'blockers', + 'warnings', + } + + +def test_empty_config_is_not_legacy(): + result = planner().plan_legacy_pipeline({}) + assert result == { + 'state': 'not_legacy', + 'legacy_runner': None, + 'target_runner_id': None, + 'target_plugin': None, + 'config': None, + 'changed_paths': [], + 'blockers': [], + 'warnings': [], + } + + +def test_default_local_agent_blocks_instead_of_inventing_round_translation(): + source = { + 'ai': { + 'runner': {'runner': 'local-agent', 'expire-time': 0}, + 'local-agent': {'model': 'model-id', 'max-round': 10}, + } + } + original = copy.deepcopy(source) + result = planner().plan_legacy_pipeline(source) + assert planner().PLANNER_VERSION == '3' + assert result['state'] == 'blocked' + assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default' + assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.6'} + assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers'] + assert result['config'] is None + assert source == original diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py new file mode 100644 index 000000000..8656c156c --- /dev/null +++ b/tests/unit_tests/pipeline/test_migration_current_shape.py @@ -0,0 +1,67 @@ +"""Migration output must be accepted by the guarded current editor.""" + +import copy +import json +from pathlib import Path + +import pytest + +from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline + + +FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text()) + + +@pytest.mark.parametrize('runner', FIXTURES) +def test_migrated_ai_is_canonical_and_complete_source_remains_available_for_snapshot(runner): + source = { + 'ai': {'runner': {'runner': runner, 'expire-time': 0}, **copy.deepcopy(FIXTURES)}, + 'output': {'misc': {'remove-think': False}}, + 'trigger': {'untouched': [False, 0, None]}, + } + original = copy.deepcopy(source) + result = plan_legacy_pipeline(source) + assert result['state'] == 'ready', result['blockers'] + assert set(result['config']['ai']) == {'runner', 'runner_config'} + assert {'code': 'migration.legacy_sections_archived', 'field': 'ai'} in result['warnings'] + assert result['config']['trigger'] == original['trigger'] + assert source == original + assert set(result['changed_paths']) == { + 'ai.runner.runner', + 'ai.runner.id', + 'ai.runner_config', + *[f'ai.{name}' for name in FIXTURES], + } + + +def test_sdk_valid_omitted_prompt_content_is_preserved_exactly(): + from langbot_plugin.api.entities.builtin.provider.message import Message + + prompt = [{'role': 'assistant', 'tool_calls': [], 'name': 'edited', 'provider_specific_fields': {'cache': False}}] + Message.model_validate(prompt[0]) + source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} + source['ai']['local-agent']['prompt'] = prompt + result = plan_legacy_pipeline(source) + assert result['state'] == 'ready', result['blockers'] + migrated = result['config']['ai']['runner_config'][result['target_runner_id']]['prompt'] + assert migrated == prompt + assert 'content' not in migrated[0] + + +@pytest.mark.parametrize('template', ['', '{launcher_type}_{launcher_id}']) +def test_standard_box_template_is_explicit_reset_not_custom_scope_block(template): + source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} + source['ai']['local-agent']['box-session-id-template'] = template + result = plan_legacy_pipeline(source) + assert result['state'] == 'ready', result['blockers'] + assert {'code': 'local.box_state_reset', 'field': 'ai.local-agent.box-session-id-template'} in result['warnings'] + assert 'box-session-id-template' not in result['config']['ai']['runner_config'][result['target_runner_id']] + + +@pytest.mark.parametrize('template', ['global', '{launcher_id}', '{workspace}', ' {launcher_type}_{launcher_id}']) +def test_custom_box_sharing_stays_blocked(template): + source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} + source['ai']['local-agent']['box-session-id-template'] = template + result = plan_legacy_pipeline(source) + assert result['state'] == 'blocked' + assert {'code': 'local.box_scope', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers'] diff --git a/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py b/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py new file mode 100644 index 000000000..462616a65 --- /dev/null +++ b/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py @@ -0,0 +1,47 @@ +"""Preparation must not publish or evict a previously usable runtime.""" + +import copy +from types import SimpleNamespace as NS +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.pipeline.pipelinemgr import PipelineManager + + +@pytest.mark.asyncio +async def test_prepare_is_unpublished_and_failure_retains_old_runtime(monkeypatch): + assert hasattr(PipelineManager, 'prepare_pipeline'), 'runtime preparation seam missing' + ctx = ExecutionContext('instance', 'workspace', 1, pipeline_uuid='pipeline') + ap = NS(workspace_service=NS(get_execution_binding=AsyncMock()), logger=Mock()) + mgr = PipelineManager(ap) + mgr.stage_dict = {} + source = dict( + uuid='pipeline', + workspace_uuid='workspace', + name='old', + description='', + stages=[], + config={}, + extensions_preferences={}, + ) + await mgr.load_pipeline(ctx, copy.deepcopy(source)) + old = mgr.pipelines[0] + candidate = await mgr.prepare_pipeline(ctx, {**copy.deepcopy(source), 'name': 'new'}) + assert mgr.pipelines == [old] + mgr.publish_pipeline(candidate) + assert mgr.pipelines == [candidate] + assert candidate.pipeline_entity.name == 'new' + + class BrokenStage: + def __init__(self, ap): + pass + + async def initialize(self, config): + raise RuntimeError('preparation failed') + + mgr.stage_dict = {'broken': BrokenStage} + with pytest.raises(RuntimeError, match='preparation failed'): + await mgr.prepare_pipeline(ctx, {**copy.deepcopy(source), 'stages': ['broken']}) + assert mgr.pipelines == [candidate] diff --git a/tests/unit_tests/plugin/test_runner_reasoning_override.py b/tests/unit_tests/plugin/test_runner_reasoning_override.py new file mode 100644 index 000000000..75e27c012 --- /dev/null +++ b/tests/unit_tests/plugin/test_runner_reasoning_override.py @@ -0,0 +1,245 @@ +"""Actual secured Host actions consume frozen policy, not plugin payload hints.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.plugin import handler as handler_module +from tests.unit_tests.agent.test_runner_model_reasoning import PRIMARY, FALLBACK, OTHER +from tests.unit_tests.plugin.test_handler_actions import make_handler, make_result +from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model + + +ACTIONS = [ + PluginToRuntimeAction.INVOKE_LLM, + PluginToRuntimeAction.INVOKE_LLM_STREAM, + PluginToRuntimeAction.COUNT_TOKENS, +] + + +class RecordingProvider: + """Network-free boundary; reasoning translation remains the real requester.""" + + def __init__(self, request): + self.requester = request + self.calls = [] + + async def record(self, kwargs): + await asyncio.sleep(0) + built = self.requester._build_reasoning_args(kwargs['model']) + self.calls.append((kwargs, built)) + + async def invoke_llm(self, **kwargs): + await self.record(kwargs) + return provider_message.Message(role='assistant', content='ok') + + async def invoke_llm_stream(self, **kwargs): + await self.record(kwargs) + yield provider_message.MessageChunk(role='assistant', content='ok') + + async def count_tokens(self, **kwargs): + await self.record(kwargs) + return 37 + + +@pytest.fixture +async def host(monkeypatch): + registry = AgentRunSessionRegistry() + monkeypatch.setattr(handler_module, 'get_session_registry', lambda: registry) + request = _requester('openai') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + provider = RecordingProvider(request) + monkeypatch.setattr(request, 'count_tokens', provider.count_tokens) + models = {} + for model_id in (PRIMARY, FALLBACK): + model = _runtime_model(request, 'medium', name='gpt-5') + model.model_entity.uuid = model_id + model.model_entity.workspace_uuid = 'workspace-a' + model.provider = provider + models[model_id] = model + ap = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(side_effect=lambda context, model_id: models[model_id])), + persistence_mgr=SimpleNamespace( + execute_async=AsyncMock(return_value=make_result(SimpleNamespace(uuid=PRIMARY))) + ), + ) + runtime = make_handler(ap) + + async def register( + run_id='run', overrides=None, workspace='workspace-a', plugin='test-author/test-plugin', operations=None + ): + await registry.register( + run_id=run_id, + runner_id='plugin:test-author/test-plugin/arbitrary', + query_id=None, + plugin_identity=plugin, + workspace_id=workspace, + resources={ + 'models': [ + {'model_id': model_id, **({'operations': operations} if operations else {})} + for model_id in (PRIMARY, FALLBACK) + ] + }, + model_reasoning_overrides=overrides, + ) + return await registry.get(run_id) + + return SimpleNamespace( + registry=registry, models=models, provider=provider, runtime=runtime, register=register, ap=ap + ) + + +async def call(host, action, model_id=PRIMARY, run_id='run', **extra): + payload = { + 'llm_model_uuid': model_id, + 'messages': [{'role': 'user', 'content': 'hello'}], + 'extra_args': {'temperature': 0.7}, + **extra, + } + if run_id is not None: + payload['run_id'] = run_id + if action == PluginToRuntimeAction.INVOKE_LLM_STREAM: + return [response async for response in host.runtime.actions[action.value](payload)] + return [await host.runtime.actions[action.value](payload)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_policy(host, action): + await host.register(overrides={PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}) + for model_id, level in [(PRIMARY, 'high'), (FALLBACK, 'low'), (FALLBACK, 'low')]: + responses = await call(host, action, model_id) + assert all(response.code == 0 for response in responses) + kwargs, built = host.provider.calls[-1] + assert built == {'reasoning_effort': level} + assert kwargs['model'] is not host.models[model_id] + assert kwargs['model'].reasoning_config_override == {'level': level} + assert kwargs['extra_args'] == {'temperature': 0.7} + assert 'model_reasoning_overrides' not in kwargs + assert all(model.reasoning_config_override is None for model in host.models.values()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +@pytest.mark.parametrize('level', [None, 'provider_default']) +async def test_absent_and_explicit_provider_default_are_distinct(host, action, level): + await host.register(overrides={PRIMARY: {'level': level}} if level else None) + assert all(response.code == 0 for response in await call(host, action)) + kwargs, built = host.provider.calls[-1] + assert built == ({} if level else {'reasoning_effort': 'medium'}) + assert (kwargs['model'] is host.models[PRIMARY]) is (level is None) + assert host.models[PRIMARY].model_entity.reasoning_config == {'level': 'medium'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_regular_plugin_without_run_keeps_model_defaults_and_ignores_forged_map(host, action): + responses = await call( + host, + action, + run_id=None, + model_reasoning_overrides={PRIMARY: {'level': 'max'}}, + reasoning_config_override={'level': 'disabled'}, + ) + assert all(response.code == 0 for response in responses) + kwargs, built = host.provider.calls[-1] + assert kwargs['model'] is host.models[PRIMARY] + assert built == {'reasoning_effort': 'medium'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_plugin_cannot_replace_host_map(host, action): + await host.register(overrides={PRIMARY: {'level': 'high'}}) + responses = await call( + host, + action, + model_reasoning_overrides={PRIMARY: {'level': 'disabled'}}, + reasoning_config_override={'level': 'disabled'}, + ) + assert all(response.code == 0 for response in responses) + assert host.provider.calls[-1][1] == {'reasoning_effort': 'high'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +@pytest.mark.parametrize('denial', ['workspace', 'plugin', 'unselected', 'operation', 'expired']) +async def test_authorization_denial_happens_before_model_access(host, action, denial): + await host.register( + overrides={PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}}, + workspace='workspace-b' if denial == 'workspace' else 'workspace-a', + plugin='other/plugin' if denial == 'plugin' else 'test-author/test-plugin', + operations=['rerank'] if denial == 'operation' else None, + ) + responses = await call( + host, action, OTHER if denial == 'unselected' else PRIMARY, run_id='expired' if denial == 'expired' else 'run' + ) + assert all(response.code != 0 for response in responses) + assert not host.provider.calls + host.ap.model_mgr.get_model_by_uuid.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(host, action): + await host.register('high-run', {PRIMARY: {'level': 'high'}}) + await host.register('low-run', {PRIMARY: {'level': 'low'}}) + results = await asyncio.gather(*(call(host, action, run_id=run_id) for run_id in ['high-run', 'low-run'] * 3)) + assert all(response.code == 0 for result in results for response in result) + assert sorted(built['reasoning_effort'] for _, built in host.provider.calls) == ['high'] * 3 + ['low'] * 3 + assert len({id(kwargs['model']) for kwargs, _ in host.provider.calls}) == 6 + assert host.models[PRIMARY].reasoning_config_override is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_model_runtime_workspace_mismatch_denies(host, action): + await host.register(overrides={PRIMARY: {'level': 'high'}}) + host.models[PRIMARY].model_entity.workspace_uuid = 'workspace-b' + responses = await call(host, action) + assert all(response.code != 0 for response in responses) + assert not host.provider.calls + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_host_reuses_core_ability_validation_before_provider_call(host, action): + await host.register(overrides={PRIMARY: {'level': 'high'}}) + host.models[PRIMARY].model_entity.abilities = [] + with pytest.raises(ValueError, match='reasoning ability'): + await call(host, action) + assert not host.provider.calls + + +@pytest.mark.asyncio +@pytest.mark.parametrize('action', ACTIONS) +async def test_snapshot_survives_configuration_edits_and_tool_followup(host, action): + config = {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}} + await host.register(overrides=config) + config[PRIMARY]['level'] = 'disabled' + config[FALLBACK]['level'] = 'max' + responses = await call( + host, + action, + FALLBACK, + messages=[ + {'role': 'user', 'content': 'search'}, + { + 'role': 'assistant', + 'content': '', + 'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}], + }, + {'role': 'tool', 'content': 'search result', 'tool_call_id': 'call-1'}, + ], + ) + assert all(response.code == 0 for response in responses) + assert host.provider.calls[-1][1] == {'reasoning_effort': 'low'} diff --git a/tests/unit_tests/provider/test_codex_reasoning_override.py b/tests/unit_tests/provider/test_codex_reasoning_override.py new file mode 100644 index 000000000..7ae5a848e --- /dev/null +++ b/tests/unit_tests/provider/test_codex_reasoning_override.py @@ -0,0 +1,208 @@ +"""Request-local reasoning must reach the native Codex Responses builder.""" + +from copy import copy, deepcopy +from types import SimpleNamespace + +import pytest +import langbot_plugin.api.entities.builtin.provider.message as pm + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.entity.persistence import model as persistence_model +from langbot.pkg.provider.modelmgr.requester import RuntimeLLMModel +from tests.unit_tests.provider.test_codex import TOKENS, requester, stream + + +@pytest.fixture +def codex(monkeypatch): + def unexpected_request(request): + pytest.fail('Builder tests must not send HTTP requests') + + return requester(monkeypatch, unexpected_request) + + +def runtime_model(codex, name='primary', level='high'): + context = ExecutionContext(instance_uuid='instance', workspace_uuid='w', placement_generation=1) + provider = SimpleNamespace( + execution_context=context, + provider_entity=persistence_model.ModelProvider( + workspace_uuid='w', uuid='p', name='codex', requester='codex', api_keys=[] + ), + requester=codex, + ) + entity = persistence_model.LLMModel( + workspace_uuid='w', + uuid=name, + name=name, + provider_uuid='p', + abilities=['reasoning', 'func_call'], + reasoning_config={'level': level}, + extra_args={}, + ) + return RuntimeLLMModel(context, entity, provider) + + +def entity_values(model): + return { + column.name: deepcopy(getattr(model.model_entity, column.name)) + for column in model.model_entity.__table__.columns + } + + +def build(codex, model): + return codex._body(None, model, [pm.Message(role='user', content='Hi')], None, None, TOKENS) + + +@pytest.mark.parametrize('level', ['provider_default', 'low', 'medium', 'high', 'xhigh']) +def test_canonical_codex_override_levels(codex, level): + model = runtime_model(codex, level='medium') + before = deepcopy(entity_values(model)) + model.reasoning_config_override = {'level': level} + assert codex.get_reasoning_capabilities(model)['levels'] == ['provider_default', 'low', 'medium', 'high', 'xhigh'] + body = build(codex, model) + if level == 'provider_default': + assert 'reasoning' not in body + else: + assert body['reasoning'] == {'effort': level, 'summary': 'auto'} + assert model.reasoning_config_override == {'level': level} + assert entity_values(model) == before + assert 'reasoning_config_override' not in body + + +def test_primary_fallback_and_shared_entity_clones_are_isolated(codex): + primary = runtime_model(codex) + fallback = runtime_model(codex, name='fallback', level='medium') + primary_run, fallback_run, another_run = copy(primary), copy(fallback), copy(primary) + primary_run.reasoning_config_override = {'level': 'low'} + fallback_run.reasoning_config_override = {'level': 'xhigh'} + another_run.reasoning_config_override = {'level': 'medium'} + originals = [primary, fallback] + before = [deepcopy(entity_values(model)) for model in originals] + for scoped, expected in [(primary_run, 'low'), (fallback_run, 'xhigh'), (another_run, 'medium')]: + assert build(codex, scoped)['reasoning']['effort'] == expected + assert primary_run.model_entity is another_run.model_entity is primary.model_entity + assert primary_run.provider is another_run.provider is primary.provider + assert build(codex, primary)['reasoning']['effort'] == 'high' + assert build(codex, fallback)['reasoning']['effort'] == 'medium' + assert [entity_values(model) for model in originals] == before + assert all(model.reasoning_config_override is None for model in originals) + + +def test_explicit_provider_default_overrides_persisted_high(codex): + model = runtime_model(codex) + model.reasoning_config_override = {'level': 'provider_default'} + assert 'reasoning' not in build(codex, model) + assert model.model_entity.reasoning_config == {'level': 'high'} + + +@pytest.mark.parametrize('missing_attribute', [False, True]) +@pytest.mark.parametrize('level', ['provider_default', 'high']) +def test_absent_override_preserves_persisted_config(codex, missing_attribute, level): + model = runtime_model(codex, level=level) + if missing_attribute: + del model.reasoning_config_override + body = build(codex, model) + assert body.get('reasoning') == (None if level == 'provider_default' else {'effort': level, 'summary': 'auto'}) + + +@pytest.mark.parametrize( + 'config', + [ + {'level': 'turbo'}, + {'level': 'disabled'}, + {'level': 'enabled'}, + {'level': 'minimal'}, + {'level': 'max'}, + {'unknown': True}, + 'high', + ], +) +def test_override_uses_same_validation_errors_as_persisted_config(codex, config): + persisted = runtime_model(codex) + persisted.model_entity.reasoning_config = deepcopy(config) + with pytest.raises(ValueError) as expected: + build(codex, persisted) + scoped = runtime_model(codex) + scoped.reasoning_config_override = deepcopy(config) + before = deepcopy(entity_values(scoped)) + with pytest.raises(ValueError) as actual: + build(codex, scoped) + assert str(actual.value) == str(expected.value) + assert TOKENS['access_token'] not in str(actual.value) + assert TOKENS['account_id'] not in str(actual.value) + assert entity_values(scoped) == before + + +def test_advanced_args_remain_unchanged(codex): + model = runtime_model(codex, level='provider_default') + model.model_entity.extra_args = {'reasoning': {'effort': 'low'}, 'parallel_tool_calls': False} + before = deepcopy(entity_values(model)) + baseline = build(codex, model) + model.reasoning_config_override = {'level': 'provider_default'} + assert build(codex, model) == baseline + assert entity_values(model) == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('streaming', [False, True]) +async def test_override_preserves_stream_tools_usage_and_replay(monkeypatch, streaming): + import json + + call = {'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{}'} + output = [{'type': 'reasoning', 'encrypted_content': 'opaque-secret'}, call] + requests = [] + + def handler(request): + requests.append(request) + return stream( + [ + {'type': 'response.output_text.delta', 'delta': 'Hello'}, + {'type': 'response.output_item.done', 'item': call, 'output_index': 1}, + { + 'type': 'response.completed', + 'response': { + 'id': 'resp_1', + 'status': 'completed', + 'output': output, + 'usage': {'input_tokens': 4, 'output_tokens': 3}, + }, + }, + ] + ) + + codex = requester(monkeypatch, handler) + model = runtime_model(codex) + scoped = copy(model) + scoped.reasoning_config_override = {'level': 'low'} + before = deepcopy(entity_values(model)) + provider_before = dict(vars(model.provider)) + query = SimpleNamespace(query_id='q', variables={}) + messages = [pm.Message(role='user', content='Hi')] + funcs = [SimpleNamespace(name='lookup', description='Look up', parameters={'type': 'object'})] + baseline = codex._body(query, model, messages, funcs, None, TOKENS) + if streaming: + chunks = [chunk async for chunk in codex.invoke_llm_stream(query, scoped, messages, funcs)] + assert ''.join(chunk.content or '' for chunk in chunks) == 'Hello' + assert sum(len(chunk.tool_calls or []) for chunk in chunks) == 1 + assert next(chunk.tool_calls[0] for chunk in chunks if chunk.tool_calls).function.arguments == '{}' + assert chunks[-1].is_final + result = pm.Message( + role='assistant', content='Hello', provider_specific_fields=chunks[-1].provider_specific_fields + ) + else: + result, usage = await codex.invoke_llm(query, scoped, messages, funcs) + assert result.content == 'Hello' + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.arguments == '{}' + assert usage['total_tokens'] == 7 + body = json.loads(requests[0].content) + assert body.pop('reasoning') == {'effort': 'low', 'summary': 'auto'} + baseline.pop('reasoning') + assert body == baseline + assert query.variables['_stream_usage']['total_tokens'] == 7 + assert 'opaque-secret' not in result.model_dump_json() + assert codex._body(query, scoped, [result], None, None, TOKENS)['input'] == output + assert requests[0].headers['authorization'] == 'Bearer ' + TOKENS['access_token'] + assert entity_values(model) == before + assert vars(model.provider) == provider_before + assert model.reasoning_config_override is None + codex.auth.access.assert_awaited_once_with('w', 'p') diff --git a/web/playwright.completion.config.ts b/web/playwright.completion.config.ts new file mode 100644 index 000000000..8f5398deb --- /dev/null +++ b/web/playwright.completion.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; +import base from './playwright.config'; + +export default defineConfig({ + ...base, + retries: 0, + outputDir: 'test-results-completion-migration', + use: { ...base.use, baseURL: 'http://127.0.0.1:4198' }, + webServer: { + command: 'pnpm exec vite --host 127.0.0.1 --port 4198 --strictPort', + url: 'http://127.0.0.1:4198', + reuseExistingServer: false, + timeout: 120000, + }, +}); diff --git a/web/playwright.reasoning.config.ts b/web/playwright.reasoning.config.ts new file mode 100644 index 000000000..ee21e8df7 --- /dev/null +++ b/web/playwright.reasoning.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: './tests/e2e', + workers: 1, + retries: 0, + timeout: 30000, + reporter: 'list', + outputDir: '/tmp/411-reasoning-results', + use: { baseURL: 'http://127.0.0.1:4196' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'taskset -c 0,1 node node_modules/vite/bin/vite.js --config vite.reasoning.config.ts --host 127.0.0.1 --port 4196', + url: 'http://127.0.0.1:4196', + reuseExistingServer: true, + }, +}); diff --git a/web/playwright.structured.config.ts b/web/playwright.structured.config.ts new file mode 100644 index 000000000..978b17602 --- /dev/null +++ b/web/playwright.structured.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; +import base from './playwright.config'; +export default defineConfig({ + ...base, + retries: 0, + outputDir: 'test-results-structured', + use: { ...base.use, baseURL: 'http://127.0.0.1:4197' }, + webServer: { + command: + 'taskset -c 0,1 node node_modules/vite/bin/vite.js --config vite.structured.config.ts --host 127.0.0.1 --port 4197 --strictPort', + url: 'http://127.0.0.1:4197', + reuseExistingServer: false, + timeout: 120000, + }, +}); diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index abe7cbe2d..0693f1e24 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -32,7 +32,13 @@ import AgentFormComponent, { RunnerStatus, } from './components/AgentFormComponent'; -export default function AgentDetailContent({ id }: { id: string }) { +export default function AgentDetailContent({ + id, + pipelineRevision, +}: { + id: string; + pipelineRevision?: string; +}) { const isCreateMode = id === 'new'; const navigate = useNavigate(); const { t } = useTranslation(); @@ -130,7 +136,13 @@ export default function AgentDetailContent({ id }: { id: string }) { if (loading || !agent) return ; if (agent.kind === 'pipeline') { - return ; + return ( + + ); } async function saveBasicInfo(values: EntityBasicInfoValues) { diff --git a/web/src/app/home/agents/page.tsx b/web/src/app/home/agents/page.tsx index 169717a56..f46b1a487 100644 --- a/web/src/app/home/agents/page.tsx +++ b/web/src/app/home/agents/page.tsx @@ -1,19 +1,47 @@ +import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useCurrentWorkspace } from '@/app/infra/http'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import PipelineMigration, { + migrationWorkspaceKey, +} from '@/app/home/pipelines/PipelineMigration'; import AgentDetailContent from './AgentDetailContent'; export default function AgentsPage() { const { t } = useTranslation(); const [searchParams] = useSearchParams(); const detailId = searchParams.get('id'); - - if (detailId) { - return ; - } + const workspace = useCurrentWorkspace(); + const { refreshPipelines } = useSidebarData(); + const [revision, setRevision] = useState(0); + const scopeKey = migrationWorkspaceKey(workspace); return ( -
-

{t('agents.selectFromSidebar')}

+
+ {workspace && workspace.permissions.includes('resource.view') && ( + { + void refreshPipelines(); + setRevision((current) => current + 1); + }} + /> + )} +
+ {detailId ? ( + + ) : ( +
+

{t('agents.selectFromSidebar')}

+
+ )} +
); } diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index a8ed2fa71..5ec6eff88 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -6,6 +6,7 @@ import { import { useForm } from 'react-hook-form'; import type { ControllerRenderProps } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { isJsonValue, isPromptValue } from './StructuredFieldValue'; import { z } from 'zod'; import { Form, @@ -133,13 +134,10 @@ function getValueSchema(spec: DynamicFormValueSpec) { fallbacks: z.array(z.string()), reasoning: z.record(z.string()), }); + case DynamicFormItemType.JSON: + return z.custom(isJsonValue); case DynamicFormItemType.PROMPT_EDITOR: - return z.array( - z.object({ - content: z.string(), - role: z.string(), - }), - ); + return z.custom(isPromptValue); default: return z.string(); } @@ -656,6 +654,7 @@ export default function DynamicFormComponent({ }; const fieldKey = config.id || config.name || `field-${index}`; + let isHiddenByCondition = false; if (config.show_if) { const dependValue = resolveShowIfValue( config.show_if.field, @@ -668,23 +667,32 @@ export default function DynamicFormComponent({ config.show_if.operator === 'eq' && dependValue !== config.show_if.value ) { - return null; + isHiddenByCondition = true; } if ( config.show_if.operator === 'neq' && dependValue === config.show_if.value ) { - return null; + isHiddenByCondition = true; } if ( config.show_if.operator === 'in' && Array.isArray(config.show_if.value) && !config.show_if.value.includes(dependValue) ) { - return null; + isHiddenByCondition = true; } } + // Keep structured drafts mounted across conditional hiding so invalid + // JSON cannot disappear from validation when Advanced Settings closes. + if ( + isHiddenByCondition && + normalizedConfig.type !== DynamicFormItemType.JSON && + normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR + ) + return null; + // Keep locked fields visible and resolve only the applicable reason. const { isDisabledByCondition, disabledTooltip: tooltip } = resolveDisabledState( @@ -957,7 +965,10 @@ export default function DynamicFormComponent({ setFormValue, }); return ( - +