mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-13 15:46:38 +08:00
feat: add verified manual pipeline migration for plugin runners
This commit is contained in:
@@ -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-*` 的实际日志和源码哈希为准。
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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')
|
||||
@@ -71,6 +71,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'pipeline_migration_snapshots',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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']
|
||||
|
||||
@@ -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'}
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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]
|
||||
@@ -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'}
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 <EntityLoadState />;
|
||||
|
||||
if (agent.kind === 'pipeline') {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
return (
|
||||
<PipelineDetailContent
|
||||
key={pipelineRevision}
|
||||
id={id}
|
||||
routeBase="/home/agents"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
|
||||
@@ -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 <AgentDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<AgentDetailContent
|
||||
key={detailId}
|
||||
id={detailId}
|
||||
pipelineRevision={`${scopeKey}:${revision}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<FormItem className="min-w-0">
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
IDynamicFormItemSchema,
|
||||
IFileConfig,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import StructuredFieldEditor from './StructuredFieldEditor';
|
||||
import { isSimplePrompt } from './StructuredFieldValue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -490,14 +492,7 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.JSON:
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
);
|
||||
return <StructuredFieldEditor field={field} />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return (
|
||||
@@ -1261,11 +1256,11 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
const updateModelReasoning = (
|
||||
modelUuid: string,
|
||||
level: ReasoningLevel,
|
||||
level: ReasoningLevel | undefined,
|
||||
) => {
|
||||
if (!modelUuid) return;
|
||||
const updated = { ...modelValue.reasoning };
|
||||
if (level === 'provider_default') {
|
||||
if (level === undefined) {
|
||||
delete updated[modelUuid];
|
||||
} else {
|
||||
updated[modelUuid] = level;
|
||||
@@ -1311,6 +1306,8 @@ export default function DynamicFormItemComponent({
|
||||
<ReasoningLevelPicker
|
||||
value={currentLevel}
|
||||
levels={levels}
|
||||
inherited={!Object.hasOwn(modelValue.reasoning, modelUuid)}
|
||||
onInherit={() => updateModelReasoning(modelUuid, undefined)}
|
||||
onChange={(level) => updateModelReasoning(modelUuid, level)}
|
||||
/>
|
||||
);
|
||||
@@ -1872,6 +1869,9 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||
if (!isSimplePrompt(field.value)) {
|
||||
return <StructuredFieldEditor field={field} prompt />;
|
||||
}
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
// single system-prompt entry to prevent the .map() crash.
|
||||
|
||||
@@ -56,7 +56,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
|
||||
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
|
||||
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
|
||||
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
|
||||
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
|
||||
};
|
||||
if (value in typeMap) {
|
||||
return typeMap[value];
|
||||
|
||||
@@ -80,6 +80,7 @@ export function normalizeDynamicFormFieldValue(
|
||||
}
|
||||
|
||||
const reasoningLevels = new Set([
|
||||
'provider_default',
|
||||
'disabled',
|
||||
'enabled',
|
||||
'minimal',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { parseStructuredDraft } from './StructuredFieldValue';
|
||||
|
||||
/** Draft text stays local: only explicitly parsed edits enter persisted state. */
|
||||
export default function StructuredFieldEditor({
|
||||
field,
|
||||
prompt = false,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, any>;
|
||||
prompt?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
const [draft, setDraft] = useState(
|
||||
() => JSON.stringify(field.value, null, 2) ?? '',
|
||||
);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const emitted = useRef(field.value);
|
||||
const input = useRef<HTMLTextAreaElement | null>(null);
|
||||
const error = t('agents.debugData.invalidField', {
|
||||
field: `${field.name} (JSON)`,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (JSON.stringify(field.value) !== JSON.stringify(emitted.current)) {
|
||||
emitted.current = field.value;
|
||||
setDraft(JSON.stringify(field.value, null, 2) ?? '');
|
||||
setInvalid(false);
|
||||
input.current?.setCustomValidity('');
|
||||
}
|
||||
}, [field.value]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={(element) => {
|
||||
input.current = element;
|
||||
field.ref(element);
|
||||
}}
|
||||
name={field.name}
|
||||
aria-label={`${field.name} (JSON)`}
|
||||
data-testid={`structured-editor-${field.name}`}
|
||||
aria-invalid={invalid}
|
||||
className="min-h-48 font-mono"
|
||||
value={draft}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDraft(next);
|
||||
try {
|
||||
const value = parseStructuredDraft(next, prompt);
|
||||
event.target.setCustomValidity('');
|
||||
setInvalid(false);
|
||||
form.clearErrors(field.name);
|
||||
emitted.current = value;
|
||||
field.onChange(value);
|
||||
} catch {
|
||||
event.target.setCustomValidity(error);
|
||||
setInvalid(true);
|
||||
form.setError(field.name, { type: 'json', message: error });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{invalid && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
export type PromptMessage = {
|
||||
role: string;
|
||||
content?: string | null | JsonValue[];
|
||||
[key: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
||||
return true;
|
||||
if (typeof value === 'number') return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.every(isJsonValue);
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.values(value).every(isJsonValue)
|
||||
);
|
||||
}
|
||||
export function isPromptValue(value: unknown): value is PromptMessage[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(item) =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
typeof item.role === 'string' &&
|
||||
(!Object.prototype.hasOwnProperty.call(item, 'content') ||
|
||||
typeof item.content === 'string' ||
|
||||
item.content === null ||
|
||||
Array.isArray(item.content)) &&
|
||||
isJsonValue(item),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function parseStructuredDraft(
|
||||
draft: string,
|
||||
prompt: boolean,
|
||||
): JsonValue {
|
||||
const value: unknown = JSON.parse(draft);
|
||||
if (!(prompt ? isPromptValue(value) : isJsonValue(value)))
|
||||
throw new TypeError('JSON');
|
||||
return value as JsonValue;
|
||||
}
|
||||
export function isSimplePrompt(
|
||||
value: unknown,
|
||||
): value is { role: string; content: string }[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item, index) =>
|
||||
item &&
|
||||
typeof item.content === 'string' &&
|
||||
(index === 0
|
||||
? item.role === 'system'
|
||||
: ['user', 'assistant'].includes(item.role)) &&
|
||||
Object.keys(item).every((key) => key === 'role' || key === 'content'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ interface ReasoningLevelPickerProps {
|
||||
value: ReasoningLevel;
|
||||
levels: ReasoningLevel[];
|
||||
disabled?: boolean;
|
||||
inherited?: boolean;
|
||||
onInherit?: () => void;
|
||||
onChange: (value: ReasoningLevel) => void;
|
||||
}
|
||||
|
||||
@@ -44,6 +46,8 @@ export default function ReasoningLevelPicker({
|
||||
value,
|
||||
levels,
|
||||
disabled = false,
|
||||
inherited = false,
|
||||
onInherit,
|
||||
onChange,
|
||||
}: ReasoningLevelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,8 +56,14 @@ export default function ReasoningLevelPicker({
|
||||
const safeValue: ReasoningLevel = safeLevels.includes(value)
|
||||
? value
|
||||
: safeLevels[0];
|
||||
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
|
||||
const isExplicit = safeValue !== 'provider_default';
|
||||
const isInherited = Boolean(onInherit && inherited);
|
||||
const currentLabel = t(
|
||||
isInherited
|
||||
? 'models.reasoningLevels.useModelSetting'
|
||||
: REASONING_LEVEL_LABEL_KEYS[safeValue],
|
||||
);
|
||||
const isExplicit =
|
||||
!isInherited && (Boolean(onInherit) || safeValue !== 'provider_default');
|
||||
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
|
||||
|
||||
return (
|
||||
@@ -63,7 +73,7 @@ export default function ReasoningLevelPicker({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || safeLevels.length <= 1}
|
||||
disabled={disabled || (safeLevels.length <= 1 && !onInherit)}
|
||||
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
|
||||
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
|
||||
>
|
||||
@@ -77,6 +87,30 @@ export default function ReasoningLevelPicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[272px] p-4">
|
||||
{onInherit && (
|
||||
<div className="mb-3 flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={isInherited}
|
||||
onClick={onInherit}
|
||||
>
|
||||
{t('models.reasoningLevels.useModelSetting')}
|
||||
</Button>
|
||||
{safeLevels.includes('provider_default') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={!isInherited && safeValue === 'provider_default'}
|
||||
onClick={() => onChange('provider_default')}
|
||||
>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS.provider_default)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronRight className="size-3.5" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { isCurrentPipelineConfig } from './pipeline-config-safety';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import PipelineFormComponent, {
|
||||
PipelineFormHandle,
|
||||
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||
@@ -150,6 +152,25 @@ export default function PipelineDetailContent({
|
||||
);
|
||||
if (!pipelineDetails) return <EntityLoadState />;
|
||||
|
||||
// Never hydrate plugin defaults or mount debug autosave for legacy bindings.
|
||||
if (!isCurrentPipelineConfig(pipelineDetails.config)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pipelineDetails.emoji || '⚙️'} {pipelineDetails.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pipelineDetails.description}
|
||||
</p>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.legacyGate')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
@@ -178,6 +199,7 @@ export default function PipelineDetailContent({
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
onLegacyPipeline={setPipelineDetails}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { migrationIssueKey } from './pipeline-migration-issues';
|
||||
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
|
||||
import type {
|
||||
PipelineMigrationIssue,
|
||||
PipelineMigrationItem,
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationResult,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
|
||||
return workspace
|
||||
? `${workspace.workspace.instance_uuid}:${workspace.workspace.uuid}:${workspace.placement_generation}:${workspace.permissions.join(',')}`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeField(field?: string | null) {
|
||||
return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field)
|
||||
? field
|
||||
: null;
|
||||
}
|
||||
|
||||
const resultStates = new Set([
|
||||
'pending',
|
||||
'migrated',
|
||||
'already_current',
|
||||
'blocked',
|
||||
'stale',
|
||||
'failed',
|
||||
'activation_pending',
|
||||
]);
|
||||
type Status =
|
||||
| 'idle'
|
||||
| 'submitting'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'failed'
|
||||
| 'lost'
|
||||
| 'requestError';
|
||||
|
||||
export default function PipelineMigration({
|
||||
workspace,
|
||||
onComplete,
|
||||
}: {
|
||||
workspace: CurrentWorkspace;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
const canManage = workspace.permissions.includes('resource.manage');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<PipelineMigrationPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [valid, setValid] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
|
||||
const active = useRef(false);
|
||||
const submitting = useRef(false);
|
||||
const previewGeneration = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
const isCurrent = useCallback(
|
||||
() =>
|
||||
active.current &&
|
||||
migrationWorkspaceKey(getCurrentWorkspaceSnapshot()) === scopeKey,
|
||||
[scopeKey],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (allowSelection = true) => {
|
||||
if (!isCurrent()) return;
|
||||
const generation = ++previewGeneration.current;
|
||||
setSelected([]);
|
||||
setConfirmed(false);
|
||||
setValid(false);
|
||||
setLoading(true);
|
||||
setPreviewError(false);
|
||||
try {
|
||||
const response = await httpClient.getPipelineMigrationPreview({
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent() || generation !== previewGeneration.current) return;
|
||||
if (
|
||||
response.workspace_uuid !== workspace.workspace.uuid ||
|
||||
!Array.isArray(response.items) ||
|
||||
response.total !== response.items.length
|
||||
)
|
||||
throw new Error('Invalid preview');
|
||||
setPreview(response);
|
||||
setValid(allowSelection);
|
||||
} catch {
|
||||
if (isCurrent() && generation === previewGeneration.current) {
|
||||
setPreviewError(true);
|
||||
setPreview(null);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent() && generation === previewGeneration.current)
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[isCurrent, workspace.workspace.uuid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
controller.current = new AbortController();
|
||||
void refresh();
|
||||
const onFocus = () => {
|
||||
if (!submitting.current) void refresh();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
active.current = false;
|
||||
controller.current?.abort();
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const busy = status === 'submitting' || status === 'running';
|
||||
const eligible = (item: PipelineMigrationItem) =>
|
||||
['ready', 'activation_pending'].includes(item.state) &&
|
||||
!!item.preview_token;
|
||||
const rows = preview?.items ?? [];
|
||||
const count = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
).length;
|
||||
|
||||
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
|
||||
// Unknown server codes use localized fallbacks, never raw upstream messages.
|
||||
const key = migrationIssueKey(issue.code);
|
||||
const fallback = t(
|
||||
warning
|
||||
? 'pipelineMigration.warningFallback'
|
||||
: 'pipelineMigration.blockerFallback',
|
||||
);
|
||||
const message = key
|
||||
? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback })
|
||||
: fallback;
|
||||
const field = safeField(issue.field);
|
||||
return (
|
||||
<span>
|
||||
{message}
|
||||
{field && (
|
||||
<>
|
||||
{' '}
|
||||
— <code className="text-xs">{field}</code>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function execute() {
|
||||
if (
|
||||
submitting.current ||
|
||||
!isCurrent() ||
|
||||
!canManage ||
|
||||
!valid ||
|
||||
loading ||
|
||||
!confirmed ||
|
||||
selected.length === 0 ||
|
||||
selected.length > 50
|
||||
)
|
||||
return;
|
||||
const items = rows
|
||||
.filter((item) => selected.includes(item.pipeline_uuid) && eligible(item))
|
||||
.map((item) => ({
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
preview_token: item.preview_token!,
|
||||
}));
|
||||
if (items.length !== selected.length) return;
|
||||
submitting.current = true;
|
||||
++previewGeneration.current;
|
||||
setStatus('submitting');
|
||||
setValid(false);
|
||||
setConfirmed(false);
|
||||
setResults(
|
||||
items.map((item) => ({
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending',
|
||||
code: null,
|
||||
})),
|
||||
);
|
||||
|
||||
const loseObservation = () => {
|
||||
if (!isCurrent()) return;
|
||||
submitting.current = false;
|
||||
setStatus('lost');
|
||||
void refresh(false);
|
||||
onCompleteRef.current();
|
||||
};
|
||||
try {
|
||||
const { task_id } = await httpClient.executePipelineMigration(
|
||||
{ confirmed: true, items },
|
||||
{ signal: controller.current?.signal },
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
setStatus('running');
|
||||
const poll = async () => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const task = await httpClient.getAsyncTask(task_id, {
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const metadata = task.task_context?.metadata;
|
||||
if (
|
||||
task.id !== task_id ||
|
||||
metadata?.kind !== 'pipeline_migration' ||
|
||||
!Array.isArray(metadata.results)
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const outcome = metadata.results as PipelineMigrationResult[];
|
||||
if (
|
||||
outcome.some(
|
||||
(item) =>
|
||||
!item ||
|
||||
!resultStates.has(item.state) ||
|
||||
!items.some(
|
||||
(submitted) => submitted.pipeline_uuid === item.pipeline_uuid,
|
||||
),
|
||||
) ||
|
||||
(task.runtime.done && outcome.length !== items.length) ||
|
||||
new Set(outcome.map((item) => item.pipeline_uuid)).size !==
|
||||
outcome.length
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const scopedResults = items.map(
|
||||
(item) =>
|
||||
outcome.find(
|
||||
(result) => result.pipeline_uuid === item.pipeline_uuid,
|
||||
) ?? {
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending' as const,
|
||||
code: null,
|
||||
},
|
||||
);
|
||||
setResults(scopedResults);
|
||||
if (task.runtime.done) {
|
||||
if (
|
||||
!task.runtime.exception &&
|
||||
scopedResults.some((item) => item.state === 'pending')
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus(task.runtime.exception ? 'failed' : 'finished');
|
||||
void refresh(false);
|
||||
onCompleteRef.current();
|
||||
} else {
|
||||
timer.current = setTimeout(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
loseObservation();
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
const code =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? error.code
|
||||
: undefined;
|
||||
if (code === -1 || code === -2 || code === undefined) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus('requestError');
|
||||
setSelected([]);
|
||||
}
|
||||
}
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!submitting.current) {
|
||||
setSelected([]);
|
||||
setConfirmed(false);
|
||||
if (next) void refresh(status === 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(count > 0 || previewError || results.length > 0) && (
|
||||
<Alert className="mb-4 shrink-0">
|
||||
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{previewError
|
||||
? t('pipelineMigration.previewError')
|
||||
: t('pipelineMigration.detected', { count })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => changeOpen(true)}
|
||||
>
|
||||
{t('pipelineMigration.review')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('pipelineMigration.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!canManage && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.readOnly')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{previewError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.previewError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{status !== 'idle' && (
|
||||
<Alert
|
||||
variant={
|
||||
status === 'failed' || status === 'requestError'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
<AlertDescription role="status">
|
||||
{t(`pipelineMigration.${status}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 pr-3">
|
||||
{rows.map((item) => (
|
||||
<div
|
||||
key={item.pipeline_uuid}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
aria-label={item.name}
|
||||
checked={selected.includes(item.pipeline_uuid)}
|
||||
disabled={
|
||||
!canManage ||
|
||||
busy ||
|
||||
loading ||
|
||||
!valid ||
|
||||
!eligible(item) ||
|
||||
(selected.length >= 50 &&
|
||||
!selected.includes(item.pipeline_uuid))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setConfirmed(false);
|
||||
setSelected((current) =>
|
||||
checked
|
||||
? [...current, item.pipeline_uuid]
|
||||
: current.filter((id) => id !== item.pipeline_uuid),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-sm break-words">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{item.legacy_runner ?? '—'} →{' '}
|
||||
{item.target_plugin?.name ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t(`pipelineMigration.states.${item.state}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.blockers.map((issue, index) => (
|
||||
<p className="text-sm text-destructive" key={`b-${index}`}>
|
||||
{renderIssue(issue)}
|
||||
</p>
|
||||
))}
|
||||
{item.warnings.map((issue, index) => (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
key={`w-${index}`}
|
||||
>
|
||||
{renderIssue(issue, true)}
|
||||
</p>
|
||||
))}
|
||||
{item.changed_paths.filter((path) => safeField(path)).length >
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.changedFields')}:{' '}
|
||||
{item.changed_paths
|
||||
.filter((path) => safeField(path))
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{item.state === 'activation_pending' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
item.preview_token
|
||||
? 'pipelineMigration.activationRetryHint'
|
||||
: 'pipelineMigration.activationHint',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{rows.some((item) => item.state === 'needs_plugin') && (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{t('pipelineMigration.pluginHint')}</p>
|
||||
<Button variant="link" asChild className="h-auto p-0">
|
||||
<a
|
||||
href="/home/extensions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('pipelineMigration.extensions')}
|
||||
</a>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label={t('pipelineMigration.results')}
|
||||
>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('pipelineMigration.results')}
|
||||
</h3>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
key={result.pipeline_uuid}
|
||||
data-testid={`migration-result-${result.pipeline_uuid}`}
|
||||
>
|
||||
<p>
|
||||
{rows.find(
|
||||
(item) => item.pipeline_uuid === result.pipeline_uuid,
|
||||
)?.name ?? result.pipeline_uuid}{' '}
|
||||
— {t(`pipelineMigration.states.${result.state}`)}
|
||||
</p>
|
||||
{result.code && (
|
||||
<p className="text-muted-foreground">
|
||||
{renderIssue({ code: result.code })}
|
||||
</p>
|
||||
)}
|
||||
{result.state === 'activation_pending' && (
|
||||
<p>{t('pipelineMigration.activationHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.selection', { count: selected.length })}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={confirmed}
|
||||
onCheckedChange={(checked) => setConfirmed(checked === true)}
|
||||
disabled={
|
||||
!canManage || busy || loading || !valid || !selected.length
|
||||
}
|
||||
/>
|
||||
{t('pipelineMigration.confirm')}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => changeOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || loading}
|
||||
onClick={() => {
|
||||
setStatus('idle');
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
{t('pipelineMigration.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!canManage ||
|
||||
busy ||
|
||||
loading ||
|
||||
!valid ||
|
||||
!selected.length ||
|
||||
!confirmed
|
||||
}
|
||||
onClick={() => {
|
||||
void execute();
|
||||
}}
|
||||
>
|
||||
{t('pipelineMigration.execute')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { preserveRunnerConfig } from './RunnerConfigPreservation';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { isCurrentPipelineConfig } from '../../pipeline-config-safety';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -67,6 +71,35 @@ import {
|
||||
type InstalledRunner,
|
||||
} from '@/app/home/agents/runner-marketplace';
|
||||
|
||||
/** A mount-scoped editing session: normalized defaults are UI state, not edits. */
|
||||
function PersistedRunnerForm(
|
||||
props: ComponentProps<typeof DynamicFormComponent>,
|
||||
) {
|
||||
const initialValues = useRef(props.initialValues);
|
||||
const previous = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
const current = useRef(props.initialValues || {});
|
||||
current.current = props.initialValues || {};
|
||||
return (
|
||||
<DynamicFormComponent
|
||||
{...props}
|
||||
initialValues={initialValues.current}
|
||||
onSubmit={(values) => {
|
||||
const emitted = values as Record<string, unknown>;
|
||||
const next = preserveRunnerConfig(
|
||||
current.current,
|
||||
previous.current,
|
||||
emitted,
|
||||
);
|
||||
previous.current = structuredClone(emitted);
|
||||
if (JSON.stringify(next) !== JSON.stringify(current.current)) {
|
||||
current.current = next;
|
||||
props.onSubmit?.(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineFormComponentProps {
|
||||
pipelineId?: string;
|
||||
isEditMode: boolean;
|
||||
@@ -78,6 +111,7 @@ interface PipelineFormComponentProps {
|
||||
onCancel?: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
onLegacyPipeline?: (pipeline: Pipeline) => void;
|
||||
}
|
||||
|
||||
export interface PipelineFormHandle {
|
||||
@@ -103,6 +137,7 @@ const PipelineFormComponent = forwardRef<
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
onLegacyPipeline,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -111,7 +146,10 @@ const PipelineFormComponent = forwardRef<
|
||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const pipelineFormElement = useRef<HTMLFormElement>(null);
|
||||
const isSavingRef = useRef(false);
|
||||
const legacyConfigRef = useRef(false);
|
||||
const [legacyConfig, setLegacyConfig] = useState(false);
|
||||
|
||||
const formSchema = isEditMode
|
||||
? z.object({
|
||||
@@ -291,6 +329,12 @@ const PipelineFormComponent = forwardRef<
|
||||
.getPipeline(pipelineId || '')
|
||||
.then((resp: GetPipelineResponseData) => {
|
||||
if (cancelled) return;
|
||||
if (!isCurrentPipelineConfig(resp.pipeline.config)) {
|
||||
legacyConfigRef.current = true;
|
||||
setLegacyConfig(true);
|
||||
onLegacyPipeline?.(resp.pipeline);
|
||||
return;
|
||||
}
|
||||
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
|
||||
|
||||
const loadedValues = {
|
||||
@@ -316,7 +360,7 @@ const PipelineFormComponent = forwardRef<
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, isEditMode, pipelineId, loadAttempt]);
|
||||
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -404,7 +448,15 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
async function handleModify(values: FormValues): Promise<boolean> {
|
||||
if (isSavingRef.current) return false;
|
||||
// Imperative saves bypass native form submission validation. In particular,
|
||||
// never save an old structured value while its visible JSON draft is invalid.
|
||||
if (
|
||||
pipelineFormElement.current &&
|
||||
!pipelineFormElement.current.checkValidity()
|
||||
)
|
||||
return false;
|
||||
if (isSavingRef.current || legacyConfigRef.current || !pipelineLoaded)
|
||||
return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
const realConfig = {
|
||||
ai: values.ai,
|
||||
@@ -468,6 +520,7 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (legacyConfigRef.current || !pipelineLoaded) return false;
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current || !isEditMode) return false;
|
||||
const valid = await form.trigger();
|
||||
@@ -488,7 +541,9 @@ const PipelineFormComponent = forwardRef<
|
||||
values: object,
|
||||
) {
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
const isFirstEmission =
|
||||
!initializedStagesRef.current.has(stageKey) &&
|
||||
!(formName === 'output' && stageName === 'misc');
|
||||
|
||||
const currentValues =
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
@@ -524,6 +579,29 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
form.setValue(formName, nextValues);
|
||||
if (formName === 'output' && stageName === 'misc') {
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = (
|
||||
currentValues.misc as Record<string, unknown> | undefined
|
||||
)?.['remove-think'];
|
||||
const runnerId = form.getValues('ai.runner.id') as string;
|
||||
const supported = aiConfigTabSchema?.stages.some(
|
||||
(stage) =>
|
||||
stage.name === runnerId &&
|
||||
stage.config.some((item) => item.name === 'remove-think'),
|
||||
);
|
||||
if (
|
||||
supported &&
|
||||
typeof removeThink === 'boolean' &&
|
||||
removeThink !== previousThink
|
||||
) {
|
||||
const configs = form.getValues('ai.runner_config') || {};
|
||||
form.setValue('ai.runner_config', {
|
||||
...configs,
|
||||
[runnerId]: { ...configs[runnerId], 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
@@ -539,22 +617,23 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
function handleRunnerConfigEmit(stageName: string, values: object) {
|
||||
const stageKey = `ai.runner_config.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentRunnerConfigs =
|
||||
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
|
||||
(form.getValues('ai.runner_config') as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>) || {};
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = currentRunnerConfigs[stageName]?.['remove-think'];
|
||||
form.setValue('ai.runner_config', {
|
||||
...currentRunnerConfigs,
|
||||
[stageName]: values,
|
||||
});
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
const currentSnapshot = JSON.stringify(form.getValues());
|
||||
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
|
||||
savedSnapshotRef.current = currentSnapshot;
|
||||
}
|
||||
if (typeof removeThink === 'boolean' && removeThink !== previousThink) {
|
||||
const output = form.getValues('output') || {};
|
||||
form.setValue('output', {
|
||||
...output,
|
||||
misc: { ...output.misc, 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +710,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<PersistedRunnerForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -645,6 +724,10 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
}
|
||||
|
||||
const StageForm =
|
||||
formName === 'output' && stage.name === 'misc'
|
||||
? PersistedRunnerForm
|
||||
: DynamicFormComponent;
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
|
||||
@@ -659,7 +742,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<StageForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -711,6 +794,12 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
if (legacyConfig)
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{t('pipelineMigration.legacyGate')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (loadFailed)
|
||||
return (
|
||||
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||
@@ -723,6 +812,7 @@ const PipelineFormComponent = forwardRef<
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="pipeline-form"
|
||||
ref={pipelineFormElement}
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
@@ -920,8 +1010,11 @@ const PipelineFormComponent = forwardRef<
|
||||
{/* Dynamic config sections (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
{activeSection === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema && (
|
||||
<div
|
||||
className="space-y-6"
|
||||
hidden={activeSection !== 'ai'}
|
||||
>
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type Values = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is Values =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Apply only edits relative to the mounted form's normalized emission.
|
||||
* The raw persisted object remains authoritative for unedited values, including
|
||||
* fields not represented by the schema, nulls, omitted defaults and whitespace.
|
||||
*/
|
||||
export function preserveRunnerConfig(
|
||||
current: Values,
|
||||
previous: Values | undefined,
|
||||
emitted: Values,
|
||||
): Values {
|
||||
if (!previous) return current;
|
||||
const result = { ...current };
|
||||
for (const key of new Set([
|
||||
...Object.keys(previous),
|
||||
...Object.keys(emitted),
|
||||
])) {
|
||||
if (JSON.stringify(previous[key]) === JSON.stringify(emitted[key]))
|
||||
continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(emitted, key)) {
|
||||
delete result[key];
|
||||
} else if (isRecord(previous[key]) && isRecord(emitted[key])) {
|
||||
result[key] = preserveRunnerConfig(
|
||||
isRecord(current[key]) ? current[key] : {},
|
||||
previous[key],
|
||||
emitted[key],
|
||||
);
|
||||
} else {
|
||||
result[key] = emitted[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,19 +1,44 @@
|
||||
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 PipelineDetailContent from './PipelineDetailContent';
|
||||
import PipelineMigration, { migrationWorkspaceKey } from './PipelineMigration';
|
||||
|
||||
export default function PipelineConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <PipelineDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<PipelineDetailContent
|
||||
key={`${scopeKey}:${detailId}:${revision}`}
|
||||
id={detailId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Only unambiguous current containers may hydrate editor defaults. */
|
||||
export function isCurrentPipelineConfig(config: unknown): boolean {
|
||||
if (!record(config) || !record(config.ai) || !record(config.ai.runner))
|
||||
return false;
|
||||
const ai = config.ai;
|
||||
const runner = ai.runner as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(ai).some((key) => !['runner', 'runner_config'].includes(key)) ||
|
||||
Object.keys(runner).some((key) => !['id', 'expire-time'].includes(key)) ||
|
||||
typeof runner.id !== 'string'
|
||||
)
|
||||
return false;
|
||||
|
||||
// The canonical current default has no selected runner yet. Do not confuse
|
||||
// it with a legacy selector or an orphaned configuration needing review.
|
||||
if (runner.id === '')
|
||||
return (
|
||||
record(ai.runner_config) && Object.keys(ai.runner_config).length === 0
|
||||
);
|
||||
|
||||
return runner.id.startsWith('plugin:');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const issueKeys: Record<string, string> = {
|
||||
'local.context_defaults': 'contextDefaults',
|
||||
'local.model_reasoning': 'modelReasoning',
|
||||
'local.serial_tools_preserved': 'serialTools',
|
||||
'local.retrieval_defaults': 'retrievalDefaults',
|
||||
'local.box_state_reset': 'boxReset',
|
||||
'coze.persistent_history': 'persistentHistory',
|
||||
'langflow.persistent_history': 'persistentHistory',
|
||||
'langflow.tweaks_default': 'tweaksDefault',
|
||||
'dify.timeout_default': 'timeoutDefault',
|
||||
'n8n.session_ids_reset': 'historyReset',
|
||||
'weknora.session_title_changed': 'sessionTitle',
|
||||
'migration.alias_repaired': 'aliasRepaired',
|
||||
'migration.null_default': 'nullDefault',
|
||||
'migration.output_policy_copied': 'outputPolicy',
|
||||
'migration.filtered_variables': 'filteredVariables',
|
||||
'migration.history_reset': 'historyReset',
|
||||
'migration.identity_preserved': 'identityPreserved',
|
||||
'migration.new_defaults': 'newDefaults',
|
||||
'migration.legacy_sections_archived': 'legacyArchive',
|
||||
'external.state_validation_required': 'externalState',
|
||||
'runtime.plugin_version_mismatch': 'pluginVersion',
|
||||
'runtime.schema_invalid': 'schemaChanged',
|
||||
'runtime.schema_missing': 'schemaChanged',
|
||||
'extensions.runner_excluded': 'runnerExcluded',
|
||||
'local.box_scope': 'boxScope',
|
||||
'runtime.pending_interaction': 'pendingInteraction',
|
||||
};
|
||||
|
||||
/** Only locally reviewed codes may select localized copy. */
|
||||
export function migrationIssueKey(code: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(issueKeys, code)
|
||||
? issueKeys[code]
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type PipelineMigrationState =
|
||||
| 'ready'
|
||||
| 'needs_plugin'
|
||||
| 'blocked'
|
||||
| 'already_current'
|
||||
| 'not_legacy'
|
||||
| 'activation_pending';
|
||||
|
||||
export interface PipelineMigrationIssue {
|
||||
code: string;
|
||||
field?: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationItem {
|
||||
pipeline_uuid: string;
|
||||
name: string;
|
||||
state: PipelineMigrationState;
|
||||
legacy_runner: string | null;
|
||||
target_runner_id: string | null;
|
||||
target_plugin: { author: string; name: string; version: string } | null;
|
||||
changed_paths: string[];
|
||||
blockers: PipelineMigrationIssue[];
|
||||
warnings: PipelineMigrationIssue[];
|
||||
preview_token: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationPreview {
|
||||
planner_version: string;
|
||||
workspace_uuid: string;
|
||||
items: PipelineMigrationItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationRequest {
|
||||
confirmed: true;
|
||||
items: { pipeline_uuid: string; preview_token: string }[];
|
||||
}
|
||||
|
||||
export interface PipelineMigrationResult {
|
||||
pipeline_uuid: string;
|
||||
state:
|
||||
| 'pending'
|
||||
| 'migrated'
|
||||
| 'already_current'
|
||||
| 'blocked'
|
||||
| 'stale'
|
||||
| 'failed'
|
||||
| 'activation_pending';
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationTaskMetadata {
|
||||
kind: 'pipeline_migration';
|
||||
results: PipelineMigrationResult[];
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import type {
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationRequest,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
@@ -441,6 +445,19 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/pipelines/${uuid}`);
|
||||
}
|
||||
|
||||
public getPipelineMigrationPreview(
|
||||
config?: RequestConfig,
|
||||
): Promise<PipelineMigrationPreview> {
|
||||
return this.get('/api/v1/pipelines/_/migration/preview', undefined, config);
|
||||
}
|
||||
|
||||
public executePipelineMigration(
|
||||
body: PipelineMigrationRequest,
|
||||
config?: RequestConfig,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
|
||||
}
|
||||
|
||||
public createPipeline(pipeline: Pipeline): Promise<{
|
||||
uuid: string;
|
||||
}> {
|
||||
@@ -1344,8 +1361,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
public getAsyncTask(id: number): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`);
|
||||
public getAsyncTask(id: number, config?: RequestConfig): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`, undefined, config);
|
||||
}
|
||||
|
||||
public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/en-US';
|
||||
|
||||
const enUS = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'Home',
|
||||
extensions: 'Extensions',
|
||||
@@ -260,6 +263,7 @@ const enUS = {
|
||||
reasoningAbility: 'Reasoning',
|
||||
reasoningLevel: 'Reasoning level',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'Use model setting',
|
||||
providerDefault: 'Provider default',
|
||||
disabled: 'Off',
|
||||
enabled: 'On',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/es-ES';
|
||||
|
||||
const esES = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'Inicio',
|
||||
extensions: 'Extensiones',
|
||||
@@ -261,6 +264,7 @@ const esES = {
|
||||
reasoningAbility: 'Razonamiento',
|
||||
reasoningLevel: 'Nivel de razonamiento',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'Usar ajuste del modelo',
|
||||
providerDefault: 'Predeterminado del proveedor',
|
||||
disabled: 'Desactivado',
|
||||
enabled: 'Activado',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/ja-JP';
|
||||
|
||||
const jaJP = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'ホーム',
|
||||
extensions: '拡張機能',
|
||||
@@ -265,6 +268,7 @@ const jaJP = {
|
||||
reasoningAbility: '推論',
|
||||
reasoningLevel: '推論レベル',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'モデル設定を使用',
|
||||
providerDefault: 'Provider デフォルト',
|
||||
disabled: 'オフ',
|
||||
enabled: 'オン',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.',
|
||||
contextDefaults:
|
||||
'History will use the new context budget and summarization defaults instead of a fixed round limit.',
|
||||
modelReasoning:
|
||||
'Per-model reasoning settings are preserved and applied by the host.',
|
||||
serialTools: 'Tool calls remain sequential after migration.',
|
||||
retrievalDefaults:
|
||||
'Retrieval uses the new top-k and result limits. Review them after migration.',
|
||||
boxReset:
|
||||
'Existing Box session state is not transferred; a new isolated session will be created.',
|
||||
persistentHistory:
|
||||
'New conversations use persistent, isolated history. Existing remote history is not imported.',
|
||||
tweaksDefault: 'Langflow tweaks default to an empty object.',
|
||||
timeoutDefault: 'Dify uses a 30-second request timeout by default.',
|
||||
historyReset:
|
||||
'Existing conversation history and session identifiers are not transferred. New conversations start after migration.',
|
||||
sessionTitle: 'New WeKnora sessions use titles generated by the plugin.',
|
||||
aliasRepaired: 'The old field name is mapped to its supported replacement.',
|
||||
nullDefault: 'This empty value uses the new plugin default.',
|
||||
outputPolicy:
|
||||
'The existing reasoning-output visibility setting is preserved.',
|
||||
filteredVariables:
|
||||
'Only supported variables are transferred; reserved variables are supplied by the conversation context.',
|
||||
identityPreserved: 'The provider user identity keeps its legacy source.',
|
||||
newDefaults:
|
||||
'New plugin options use their documented defaults; existing supported values are retained.',
|
||||
externalState:
|
||||
'Complete or cancel pending remote interactions before migration; their live state is not transferred.',
|
||||
pluginVersion:
|
||||
'Install the required plugin version, then refresh. Older versions are not compatible with this migration.',
|
||||
schemaChanged:
|
||||
'The installed runner configuration does not match the migration target. Check the plugin version and refresh.',
|
||||
runnerExcluded:
|
||||
'This pipeline excludes the required runner plugin. Update its extension settings first.',
|
||||
boxScope:
|
||||
'A custom Box session template cannot be migrated safely. Remove it or review the isolation requirements first.',
|
||||
pendingInteraction:
|
||||
'A conversation is waiting for input. Complete or cancel it before migration.',
|
||||
},
|
||||
title: 'Pipeline migration',
|
||||
description:
|
||||
'Nothing changes until you select pipelines and explicitly confirm. Migration preserves pipeline identity and unrelated settings.',
|
||||
detected: '{{count}} pipelines need migration review.',
|
||||
review: 'Review migration',
|
||||
readOnly: 'Only workspace managers can migrate pipelines.',
|
||||
previewError: 'Preview unavailable. Refresh to check again.',
|
||||
submitting: 'Submitting selected pipelines…',
|
||||
running:
|
||||
'Migration is running. Closing this dialog does not cancel the task.',
|
||||
finished: 'Task finished. Check each pipeline result below.',
|
||||
failed: 'Task failed. Check each pipeline result below.',
|
||||
lost: 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
|
||||
requestError:
|
||||
'Request not completed. Refresh the preview before selecting again.',
|
||||
warningFallback: 'Review this setting before migration.',
|
||||
blockerFallback:
|
||||
'This setting or runtime condition cannot be migrated safely. Resolve it and refresh the preview.',
|
||||
changedFields: 'Changed fields',
|
||||
activationHint:
|
||||
'Configuration was saved, but activation is pending. Ask a workspace administrator to check runtime availability, then refresh. Do not rerun migration blindly.',
|
||||
pluginHint:
|
||||
'Install or enable the required runner plugin through Extensions / Extension Market. Review your workspace extension quota there; this assistant never installs plugins. Return here and refresh the preview.',
|
||||
extensions: 'Open Extensions',
|
||||
results: 'Per-pipeline results',
|
||||
selection: '{{count}} selected (maximum 50)',
|
||||
confirm: 'I confirm migration of the selected pipelines.',
|
||||
refresh: 'Refresh preview',
|
||||
execute: 'Migrate selected',
|
||||
legacyGate:
|
||||
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
|
||||
states: {
|
||||
ready: 'Ready',
|
||||
needs_plugin: 'Plugin required',
|
||||
blocked: 'Blocked',
|
||||
already_current: 'Already current',
|
||||
not_legacy: 'Not legacy',
|
||||
activation_pending: 'Activation pending',
|
||||
pending: 'Pending',
|
||||
migrated: 'Migrated',
|
||||
stale: 'Stale preview',
|
||||
failed: 'Failed',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'La configuración activa contendrá solo el Runner seleccionado. Todos los ajustes anteriores, incluidos los inactivos, se conservan en la copia de seguridad de migración.',
|
||||
contextDefaults:
|
||||
'El historial usará el nuevo presupuesto de contexto y los valores de resumen, en lugar de un límite fijo de turnos.',
|
||||
modelReasoning:
|
||||
'Se conservan los ajustes de razonamiento por modelo y el host los aplica.',
|
||||
serialTools: 'Las herramientas seguirán ejecutándose de forma secuencial.',
|
||||
retrievalDefaults:
|
||||
'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.',
|
||||
boxReset:
|
||||
'No se transfiere el estado de Box; se creará una nueva sesión aislada.',
|
||||
persistentHistory:
|
||||
'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.',
|
||||
tweaksDefault:
|
||||
'Langflow tweaks usa un objeto vacío de forma predeterminada.',
|
||||
timeoutDefault:
|
||||
'Dify usa un tiempo de espera de 30 segundos de forma predeterminada.',
|
||||
historyReset:
|
||||
'No se transfieren el historial ni los identificadores de sesión. Después de migrar se iniciarán conversaciones nuevas.',
|
||||
sessionTitle:
|
||||
'Las nuevas sesiones de WeKnora usan títulos generados por el plugin.',
|
||||
aliasRepaired:
|
||||
'El nombre antiguo del campo se sustituye por el nombre admitido.',
|
||||
nullDefault:
|
||||
'Este valor vacío usará el valor predeterminado del nuevo plugin.',
|
||||
outputPolicy:
|
||||
'Se conserva la visibilidad actual del contenido de razonamiento.',
|
||||
filteredVariables:
|
||||
'Solo se transfieren variables admitidas; el contexto de la conversación proporciona las variables reservadas.',
|
||||
identityPreserved:
|
||||
'La identidad de usuario del proveedor conserva su origen anterior.',
|
||||
newDefaults:
|
||||
'Las opciones nuevas usan los valores predeterminados documentados; se conservan los valores anteriores admitidos.',
|
||||
externalState:
|
||||
'Completa o cancela las interacciones remotas pendientes antes de migrar; su estado activo no se transfiere.',
|
||||
pluginVersion:
|
||||
'Instala la versión requerida del plugin y actualiza. Las versiones anteriores no admiten esta migración.',
|
||||
schemaChanged:
|
||||
'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.',
|
||||
runnerExcluded:
|
||||
'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.',
|
||||
boxScope:
|
||||
'La plantilla personalizada de sesión de Box no se puede migrar de forma segura. Elimínala o revisa los requisitos de aislamiento.',
|
||||
pendingInteraction:
|
||||
'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.',
|
||||
},
|
||||
title: 'Migración de pipelines',
|
||||
description:
|
||||
'No se cambia nada hasta seleccionar pipelines y confirmar explícitamente. Se conservan su identidad y los ajustes no relacionados.',
|
||||
detected: '{{count}} pipelines requieren revisión.',
|
||||
review: 'Revisar migración',
|
||||
readOnly: 'Solo los gestores del espacio pueden migrar pipelines.',
|
||||
previewError: 'Vista previa no disponible. Actualícela.',
|
||||
submitting: 'Enviando pipelines seleccionados…',
|
||||
running: 'Migración en curso. Cerrar el diálogo no cancela la tarea.',
|
||||
finished: 'Tarea finalizada. Revise el resultado de cada pipeline.',
|
||||
failed: 'La tarea falló. Revise el resultado de cada pipeline.',
|
||||
lost: 'Se perdió el seguimiento de la tarea; se desconoce el resultado. Actualice la vista previa antes de actuar.',
|
||||
requestError:
|
||||
'Solicitud no completada. Actualice la vista previa y vuelva a seleccionar.',
|
||||
warningFallback: 'Revise este ajuste antes de migrar.',
|
||||
blockerFallback:
|
||||
'Este ajuste o estado de ejecución no se puede migrar de forma segura. Resuélvalo y actualice la vista previa.',
|
||||
changedFields: 'Campos modificados',
|
||||
activationHint:
|
||||
'Configuración guardada, pero activación pendiente. Pida al administrador que compruebe el entorno y actualice. No repita la migración a ciegas.',
|
||||
pluginHint:
|
||||
'Instale o active el plugin requerido desde Extensiones / Mercado de extensiones y compruebe la cuota del espacio. Este asistente nunca instala plugins. Al volver, actualice la vista previa.',
|
||||
extensions: 'Abrir Extensiones',
|
||||
results: 'Resultados por pipeline',
|
||||
selection: '{{count}} seleccionados (máximo 50)',
|
||||
confirm: 'Confirmo la migración de los pipelines seleccionados.',
|
||||
refresh: 'Actualizar vista previa',
|
||||
execute: 'Migrar seleccionados',
|
||||
legacyGate:
|
||||
'La configuración antigua es de solo lectura hasta la migración. Guardar y depurar no están disponibles para evitar una conversión implícita.',
|
||||
states: {
|
||||
ready: 'Listo',
|
||||
needs_plugin: 'Plugin requerido',
|
||||
blocked: 'Bloqueado',
|
||||
already_current: 'Ya actualizado',
|
||||
not_legacy: 'No es antiguo',
|
||||
activation_pending: 'Activación pendiente',
|
||||
pending: 'Pendiente',
|
||||
migrated: 'Migrado',
|
||||
stale: 'Vista previa caducada',
|
||||
failed: 'Fallido',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。',
|
||||
contextDefaults:
|
||||
'履歴には固定の往復数制限ではなく、新しいコンテキスト予算と要約の既定値を適用します。',
|
||||
modelReasoning: 'モデル別の推論設定を保持し、ホスト側で適用します。',
|
||||
serialTools: '移行後もツール呼び出しは順番に実行します。',
|
||||
retrievalDefaults:
|
||||
'検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。',
|
||||
boxReset:
|
||||
'既存の Box セッション状態は引き継がず、新しい分離セッションを作成します。',
|
||||
persistentHistory:
|
||||
'新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。',
|
||||
tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。',
|
||||
timeoutDefault: 'Dify のリクエストタイムアウトは既定で 30 秒です。',
|
||||
historyReset:
|
||||
'既存の会話履歴とセッション ID は引き継ぎません。移行後は新しい会話を開始します。',
|
||||
sessionTitle:
|
||||
'新しい WeKnora セッションにはプラグインが生成したタイトルを使用します。',
|
||||
aliasRepaired: '旧フィールド名を現在サポートされている名前に置き換えます。',
|
||||
nullDefault: 'この空の値には新しいプラグインの既定値を使用します。',
|
||||
outputPolicy: '推論内容の表示設定は保持します。',
|
||||
filteredVariables:
|
||||
'対応する変数のみ移行します。予約変数は会話コンテキストから提供します。',
|
||||
identityPreserved:
|
||||
'サービス側のユーザー識別子には従来と同じ情報源を使用します。',
|
||||
newDefaults:
|
||||
'追加された設定には文書化された既定値を使い、対応する既存の値は保持します。',
|
||||
externalState:
|
||||
'移行前に待機中のリモート操作を完了またはキャンセルしてください。実行状態は引き継ぎません。',
|
||||
pluginVersion:
|
||||
'必要なバージョンのプラグインをインストールして再読み込みしてください。旧バージョンは移行に対応していません。',
|
||||
schemaChanged:
|
||||
'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。',
|
||||
runnerExcluded:
|
||||
'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。',
|
||||
boxScope:
|
||||
'独自の Box セッションテンプレートは安全に移行できません。削除するか、分離要件を確認してください。',
|
||||
pendingInteraction:
|
||||
'入力待ちの会話があります。完了またはキャンセルしてから移行してください。',
|
||||
},
|
||||
title: 'パイプライン移行',
|
||||
description:
|
||||
'パイプラインを選択し、明示的に確認するまで変更されません。識別情報と無関係な設定は保持されます。',
|
||||
detected: '{{count}} 件のパイプラインに移行確認が必要です。',
|
||||
review: '移行を確認',
|
||||
readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。',
|
||||
previewError: 'プレビューを取得できません。更新してください。',
|
||||
submitting: '選択したパイプラインを送信中…',
|
||||
running: '移行中です。ダイアログを閉じてもタスクは停止しません。',
|
||||
finished: 'タスクが終了しました。各パイプラインの結果を確認してください。',
|
||||
failed: 'タスクが失敗しました。各パイプラインの結果を確認してください。',
|
||||
lost: 'タスクの追跡が失われ、結果は不明です。次の操作前にプレビューを更新してください。',
|
||||
requestError:
|
||||
'リクエストを完了できませんでした。プレビューを更新して再選択してください。',
|
||||
warningFallback: '移行前にこの設定を確認してください。',
|
||||
blockerFallback:
|
||||
'この設定または実行状態は安全に移行できません。解決してプレビューを更新してください。',
|
||||
changedFields: '変更フィールド',
|
||||
activationHint:
|
||||
'設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。',
|
||||
pluginHint:
|
||||
'「拡張機能 / 拡張機能マーケット」で必要なランナープラグインをインストールまたは有効化し、ワークスペースの上限を確認してください。このアシスタントはインストールしません。戻った後にプレビューを更新してください。',
|
||||
extensions: '拡張機能を開く',
|
||||
results: 'パイプラインごとの結果',
|
||||
selection: '{{count}} 件選択(最大 50 件)',
|
||||
confirm: '選択したパイプラインの移行を確認します。',
|
||||
refresh: 'プレビューを更新',
|
||||
execute: '選択項目を移行',
|
||||
legacyGate:
|
||||
'旧設定は移行まで読み取り専用です。暗黙の変換を防ぐため、保存とデバッグは使用できません。',
|
||||
states: {
|
||||
ready: '移行可能',
|
||||
needs_plugin: 'プラグインが必要',
|
||||
blocked: '移行不可',
|
||||
already_current: '移行済みの形式',
|
||||
not_legacy: '旧形式ではない',
|
||||
activation_pending: '有効化待ち',
|
||||
pending: '待機中',
|
||||
migrated: '移行済み',
|
||||
stale: 'プレビューが古い',
|
||||
failed: '失敗',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.',
|
||||
contextDefaults:
|
||||
'Вместо фиксированного числа раундов история будет использовать новый бюджет контекста и настройки суммаризации.',
|
||||
modelReasoning:
|
||||
'Настройки рассуждений для каждой модели сохраняются и применяются хостом.',
|
||||
serialTools: 'Вызовы инструментов останутся последовательными.',
|
||||
retrievalDefaults:
|
||||
'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.',
|
||||
boxReset:
|
||||
'Состояние сеанса Box не переносится; будет создан новый изолированный сеанс.',
|
||||
persistentHistory:
|
||||
'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.',
|
||||
tweaksDefault:
|
||||
'Для Langflow tweaks по умолчанию используется пустой объект.',
|
||||
timeoutDefault: 'Тайм-аут запроса Dify по умолчанию составляет 30 секунд.',
|
||||
historyReset:
|
||||
'История и идентификаторы сеансов не переносятся. После миграции начнутся новые беседы.',
|
||||
sessionTitle:
|
||||
'Новые сеансы WeKnora используют заголовки, созданные плагином.',
|
||||
aliasRepaired: 'Старое имя поля заменяется поддерживаемым именем.',
|
||||
nullDefault:
|
||||
'Для этого пустого значения применяется значение нового плагина по умолчанию.',
|
||||
outputPolicy: 'Текущая настройка видимости рассуждений сохраняется.',
|
||||
filteredVariables:
|
||||
'Переносятся только поддерживаемые переменные; зарезервированные переменные задаются контекстом беседы.',
|
||||
identityPreserved:
|
||||
'Источник пользовательского идентификатора у провайдера остаётся прежним.',
|
||||
newDefaults:
|
||||
'Новые параметры используют документированные значения по умолчанию; поддерживаемые прежние значения сохраняются.',
|
||||
externalState:
|
||||
'До миграции завершите или отмените ожидающие удалённые взаимодействия: их текущее состояние не переносится.',
|
||||
pluginVersion:
|
||||
'Установите требуемую версию плагина и обновите список. Старые версии не поддерживают эту миграцию.',
|
||||
schemaChanged:
|
||||
'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.',
|
||||
runnerExcluded:
|
||||
'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.',
|
||||
boxScope:
|
||||
'Пользовательский шаблон сеанса Box нельзя безопасно перенести. Удалите его или проверьте требования изоляции.',
|
||||
pendingInteraction:
|
||||
'Беседа ожидает ввода. Завершите или отмените её перед миграцией.',
|
||||
},
|
||||
title: 'Миграция конвейеров',
|
||||
description:
|
||||
'Изменения выполняются только после выбора конвейеров и явного подтверждения. Идентификаторы и прочие настройки сохраняются.',
|
||||
detected: '{{count}} конвейеров требуют проверки миграции.',
|
||||
review: 'Проверить миграцию',
|
||||
readOnly:
|
||||
'Миграция доступна только пользователям с правами управления пространством.',
|
||||
previewError: 'Предпросмотр недоступен. Обновите его.',
|
||||
submitting: 'Отправка выбранных конвейеров…',
|
||||
running: 'Выполняется миграция. Закрытие окна не отменяет задачу.',
|
||||
finished: 'Задача завершена. Проверьте результат каждого конвейера.',
|
||||
failed: 'Задача завершилась ошибкой. Проверьте результат каждого конвейера.',
|
||||
lost: 'Наблюдение за задачей потеряно; результат неизвестен. Обновите предпросмотр перед дальнейшими действиями.',
|
||||
requestError: 'Запрос не завершён. Обновите предпросмотр и выберите заново.',
|
||||
warningFallback: 'Проверьте эту настройку перед миграцией.',
|
||||
blockerFallback:
|
||||
'Эту настройку или состояние среды нельзя безопасно перенести. Устраните проблему и обновите предпросмотр.',
|
||||
changedFields: 'Изменяемые поля',
|
||||
activationHint:
|
||||
'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.',
|
||||
pluginHint:
|
||||
'Установите или включите нужный плагин через «Расширения / Маркет» и проверьте квоту пространства. Этот помощник не устанавливает плагины. Вернувшись, обновите предпросмотр.',
|
||||
extensions: 'Открыть расширения',
|
||||
results: 'Результаты по конвейерам',
|
||||
selection: 'Выбрано {{count}} (максимум 50)',
|
||||
confirm: 'Подтверждаю миграцию выбранных конвейеров.',
|
||||
refresh: 'Обновить предпросмотр',
|
||||
execute: 'Перенести выбранные',
|
||||
legacyGate:
|
||||
'Старая конфигурация доступна только для чтения до миграции. Сохранение и отладка отключены во избежание неявного преобразования.',
|
||||
states: {
|
||||
ready: 'Готов',
|
||||
needs_plugin: 'Нужен плагин',
|
||||
blocked: 'Заблокирован',
|
||||
already_current: 'Уже актуален',
|
||||
not_legacy: 'Не устаревший',
|
||||
activation_pending: 'Ожидает активации',
|
||||
pending: 'Ожидает',
|
||||
migrated: 'Перенесён',
|
||||
stale: 'Предпросмотр устарел',
|
||||
failed: 'Ошибка',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย',
|
||||
contextDefaults:
|
||||
'ประวัติจะใช้งบประมาณบริบทและค่าการสรุปใหม่ แทนการจำกัดจำนวนรอบแบบตายตัว',
|
||||
modelReasoning:
|
||||
'การตั้งค่าการให้เหตุผลของแต่ละโมเดลจะถูกเก็บไว้และใช้โดยโฮสต์',
|
||||
serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ',
|
||||
retrievalDefaults:
|
||||
'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล',
|
||||
boxReset: 'สถานะเซสชัน Box เดิมจะไม่ถูกย้าย ระบบจะสร้างเซสชันแยกใหม่',
|
||||
persistentHistory:
|
||||
'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม',
|
||||
tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น',
|
||||
timeoutDefault: 'Dify ใช้เวลารอคำขอเริ่มต้น 30 วินาที',
|
||||
historyReset:
|
||||
'ประวัติและรหัสเซสชันเดิมจะไม่ถูกย้าย ระบบจะเริ่มบทสนทนาใหม่หลังย้ายข้อมูล',
|
||||
sessionTitle: 'เซสชัน WeKnora ใหม่จะใช้ชื่อที่ปลั๊กอินสร้าง',
|
||||
aliasRepaired: 'ชื่อฟิลด์เดิมจะเปลี่ยนเป็นชื่อที่รองรับในปัจจุบัน',
|
||||
nullDefault: 'ค่าว่างนี้จะใช้ค่าเริ่มต้นของปลั๊กอินใหม่',
|
||||
outputPolicy: 'การตั้งค่าการแสดงเนื้อหาการให้เหตุผลเดิมจะถูกเก็บไว้',
|
||||
filteredVariables:
|
||||
'ย้ายเฉพาะตัวแปรที่รองรับ ส่วนตัวแปรสงวนจะมาจากบริบทการสนทนา',
|
||||
identityPreserved: 'ข้อมูลระบุตัวผู้ใช้ของผู้ให้บริการยังใช้แหล่งเดิม',
|
||||
newDefaults: 'ตัวเลือกใหม่ใช้ค่าเริ่มต้นตามเอกสาร และคงค่าเดิมที่รองรับไว้',
|
||||
externalState:
|
||||
'โปรดดำเนินการหรือยกเลิกการโต้ตอบระยะไกลที่ค้างอยู่ก่อนย้ายข้อมูล สถานะที่กำลังทำงานจะไม่ถูกย้าย',
|
||||
pluginVersion:
|
||||
'ติดตั้งปลั๊กอินเวอร์ชันที่กำหนดแล้วรีเฟรช เวอร์ชันเก่าไม่รองรับการย้ายข้อมูลนี้',
|
||||
schemaChanged:
|
||||
'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช',
|
||||
runnerExcluded:
|
||||
'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน',
|
||||
boxScope:
|
||||
'ไม่สามารถย้ายเทมเพลตเซสชัน Box แบบกำหนดเองได้อย่างปลอดภัย โปรดลบหรือทบทวนข้อกำหนดการแยกเซสชัน',
|
||||
pendingInteraction:
|
||||
'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล',
|
||||
},
|
||||
title: 'ย้ายไปป์ไลน์',
|
||||
description:
|
||||
'จะไม่มีการเปลี่ยนแปลงจนกว่าคุณจะเลือกไปป์ไลน์และยืนยันอย่างชัดเจน โดยคงตัวตนและการตั้งค่าอื่นไว้',
|
||||
detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย',
|
||||
review: 'ตรวจสอบการย้าย',
|
||||
readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้',
|
||||
previewError: 'ไม่สามารถดูตัวอย่างได้ โปรดรีเฟรช',
|
||||
submitting: 'กำลังส่งไปป์ไลน์ที่เลือก…',
|
||||
running: 'กำลังย้าย การปิดหน้าต่างไม่ยกเลิกงาน',
|
||||
finished: 'งานสิ้นสุดแล้ว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
|
||||
failed: 'งานล้มเหลว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
|
||||
lost: 'ไม่สามารถติดตามงานได้ ไม่ทราบผลลัพธ์ โปรดรีเฟรชตัวอย่างก่อนดำเนินการต่อ',
|
||||
requestError: 'คำขอไม่เสร็จสมบูรณ์ โปรดรีเฟรชตัวอย่างและเลือกใหม่',
|
||||
warningFallback: 'ตรวจสอบการตั้งค่านี้ก่อนย้าย',
|
||||
blockerFallback:
|
||||
'ไม่สามารถย้ายการตั้งค่าหรือสถานะรันนี้ได้อย่างปลอดภัย โปรดแก้ไขแล้วรีเฟรชตัวอย่าง',
|
||||
changedFields: 'ฟิลด์ที่เปลี่ยน',
|
||||
activationHint:
|
||||
'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ',
|
||||
pluginHint:
|
||||
'ติดตั้งหรือเปิดใช้ปลั๊กอินรันเนอร์ผ่านส่วนขยาย / ตลาดส่วนขยาย และตรวจสอบโควตาพื้นที่ทำงานด้วยตนเอง ผู้ช่วยนี้ไม่ติดตั้งปลั๊กอิน เมื่อกลับมาโปรดรีเฟรชตัวอย่าง',
|
||||
extensions: 'เปิดส่วนขยาย',
|
||||
results: 'ผลลัพธ์แต่ละไปป์ไลน์',
|
||||
selection: 'เลือก {{count}} รายการ (สูงสุด 50)',
|
||||
confirm: 'ฉันยืนยันการย้ายไปป์ไลน์ที่เลือก',
|
||||
refresh: 'รีเฟรชตัวอย่าง',
|
||||
execute: 'ย้ายรายการที่เลือก',
|
||||
legacyGate:
|
||||
'การตั้งค่าเดิมอ่านได้อย่างเดียวจนกว่าจะย้าย ปิดการบันทึกและดีบักเพื่อป้องกันการแปลงโดยไม่ตั้งใจ',
|
||||
states: {
|
||||
ready: 'พร้อม',
|
||||
needs_plugin: 'ต้องการปลั๊กอิน',
|
||||
blocked: 'ถูกบล็อก',
|
||||
already_current: 'เป็นรุ่นใหม่แล้ว',
|
||||
not_legacy: 'ไม่ใช่รุ่นเก่า',
|
||||
activation_pending: 'รอเปิดใช้งาน',
|
||||
pending: 'รอดำเนินการ',
|
||||
migrated: 'ย้ายแล้ว',
|
||||
stale: 'ตัวอย่างล้าสมัย',
|
||||
failed: 'ล้มเหลว',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'Cấu hình hoạt động chỉ giữ Runner đã chọn. Tất cả thiết lập Runner cũ, kể cả các thiết lập không dùng, được giữ trong bản sao lưu di chuyển.',
|
||||
contextDefaults:
|
||||
'Lịch sử sẽ dùng ngân sách ngữ cảnh và thiết lập tóm tắt mới thay vì giới hạn số lượt cố định.',
|
||||
modelReasoning:
|
||||
'Thiết lập suy luận của từng mô hình được giữ lại và do máy chủ áp dụng.',
|
||||
serialTools: 'Các công cụ vẫn được gọi lần lượt sau khi di chuyển.',
|
||||
retrievalDefaults:
|
||||
'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.',
|
||||
boxReset:
|
||||
'Trạng thái phiên Box hiện tại không được chuyển; một phiên cách ly mới sẽ được tạo.',
|
||||
persistentHistory:
|
||||
'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.',
|
||||
tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.',
|
||||
timeoutDefault: 'Dify mặc định có thời gian chờ yêu cầu là 30 giây.',
|
||||
historyReset:
|
||||
'Lịch sử và mã phiên cũ không được chuyển. Hội thoại mới sẽ bắt đầu sau khi di chuyển.',
|
||||
sessionTitle: 'Phiên WeKnora mới dùng tiêu đề do plugin tạo.',
|
||||
aliasRepaired: 'Tên trường cũ được ánh xạ sang tên đang được hỗ trợ.',
|
||||
nullDefault: 'Giá trị trống này sẽ dùng giá trị mặc định của plugin mới.',
|
||||
outputPolicy: 'Thiết lập hiển thị nội dung suy luận hiện tại được giữ lại.',
|
||||
filteredVariables:
|
||||
'Chỉ chuyển các biến được hỗ trợ; biến dành riêng do ngữ cảnh hội thoại cung cấp.',
|
||||
identityPreserved:
|
||||
'Danh tính người dùng phía nhà cung cấp giữ nguyên nguồn cũ.',
|
||||
newDefaults:
|
||||
'Tùy chọn mới dùng giá trị mặc định đã mô tả; các giá trị cũ được hỗ trợ vẫn được giữ.',
|
||||
externalState:
|
||||
'Hoàn tất hoặc hủy các tương tác từ xa đang chờ trước khi di chuyển; trạng thái đang chạy không được chuyển.',
|
||||
pluginVersion:
|
||||
'Cài phiên bản plugin được yêu cầu rồi làm mới. Phiên bản cũ không hỗ trợ lần di chuyển này.',
|
||||
schemaChanged:
|
||||
'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.',
|
||||
runnerExcluded:
|
||||
'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.',
|
||||
boxScope:
|
||||
'Mẫu phiên Box tùy chỉnh không thể di chuyển an toàn. Hãy xóa mẫu hoặc kiểm tra yêu cầu cách ly.',
|
||||
pendingInteraction:
|
||||
'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.',
|
||||
},
|
||||
title: 'Di chuyển pipeline',
|
||||
description:
|
||||
'Chỉ thay đổi sau khi bạn chọn pipeline và xác nhận rõ ràng. Danh tính và các cài đặt không liên quan được giữ nguyên.',
|
||||
detected: '{{count}} pipeline cần xem xét di chuyển.',
|
||||
review: 'Xem xét di chuyển',
|
||||
readOnly:
|
||||
'Chỉ người có quyền quản lý không gian mới được di chuyển pipeline.',
|
||||
previewError: 'Không lấy được bản xem trước. Hãy làm mới.',
|
||||
submitting: 'Đang gửi các pipeline đã chọn…',
|
||||
running: 'Đang di chuyển. Đóng hộp thoại không hủy tác vụ.',
|
||||
finished: 'Tác vụ đã kết thúc. Hãy kiểm tra kết quả từng pipeline.',
|
||||
failed: 'Tác vụ thất bại. Hãy kiểm tra kết quả từng pipeline.',
|
||||
lost: 'Mất theo dõi tác vụ; chưa biết kết quả. Hãy làm mới bản xem trước trước khi thao tác tiếp.',
|
||||
requestError: 'Yêu cầu chưa hoàn tất. Làm mới bản xem trước rồi chọn lại.',
|
||||
warningFallback: 'Kiểm tra cài đặt này trước khi di chuyển.',
|
||||
blockerFallback:
|
||||
'Không thể di chuyển an toàn cài đặt hoặc trạng thái chạy này. Hãy xử lý rồi làm mới bản xem trước.',
|
||||
changedFields: 'Trường thay đổi',
|
||||
activationHint:
|
||||
'Cấu hình đã lưu nhưng đang chờ kích hoạt. Nhờ quản trị viên kiểm tra môi trường rồi làm mới. Không chạy lại di chuyển một cách mù quáng.',
|
||||
pluginHint:
|
||||
'Cài hoặc bật plugin runner cần thiết qua Tiện ích / Chợ tiện ích và kiểm tra hạn mức không gian. Trợ lý này không cài plugin. Khi quay lại, hãy làm mới bản xem trước.',
|
||||
extensions: 'Mở Tiện ích',
|
||||
results: 'Kết quả từng pipeline',
|
||||
selection: 'Đã chọn {{count}} (tối đa 50)',
|
||||
confirm: 'Tôi xác nhận di chuyển các pipeline đã chọn.',
|
||||
refresh: 'Làm mới bản xem trước',
|
||||
execute: 'Di chuyển đã chọn',
|
||||
legacyGate:
|
||||
'Cấu hình cũ chỉ đọc cho đến khi di chuyển. Lưu và gỡ lỗi bị tắt để tránh chuyển đổi ngầm.',
|
||||
states: {
|
||||
ready: 'Sẵn sàng',
|
||||
needs_plugin: 'Cần plugin',
|
||||
blocked: 'Bị chặn',
|
||||
already_current: 'Đã cập nhật',
|
||||
not_legacy: 'Không phải bản cũ',
|
||||
activation_pending: 'Chờ kích hoạt',
|
||||
pending: 'Đang chờ',
|
||||
migrated: 'Đã di chuyển',
|
||||
stale: 'Bản xem trước đã cũ',
|
||||
failed: 'Thất bại',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
|
||||
contextDefaults:
|
||||
'历史上下文将采用新的容量预算与摘要默认值,不再按固定轮数截断。',
|
||||
modelReasoning: '各模型的推理设置会保留,并由主程序应用。',
|
||||
serialTools: '迁移后工具调用仍按顺序执行。',
|
||||
retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。',
|
||||
boxReset: '现有 Box 会话状态不迁移,后续将创建新的隔离会话。',
|
||||
persistentHistory:
|
||||
'新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。',
|
||||
tweaksDefault: 'Langflow tweaks 默认设为空对象。',
|
||||
timeoutDefault: 'Dify 请求超时默认设为 30 秒。',
|
||||
historyReset: '原有会话历史和会话标识不迁移,迁移后开始新会话。',
|
||||
sessionTitle: '新的 WeKnora 会话使用插件生成的标题。',
|
||||
aliasRepaired: '旧字段名称将映射为当前支持的名称。',
|
||||
nullDefault: '此空值将使用新插件的默认值。',
|
||||
outputPolicy: '现有推理内容显示设置会保留。',
|
||||
filteredVariables: '仅迁移支持的变量;保留变量由会话上下文提供。',
|
||||
identityPreserved: '服务端用户身份沿用旧配置的来源。',
|
||||
newDefaults: '新增插件选项采用已说明的默认值,原有受支持配置会保留。',
|
||||
externalState: '迁移前请完成或取消等待中的远端交互,其运行状态不会转移。',
|
||||
pluginVersion: '请安装所需插件版本后刷新。旧版本不支持此次迁移。',
|
||||
schemaChanged:
|
||||
'已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。',
|
||||
runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。',
|
||||
boxScope: '自定义 Box 会话模板无法安全迁移,请先移除或检查隔离要求。',
|
||||
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
|
||||
},
|
||||
title: '流水线迁移',
|
||||
description:
|
||||
'仅在选择流水线并明确确认后才会更改配置。迁移保留流水线身份及无关设置。',
|
||||
detected: '{{count}} 条流水线需要检查迁移。',
|
||||
review: '检查迁移',
|
||||
readOnly: '只有工作空间管理者可以迁移流水线。',
|
||||
previewError: '无法获取预览,请刷新重试。',
|
||||
submitting: '正在提交所选流水线…',
|
||||
running: '正在迁移。关闭对话框不会取消任务。',
|
||||
finished: '任务已结束,请逐条检查下方结果。',
|
||||
failed: '任务失败,请逐条检查下方结果。',
|
||||
lost: '已失去任务状态连接,结果未知。执行其他操作前请刷新预览。',
|
||||
requestError: '请求未完成。请刷新预览后重新选择。',
|
||||
warningFallback: '迁移前请检查此设置。',
|
||||
blockerFallback: '此设置或运行状态无法安全迁移。请处理后刷新预览。',
|
||||
changedFields: '变更字段',
|
||||
activationHint:
|
||||
'配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。',
|
||||
pluginHint:
|
||||
'请通过「扩展 / 扩展市场」安装或启用所需的运行器插件,并自行检查工作空间扩展配额。本助手不会安装插件。返回此处后请刷新预览。',
|
||||
extensions: '打开扩展',
|
||||
results: '逐条迁移结果',
|
||||
selection: '已选择 {{count}} 条(最多 50 条)',
|
||||
confirm: '我确认迁移所选流水线。',
|
||||
refresh: '刷新预览',
|
||||
execute: '迁移所选流水线',
|
||||
legacyGate: '旧版配置在迁移前保持只读。为避免隐式转换,已禁用保存和调试。',
|
||||
states: {
|
||||
ready: '可迁移',
|
||||
needs_plugin: '需要插件',
|
||||
blocked: '已阻止',
|
||||
already_current: '已是新版',
|
||||
not_legacy: '非旧版',
|
||||
activation_pending: '等待激活',
|
||||
pending: '等待中',
|
||||
migrated: '已迁移',
|
||||
stale: '预览已过期',
|
||||
failed: '失败',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
export default {
|
||||
activationRetryHint:
|
||||
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
|
||||
notices: {
|
||||
legacyArchive:
|
||||
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
|
||||
contextDefaults:
|
||||
'歷史上下文將採用新的容量預算與摘要預設值,不再按固定輪數截斷。',
|
||||
modelReasoning: '各模型的推理設定會保留,並由主程式套用。',
|
||||
serialTools: '遷移後工具呼叫仍依序執行。',
|
||||
retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。',
|
||||
boxReset: '現有 Box 工作階段狀態不遷移,後續將建立新的隔離工作階段。',
|
||||
persistentHistory:
|
||||
'新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。',
|
||||
tweaksDefault: 'Langflow tweaks 預設為空物件。',
|
||||
timeoutDefault: 'Dify 請求逾時預設為 30 秒。',
|
||||
historyReset: '原有對話歷史與工作階段識別碼不遷移,遷移後開始新對話。',
|
||||
sessionTitle: '新的 WeKnora 對話使用外掛產生的標題。',
|
||||
aliasRepaired: '舊欄位名稱將對應至目前支援的名稱。',
|
||||
nullDefault: '此空值將使用新外掛的預設值。',
|
||||
outputPolicy: '現有推理內容顯示設定會保留。',
|
||||
filteredVariables: '僅遷移支援的變數;保留變數由對話上下文提供。',
|
||||
identityPreserved: '服務端使用者身分沿用舊設定的來源。',
|
||||
newDefaults: '新增外掛選項採用已說明的預設值,原有受支援設定會保留。',
|
||||
externalState: '遷移前請完成或取消等待中的遠端互動,其執行狀態不會轉移。',
|
||||
pluginVersion: '請安裝所需外掛版本後重新整理。舊版本不支援此次遷移。',
|
||||
schemaChanged:
|
||||
'已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。',
|
||||
runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。',
|
||||
boxScope: '自訂 Box 工作階段範本無法安全遷移,請先移除或檢查隔離要求。',
|
||||
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
|
||||
},
|
||||
title: '流水線遷移',
|
||||
description:
|
||||
'僅在選擇流水線並明確確認後才會變更設定。遷移保留流水線身分及無關設定。',
|
||||
detected: '{{count}} 條流水線需要檢查遷移。',
|
||||
review: '檢查遷移',
|
||||
readOnly: '只有工作空間管理者可以遷移流水線。',
|
||||
previewError: '無法取得預覽,請重新整理。',
|
||||
submitting: '正在提交所選流水線…',
|
||||
running: '正在遷移。關閉對話框不會取消任務。',
|
||||
finished: '任務已結束,請逐條檢查下方結果。',
|
||||
failed: '任務失敗,請逐條檢查下方結果。',
|
||||
lost: '已失去任務狀態連線,結果未知。執行其他操作前請重新整理預覽。',
|
||||
requestError: '請求未完成。請重新整理預覽後重新選擇。',
|
||||
warningFallback: '遷移前請檢查此設定。',
|
||||
blockerFallback: '此設定或執行狀態無法安全遷移。請處理後重新整理預覽。',
|
||||
changedFields: '變更欄位',
|
||||
activationHint:
|
||||
'設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。',
|
||||
pluginHint:
|
||||
'請透過「擴充功能 / 擴充功能市場」安裝或啟用所需執行器外掛,並自行檢查工作空間配額。本助手不會安裝外掛。返回後請重新整理預覽。',
|
||||
extensions: '開啟擴充功能',
|
||||
results: '逐條遷移結果',
|
||||
selection: '已選擇 {{count}} 條(最多 50 條)',
|
||||
confirm: '我確認遷移所選流水線。',
|
||||
refresh: '重新整理預覽',
|
||||
execute: '遷移所選流水線',
|
||||
legacyGate: '舊版設定在遷移前保持唯讀。為避免隱式轉換,已停用儲存和除錯。',
|
||||
states: {
|
||||
ready: '可遷移',
|
||||
needs_plugin: '需要外掛',
|
||||
blocked: '已阻止',
|
||||
already_current: '已是新版',
|
||||
not_legacy: '非舊版',
|
||||
activation_pending: '等待啟用',
|
||||
pending: '等待中',
|
||||
migrated: '已遷移',
|
||||
stale: '預覽已過期',
|
||||
failed: '失敗',
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/ru-RU';
|
||||
|
||||
const ruRU = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'Главная',
|
||||
extensions: 'Расширения',
|
||||
@@ -257,6 +260,7 @@ const ruRU = {
|
||||
reasoningAbility: 'Рассуждение',
|
||||
reasoningLevel: 'Уровень рассуждений',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'Настройка модели',
|
||||
providerDefault: 'По умолчанию провайдера',
|
||||
disabled: 'Выключено',
|
||||
enabled: 'Включено',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/th-TH';
|
||||
|
||||
const thTH = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'หน้าแรก',
|
||||
extensions: 'ส่วนขยาย',
|
||||
@@ -252,6 +255,7 @@ const thTH = {
|
||||
reasoningAbility: 'ความสามารถในการให้เหตุผล',
|
||||
reasoningLevel: 'ระดับการให้เหตุผล',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'ใช้การตั้งค่าโมเดล',
|
||||
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
|
||||
disabled: 'ปิด',
|
||||
enabled: 'เปิด',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/vi-VN';
|
||||
|
||||
const viVN = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: 'Trang chủ',
|
||||
extensions: 'Tiện ích mở rộng',
|
||||
@@ -256,6 +259,7 @@ const viVN = {
|
||||
reasoningAbility: 'Khả năng suy luận',
|
||||
reasoningLevel: 'Mức độ suy luận',
|
||||
reasoningLevels: {
|
||||
useModelSetting: 'Dùng cài đặt mô hình',
|
||||
providerDefault: 'Mặc định của nhà cung cấp',
|
||||
disabled: 'Tắt',
|
||||
enabled: 'Bật',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hans';
|
||||
|
||||
const zhHans = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: '首页',
|
||||
extensions: '扩展',
|
||||
@@ -250,6 +253,7 @@ const zhHans = {
|
||||
reasoningAbility: '思考能力',
|
||||
reasoningLevel: '思考档位',
|
||||
reasoningLevels: {
|
||||
useModelSetting: '使用模型设置',
|
||||
providerDefault: 'Provider 默认',
|
||||
disabled: '关闭',
|
||||
enabled: '开启',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hant';
|
||||
|
||||
const zhHant = {
|
||||
pipelineMigration,
|
||||
sidebar: {
|
||||
home: '首頁',
|
||||
extensions: '擴展',
|
||||
@@ -244,6 +247,7 @@ const zhHant = {
|
||||
reasoningAbility: '思考能力',
|
||||
reasoningLevel: '思考等級',
|
||||
reasoningLevels: {
|
||||
useModelSetting: '使用模型設定',
|
||||
providerDefault: '供應商預設',
|
||||
disabled: '關閉',
|
||||
enabled: '開啟',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = JSON.parse(
|
||||
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
|
||||
);
|
||||
const outputSchema = contract.output_schema;
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: ['llm-fallback'],
|
||||
reasoning: { 'llm-valid': 'high', 'llm-fallback': 'low' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/provider/models/llm', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
models: ['llm-valid', 'llm-fallback'].map((uuid) => ({
|
||||
uuid,
|
||||
name: uuid,
|
||||
provider_uuid: 'provider-valid',
|
||||
provider: { uuid: 'provider-valid', name: 'Mock Provider' },
|
||||
reasoning: { level: 'high' },
|
||||
reasoning_capabilities: {
|
||||
levels: ['provider_default', 'low', 'high'],
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
|
||||
test('reasoning edit distinguishes provider default from model inheritance and preserves fallbacks', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent');
|
||||
await page
|
||||
.getByRole('button', { name: 'Reasoning level: High', exact: true })
|
||||
.click();
|
||||
await page.getByRole('slider').press('Home');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: {
|
||||
...(raw.model as object),
|
||||
reasoning: { 'llm-valid': 'provider_default', 'llm-fallback': 'low' },
|
||||
},
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Provider default',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use model setting', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: { ...(raw.model as object), reasoning: { 'llm-fallback': 'low' } },
|
||||
});
|
||||
expect(writes[1].config.output).toEqual(writes[0].config.output);
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reasoning level: Use model setting',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
// The reverse transition must not require moving through another level.
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Use model setting',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Provider default', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(3);
|
||||
expect(writes[2].config.ai.runner_config[id]).toEqual(
|
||||
writes[0].config.ai.runner_config[id],
|
||||
);
|
||||
// Clearing a fallback override must not clear the primary override.
|
||||
await page
|
||||
.getByRole('button', { name: 'Reasoning level: Low', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use model setting', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(4);
|
||||
expect(writes[3].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: {
|
||||
...(raw.model as object),
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('standalone picker keeps its original levels and provider default API', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/home/pipelines');
|
||||
await page.evaluate(async () => {
|
||||
// Vite serves the actual component, not a test replacement.
|
||||
const resources = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((entry) => entry.name);
|
||||
const reactPath = resources.find((url) =>
|
||||
new URL(url).pathname.endsWith('/react.js'),
|
||||
)!;
|
||||
const domPath = resources.find((url) =>
|
||||
new URL(url).pathname.endsWith('/react-dom_client.js'),
|
||||
)!;
|
||||
const pickerPath =
|
||||
'/src/app/home/components/reasoning/ReasoningLevelPicker.tsx';
|
||||
const reactModule = await import(reactPath);
|
||||
const React = reactModule.default || reactModule;
|
||||
const domModule = await import(domPath);
|
||||
const { createRoot } = domModule.default || domModule;
|
||||
const { default: Picker } = await import(pickerPath);
|
||||
const host = document.createElement('div');
|
||||
const app = document.getElementById('root');
|
||||
if (app) app.style.display = 'none';
|
||||
document.body.appendChild(host);
|
||||
function Harness() {
|
||||
const [value, setValue] = React.useState('provider_default');
|
||||
return React.createElement(Picker, {
|
||||
value,
|
||||
levels: ['provider_default', 'high'],
|
||||
onChange: (next: string) => {
|
||||
host.dataset.value = next;
|
||||
setValue(next);
|
||||
},
|
||||
});
|
||||
}
|
||||
host.id = 'standalone-reasoning';
|
||||
createRoot(host).render(React.createElement(Harness));
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Provider default',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Use model setting', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole('slider').press('End');
|
||||
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
|
||||
'data-value',
|
||||
'high',
|
||||
);
|
||||
await page.getByRole('slider').press('Home');
|
||||
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
|
||||
'data-value',
|
||||
'provider_default',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = JSON.parse(
|
||||
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
|
||||
);
|
||||
const outputSchema = contract.output_schema;
|
||||
const switchField = (page: Page, label: string) =>
|
||||
page
|
||||
.locator('label')
|
||||
.filter({ hasText: label })
|
||||
.locator('../..')
|
||||
.getByRole('switch');
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
for (const candidate of contract.cases) {
|
||||
test(`actual converter output stays editable and unchanged for ${candidate.legacy_runner}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = candidate.plan.target_plugin.name;
|
||||
const folder = Object.keys(contract.plugins).find(
|
||||
(key) => contract.plugins[key].manifest.metadata.name === name,
|
||||
)!;
|
||||
const { writes } = await setup(
|
||||
page,
|
||||
folder,
|
||||
name,
|
||||
null,
|
||||
candidate.plan.config,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
expect(writes).toHaveLength(0);
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
await switchField(page, 'Quote Origin Message').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai).toEqual(candidate.plan.config.ai);
|
||||
expect(writes[0].config.output.misc['quote-origin']).toBe(
|
||||
!candidate.plan.config.output.misc['quote-origin'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const agentId of [null, 'absent'] as const) {
|
||||
test(`mounted real WeKnora schema preserves ${agentId} agent ID and hidden policy`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, raw, id } = await setup(
|
||||
page,
|
||||
'weknora-agent',
|
||||
'WeKnoraAgent',
|
||||
agentId,
|
||||
);
|
||||
await expect(page.getByText('Agent ID', { exact: true })).toBeVisible();
|
||||
expect(writes).toEqual([]);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
await switchField(page, 'Enable Web Search').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
'web-search-enabled': true,
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await expect(switchField(page, 'Enable Web Search')).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
test('remove-think is synchronized only after intentional output or runner toggles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, id } = await setup(page, 'LocalAgent', 'LocalAgent');
|
||||
const runnerThink = switchField(page, 'Remove');
|
||||
await expect(runnerThink).toBeChecked();
|
||||
expect(writes).toEqual([]);
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
const outputThink = switchField(page, 'Remove CoT');
|
||||
await expect(outputThink).not.toBeChecked();
|
||||
await outputThink.click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.output.misc['remove-think']).toBe(true);
|
||||
expect(writes[0].config.ai.runner_config[id]['remove-think']).toBe(true);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await runnerThink.click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.output.misc['remove-think']).toBe(false);
|
||||
expect(writes[1].config.ai.runner_config[id]['remove-think']).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = contract.cases[0].plan.config;
|
||||
const outputSchema = contract.output_schema;
|
||||
const switchField = (page: Page, label: string) =>
|
||||
page
|
||||
.locator('label')
|
||||
.filter({ hasText: label })
|
||||
.locator('../..')
|
||||
.getByRole('switch');
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
|
||||
for (const kind of ['json', 'prompt', 'prompt-omitted'] as const) {
|
||||
test(`canonical ${kind} edits preserve types and metadata, reject invalid drafts`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const candidate = contract.cases.find(
|
||||
(c: any) =>
|
||||
c.legacy_runner === (kind === 'json' ? 'langflow-api' : 'local-agent'),
|
||||
);
|
||||
const config = structuredClone(candidate.plan.config);
|
||||
const id = candidate.plan.target_runner_id;
|
||||
const field = kind === 'json' ? 'tweaks' : 'prompt';
|
||||
const initial =
|
||||
kind === 'json'
|
||||
? {
|
||||
node: {
|
||||
enabled: false,
|
||||
count: 0,
|
||||
missing: null,
|
||||
list: [false, 0, null],
|
||||
},
|
||||
}
|
||||
: kind === 'prompt-omitted'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [],
|
||||
name: 'original',
|
||||
provider_specific_fields: { cache: false, count: 0 },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
name: 'alice',
|
||||
metadata: { flag: false, count: 0 },
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call1',
|
||||
type: 'function',
|
||||
function: { name: 'foo', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', content: [], tool_call_id: 'call1' },
|
||||
];
|
||||
config.ai.runner_config[id][field] = initial;
|
||||
config.ai.runner_config[id]['advanced-settings'] = true;
|
||||
const { writes } = await setup(
|
||||
page,
|
||||
kind === 'json' ? 'langflow-agent' : 'LocalAgent',
|
||||
candidate.plan.target_plugin.name,
|
||||
null,
|
||||
config,
|
||||
);
|
||||
const editor = page.getByTestId(`structured-editor-${field}`);
|
||||
await expect(editor).toBeVisible();
|
||||
expect(JSON.parse(await editor.inputValue())).toEqual(initial);
|
||||
expect(writes).toHaveLength(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
const edited = structuredClone(initial) as any;
|
||||
if (kind === 'json') edited.node.count = 7;
|
||||
else if (kind === 'prompt-omitted') edited[0].name = 'edited';
|
||||
else edited[0].content[0].text = 'edited';
|
||||
await editor.fill(JSON.stringify(edited, null, 2));
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id][field]).toEqual(edited);
|
||||
if (kind === 'prompt-omitted')
|
||||
expect(
|
||||
Object.hasOwn(
|
||||
writes[0].config.ai.runner_config[id][field][0],
|
||||
'content',
|
||||
),
|
||||
).toBe(false);
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
expect(JSON.parse(await editor.inputValue())).toEqual(edited);
|
||||
await editor.fill('{ invalid');
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: `${field} (JSON)` }),
|
||||
).toBeVisible();
|
||||
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
|
||||
// Make another valid edit so Save remains available: invalid JSON must
|
||||
// block the entire save, not silently persist the previous value.
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
await switchField(page, 'Quote Origin Message').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
expect(writes).toHaveLength(1);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
|
||||
await expect(editor).toHaveValue('{ invalid');
|
||||
await editor.fill(JSON.stringify(edited));
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.ai.runner_config[id][field]).toEqual(edited);
|
||||
});
|
||||
}
|
||||
@@ -3,20 +3,33 @@ import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadCatalog(url) {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(url, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
const require = (specifier) => {
|
||||
assert.ok(specifier.startsWith('./'), 'Locale imports must be relative');
|
||||
return loadCatalog(new URL(`${specifier}.ts`, url));
|
||||
};
|
||||
new Function('module', 'exports', 'require', compiled)(
|
||||
module,
|
||||
module.exports,
|
||||
require,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
|
||||
const directory = new URL('../../src/i18n/locales/', import.meta.url);
|
||||
const files = fs
|
||||
.readdirSync(directory)
|
||||
.filter((file) => file.endsWith('.ts'));
|
||||
assert.equal(files.length, 8);
|
||||
let expected;
|
||||
for (const file of fs.readdirSync(directory)) {
|
||||
const compiled = ts.transpileModule(
|
||||
fs.readFileSync(new URL(file, directory), 'utf8'),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
const catalog = (module.exports.default || Object.values(module.exports)[0])
|
||||
.models.codex;
|
||||
for (const file of files) {
|
||||
const exports = loadCatalog(new URL(file, directory));
|
||||
const catalog = (exports.default || Object.values(exports)[0]).models.codex;
|
||||
const keys = Object.keys(catalog).sort();
|
||||
expected ??= keys;
|
||||
assert.deepEqual(keys, expected, file);
|
||||
|
||||
@@ -66,6 +66,7 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
|
||||
fallbacks: ['fallback-model'],
|
||||
reasoning: {
|
||||
'primary-model': 'high',
|
||||
'fallback-model': 'provider_default',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = readFileSync(
|
||||
'src/app/home/pipelines/pipeline-config-safety.ts',
|
||||
'utf8',
|
||||
);
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const { isCurrentPipelineConfig } = context.exports;
|
||||
const plugin = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
test('canonical current empty runner remains editable', () => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
'../src/langbot/templates/default-pipeline-config.json',
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(isCurrentPipelineConfig(config), true);
|
||||
});
|
||||
|
||||
test('configured current binding remains editable', () => {
|
||||
assert.equal(
|
||||
isCurrentPipelineConfig({
|
||||
ai: { runner: { id: plugin }, runner_config: {} },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, ai] of Object.entries({
|
||||
legacy: { runner: { runner: 'local-agent' } },
|
||||
mixedSelector: { runner: { id: plugin, runner: 'local-agent' } },
|
||||
mixedLegacySection: {
|
||||
runner: { id: plugin },
|
||||
runner_config: {},
|
||||
'local-agent': {},
|
||||
},
|
||||
emptyMixedLegacySection: {
|
||||
runner: { id: '' },
|
||||
runner_config: {},
|
||||
'dify-service-api': {},
|
||||
},
|
||||
emptyMissingContainer: { runner: { id: '' } },
|
||||
emptyMalformedContainer: { runner: { id: '' }, runner_config: [] },
|
||||
emptyAmbiguousContainer: {
|
||||
runner: { id: '' },
|
||||
runner_config: { orphan: {} },
|
||||
},
|
||||
emptyAmbiguousSelector: {
|
||||
runner: { id: '', legacy: 'local-agent' },
|
||||
runner_config: {},
|
||||
},
|
||||
missingId: { runner: {}, runner_config: {} },
|
||||
nonPlugin: { runner: { id: 'local-agent' }, runner_config: {} },
|
||||
})) {
|
||||
test(`${name} does not hydrate current editor defaults`, () => {
|
||||
assert.equal(isCurrentPipelineConfig({ ai }), false);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
function load(path) {
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(readFileSync(path, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
return context.exports;
|
||||
}
|
||||
|
||||
const warnings = [
|
||||
...new Set(
|
||||
[
|
||||
...readFileSync(
|
||||
'../src/langbot/pkg/pipeline/legacy_config_migration.py',
|
||||
'utf8',
|
||||
).matchAll(/_warn\(result,\s*'([^']+)'/g),
|
||||
].map((match) => match[1]),
|
||||
),
|
||||
];
|
||||
|
||||
test('every converter warning has a specific explanation in all eight locales', () => {
|
||||
const { migrationIssueKey } = load(
|
||||
'src/app/home/pipelines/pipeline-migration-issues.ts',
|
||||
);
|
||||
const base = 'src/i18n/locales/pipeline-migration';
|
||||
const locales = readdirSync(base).filter((name) => name.endsWith('.ts'));
|
||||
assert.equal(locales.length, 8);
|
||||
assert.ok(
|
||||
warnings.length > 10,
|
||||
'warning discovery must not silently go empty',
|
||||
);
|
||||
for (const file of locales) {
|
||||
const catalog = load(`${base}/${file}`).default;
|
||||
for (const code of warnings) {
|
||||
const key = migrationIssueKey(code);
|
||||
assert.ok(key, `${file}: missing mapping for ${code}`);
|
||||
assert.equal(typeof catalog.notices?.[key], 'string', `${file}: ${code}`);
|
||||
assert.ok(catalog.notices[key].length > 10);
|
||||
}
|
||||
assert.ok(catalog.notices.pluginVersion);
|
||||
assert.ok(catalog.notices.pendingInteraction);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown backend issue codes never become translation keys or displayed messages', () => {
|
||||
const { migrationIssueKey } = load(
|
||||
'src/app/home/pipelines/pipeline-migration-issues.ts',
|
||||
);
|
||||
for (const code of [
|
||||
'unknown',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'secret-token.example',
|
||||
]) {
|
||||
assert.equal(migrationIssueKey(code), undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
const source = fs.readFileSync(
|
||||
'src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx',
|
||||
'utf8',
|
||||
);
|
||||
// Exercise the actual field callback without mocking React's form lifecycle.
|
||||
const callback = source.slice(
|
||||
source.indexOf('const updateModelReasoning ='),
|
||||
source.indexOf('const replaceModel ='),
|
||||
);
|
||||
function edit(reasoning, uuid, level) {
|
||||
let result;
|
||||
const code = ts.transpileModule(
|
||||
callback + '\nupdateModelReasoning(uuid, level);',
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
new Function('modelValue', 'updateValue', 'uuid', 'level', code)(
|
||||
{ reasoning },
|
||||
(value) => {
|
||||
result = value.reasoning;
|
||||
},
|
||||
uuid,
|
||||
level,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
test('provider default is an explicit override, not inheritance', () => {
|
||||
assert.deepEqual(edit({ a: 'high', b: 'low' }, 'a', 'provider_default'), {
|
||||
a: 'provider_default',
|
||||
b: 'low',
|
||||
});
|
||||
});
|
||||
test('inherit deletes only the selected override without mutating input', () => {
|
||||
const input = Object.freeze({ a: 'provider_default', b: 'high' });
|
||||
const output = edit(input, 'a', undefined);
|
||||
assert.equal(Object.hasOwn(output, 'a'), false);
|
||||
assert.deepEqual(output, { b: 'high' });
|
||||
assert.deepEqual(input, { a: 'provider_default', b: 'high' });
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
function load(relative) {
|
||||
const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const helper =
|
||||
'../../src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts';
|
||||
test('mount emission is not a persisted edit', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
'agent-id': null,
|
||||
'enable-all-tools': false,
|
||||
};
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, undefined, {
|
||||
'api-key': 'secret',
|
||||
'agent-id': '',
|
||||
mode: 'chat',
|
||||
}),
|
||||
raw,
|
||||
);
|
||||
});
|
||||
test('unrelated edits preserve hidden Host policy, absent/null and credentials', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
'agent-id': null,
|
||||
'enable-all-tools': false,
|
||||
tools: ['one'],
|
||||
'knowledge-bases': ['kb'],
|
||||
'mcp-resources': [{ uri: 'private' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
const first = {
|
||||
'api-key': 'secret',
|
||||
'agent-id': '',
|
||||
mode: 'chat',
|
||||
absent: 'default',
|
||||
};
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, first, { ...first, mode: 'agent' }),
|
||||
{ ...raw, mode: 'agent' },
|
||||
);
|
||||
});
|
||||
test('intentional edits, removals and nested edits win without normalizing untouched siblings', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: ['two'],
|
||||
reasoning: { one: 'provider_default', two: 'high' },
|
||||
},
|
||||
};
|
||||
const first = { 'api-key': 'secret', model: raw.model };
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, first, {
|
||||
'api-key': 'new',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: [],
|
||||
reasoning: { one: 'provider_default' },
|
||||
},
|
||||
}),
|
||||
{
|
||||
'api-key': 'new',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: [],
|
||||
reasoning: { one: 'provider_default' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
test('save normalization preserves explicit selected per-model provider_default', () => {
|
||||
const { normalizeDynamicFormValuesForSave } = load(
|
||||
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
|
||||
);
|
||||
const value = {
|
||||
primary: 'one',
|
||||
fallbacks: ['two'],
|
||||
reasoning: { one: 'provider_default', two: 'high', removed: 'high' },
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeDynamicFormValuesForSave(
|
||||
[{ name: 'model', type: 'model-fallback-selector' }],
|
||||
{ model: value },
|
||||
).model.reasoning,
|
||||
{ one: 'provider_default', two: 'high' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
const file = 'src/app/home/components/dynamic-form/StructuredFieldValue.ts';
|
||||
function load() {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const m = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(m, m.exports);
|
||||
return m.exports;
|
||||
}
|
||||
test('JSON parsing preserves nested false, zero, null and arrays as typed values', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
const value = { n: { a: false, b: 0, c: null, d: [false, 0, null] } };
|
||||
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), false), value);
|
||||
});
|
||||
test('prompt parsing preserves structured content, null, empty arrays, metadata and first role', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
const value = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'x' }], name: 'alice' },
|
||||
{ role: 'assistant', content: null, tool_calls: [] },
|
||||
{ role: 'tool', content: [], tool_call_id: 'x' },
|
||||
];
|
||||
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), true), value);
|
||||
});
|
||||
test('prompt metadata edits preserve omitted content without inserting null', () => {
|
||||
const { parseStructuredDraft, isPromptValue } = load();
|
||||
const value = [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [],
|
||||
name: 'edited',
|
||||
provider_specific_fields: { cache: false },
|
||||
},
|
||||
];
|
||||
assert.equal(isPromptValue(value), true);
|
||||
const result = parseStructuredDraft(JSON.stringify(value), true);
|
||||
assert.deepEqual(result, value);
|
||||
assert.equal(Object.hasOwn(result[0], 'content'), false);
|
||||
for (const content of [false, 0, {}, undefined])
|
||||
assert.equal(isPromptValue([{ ...value[0], content }]), false);
|
||||
});
|
||||
test('invalid JSON and non-message prompt shapes reject rather than silently retain old values', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
for (const raw of ['{broken', '', 'undefined'])
|
||||
assert.throws(() => parseStructuredDraft(raw, false));
|
||||
for (const raw of [
|
||||
'{}',
|
||||
'null',
|
||||
'[{"role":0,"content":"x"}]',
|
||||
'[{"role":"user","content":false}]',
|
||||
])
|
||||
assert.throws(() => parseStructuredDraft(raw, true));
|
||||
});
|
||||
test('friendly prompt editor is restricted to losslessly represented roles and string content', () => {
|
||||
const { isSimplePrompt } = load();
|
||||
assert.equal(
|
||||
isSimplePrompt([
|
||||
{ role: 'system', content: '' },
|
||||
{ role: 'user', content: 'x' },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
for (const value of [
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[{ role: 'system', content: null }],
|
||||
[{ role: 'system', content: [] }],
|
||||
[{ role: 'system', content: 'x', name: 'a' }],
|
||||
[],
|
||||
])
|
||||
assert.equal(isSimplePrompt(value), false);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
cacheDir: '/tmp/411-reasoning-vite-cache',
|
||||
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
cacheDir: '/tmp/411-structured-vite-cache',
|
||||
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
||||
});
|
||||
Reference in New Issue
Block a user