feat(agent-runner): finalize 4.x processor integration

This commit is contained in:
huanghuoguoguo
2026-07-12 15:44:05 +08:00
parent 29689962f3
commit e6384aae5d
109 changed files with 2200 additions and 1204 deletions
@@ -104,7 +104,7 @@ bin/lbs case list
1. 打开 WebUI Pipeline 配置页。
2. 查看 AI runner 下拉列表。
3. 选择 `plugin:langbot/local-agent/default`
3. 选择 `plugin:langbot-team/LocalAgent/default`
4. 保存并刷新页面。
通过条件:
@@ -120,7 +120,7 @@ bin/lbs case list
步骤:
1. 使用绑定 `plugin:langbot/local-agent/default` 的 Pipeline。
1. 使用绑定 `plugin:langbot-team/LocalAgent/default` 的 Pipeline。
2. 在 Debug Chat 发送确定性普通文本。
3. 查看 WebUI 回复和后端日志。
@@ -160,7 +160,7 @@ Smoke 前应优先保留一层轻量单测或 fixture 测试:session 创建/
步骤:
1. 确认目标 harness(例如 ACP daemon、Claude Code 或 Codex)在对应机器上可执行且已登录。
2. 绑定目标 runner,例如 `plugin:langbot/acp-agent-runner/default``plugin:langbot/claude-code-agent/default``plugin:langbot/codex-agent/default`
2. 绑定目标 runner,例如 `plugin:langbot-team/ACPAgentRunner/default``plugin:langbot-team/ClaudeCodeAgent/default``plugin:langbot-team/CodexAgent/default`
3. 配置 runner 必要字段,例如 remote target、workspace、provider、startup timeout、reuse session 等。
4. 在 Debug Chat 执行一次确定性真实 smoke。
5. 检查 LangBot MCP gateway、`run_id` 回填和 host-owned state。
@@ -5,13 +5,13 @@
> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)Host 内部模型);本文只讲 EBA 语义,不重抄 schema。
> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
本文描述 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把协议边界说清楚,避免当前消息入口继续绑死 Pipeline 和用户文本消息
本文描述 EBA 接入时,事件如何进入 LangBot、如何在平级的 Pipeline / Agent 处理器之间路由,以及 Agent 分支如何复用插件化 AgentRunner 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把处理器路由与 runner 协议边界说清楚。
## 1. 设计目标
- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`
- AgentRunner 通过同一套 orchestrator 调用。
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 选择一个 Pipeline 或 Agent 处理器
- Pipeline 目标执行完整消息 Stage 链;Agent 目标通过统一 orchestrator 调用 AgentRunner
- 非消息事件不伪造成用户文本消息。
- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。
@@ -42,11 +42,11 @@
## 4. Event Envelope 与 Binding
- 入口事件用 `AgentEventEnvelope`HOST_SDK §4.1)承载;顶层字段使用 LangBot 稳定协议名,平台原始事件名和原始 payload 放 `metadata` / `raw_ref`
- 触发关系用 `AgentBinding`HOST_SDK §4.2)表达。EBA 阶段 binding 通过 `event_types``scope``filters` 决定哪些事件触发当前 bot / channel 绑定的 Agent
- EBA 持久路由通过 `event_pattern``filters``target_type``target_uuid` 选择处理器。只有 `target_type=agent`,或 Pipeline AI Stage 需要调用 runner 时,才进一步解析 `AgentBinding`HOST_SDK §4.2
EBA dispatch 基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准;本节只说明外部 EBA 分支的 EventRouter 如何产出当前 v1 主线需要的 binding
EBA 每个事件只选择一个有效处理器;AgentRunner 调用的基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准。
Binding scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。Pipeline 可迁移为 `message.received` 的临时 binding source,但目标持久配置应是 Agent,不是 Pipeline
路由 scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。Pipeline `message.*` 场景的一等处理器,适合需要预处理、AI、后处理、扩展和输出控制的消息链路;Agent 是 runner 驱动的一等处理器,可处理其声明支持的消息与非消息事件。二者都不会被转换成对方
Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui``http_api``scheduler``system`。EventRouter 不应写死平台 adapter 的类名。
@@ -56,15 +56,15 @@ Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram
Platform Adapter / WebUI / API
-> Event Gateway normalize payload
-> EventLog append raw event
-> EventRouter resolve one effective AgentBinding
-> AgentRunOrchestrator.run(event, binding)
-> AgentRunContextBuilder.build(event, binding)
-> PluginRuntimeConnector.run_agent()
-> AgentRunResult stream
-> EventRouter resolve one Processor target
-> target_type=pipeline: MessageAggregator -> QueryPool -> Pipeline stages
-> target_type=agent: resolve AgentBinding -> AgentRunOrchestrator
-> AgentRunContextBuilder -> PluginRuntimeConnector.run_agent()
-> AgentRunResult stream
-> DeliveryController render / platform action
```
约束:必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorizationdelivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。
约束:Pipeline 和 Agent 是 EventRouter 的平级目标;Pipeline 仅接受消息事件,Agent 受其事件能力声明约束。任何 AgentRunner 调用都必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 协议;非消息事件不能绕过 resource authorizationdelivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。
## 6. 平台动作执行
@@ -87,6 +87,6 @@ EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CO
## 8. EBA 分支联调内容
外部 EBA 分支负责联调 EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。
外部 EBA 分支负责联调 EventGateway 完整实现、Pipeline / Agent 处理器路由、AgentBindingResolver 集成、事件绑定持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。
当前底座已完成:① 把当前 Pipeline 消息入口适配成 `message.received` event → ② 增加 `AgentBinding` 抽象,先由 current config 生成 → ③ context builder 改为从 event + binding 构造 → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调:⑤ 非消息事件协议测试与真实事件来源 → ⑥ 真实 EventRouter、binding persistence / UI 和 platform action。
当前底座已完成:① Pipeline 消息链可投影 `message.received` 并在 AI Stage 复用 AgentRunner → ② 独立 Agent 可从 EBA 路由直接生成 `AgentBinding` → ③ context builder 从 event + binding 构造 runner context → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调真实事件来源、处理器路由、binding persistence / UI 和 platform action。
@@ -12,7 +12,7 @@
| --- | --- | --- |
| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同:discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 |
| Host runner 外化底座 | 提供 `AgentEventEnvelope``AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / State / sandbox 文件边界。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 |
| 当前 Pipeline 入口 | 通过 `QueryEntryAdapter` Query / Pipeline config 投影成 event + binding,作为迁移期入口。 | 不继续把 Pipeline 当作长期 agent 配置中心。 |
| Pipeline 的 AgentRunner 接入 | Pipeline 作为一等消息处理器执行完整 Stage 链;仅在 AI Stage 调用 runner 时,`QueryEntryAdapter`当前 Query/config 投影成 event + binding。 | 不把整个 Pipeline 当成临时 Agent;不复制 Pipeline 配置来自动创建 Agent。 |
| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 |
## 2. 扩展矩阵
@@ -20,7 +20,7 @@
| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 |
| --- | --- | --- | --- | --- |
| Product `Agent` | 已有 `agents` 产品表 / API 和运行期 `AgentConfig` / `AgentBinding` 投影;完整 binding persistence / EventRouter / UI 闭环仍未完成。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 |
| Bot / channel 绑定 Agent | 已有单次运行前的 `AgentBinding` 解析投影;目标调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding` | 不在本矩阵重定义 fan-out / observer 语义;需要时按 §3 新增设计。 |
| Agent 处理器调用 runner | 已有单次运行前的 `AgentBinding` 解析投影;AgentRunner 调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 先选中 Agent 处理器,再根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`Pipeline 目标走独立 Stage 链。 | 不用 `AgentBinding` 取代 EBA 的 Pipeline / Agent 处理器选择;不在本矩阵重定义 fan-out / observer 语义。 |
| Agent session / run | 已有持久 `AgentRun` / `AgentRunEvent` ledger 和 active `AgentRunSessionRegistry`;还没有独立 `AgentSession` / task 产品模型。 | Agent Platform / Runtime Control Plane。 | 如需要可新增 `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 |
| EventLog / Transcript / Sandbox files | 已完成 Host-owned store、history pull API 和 sandbox 文件边界;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript;当前 run 文件通过 sandbox/workspace staging 共享。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 |
| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State APIstorage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 |
@@ -11,14 +11,14 @@
LangBot 要转为 agent host,而不是内置 runner 容器:
- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。
- 根据事件、bot、workspace、scope 解析应该调用的 Agent / agent binding。
- 接收 EBA 选中的 Agent 处理器,并根据事件、bot、workspace、scope 解析 AgentRunner binding。
- 发现、校验和调用插件提供的 AgentRunner。
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。
## 2. 非目标
- Pipeline 当作长期架构中心
-定义 Pipeline 的 Stage 编排语义;Pipeline 是 EBA 的同级处理器,其 AI Stage 只在需要 runner 时接入本 Host 边界
- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。
- 不在 host 中实现通用 agentic prompt assembler。
@@ -34,27 +34,30 @@ IM / WebUI / API / EventRouter (external EBA branch)
Event Gateway (external EBA branch)
|
v
AgentBindingResolver
EventRouter -> one Processor target
|-- target_type=pipeline -> Pipeline Stage chain
|
v
AgentRunOrchestrator
|-- AgentRunnerRegistry
|-- AgentResourceBuilder
|-- AgentContextBuilder
|-- AgentRunSessionRegistry
|-- PersistentStateStore / EventLogStore / TranscriptStore
|-- Sandbox / workspace file tools
v
Plugin Runtime / AgentRunner
|
v
AgentRunResult stream
`-- target_type=agent -> AgentBindingResolver
|
v
AgentRunOrchestrator
|-- AgentRunnerRegistry
|-- AgentResourceBuilder
|-- AgentContextBuilder
|-- AgentRunSessionRegistry
|-- PersistentStateStore / EventLogStore / TranscriptStore
|-- Sandbox / workspace file tools
v
Plugin Runtime / AgentRunner
|
v
AgentRunResult stream
|
v
Delivery / Renderer / Platform API
```
目标产品模型、单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。本文只说明 Host 如何把当前入口投影为内部模型。当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received` 并投影出临时 `AgentConfig` / `AgentBinding`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway / EventRouter 由外部 EBA 分支实现并联调。
Pipeline 与 Agent 是 EventRouter 的平级处理器目标。本文只定义 AgentRunner Host 边界:Agent 目标直接解析 `AgentBinding`;Pipeline 目标执行自己的完整 Stage 链,仅在 AI Stage 调用 runner 时通过 Query entry adapter 构造一次性 `AgentConfig` / `AgentBinding`。该 runner 调用投影不改变 Pipeline 的一等处理器地位,也不会把 Pipeline 持久化为 Agent。AgentRunner 的单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。EventGateway / EventRouter 由外部 EBA 分支实现并联调。
## 4. LangBot 侧能力
@@ -88,7 +91,7 @@ class AgentEventEnvelope(BaseModel):
### 4.2 AgentConfig 与 AgentBinding
`AgentConfig`迁移期的 Host 内部 Agent 配置投影(不暴露给 SDK)。当前 Query entry adapter 从 Pipeline config 投影出它;未来持久 Agent 也应先投影成这个运行期配置,再由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`
`AgentConfig` 是 Host 内部的一次 AgentRunner 调用配置投影(不暴露给 SDK)。独立 Agent 从自己的持久配置生成它;Pipeline 只在 AI Stage 调用 runner 时,由 Query entry adapter 从该 Stage 的当前配置生成它。两种来源随后都由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`。Pipeline 本身不是 `AgentConfig`,该调用投影也不会创建或更新持久 Agent
```python
class AgentConfig(BaseModel):
@@ -122,11 +125,11 @@ class AgentBinding(BaseModel):
BindingResolver 的基数、fan-out 和冲突处理约束见 PROTOCOL_V1 §13;本节只定义 Host 内部投影形态。
**当前 adapter source**`QueryEntryAdapter.config_to_agent_config(query, runner_id)`
把 current config 投影为迁移`AgentConfig`,再由
只在 Pipeline AI Stage 调用 runner 时,把 current config 临时投影为运行`AgentConfig`,再由
`AgentBindingResolver.resolve_one(event, [agent_config])` 解析出唯一
`AgentBinding`。Pipeline 当前只是迁移期 Agent config sourceAI runner config
`AgentBinding`该调用从 Pipeline 取得 AI runner config
→ runner_config、extension preference → resource_policy、output settings →
delivery_policy,但新设计不再把这些字段命名为 Pipeline 专属概念
delivery_policy,但 Pipeline 仍执行并拥有完整 Stage/config 语义。该适配不会把 Pipeline 持久化为 Agent;独立 Agent 由用户自行新增和绑定
### 4.3 AgentRunnerRegistry
@@ -12,7 +12,7 @@
```text
langbot-app/
langbot-local-agent/ # plugin:langbot/local-agent/default
langbot-local-agent/ # plugin:langbot-team/LocalAgent/default
manifest.yaml
components/agent_runner/default.{yaml,py}
langbot-agent-runner/ # 外部服务 runner 仓库
@@ -25,18 +25,18 @@ langbot-app/
| 旧 runner | 官方插件 | runner id |
| --- | --- | --- |
| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` |
| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` |
| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` |
| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` |
| - | `langbot/acp-agent-runner` | `plugin:langbot/acp-agent-runner/default` |
| - | `langbot/claude-code-agent` | `plugin:langbot/claude-code-agent/default` |
| - | `langbot/codex-agent` | `plugin:langbot/codex-agent/default` |
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
| `deerflow-api` | `langbot/deerflow-agent` | `plugin:langbot/deerflow-agent/default` |
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
| `weknora-api` | `langbot/weknora-agent` | `plugin:langbot/weknora-agent/default` |
| `local-agent` | `langbot-team/LocalAgent` | `plugin:langbot-team/LocalAgent/default` |
| `dify-service-api` | `langbot-team/DifyAgent` | `plugin:langbot-team/DifyAgent/default` |
| `n8n-service-api` | `langbot-team/N8nAgent` | `plugin:langbot-team/N8nAgent/default` |
| `coze-api` | `langbot-team/CozeAgent` | `plugin:langbot-team/CozeAgent/default` |
| - | `langbot-team/ACPAgentRunner` | `plugin:langbot-team/ACPAgentRunner/default` |
| - | `langbot-team/ClaudeCodeAgent` | `plugin:langbot-team/ClaudeCodeAgent/default` |
| - | `langbot-team/CodexAgent` | `plugin:langbot-team/CodexAgent/default` |
| `dashscope-app-api` | `langbot-team/DashScopeAgent` | `plugin:langbot-team/DashScopeAgent/default` |
| `deerflow-api` | `langbot-team/DeerFlowAgent` | `plugin:langbot-team/DeerFlowAgent/default` |
| `langflow-api` | `langbot-team/LangflowAgent` | `plugin:langbot-team/LangflowAgent/default` |
| `tbox-app-api` | `langbot-team/TboxAgent` | `plugin:langbot-team/TboxAgent/default` |
| `weknora-api` | `langbot-team/WeKnoraAgent` | `plugin:langbot-team/WeKnoraAgent/default` |
每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`
@@ -115,7 +115,7 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/
当前形态:
- Runner ID 示例:`plugin:langbot/acp-agent-runner/default``plugin:langbot/claude-code-agent/default``plugin:langbot/codex-agent/default`
- Runner ID 示例:`plugin:langbot-team/ACPAgentRunner/default``plugin:langbot-team/ClaudeCodeAgent/default``plugin:langbot-team/CodexAgent/default`
- Runner 可通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API 调用 harnessharness 的安装、登录态、workspace 和 provider-native 权限由该运行环境负责。
- Runner 会把当前 LangBot `run_id`、可访问资源摘要和 gateway 使用规则注入本次消息;harness 通过 gateway 回填 `run_id` 后访问 LangBot 资产。
- 外部 session id / workspace / checkpoint 写回 Host state 或 plugin storage,后续轮次可复用目标 harness 会话。
@@ -126,7 +126,7 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/
## 8. 发布和安装策略
最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用
最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装,或由用户从 marketplace 安装。当前分支未发布,因此不保留历史 Pipeline Agent 配置兼容旧内置 runner fallback,也不把旧 Pipeline 内的 Agent 配置迁移成独立 Agent。4.x 只读取 `ai.runner.id``ai.runner_config[id]`;升级后由用户选择或安装需要的 AgentRunner
## 9. 验收标准
+10 -9
View File
@@ -33,8 +33,8 @@ Protocol v1 **不定义**
| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 |
| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2)。 |
产品层 `Agent` 替代旧 Pipeline 承载 agent 配置:bot / IM channel
绑定一个 Agent,一个 Agent 可以被多个 bot / channel 复用。Host 内部的
产品层同时保留 Pipeline 与独立 `Agent`:现有 Pipeline 不迁移为 Agent
用户可以新建 Agent 并绑定到 bot / IM channel,一个 Agent 可以被多个 bot / channel 复用。Host 内部的
`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的
`ctx.config``ctx.resources``ctx.context``ctx.delivery`。SDK 不需要知道
Agent / binding 的持久化形态。
@@ -700,11 +700,12 @@ Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt as
> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
## 12. Pipeline Adapter 边界
## 12. Pipeline AI Stage Adapter 边界
Pipeline 是当前入口 adapter,不是协议中心。目标产品模型中 Agent 会替代
Pipeline 承载 runner config、resource policy 和 delivery policy;当前 Query
entry adapter 只是迁移桥。它负责:
Pipeline 与 Agent 是 EBA 中平级的处理器:Pipeline 处理消息事件并执行完整
Stage 链,Agent 处理其声明支持的消息或非消息事件。本协议只约束 AgentRunner
调用,因此 Pipeline 仅在 AI Stage 调用 runner 时进入 Query entry adapter
该适配不会把 Pipeline 变成 Agent,也不会创建或更新持久 Agent。adapter 负责:
-`Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。
- 从当前 Agent/runner config 构造 `ctx.config`
@@ -719,9 +720,9 @@ entry adapter 只是迁移桥。它负责:
## 13. 已确认约束
- v1 / EBA 主线`one event -> one AgentBinding -> one run_id -> one runner`
- 一个 bot / IM channel 在同一时间只绑定一个负责 agentic 处理的 Agent;一个 Agent 可以被多个 bot / channel 复用。
- 如果配置层出现多个匹配 AgentBindingBindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。
- EBA 路由层`one event -> one Processor target (Pipeline | Agent)`;同一 bot / channel 可以让不同事件绑定不同类型的处理器
- 进入 AgentRunner Protocol 后,调用基数是 `one AgentBinding -> one run_id -> one runner`。这既适用于独立 Agent,也适用于 Pipeline AI Stage 的单次 runner 调用。
- 一个 Agent 可以被多个 bot / channel 复用。如果 Agent 分支出现多个匹配 bindingBindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。
- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。
- `AgentRunnerDescriptor.source` 只允许 `plugin`Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。
- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshotruntime handler 不应重新执行资源裁剪。
+7 -7
View File
@@ -17,8 +17,7 @@
**本分支目标:AgentRunner 外化 / 插件化基础设施**
本分支只做 LangBot 作为 Agent Host 的基础能力建设,为后续用 `Agent`
替代 Pipeline 承载 agent 配置打底:
本分支只做 LangBot 作为 Agent Host 的基础能力建设,让现有 Pipeline 与用户新建的独立 `Agent` 都能调用插件化 AgentRunner;不负责把两者做持久化迁移:
- LangBot 与 SDK 的稳定协议合同(Protocol v1
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型
@@ -45,15 +44,15 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr
## 目标产品模型
未来产品层应把 `Agent` 理解为 Pipeline 的替代物:原先 bot 绑定 PipelinePipeline 携带 agent/provider/RAG/tool 等配置;后续应改为 bot 或 IM channel 绑定一个 AgentAgent 携带 runner id、runner config、resource/state/delivery policy 等 agent 配置
产品层同时保留 Pipeline 与独立 `Agent`。现有 Pipeline 保持原实体和执行链,也可继续被 bot 绑定;新建 Agent 则携带 runner id、runner config、resource/state/delivery policy 等配置,并可被多个 bot / IM channel 复用。统一产品列表可以聚合展示两类处理器,但不会把 Pipeline 或其内嵌 runner 配置自动迁移成 Agent;用户需要 Agent 时自行新增并绑定
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;README 不复写这些约束。
## 当前入口关系
## Pipeline 与 AgentRunner 的关系
**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心**
**Pipeline 与 Agent 是 EBA 中平级的处理器;`QueryEntryAdapter` 只适配 Pipeline 内部的 AgentRunner 调用**
主入口仍可由 Pipeline 触发,但内部已转换成 event-first path`run_from_query()``QueryEntryAdapter``Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`Pipeline path 因此获得了 event-first host capabilitiesEventLog / Transcript / PersistentStateStore 写入,History / Event / State pull API 和 sandbox/workspace 文件读写能力可用)
EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完整 Stage 链;当 Pipeline 的 AI Stage 调用 runner 时,`run_from_query()``QueryEntryAdapter``Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`Agent 目标则直接从自己的持久配置构造 binding。两条路径可以复用同一套 AgentRunner Host capabilities,但 Pipeline 本身不会被投影或持久化为 Agent
下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
@@ -62,8 +61,9 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr
| 术语 | 含义 |
| --- | --- |
| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同:discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 |
| Processor | EBA 的处理器上位概念;当前平级类型为 Pipeline 与 Agent。 |
| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 |
| AgentConfig | Host 内部迁移期配置投影,由当前 Pipeline config 或未来持久 Agent 生成。 |
| AgentConfig | Host 内部的单次 AgentRunner 调用配置投影,由 Pipeline AI Stage 或持久 Agent 生成;投影本身不会创建 Agent。 |
| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 |
| envelope | Host 内部事件封装,即 `AgentEventEnvelope`runner 看到的是由它投影出的 `ctx.event`。 |
| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 |
@@ -532,7 +532,7 @@ Tests: 40+ 个文件
## 12. 待定问题
- Host 是否需要最小持久 `Agent` / `Binding` 模型,还是继续由 Pipeline / Platform 插件投影运行期 `AgentBinding`
- 已确认:Agent 与 Pipeline 都持久存在,并由 EBA 处理器 binding 指向其中之一;Pipeline 仅在 AI Stage 调用 runner 时投影一次性 `AgentBinding`,不会充当持久 Agent 的替代物
- Platform 插件创建 run 时,是否传完整 `AgentBinding` snapshot,还是引用 Host-owned binding id。
- `AgentRunEvent` 与现有 `EventLog` / `Transcript` 的查询关系:直接 join,还是通过专门 view 聚合。
- `run.append_result` 的认证粒度:runner plugin identity、run token、scoped capability token,或 SDK runtime 内部 channel。
@@ -16,7 +16,7 @@ local-agent 已移植 Pi 的事件生命周期、并行工具语义、hook 扩
### 1.1 问题
IM 场景下用户在 agent 运行中追加消息非常常见(补充信息、纠正方向、"算了别查了")。
当前主线是 `one event -> one AgentBinding -> one run_id -> one runner`
EBA 先按事件选择一个 Pipeline 或 Agent 处理器;进入 AgentRunner 后,当前调用链是 `one AgentBinding -> one run_id -> one runner`
PROTOCOL_V1 §13):同会话的新消息要么等待当前 run 结束后触发新 run,
要么并发触发独立 run。两种行为都无法把新消息送进**正在执行的 tool loop**
用户体验是"agent 自顾自跑完过期任务,然后才看到新消息"。
+2 -2
View File
@@ -36,8 +36,8 @@
| Runner | 状态 | 最近证据 |
| --- | --- | --- |
| `plugin:langbot/local-agent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 |
| `plugin:langbot/acp-agent-runner/default` / `plugin:langbot/claude-code-agent/default` / `plugin:langbot/codex-agent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 |
| `plugin:langbot-team/LocalAgent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 |
| `plugin:langbot-team/ACPAgentRunner/default` / `plugin:langbot-team/ClaudeCodeAgent/default` / `plugin:langbot-team/CodexAgent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 |
| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
## Host / SDK 验收状态
@@ -1,340 +1,342 @@
# EBA Productization and Release Plan
# EBA 产品化与发布计划
> Status: planning draft, 2026-07-01
> 状态:规划草案,2026-07-01
>
> Scope: productizing the merged AgentRunner pluginization and Event Based Agent adapter work into a product that non-technical users can start with quickly. This document focuses on product gaps, release gates, and SaaS multi-namespace tenancy. It does not introduce new protocol schema; protocol facts remain in [PROTOCOL_V1.md](./PROTOCOL_V1.md) and host model facts remain in [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md).
> 范围:将已经合并的 AgentRunner 插件化和 Event Based Agent 适配器工作产品化,使非技术用户也能快速上手。本文聚焦产品缺口、发布门禁和 SaaS 多命名空间租户能力。本文不引入新的协议 schema;协议事实仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准,Host 模型事实仍以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 为准。
## 1. Product Direction
## 1. 产品方向
The current technical direction is correct: LangBot should treat platform input as events, resolve one effective route for each event, and invoke a processing asset through the AgentRunner host boundary. However, this is not yet a product that non-technical users can adopt without understanding internal architecture.
当前技术方向是正确的:LangBot 应该把平台输入视为事件,为每个事件解析出一个有效路由,并通过 AgentRunner Host 边界调用一个处理资产。但这还不是一个非技术用户无需理解内部架构就能采用的产品。
The product-level model should be:
产品层模型应当是:
- **Bot**: the platform connection and event routing surface. A bot owns adapter credentials, platform permissions, incoming event visibility, and the route table for those events.
- **Processor**: a reusable processing asset that can handle routed events. Current processor types are **Agent** and **Pipeline**. Future types can include **Workflow**.
- **Pipeline**: a first-class no-code message processor kept for compatibility and low-code users. It is message-only and should be bindable only to `message.*` events.
- **Agent**: a runner-backed processor for event-first processing. It can handle message and non-message events according to its declared supported event range.
- **Solution**: a future distribution/export unit containing processors, route templates, dependency manifests, variables, and docs. A solution must not include concrete bot credentials, tenant secrets, or installed UUID bindings.
- **机器人(Bot**:平台连接与事件路由入口。机器人拥有适配器凭据、平台权限、入站事件可见性,以及这些事件的路由表。
- **处理器(Processor**:可复用的事件处理资产。当前处理器类型包括 **Agent** **Pipeline**。未来可以增加 **Workflow**
- **Pipeline**:一等的无代码消息处理器,通过完整 Stage 链提供预处理、AI、后处理、扩展和输出控制。Pipeline 只处理消息,应当只能绑定到 `message.*` 事件。
- **Agent**:由 runner 驱动的事件优先处理器。Agent 可以根据自身声明的事件支持范围处理消息事件和非消息事件。
- **Solution(方案包)**:未来的分发/导出单元,包含处理器、路由模板、依赖清单、变量和文档。Solution 不应包含具体机器人凭据、租户密钥或已安装资产的 UUID 绑定。
`EBA` is an internal engineering term. It can appear in internal design documents, but it should not be visible in primary product flows. User-facing language should prefer terms such as "channel", "event routing", "processor", "message pipeline", "automation", and "route template".
`EBA` 是内部工程术语。它可以出现在内部设计文档中,但不应出现在主要产品流程里。面向用户的语言应优先使用“频道”“事件路由”“处理器”“消息流水线”“自动化”“路由模板”等表达。
## 2. Current Foundation
## 2. 当前基础
The merged branch has enough technical foundation for internal smoke testing:
已合并分支已经具备内部冒烟测试所需的技术基础:
- EBA adapters can normalize platform activity into stable host event names.
- Bots can persist `event_bindings` and route events to an Agent, Pipeline, or discard target.
- Legacy message input can be projected into the canonical `message.received` event path.
- Pipeline remains available as a message-only no-code processor.
- AgentRunner pluginization provides the host-runner contract, event-first context, result stream, and runtime integration boundary.
- Official local and external runner plugins can validate that runner behavior is no longer hard-coded into LangBot core.
- The WebUI has an Agent/processor management surface and bot-side event routing surface.
- EBA 适配器可以把平台活动规范化为稳定的 Host 事件名。
- 机器人可以持久化 `event_bindings`,并将事件路由到 AgentPipeline 或丢弃目标。
- 旧消息输入可以投射到标准的 `message.received` 事件路径。
- Pipeline 仍可作为只处理消息的无代码处理器使用。
- AgentRunner 插件化提供了 Host 与 Runner 的契约、事件优先上下文、结果流和运行时集成边界。
- 官方 local runner 和 external runner 插件可以验证 runner 行为已经不再硬编码在 LangBot Core 中。
- WebUI 已具备 Agent/处理器管理页面,以及机器人侧事件路由页面。
- 机器人事件路由已具备 dry-run 诊断、运行时状态展示,以及安全的合成测试事件派发;测试事件会走已保存的 runtime 路由,但抑制真实平台出站动作。
- MCP 工具面已暴露机器人事件路由状态查询和合成测试事件派发,便于 QA agent 或外部调试工具复用。
This is a technical convergence milestone, not a product-ready release.
这是一个技术收敛里程碑,还不是产品就绪版本。
## 3. Gap to a Non-Technical Product
## 3. 距离非技术产品的缺口
### 3.1 Conceptual Load
### 3.1 概念负担
Current users still need to understand too many internal concepts: EBA, adapter event names, runner identifiers, plugin runtime health, event patterns, priority, and binding targets. A non-technical product should guide users through intent and outcomes:
当前用户仍需要理解过多内部概念:EBA、适配器事件名、runner 标识、插件运行时健康状态、事件模式、优先级和绑定目标。面向非技术用户的产品应当通过意图和结果来引导:
- "When a message is received, reply with this processor."
- "When a new group member joins, send a welcome message."
- "When a friend request arrives, ask an Agent to decide whether to accept."
- “收到一条消息时,用这个处理器回复。”
- “有新群成员加入时,发送欢迎语。”
- “收到好友请求时,让 Agent 判断是否接受。”
Raw event patterns such as `group.member_joined` should remain available in advanced mode, but the default UI should group events by friendly names and platform capability.
`group.member_joined` 这类原始事件模式应继续保留在高级模式中,但默认 UI 应按友好名称和平台能力对事件进行分组。
### 3.2 Onboarding
### 3.2 上手路径
The first-run path needs to start from a use case, not from architecture:
首次使用路径应当从用例开始,而不是从架构开始:
1. Choose a channel.
2. Connect the account or webhook.
3. Pick an event preset supported by that channel.
4. Choose or create a processor.
5. Send a test event.
6. Read a simple run trace if it fails.
1. 选择一个频道。
2. 连接账号或 webhook
3. 选择该频道支持的事件预设。
4. 选择或创建一个处理器。
5. 发送测试事件。
6. 如果失败,阅读简单的运行轨迹。
The current product still assumes users can diagnose whether the backend, plugin runtime, box runtime, adapter, and runner plugin are all connected. That is acceptable for developers, but not for non-technical users.
当前产品仍假设用户能诊断后端、插件运行时、Box 运行时、适配器和 runner 插件是否都已连接。对于开发者这是可接受的,但对非技术用户不可接受。
### 3.3 Adapter Readiness
### 3.3 适配器就绪度
Each adapter needs a product capability manifest, not only an engineering implementation:
每个适配器都需要产品能力清单,而不仅是工程实现:
- supported event list with friendly labels;
- supported outgoing actions;
- required credentials and setup steps;
- local/self-host/SaaS availability;
- test signal availability;
- deprecated/legacy status;
- known limitations.
- 支持的事件列表及友好标签;
- 支持的出站动作;
- 所需凭据和配置步骤;
- 本地部署、自托管、SaaS 可用性;
- 测试信号可用性;
- 废弃/遗留状态;
- 已知限制。
Deprecated adapters should be clearly marked as "deprecated" or "legacy" in the product. New event-based adapters should be described by channel name and capability, not by the EBA acronym.
废弃适配器应在产品中明确标记为“已废弃”或“遗留”。新的事件型适配器应按频道名称和能力描述,而不是使用 EBA 缩写。
### 3.4 Processor UX
### 3.4 处理器体验
The Agent page should manage reusable processors, not make users think about all event routing decisions at once.
处理器页面应管理可复用的 Agent 与 Pipeline,而不是让用户一次性理解所有事件路由决策。
- Agent creation should provide opinionated runner templates.
- Pipeline creation should remain a no-code message pipeline path.
- Future Workflow creation can be introduced as another processor type when execution semantics are stable.
- Supported event range should be shown as capability information and advanced constraints, not as the main creation concept.
- Dependency health should be visible: runner plugin installed, runtime connected, required model configured, required resource accessible.
- 创建 Agent 时应提供有倾向性的 runner 模板。
- 创建 Pipeline 时应继续保持无代码消息流水线路径。
- 未来 Workflow 的执行语义稳定后,可以作为另一种处理器类型引入。
- 支持的事件范围应作为能力信息和高级约束展示,而不是作为创建流程的主要概念。
- 依赖健康状态应可见:runner 插件是否安装、运行时是否连接、所需模型是否配置、所需资源是否可访问。
### 3.5 Bot Event Routing UX
### 3.5 机器人事件路由体验
The Bot page should be the primary place for platform-specific event routing because platform events are easiest to understand in the context of the connected channel.
机器人页面应成为平台特定事件路由的主要配置位置,因为平台事件在已连接频道的上下文中最容易被用户理解。
Minimum product requirements:
最低产品要求:
- friendly event selector generated from adapter capability;
- target selector filtered by event compatibility;
- Pipeline hidden for non-message events;
- conflict warnings for overlapping routes;
- priority explained visually rather than as a raw number first;
- route test button that can inject or replay a sample event;
- per-route status showing last matched run and last failure reason;
- safe fallback route, including explicit discard.
- 基于适配器能力生成友好的事件选择器;
- 目标选择器按事件兼容性过滤;
- 对非消息事件隐藏 Pipeline
- 对重叠路由给出冲突警告;
- 优先级先用视觉方式解释,而不是首先展示原始数字;
- 提供路由测试按钮,可以注入或重放样例事件;
- 每条路由展示状态,包括最近一次匹配的 run 和最近失败原因;
- 提供安全的兜底路由,包括显式丢弃。
### 3.6 Observability
### 3.6 可观测性
Non-technical users need a short run trace rather than raw logs:
非技术用户需要的是简短运行轨迹,而不是原始日志:
```text
Event received -> Route matched -> Processor started -> Action delivered
收到事件 -> 命中路由 -> 启动处理器 -> 动作已投递
```
When something fails, the UI should point to the failing layer:
当失败发生时,UI 应指出失败层级:
- channel not connected;
- event unsupported by adapter;
- no route matched;
- processor disabled;
- runner plugin unavailable;
- model/resource missing;
- delivery permission denied.
- 频道未连接;
- 适配器不支持该事件;
- 没有路由命中;
- 处理器已禁用;
- runner 插件不可用;
- 模型/资源缺失;
- 投递权限被拒绝。
### 3.7 Documentation and Templates
### 3.7 文档和模板
The release needs product docs and templates for common scenarios:
发布需要面向产品场景的文档和模板:
- customer service bot;
- group welcome and moderation;
- friend request review;
- Dify-backed external Agent;
- local Agent with LangBot model and knowledge base;
- message Pipeline for compatibility.
- 客服机器人;
- 群欢迎和群管理;
- 好友请求审核;
- Dify 支持的外部 Agent
- 使用 LangBot 模型和知识库的本地 Agent;
- 用于多阶段消息处理的 Pipeline。
The docs should describe the product model first and expose internal terms only in advanced architecture sections.
文档应先描述产品模型,只在高级架构章节中暴露内部术语。
## 4. Recommended UX Boundary
## 4. 推荐 UX 边界
The previous "put all event orchestration in Agent" direction should be narrowed. The better boundary is:
之前“把所有事件编排都放进 Agent”的方向应当收窄。更好的边界是:
- **Bot page owns event routing**, because the event surface is platform-specific and users naturally expect channel behavior to be configured on the bot.
- **Agent page owns processor assets**, because processors should be reusable across bots and can later be packaged into solutions.
- **Pipeline remains a processor type**, not a historical object hidden behind Agent terminology.
- **机器人页面负责事件路由**,因为事件面是平台特定的,用户也自然会在机器人上配置频道行为。
- **处理器页面负责处理器资产**,因为 Agent 与 Pipeline 都应能跨机器人复用,并且未来可以被打包进 Solution
- **Pipeline 保持为一种处理器类型**,而不是隐藏在 Agent 术语背后的历史对象。
This gives a lower mental load:
这样可以降低心智负担:
- A user asks "what should this bot do when something happens?" on the Bot page.
- A user asks "what logic do I want to reuse?" on the Agent page.
- 用户在机器人页面问:“当这个机器人遇到某件事时,应该做什么?”
- 用户在处理器页面问:“我想复用什么处理逻辑?”
It also supports the requirement that different events on the same bot can use different processor types: one event can use Pipeline, another can use Agent, and a future event can use Workflow.
这也支持同一个机器人上的不同事件使用不同处理器类型:一个事件可以使用 Pipeline,另一个事件可以使用 Agent,未来另一个事件可以使用 Workflow
## 5. Future Export, Distribution, and Import Unit
## 5. 未来导出、分发和导入单元
Export/import is not in the current implementation scope, but the product boundary should not block it.
导出/导入不在当前实现范围内,但产品边界不应阻塞它。
The correct future distribution unit is **Solution**, not Bot and not Agent alone.
正确的未来分发单元是 **Solution**,不是机器人,也不是单独的 Agent。
A Solution should contain:
Solution 应包含:
- processors: Agent, Pipeline, future Workflow definitions;
- route templates: event patterns, friendly names, target logical references, default priority, and optional conditions;
- dependency manifest: required runner plugins, adapter capability requirements, models, tools, and resources;
- variables: user-provided values such as API keys, channel choices, model selections, and prompt parameters;
- docs: setup intent and expected behavior.
- 处理器:AgentPipeline、未来 Workflow 定义;
- 路由模板:事件模式、友好名称、目标逻辑引用、默认优先级和可选条件;
- 依赖清单:所需 runner 插件、适配器能力要求、模型、工具和资源;
- 变量:用户提供的值,例如 API key、频道选择、模型选择和 prompt 参数;
- 文档:配置意图和预期行为。
A Solution should not contain:
Solution 不应包含:
- concrete bot credentials;
- installed runtime tokens;
- tenant or namespace UUIDs;
- secrets;
- raw platform account identifiers;
- resolved bot event binding UUIDs.
- 具体机器人凭据;
- 已安装运行时 token
- 租户或命名空间 UUID
- 密钥;
- 原始平台账号标识;
- 已解析的机器人事件绑定 UUID
During import, route templates should be resolved inside the target namespace after the user chooses a bot/channel and grants required permissions.
导入时,应在目标命名空间内解析路由模板。用户需要先选择机器人/频道,并授予所需权限。
## 6. SaaS Multi-Namespace Architecture
## 6. SaaS 多命名空间架构
The product must support a multi-namespace SaaS architecture before public SaaS release. The event routing model is sensitive because adapters, credentials, processors, runtimes, state, and logs all cross trust boundaries.
产品在公开 SaaS 发布前必须支持多命名空间 SaaS 架构。事件路由模型很敏感,因为适配器、凭据、处理器、运行时、状态和日志都会跨越信任边界。
### 6.1 Namespace Model
### 6.1 命名空间模型
Use a layered namespace model:
采用分层命名空间模型:
| Scope | Purpose |
| 范围 | 用途 |
| --- | --- |
| Tenant | Billing, legal ownership, top-level isolation. |
| Workspace | Collaboration and product workspace inside a tenant. |
| Namespace | Deployable isolation boundary for bots, processors, runtime tokens, resources, and logs. A self-host deployment can have one default namespace. |
| Bot scope | Platform adapter instance and event route table. |
| Processor scope | Agent, Pipeline, Workflow, and related config. |
| Runtime scope | Plugin runtime, runner registrations, leases, and execution permissions. |
| Resource scope | Knowledge bases, model credentials, files, state, and secrets. |
| 租户(Tenant | 计费、法律归属、顶层隔离。 |
| 工作空间(Workspace | 租户内的协作和产品工作区。 |
| 命名空间(Namespace | 机器人、处理器、运行时 token、资源和日志的可部署隔离边界。自托管部署可以只有一个默认命名空间。 |
| 机器人范围 | 平台适配器实例和事件路由表。 |
| 处理器范围 | AgentPipelineWorkflow 及相关配置。 |
| 运行时范围 | 插件运行时、runner 注册、lease 和执行权限。 |
| 资源范围 | 知识库、模型凭据、文件、状态和密钥。 |
Core persisted objects should carry `tenant_id`, `workspace_id`, and `namespace_id` before SaaS GA. Self-hosted deployments can seed a default tenant/workspace/namespace during migration.
在 SaaS GA 前,核心持久化对象都应携带 `tenant_id``workspace_id` `namespace_id`。自托管部署可以在迁移时种子化一个默认租户/工作空间/命名空间。
### 6.2 Event Ingress Isolation
### 6.2 事件入口隔离
Every incoming event must resolve namespace before route matching:
每个入站事件都必须先解析命名空间,再进行路由匹配:
```text
adapter ingress -> tenant/workspace/namespace resolution -> event normalization -> event log append -> route match -> processor run
```
Webhook and callback endpoints should encode or look up a namespace-scoped adapter installation. A platform event from one namespace must never match a route from another namespace, even if adapter names, bot names, or raw platform IDs collide.
Webhook 和回调端点应编码或查找命名空间范围内的适配器安装。来自一个命名空间的平台事件,绝不能匹配另一个命名空间的路由,即使适配器名称、机器人名称或原始平台 ID 发生碰撞。
### 6.3 Route Target Rules
### 6.3 路由目标规则
Runtime route bindings can use installed UUIDs, but exported route templates must use logical references. In SaaS:
运行时路由绑定可以使用已安装 UUID,但导出的路由模板必须使用逻辑引用。在 SaaS 中:
- a bot route can target only processors in the same namespace by default;
- cross-namespace targets are forbidden unless explicitly shared by policy;
- Pipeline targets remain message-only;
- route conflict evaluation is namespace-local;
- route audit events must include tenant, workspace, namespace, bot, route, target, and run identifiers.
- 默认情况下,机器人路由只能指向同一命名空间内的处理器;
- 跨命名空间目标默认禁止,除非由策略明确共享;
- Pipeline 目标仍然只允许处理消息;
- 路由冲突评估应限制在命名空间内;
- 路由审计事件必须包含租户、工作空间、命名空间、机器人、路由、目标和 run 标识。
### 6.4 Runtime and Plugin Isolation
### 6.4 运行时和插件隔离
The plugin runtime and runner registry need namespace-scoped authority:
插件运行时和 runner 注册表需要命名空间范围的授权:
- runtime registration token is scoped to one namespace or an explicitly allowed namespace set;
- runner discovery is filtered by namespace permissions;
- leases and heartbeats are namespace-scoped;
- run-scoped API tokens cannot access objects outside the run namespace;
- plugin storage, state, and temporary files include namespace in their storage key;
- shared runtime is acceptable for early SaaS only if every API call is scoped and audited;
- dedicated runtime should be available for enterprise or high-risk tenants.
- 运行时注册 token 只作用于一个命名空间,或显式允许的一组命名空间;
- runner 发现结果按命名空间权限过滤;
- lease heartbeat 按命名空间隔离;
- run-scoped API token 不能访问 run 所在命名空间之外的对象;
- 插件存储、状态和临时文件的 storage key 应包含命名空间;
- 早期 SaaS 可以接受共享运行时,但每个 API 调用都必须被 scoped 并审计;
- 企业或高风险租户应支持专用运行时。
### 6.5 Secrets, Resources, and State
### 6.5 密钥、资源和状态
Secrets and resources must not be globally addressable:
密钥和资源不能全局寻址:
- adapter credentials live in namespace-scoped secret storage;
- model provider credentials can be tenant/workspace/namespace scoped according to policy;
- knowledge resources declare which namespaces can use them;
- Agent persistent state is partitioned by tenant/workspace/namespace/processor unless a sharing policy says otherwise;
- event logs and transcripts are namespace-scoped and subject to retention policy.
- 适配器凭据存放在命名空间范围的密钥存储中;
- 模型提供商凭据可以根据策略按租户/工作空间/命名空间设定范围;
- 知识资源声明允许哪些命名空间使用;
- Agent 持久状态按租户/工作空间/命名空间/处理器分区,除非共享策略另有规定;
- 事件日志和会话记录按命名空间隔离,并受保留策略约束。
### 6.6 Marketplace and Installed Assets
### 6.6 Marketplace 和已安装资产
Marketplace package visibility is not the same as installed asset visibility:
Marketplace 包可见性不等于已安装资产可见性:
- marketplace package can be public, tenant-private, or workspace-private;
- installation creates namespace-local assets or namespace-local references;
- installed processors and route templates are copied by default to avoid accidental cross-tenant mutation;
- updates should be explicit and auditable.
- Marketplace 包可以是公开、租户私有或工作空间私有;
- 安装会创建命名空间本地资产,或命名空间本地引用;
- 已安装处理器和路由模板默认复制,避免意外跨租户变更;
- 更新必须显式且可审计。
### 6.7 SaaS Test Requirements
### 6.7 SaaS 测试要求
Before SaaS beta:
SaaS beta 前必须验证:
- tenant A event cannot match tenant B routes;
- namespace A runtime cannot claim namespace B runs;
- namespace A processor cannot read namespace B resources;
- solution import cannot preserve source tenant UUIDs or secrets;
- route replay cannot expose raw event payload from another namespace;
- admin users can inspect audit trails without accessing secret values.
- 租户 A 的事件不能匹配租户 B 的路由;
- 命名空间 A 的运行时不能 claim 命名空间 B 的 run
- 命名空间 A 的处理器不能读取命名空间 B 的资源;
- Solution 导入不能保留源租户 UUID 或密钥;
- 路由重放不能暴露另一个命名空间的原始事件 payload;
- 管理员可以查看审计轨迹,但不能访问密钥值。
## 7. Release Plan
## 7. 发布计划
### Phase 0: Technical Convergence
### Phase 0:技术收敛
Goal: prove the merged branch can run with event bindings and externalized runners.
目标:证明合并分支可以基于事件绑定和外置 runner 运行。
Required gates:
必要门禁:
- Bot event routing uses `event_bindings` as the single routing source.
- Legacy bot pipeline routing fields are removed or migrated.
- Pipeline works as a message-only processor.
- Agent runner plugin smoke tests pass with local and Dify runners.
- Migration naming and downgrade paths are valid.
- Primary UI no longer exposes "EBA" in user-facing adapter names.
- 机器人事件路由以 `event_bindings` 作为唯一路由来源。
- 旧机器人 pipeline 路由字段已移除或迁移。
- Pipeline 可作为只处理消息的处理器运行。
- Agent runner 插件冒烟测试通过,覆盖 local runner 和 Dify runner
- 迁移命名和 downgrade 路径有效。
- 主 UI 不再在面向用户的适配器名称中暴露 “EBA”。
### Phase 1: Private Beta for Technical Users
### Phase 1:面向技术用户的私有 Beta
Goal: make the system usable by contributors and early self-host adopters.
目标:让贡献者和早期自托管用户可用。
Required gates:
必要门禁:
- adapter capability matrix exists in docs and UI;
- route editor filters incompatible targets;
- runner/plugin health checks are visible;
- local Agent and Dify Agent setup has a guided path;
- per-route run trace exists;
- deprecated adapters are marked consistently;
- failure messages identify the failing layer.
- 文档和 UI 中存在适配器能力矩阵;
- 路由编辑器会过滤不兼容目标;
- runner/plugin 健康检查可见;
- local Agent Dify Agent 有引导式配置路径;
- 每条路由有运行轨迹;
- 废弃适配器被一致标记;
- 失败信息能指出失败层级。
### Phase 2: Product Beta for Non-Technical Users
### Phase 2:面向非技术用户的产品 Beta
Goal: let users complete common scenarios without reading architecture docs.
目标:让用户无需阅读架构文档也能完成常见场景。
Required gates:
必要门禁:
- first-run bot wizard starts from use case and channel;
- event presets hide raw event patterns by default;
- route simulation or test event exists;
- common processor templates exist;
- conflict warnings and fallback behavior are clear;
- docs use product language first and advanced terms later;
- SaaS namespace schema is implemented or migration-ready.
- 首次使用机器人向导从用例和频道开始;
- 事件预设默认隐藏原始事件模式;
- 存在路由模拟或测试事件能力;
- 存在常见处理器模板;
- 冲突警告和兜底行为清晰;
- 文档先使用产品语言,再介绍高级术语;
- SaaS 命名空间 schema 已实现,或已经具备迁移准备。
### Phase 3: SaaS Beta
### Phase 3SaaS Beta
Goal: run the product for multiple tenants safely.
目标:安全地为多个租户运行产品。
Required gates:
必要门禁:
- tenant/workspace/namespace fields exist on all routing, runtime, state, log, and resource facts;
- namespace-scoped runtime registration and run claim are enforced;
- namespace-scoped secrets and adapter installs are enforced;
- route matching and audit are namespace-local;
- quotas, retention, and admin audit surfaces exist;
- migration from self-host default namespace is documented.
- 所有路由、运行时、状态、日志和资源事实都具备租户/工作空间/命名空间字段;
- 命名空间范围的运行时注册和 run claim 已强制执行;
- 命名空间范围的密钥和适配器安装已强制执行;
- 路由匹配和审计限制在命名空间内;
- 配额、保留策略和管理员审计界面存在;
- 自托管默认命名空间迁移已有文档。
### Phase 4: GA
### Phase 4GA
Goal: make the product reliable for broad adoption.
目标:让产品具备广泛采用所需的可靠性。
Required gates:
必要门禁:
- solution export/import is implemented with dependency and variable resolution;
- marketplace distribution supports namespace-local installation;
- cross-namespace sharing policy is explicit;
- security review covers adapters, runtime tokens, runner APIs, secrets, logs, and route replay;
- upgrade and rollback procedures are documented;
- product telemetry measures onboarding drop-off and route failure categories.
- Solution 导出/导入已实现,并支持依赖和变量解析;
- Marketplace 分发支持命名空间本地安装;
- 跨命名空间共享策略是显式的;
- 安全评审覆盖适配器、运行时 tokenrunner API、密钥、日志和路由重放;
- 升级和回滚流程已有文档;
- 产品遥测可以衡量上手流失和路由失败类别。
## 8. Acceptance Checklist
## 8. 验收清单
A release can be considered product-ready only when:
只有满足以下条件,才可以认为发布版本达到产品就绪:
- a non-technical user can connect a supported channel, choose a scenario, bind a processor, test it, and understand the result without editing raw JSON;
- the product UI avoids internal terms such as EBA in primary flows;
- Bot page owns platform event routing and Agent page owns reusable processors;
- Pipeline remains visible as a no-code message processor and is not offered for non-message events;
- different events on one bot can route to different processor types;
- route failures are explained by layer;
- namespace isolation is enforced by schema, service checks, runtime tokens, storage keys, and tests;
- export/import, when implemented, uses Solution packages with route templates rather than concrete bot bindings.
- 非技术用户可以连接一个受支持频道,选择场景,绑定处理器,测试它,并在不编辑原始 JSON 的情况下理解结果;
- 产品 UI 在主要流程中避免使用 EBA 这类内部术语;
- 机器人页面负责平台事件路由,处理器页面负责可复用的 Agent 与 Pipeline
- Pipeline 仍作为无代码消息处理器可见,并且不会出现在非消息事件目标中;
- 同一个机器人的不同事件可以路由到不同处理器类型;
- 路由失败能按层级解释;
- 命名空间隔离通过 schemaservice check、运行时 tokenstorage key 和测试强制执行;
- 导出/导入实现后,使用带路由模板的 Solution 包,而不是具体机器人绑定。
## 9. Open Decisions
## 9. 待决策问题
- Final user-facing name for the combined processor surface: "Agent", "Processor", or another term.
- Whether Workflow should be a separate persisted processor type or an Agent runner category.
- Whether SaaS namespaces should map one-to-one to workspaces initially, or whether advanced tenants need multiple namespaces per workspace from the first SaaS beta.
- Which adapters are allowed in SaaS shared runtime and which require dedicated runtime isolation.
- Exact package format for future Solution export/import.
- 组合处理器入口统一使用 “Processor / 处理器”;Agent 与 Pipeline 是其中平级的类型。
- Workflow 应作为独立持久化处理器类型,还是作为 Agent runner 类别。
- SaaS 命名空间初期是否与工作空间一一映射,还是高级租户在首个 SaaS beta 就需要一个工作空间下多个命名空间。
- 哪些适配器允许在 SaaS 共享运行时中运行,哪些需要专用运行时隔离。
- 未来 Solution 导出/导入的确切包格式。
+2 -2
View File
@@ -143,7 +143,7 @@ pkg/platform/adapters/
### 3.4 事件处理器(Event Handler
> **2026-06 方向修订**:四种处理器的分类法已演进为「事件 → Agent」统一编排——所有编排目标(流水线、RequestRunner、webhook、工作流)收编为 Agent 抽象,插件 EventListener 保留为观察者角色。详见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本节保留原设计供对照。
> **2026-06 方向修订**:四种 Handler 分类法已演进为「事件 → 处理器」统一路由。Pipeline 与 Agent 是长期并存、场景不同的同级处理器:Pipeline 面向消息事件的完整 Stage 链,Agent 面向其声明支持的消息或非消息事件;插件 EventListener 保留为观察者角色。详见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本节保留原设计供对照。
四种处理器类型,用户在 WebUI 的 Bot 管理页面配置:
@@ -187,7 +187,7 @@ pkg/platform/adapters/
| [04-event-routing.md](./04-event-routing.md) | 事件路由与编排:路由引擎、处理器类型、WebUI 数据模型 |
| [05-plugin-sdk.md](./05-plugin-sdk.md) | 插件 SDK 改造:新事件/API、兼容层 |
| [06-migration-plan.md](./06-migration-plan.md) | 分阶段迁移计划 |
| [07-agent-orchestration.md](./07-agent-orchestration.md) | **产品最终形态(2026-06 修订)**Agent 统一编排、SDK Agent 组件契约、发布火车 |
| [07-agent-orchestration.md](./07-agent-orchestration.md) | **产品最终形态(2026-06 修订)**Pipeline / Agent 同级处理器编排、SDK Agent 组件契约、发布火车 |
## 6. 涉及的代码仓库
+3 -1
View File
@@ -1,6 +1,6 @@
# 事件路由与编排
> **2026-06 方向修订**:本文档的四种 handler_typepipeline / agent / webhook / plugin)分类法已被「事件 → Agent」统一编排取代,收编映射与新数据模型见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本文档中的事件匹配规则(§4)、`use_pipeline_uuid` 迁移策略(§6)、WebUI 交互骨架(§7)与 webhook 请求/响应格式(§5.4)仍然有效,将在 Agent 模型下沿用
> **2026-06 方向修订**:本文档的四种 handler_typepipeline / agent / webhook / plugin)分类法已被「事件 → 处理器(Agent / Pipeline」统一编排取代,收编映射与新数据模型见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本文档中的事件匹配规则(§4)、`use_pipeline_uuid` 路由迁移策略(§6)、WebUI 交互骨架(§7)与 webhook 请求/响应格式(§5.4)仍然有效。
## 1. 概述
@@ -601,6 +601,8 @@ class PluginHandler(AbstractEventHandler):
数据库迁移脚本将现有的 `use_pipeline_uuid` 自动转换为 `event_handlers`
这里迁移的只有 Bot 路由字段:binding 仍指向原 Pipeline,不会新建 Agent,也不会复制 Pipeline 内嵌的 runner 配置。
```python
# 迁移逻辑
for bot in all_bots:
+3 -1
View File
@@ -1,6 +1,6 @@
# 分阶段迁移计划
> **2026-06 方向修订**Phase 3 的「四种 Handler 框架」与 Phase 5 的编排面板形态,按 [07-agent-orchestration.md](./07-agent-orchestration.md) 调整为「事件 → Agent」统一编排(EventRouter + Agent 实体 + 绑定模型 + SDK Agent 组件契约)。阶段划分、依赖关系与验收标准仍然适用,按 Agent 模型重新解读即可;发布节奏见 07 §5「发布火车」。
> **2026-06 方向修订**Phase 3 的「四种 Handler 框架」与 Phase 5 的编排面板形态,按 [07-agent-orchestration.md](./07-agent-orchestration.md) 调整为「事件 → 处理器」统一路由。EventRouter 通过 `target_type` 在同级 Pipeline / Agent 间选择;二者分别保留自己的实体、配置和执行链。阶段划分、依赖关系与验收标准按 Processor 模型解读;发布节奏见 07 §5「发布火车」。
## 1. 概述
@@ -141,6 +141,8 @@ EBA 架构涉及 langbot-plugin-sdk、LangBot 后端、LangBot 前端、文档
现有 Bot 表有 `use_pipeline_uuid` 字段,需要自动迁移为 `event_handlers`
该步骤只改写 Bot 的路由表示,仍通过原 UUID 指向原 Pipeline;不得创建独立 Agent,也不得复制 Pipeline 内嵌的 runner 配置。用户需要 Agent 时自行新增并绑定。
```python
# 迁移逻辑伪代码
for bot in all_bots:
@@ -1,12 +1,12 @@
# Agent 统一编排(产品最终形态)
# Agent 与 Pipeline 统一编排(产品最终形态)
> **状态**:方向修订稿(2026-06-12),供「适配器改造 / Agent 插件化 / 工作流引擎」三条工作线评审。
>
> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一收编为 Agent 抽象**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。
> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。
## 1. 产品最终形态
**适配器接收各种事件 → 用户编排处理逻辑 → Agent 统一抽象**,实现从 0 代码到低代码再到全代码的全层面支持:
**适配器接收各种事件 → 用户编排处理逻辑 → 处理器统一选择表面**,实现从 0 代码到低代码再到全代码的全层面支持:
```
消息平台 (Telegram / Discord / 企微 / ...)
@@ -15,20 +15,18 @@
平台适配器(EBA 新结构,已迁移 12 个)
│ EBAEvent (message.* / group.* / friend.* / bot.* / feedback.* / platform.*)
EventRouter(事件 → Agent 绑定)
├─→ 选中的 Agent(响应者,单一仲裁)
│ ├─ 内置:pipeline-wrapper(旧流水线收编)/ local-agent
插件:SDK Agent 组件(全代码)
│ ├─ 低代码:工作流定义的 Agent(内部工作流引擎)
│ └─ 外部:dify / n8n / coze / dashscope / webhookRequestRunner 体系收编)
EventRouter(事件 → 处理器绑定)
├─→ 选中的处理器(响应者,单一仲裁)
│ ├─ Pipeline:保留现有实体和执行链,仅处理消息事件
Agent:用户新建并选择 AgentRunner 插件,可接本地、低代码或外部 runtime
└─→ 插件 EventListener(观察者,N 个广播,可 prevent_default
```
| 编写方式 | Agent 形态 | 代码化程度 |
| 编写方式 | 处理器形态 | 代码化程度 |
|----------|-----------|-----------|
| WebUI 配置模型 + 提示词 + 工具 | 内置 local-agent | 0 代码 |
| 沿用现有流水线 | pipeline-wrapper 内置 Agent | 0 代码(兼容) |
| WebUI 配置模型 + 提示词 + 工具 | 独立 Agent + LocalAgent 插件 | 0 代码 |
| 可视化消息编排 | Pipeline`kind=pipeline`,完整多 Stage 链) | 0 代码 |
| 市场安装 | Agent 插件(市场分发) | 0 代码(使用者视角) |
| 可视化工作流 | 工作流引擎定义的 Agent | 低代码 |
| 对接外部平台 | dify / n8n / coze / webhook 外部 Agent | 集成 |
@@ -42,24 +40,24 @@ EventRouter(事件 → Agent 绑定)
| Agent 插件化 | Agent 抽象、Agent 组件类型、市场分发 | 事件的**消费侧**统一抽象 |
| 工作流引擎 | 内部低代码工作流 | Agent 的一种**编写方式** |
**汇合点是 SDK 的 Agent 组件契约(§4)与 event→agent 绑定模型(§3)**。这两个接口冻结后,三条线可彼此 mock 独立推进。契约由本分支(EBA)牵头起草,三线评审后在 langbot-plugin-sdk 落地(发布通道:0.5.0aX pre-release 已打通)。
**汇合点是 SDK 的 Agent 组件契约(§4)与 event→处理器绑定模型(§3)**。这两个接口冻结后,三条线可彼此 mock 独立推进。契约由本分支(EBA)牵头起草,三线评审后在 langbot-plugin-sdk 落地(发布通道:0.5.0aX pre-release 已打通)。
## 2. 从四种 Handler 到 Agent 统一抽象
## 2. 从四种 Handler 到统一处理器表面
### 2.1 演进理由
04 文档中的 pipeline / agent / webhook / plugin 四种 handler_type,本质上都是"对事件作出响应的逻辑",差别只在编写和部署方式。为四种类型分别设计配置表单、执行语义和扩展机制,等于把同一个概念做四遍。统一为 Agent
04 文档中的 pipeline / agent / webhook / plugin 四种 handler_type,本质上都是"对事件作出响应的逻辑",差别只在编写和部署方式。产品层统一展示和绑定这些处理器,但不会把既有 Pipeline 持久化为 Agent
- **产品**:用户只学一个概念——"给 Bot 的事件绑 Agent"
- **工程**:路由层退化为很薄的 event → agent 分发,所有扩展集中到 Agent 抽象;
- **产品**:用户只需理解"给 Bot 的事件绑定处理器",处理器可以是 Pipeline 或 Agent
- **工程**:路由层`target_type` 分发到 Pipeline 或 AgentAgent 的扩展集中到 AgentRunner 抽象;
- **生态**:Agent 成为市场上可分发、可复用的一等公民。
### 2.2 收编映射
| 原 handler_type04 文档) | 收编后 |
|---------------------------|--------|
| `pipeline` | 内置 `pipeline-wrapper` Agent:实例配置为 `pipeline_uuid`,进程内直接复用 MessageAggregator → QueryPool → Pipeline 机制 |
| `agent`RequestRunner | 现有 Runner 体系(local-agent / dify / n8n / coze / dashscope / langflow / tbox)整体收编为内置 Agent 家族——Runner 本来就是"Agent 抽象"的前身 |
| `pipeline` | 保留 Pipeline 实体;binding 使用 `target_type=pipeline` 和原 `pipeline_uuid`,进程内直接复用 MessageAggregator → QueryPool → Pipeline 机制 |
| `agent`RequestRunner | 用户新建独立 Agent,并选择对应 AgentRunner 插件;不读取或复制旧 Pipeline 内嵌 runner 配置 |
| `webhook` | 外部 Agent 的一种:事件 POST 出去、响应解析为动作(保留 04 §5.4 的请求/响应格式) |
| `plugin`EventListener 分发) | **不收编**——角色不同,见 §2.3 |
@@ -67,26 +65,25 @@ EventRouter(事件 → Agent 绑定)
事件的消费方有两种角色,不应混为一谈:
- **响应者(Agent**:路由选中**一个**,负责对事件作出回应(回复消息、执行动作)。多条绑定匹配同一事件时按 priority 仲裁,只取最高者。
- **观察者(插件 EventListener**:**广播**给所有注册插件,做旁路逻辑(日志、审计、风控、统计)。沿用现有机制不变,包括 `prevent_default()`——观察者可拦截本次事件,使 Agent 不被调用(与现有"插件拦截流水线"行为完全兼容)。
- **响应者(Pipeline 或 Agent**:路由选中**一个**,负责对事件作出回应(回复消息、执行动作)。多条绑定匹配同一事件时按 priority 仲裁,只取最高者。
- **观察者(插件 EventListener**:**广播**给所有注册插件,做旁路逻辑(日志、审计、风控、统计)。沿用现有机制不变,包括 `prevent_default()`——观察者可拦截本次事件,使处理器不被调用(与现有"插件拦截流水线"行为完全兼容)。
执行顺序:事件到达 → 先广播观察者(按插件优先级)→ 若未被 prevent_default → 分发给选中的 Agent
执行顺序:事件到达 → 先广播观察者(按插件优先级)→ 若未被 prevent_default → 分发给选中的处理器
## 3. 数据模型:event → agent 绑定
## 3. 数据模型:event → 处理器绑定
### 3.1 Agent 实体化(推荐)
### 3.1 独立 Agent 与现有 Pipeline
Agent 作为一等实体(独立表),用户创建/安装 Agent,再在 Bot 上把事件绑定到 Agent。好处:跨 Bot 复用、市场分发、独立的配置页面
Agent 与 Pipeline 都是一等处理器。用户创建 Agent、选择已安装 AgentRunner,再把适合的事件绑定到 AgentPipeline 继续保存在 Pipeline 表中,以完整 Stage 链处理消息事件。两者可在同一处理器列表中以不同 `kind` 展示和选择;这种聚合展示不会创建额外记录,也不会在两种模型之间复制配置
```python
class Agent(Base):
"""Agent 实例:一个具体配置过的、可被事件绑定的响应者"""
uuid: str # 主键
name: str
kind: str # "builtin" | "plugin"
component_ref: str # 内置: "pipeline-wrapper" / "local-agent" / "dify" / "webhook" / ...
# 插件: "<plugin_author>/<plugin_name>/<agent_component_name>"
config: dict # JSON — 实例配置(pipeline_uuid / 模型与提示词 / 外部平台凭据 / 工作流 id ...)
kind: str # 固定为 "agent"Pipeline 使用自己的持久模型
component_ref: str # AgentRunner id,例如 plugin:<author>/<plugin>/<runner>
config: dict # JSON — runner id、runner config 与资源/状态/投递策略
# 多租户预留:归属主体字段(tenant/workspace),首版可空
```
@@ -94,14 +91,15 @@ Bot 上的绑定配置(替代 04 §2.2 的 EventHandlerConfig,沿用其匹
```python
class EventBinding(pydantic.BaseModel):
event_type: str # 精确 / "message.*" / "*",匹配规则同 04 §4
agent_uuid: str # 绑定的 Agent 实例
event_pattern: str # 精确 / "message.*" / "*",匹配规则同 04 §4
target_type: str # "agent" | "pipeline" | "discard"
target_uuid: str # Agent 或 Pipeline 的原始 UUIDdiscard 时为空
enabled: bool = True
priority: int = 0 # 多条匹配时取最高者(单一仲裁)
description: str = ''
```
`use_pipeline_uuid` 自动迁移:为每个被引用的 pipeline 生成一个 `pipeline-wrapper` Agent 实例,并写入 `{"event_type": "message.received", "agent_uuid": <wrapper>}` 绑定。观察者广播不需要配置(始终发生),04 中"兜底 plugin 规则"不再需要。
`use_pipeline_uuid` 只迁移 Bot 的路由结构:写入 `{"event_pattern": "message.received", "target_type": "pipeline", "target_uuid": <原 pipeline uuid>}`,继续引用原 Pipeline。迁移不会创建独立 Agent,也不会复制 Pipeline 内嵌的 runner 配置;需要 Agent 的用户自行新增 Agent 并修改 binding。观察者广播不需要配置(始终发生),04 中"兜底 plugin 规则"不再需要。
## 4. SDK Agent 组件契约(草案)
@@ -156,16 +154,16 @@ class AgentChunk:
```
**流式**:复用 SDK 通信协议既有的 `chunk_status: continue/end` 机制,`handle()` 的每次 yield 对应一个 chunk。
**内置与插件同构**:内置 Agentpipeline-wrapper、local-agent、各外部平台)在 LangBot 进程内实现同一接口注册,不过 RPC;插件 Agent 经 plugin runtime 分发。路由层二者不可区分
**Pipeline 与 Agent 分流**Pipeline target 继续走 LangBot 进程内的 Pipeline 执行链;独立 Agent 经 AgentRunner 插件 runtime 分发。路由层通过 binding 的 `target_type` 明确区分二者。
### 4.3 执行语义与可靠性
| 关注点 | 约定 |
|--------|------|
| 仲裁 | 单响应者:priority 最高的匹配绑定生效,其余忽略 |
| 性能 | 内置 Agent 进程内零额外开销;插件 Agent 每事件过一次 RPC 边界,消息场景需设延迟预算(评审项:目标 P95 附加延迟) |
| 性能 | Pipeline 继续走进程内链路;插件 Agent 每事件过一次 RPC 边界,消息场景需设延迟预算(评审项:目标 P95 附加延迟) |
| 会话状态 | 归 LangBot 侧(SessionHandle),插件 Agent 原则上无状态,崩溃重启不丢会话 |
| 降级 | Agent 调用失败/超时:可配置 fallback(回错误提示,或指定备用 Agent);pipeline-wrapper 作为进程内兜底与性能对照组 |
| 降级 | Agent 调用失败/超时:可配置 fallback(回错误提示,或指定备用处理器);不会隐式回退到旧 Pipeline 配置 |
| 多租户预留 | AgentContext / SessionHandle / 存储接口显式携带归属主体标识,禁止新增全局单例状态——为后续轻量 SaaS 多租户铺路 |
## 5. 发布火车
@@ -173,7 +171,7 @@ class AgentChunk:
| 版本 | 内容 | 备注 |
|------|------|------|
| 4.11(可选) | 现状成果:12 个 EBA 适配器、插件全事件订阅、`call_platform_api` | 对用户不可见的管道工程 + 插件新能力,不动产品概念 |
| **5.0** | 产品形态首发:EventRouter + event→agent 绑定 + WebUI 编排 + 数据迁移 + 内置 Agentpipeline-wrapper、local-agent、外部平台家族)+ SDK Agent 组件契约(可标 experimental) | 资格线不依赖其他两线交付;配 SDK 0.5.0 正式版;走 beta 周期;deprecation(旧 sources 适配器、legacy/*、use_pipeline_uuid)集中在此窗口处理 |
| **5.0** | 产品形态首发:EventRouter + event→处理器绑定 + WebUI 编排 + 旧 Bot 路由迁移 + 独立 Agent / AgentRunner 插件 + SDK Agent 组件契约(可标 experimental | `use_pipeline_uuid` 仅改写为指向原 Pipeline 的 binding,不生成 Agent;配 SDK 0.5.0 正式版;走 beta 周期 |
| 5.x | 工作流 Agent(工作流引擎线挂入)、Agent 市场生态、剩余适配器(satori 等)、Agent 插件化收尾 | 验证开放注册机制 |
| 多租户 | 独立评估:仅数据隔离 → 5.x 部署选项;伴随权限/计费/产品定位变化 → 6.0 | 前置条件是 §4.3 的归属主体预留已落实 |
@@ -183,5 +181,5 @@ class AgentChunk:
2. **多 Agent 协作**:单一仲裁之外,是否需要"串联/并联多个 Agent"的场景?(建议 5.0 不做,留给工作流引擎表达)
3. **工作流引擎的宿主**:核心内置,还是自身也作为一个插件交付(解释工作流定义的 Agent 插件)?
4. **插件 Agent 的延迟预算**:消息主链路过 RPC 的 P95 目标值与压测方案。
5. **Pipeline 的长期命运**pipeline-wrapper 兼容期多长,Stage 体系是否在 6.0 退役或被工作流引擎吸收
5. **Workflow 的处理器边界**:未来 Workflow 应作为与 Pipeline、Agent 平级的处理器,还是作为其中一种处理器的内部编排实现
6. **SDK 1.0 时机**:Agent 契约稳定后是否随 LangBot 5.x 给插件生态一个 API 稳定承诺。
@@ -1,8 +1,8 @@
# Agent 页面与事件编排产品设计
# 处理器页面与事件编排产品设计
> 状态:实施稿(2026-06-23
>
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种同级处理单元**Agent 编排**与**Pipeline**。Agent 页面是统一入口,但不是把 Pipeline 消除
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义
## 1. 产品边界
@@ -10,33 +10,33 @@ LangBot 的处理逻辑分成两种同级形态:
| 形态 | 定位 | 可处理事件 | 典型用户 |
| --- | --- | --- | --- |
| Agent 编排 | 面向 EBA 的事件优先处理单元,承载 AgentRunner / 外部 runner / 后续工作流 | `message.*``group.*``friend.*``bot.*``feedback.*``platform.*` | 希望按事件类型配置不同智能处理逻辑的用户 |
| Pipeline | 现有无代码消息流水线与向后兼容形态 | 仅 `message.*`,首版等价于 `message.received` | 已有 Pipeline 用户、只需要消息处理的用户 |
| Agent | runner 驱动的事件优先处理,承载 AgentRunner / 外部 runner | `message.*``group.*``friend.*``bot.*``feedback.*``platform.*`声明范围 | 需要直接处理多类平台事件或接入外部 agent runtime 的用户 |
| Pipeline | 可视化、可控、可组合的消息处理流水线,执行完整 Stage 链 | 仅 `message.*`,首版等价于 `message.received` | 需要预处理、AI、后处理、扩展和输出控制的消息场景 |
Agent 页面负责统一管理这两种处理单元:
处理器页面负责统一管理这两种处理单元:
- 创建时选择 **Agent 编排****Pipeline**
- 创建时选择 **Agent****Pipeline**
- 列表中清晰标注类型与事件能力;
- Pipeline 的编辑、调试、监控继续复用现有能力;
- Agent 编排保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。
- Agent 保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。
## 2. 信息架构
### 2.1 Agent 页面
### 2.1 处理器页面
路径:`/home/agents`
职责:
1. 展示所有可被事件绑定的处理单元,包括 Agent 编排与 Pipeline。
1. 展示所有可被事件绑定的处理单元,包括 Agent 与 Pipeline。
2. 创建时先选择类型:
- Agent 编排:创建一条 Agent 配置对象,默认支持所有 EBA 事件
- Pipeline:创建现有 legacy pipeline,只能处理消息事件
- Agent:创建一条独立 Agent 配置对象,默认支持其 runner 声明的事件范围
- Pipeline:创建一条独立 Pipeline,执行完整消息 Stage 链
3. 编辑时按类型进入不同表单:
- Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring
- Agent 编排:配置基础信息、runner、runner config 和事件能力。
- Agent:配置基础信息、runner、runner config 和事件能力。
`/home/pipelines` 保留为兼容路由,但新导航入口使用 `/home/agents`
`/home/pipelines` 继续提供 Pipeline 直接编辑路径;共享处理器入口当前使用 `/home/agents`。URL 是实现路径,不代表 Agent 包含 Pipeline
### 2.2 Bot 的事件编排
@@ -57,9 +57,9 @@ Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,
## 3. 持久化模型
### 3.1 Agent 编排实例
### 3.1 Agent 实例与 Pipeline 实例
新增 `agents`只保存 Agent 编排形态。Pipeline 继续保存在 `legacy_pipelines`
`agents` 表只保存 AgentPipeline 继续保存在 Pipeline 表中。当前物理表名 `legacy_pipelines` 是既有存储名称,不代表 Pipeline 在产品架构中是遗留或过渡形态
```python
class Agent(Base):
@@ -76,7 +76,7 @@ class Agent(Base):
updated_at: datetime
```
Agent 聚合 API `agents``legacy_pipelines` 投影成同一个前端列表:
处理器聚合服务`agents`Pipeline 投影成同一个前端列表:
```json
{
@@ -137,27 +137,27 @@ Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续
4. `priority` 数值高者优先
5. 同优先级按列表顺序
## 5. 兼容策略
## 5. 并存策略
1. 现有 `legacy_pipelines` 不迁移、不改语义
2. 现有 Bot 的 `use_pipeline_uuid` 仍作为消息事件默认 Pipeline。
1. Pipeline 与 Agent 长期并存,各自保存配置并执行自己的运行链路
2. 现有 Bot 的 `use_pipeline_uuid` 转换为仍指向原 Pipeline 的消息事件绑定
3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。
4. 新增 `event_bindings` 是 EBA 事件编排配置,允许 Pipeline 目标只限 `message.*`
5. `/api/v1/pipelines` 继续存在;新增 `/api/v1/agents` 作为聚合入口
4. `event_bindings` 允许 `target_type=pipeline|agent|discard`Pipeline 目标只限 `message.*`
5. Pipeline 与 Agent 保留各自的持久化和编辑语义;处理器聚合入口只负责统一展示和选择
## 6. 分阶段落地
### P0产品入口统一
### P0处理器入口统一
- 新增 `/home/agents`
- 侧边栏显示“Agent”,列表包含 Agent 编排与 Pipeline。
- 创建时选择 Agent 编排或 Pipeline。
- `/home/pipelines` 保留兼容
- 侧边栏显示“处理器”,列表包含平级的 Agent 与 Pipeline。
- 创建时选择 Agent 或 Pipeline。
- `/home/pipelines` 继续作为 Pipeline 直接编辑路径
### P1:配置模型落地
- 新增 `agents` 表与 `/api/v1/agents`
- Agent 编排可保存 runner 与 runner_config。
- Agent 可保存 runner 与 runner_config。
- Pipeline 继续使用原 Pipeline 表单与 API。
### P2:事件编排配置面
@@ -169,12 +169,13 @@ Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续
### P3EventRouter 执行接入
- EBA 事件先广播插件 observer。
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择 Agent 编排
- 消息事件继续优先保留现有 Pipeline / MessageAggregator 兼容路径
- 非消息事件只调用 Agent 编排,不调用 PipelineAgentRunner 输出有平台 reply target 时会投递回平台。
- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择一个处理器
- Pipeline 目标通过 MessageAggregator 进入完整 Pipeline Stage 链;Agent 目标直接进入 AgentRunner 链路
- 非消息事件只选择声明支持该事件的 Agent,不调用 PipelineAgentRunner 输出有平台 reply target 时会投递回平台。
## 7. 不做的事
- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。
- 不把 Pipeline 降级为兼容层、Agent 子类型或临时运行入口。
- 不把非消息事件伪装成用户文本塞入 Pipeline。
- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。
@@ -0,0 +1,265 @@
#!/usr/bin/env node
import {
apiJson,
createBrowser,
ensureAuthenticatedBrowser,
ensureEvidence,
evidencePaths,
exitCode,
loadEnvFiles,
localIsoWithOffset,
safeScreenshot,
scanBrowserDiagnostics,
writeResult,
} from "./lib/langbot-e2e.mjs";
const caseId = "agent-runner-health-visibility";
await loadEnvFiles();
const paths = evidencePaths(caseId);
await ensureEvidence(paths);
const readyScreenshot = paths.screenshot.replace(/\.png$/, "-ready.png");
const unavailableScreenshot = paths.screenshot.replace(
/\.png$/,
"-unavailable.png",
);
const startedAt = new Date();
const frontendUrl = process.env.LANGBOT_FRONTEND_URL || "";
const backendUrl = process.env.LANGBOT_BACKEND_URL || "";
const missingRunnerId = "plugin:qa/missing-runner/default";
let browser;
let token = "";
let agentId = "";
const result = {
source: "automation",
case_id: caseId,
run_id: paths.runId,
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: "",
finished_at_local: "",
status: "fail",
reason: "",
url: "",
visible_signals: [],
api: {},
diagnostics: null,
cleanup: null,
evidence: {
console_log: paths.consoleLog,
network_log: paths.networkLog,
screenshot: paths.screenshot,
ready_screenshot: readyScreenshot,
unavailable_screenshot: unavailableScreenshot,
automation_result_json: paths.automationResultJson,
result_json: paths.resultJson,
},
evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"],
};
function schemaDefaults(items = []) {
return Object.fromEntries(
items
.filter((item) => item.name && Object.hasOwn(item, "default"))
.map((item) => [item.name, item.default]),
);
}
async function openRunnerSettings(page, agentUrl) {
await page.goto(agentUrl, { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: /Runner|运行器|ランナー/ })
.first()
.waitFor({ timeout: 15_000 });
await page
.getByRole("button", { name: /Runner|运行器|ランナー/ })
.first()
.click();
}
try {
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
browser = await createBrowser(paths);
const { page } = browser;
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
const auth = await ensureAuthenticatedBrowser(page, {
frontendUrl,
backendUrl,
});
if (auth.status !== "pass") {
result.status = auth.status;
throw new Error(auth.reason);
}
token = await page.evaluate(() => localStorage.getItem("token") || "");
if (!token) {
result.status = "blocked";
throw new Error("Authenticated browser has no reusable local token.");
}
const pluginStatus = await apiJson(
backendUrl,
"/api/v1/system/status/plugin-system",
{ token },
);
const pluginData = pluginStatus.json.data || {};
result.api.plugin_status = {
http_status: pluginStatus.status,
code: pluginStatus.json.code ?? null,
is_enable: pluginData.is_enable ?? null,
is_connected: pluginData.is_connected ?? null,
};
if (pluginStatus.status >= 400 || pluginStatus.json.code !== 0) {
result.status = "env_issue";
throw new Error(pluginStatus.json.msg || "Plugin status request failed.");
}
if (!pluginData.is_enable || !pluginData.is_connected) {
result.status = "env_issue";
throw new Error("The plugin runtime is not enabled and connected.");
}
const metadata = await apiJson(backendUrl, "/api/v1/agents/_/metadata", {
token,
});
const runnerTab = metadata.json.data?.runner_config;
const runnerStage = runnerTab?.stages?.find(
(stage) => stage.name === "runner",
);
const runnerOptions =
runnerStage?.config?.find((item) => item.name === "id")?.options || [];
const runner = runnerOptions[0];
result.api.agent_metadata = {
http_status: metadata.status,
code: metadata.json.code ?? null,
runner_count: runnerOptions.length,
selected_runner: runner?.name || null,
};
if (metadata.status >= 400 || metadata.json.code !== 0) {
throw new Error(metadata.json.msg || "Agent metadata request failed.");
}
if (!runner?.name) {
result.status = "blocked";
throw new Error("No registered AgentRunner is available for the UI check.");
}
const runnerConfigStage = runnerTab.stages.find(
(stage) => stage.name === runner.name,
);
const create = await apiJson(backendUrl, "/api/v1/agents", {
method: "POST",
token,
body: {
kind: "agent",
name: `Runner Health ${paths.runId.slice(-40)}`,
description: "Temporary AgentRunner health visibility fixture",
emoji: "H",
component_ref: runner.name,
config: {
runner: { id: runner.name, "expire-time": 0 },
runner_config: {
[runner.name]: schemaDefaults(runnerConfigStage?.config),
},
},
enabled: true,
supported_event_patterns: ["message.*"],
},
});
agentId = create.json.data?.uuid || "";
result.api.create_agent = {
http_status: create.status,
code: create.json.code ?? null,
};
if (create.status >= 400 || create.json.code !== 0 || !agentId) {
throw new Error(create.json.msg || "Failed to create the temporary Agent.");
}
const agentUrl = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(agentId)}`;
result.url = agentUrl;
await openRunnerSettings(page, agentUrl);
await page
.getByText(/Runner ready|运行器已就绪|Runner の準備完了/, { exact: true })
.waitFor({ timeout: 15_000 });
result.visible_signals.push("registered-runner-ready");
await safeScreenshot(page, readyScreenshot);
const staleUpdate = await apiJson(
backendUrl,
`/api/v1/agents/${encodeURIComponent(agentId)}`,
{
method: "PUT",
token,
body: {
component_ref: missingRunnerId,
config: {
runner: { id: missingRunnerId, "expire-time": 0 },
runner_config: { [missingRunnerId]: {} },
},
},
},
);
result.api.set_stale_runner = {
http_status: staleUpdate.status,
code: staleUpdate.json.code ?? null,
};
if (staleUpdate.status >= 400 || staleUpdate.json.code !== 0) {
throw new Error(
staleUpdate.json.msg || "Failed to set the stale runner fixture.",
);
}
await openRunnerSettings(page, agentUrl);
await page
.getByText(
/Selected runner is unavailable|所选运行器不可用|選択した Runner は利用できません/,
{ exact: true },
)
.waitFor({ timeout: 15_000 });
await page.getByRole("link", { name: /Extensions|扩展|拡張機能/ }).waitFor();
result.visible_signals.push("stale-runner-unavailable", "recovery-action");
await safeScreenshot(page, unavailableScreenshot);
await safeScreenshot(page, paths.screenshot);
result.diagnostics = await scanBrowserDiagnostics(paths);
if (result.diagnostics.status !== "pass") {
throw new Error(result.diagnostics.reason);
}
result.status = "pass";
result.reason =
"Agent Runner settings visibly distinguished a registered runner from a stale binding.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
if (browser?.page) await safeScreenshot(browser.page, paths.screenshot);
} finally {
const cleanup = {};
if (agentId && token && backendUrl) {
const deletedAgent = await apiJson(
backendUrl,
`/api/v1/agents/${encodeURIComponent(agentId)}`,
{ method: "DELETE", token },
).catch((error) => ({
status: 0,
json: { code: null, msg: error.message },
}));
cleanup.agent_deleted =
deletedAgent.status < 400 && deletedAgent.json.code === 0;
cleanup.agent_http_status = deletedAgent.status;
}
result.cleanup = cleanup;
if (agentId && !cleanup.agent_deleted && result.status === "pass") {
result.status = "fail";
result.reason = "The temporary runner health Agent was not deleted.";
}
if (browser) await browser.close().catch(() => {});
const finishedAt = new Date();
result.finished_at = finishedAt.toISOString();
result.finished_at_local = localIsoWithOffset(finishedAt);
await writeResult(paths, result);
console.log(JSON.stringify(result, null, 2));
}
process.exit(exitCode(result.status));
@@ -70,7 +70,7 @@ const startedAt = new Date();
const targets = [
{
id: "local-agent",
expected_runner_id: "plugin:langbot/local-agent/default",
expected_runner_id: "plugin:langbot-team/LocalAgent/default",
pipeline_url: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_URL"),
pipeline_name: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_NAME"),
require_func_call_model: true,
@@ -79,7 +79,7 @@ const targets = [
},
{
id: "acp-agent-runner",
expected_runner_id: "plugin:langbot/acp-agent-runner/default",
expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default",
pipeline_url: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_AGENT_RUNNER_PIPELINE_URL"),
pipeline_name: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", "LANGBOT_AGENT_RUNNER_PIPELINE_NAME"),
require_func_call_model: false,
@@ -219,7 +219,7 @@ async function run() {
return metadata.author && metadata.name ? `${metadata.author}/${metadata.name}` : "";
})
.filter(Boolean);
const requiredPlugins = ["langbot/local-agent", "langbot/acp-agent-runner", "qa/plugin-smoke"];
const requiredPlugins = ["langbot-team/LocalAgent", "langbot-team/ACPAgentRunner", "qa/plugin-smoke"];
const pluginPresence = Object.fromEntries(requiredPlugins.map((id) => [id, installedPluginIds.includes(id)]));
for (const [id, present] of Object.entries(pluginPresence)) {
addCheck(`plugin:${id}`, present ? "pass" : "blocked", { plugin_id: id, reason: present ? "" : "Required plugin is not listed by /api/v1/plugins." });
@@ -309,7 +309,7 @@ async function run() {
const config = pipeline.config || {};
const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {};
const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {};
const runnerId = runner.id || runner.runner || "";
const runnerId = runner.id || "";
const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" ? aiConfig.runner_config : {};
const runnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" ? runnerConfigs[runnerId] : {};
const pipelineSummary = {
@@ -12,7 +12,7 @@ import {
writeResult,
} from "./lib/langbot-e2e.mjs";
const RUNNER_ID = "plugin:langbot/acp-agent-runner/default";
const RUNNER_ID = "plugin:langbot-team/ACPAgentRunner/default";
const DEFAULT_PIPELINE_NAME = "Agent QA ACP Claude Debug Chat";
const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026";
const caseId = "ensure-acp-agent-runner-pipeline";
@@ -14,7 +14,7 @@ import {
writeResult,
} from "./lib/langbot-e2e.mjs";
const RUNNER_ID = "local-agent";
const RUNNER_ID = "plugin:langbot-team/LocalAgent/default";
const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026";
const DEFAULT_PIPELINE_NAME = "LangBot QA Fake Provider Debug Chat";
const DEFAULT_PROVIDER_NAME = "LangBot QA Fake OpenAI Provider";
@@ -511,8 +511,11 @@ async function ensurePipeline({ backendUrl, token, name, modelUuid }) {
const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {};
const ai = config.ai && typeof config.ai === "object" ? config.ai : {};
const existingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object"
? ai["local-agent"]
const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object"
? ai.runner_config
: {};
const existingLocalAgentConfig = runnerConfigs[RUNNER_ID] && typeof runnerConfigs[RUNNER_ID] === "object"
? runnerConfigs[RUNNER_ID]
: {};
const localAgentConfig = {
timeout: 60,
@@ -546,10 +549,12 @@ async function ensurePipeline({ backendUrl, token, name, modelUuid }) {
runner: {
...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}),
id: RUNNER_ID,
runner: RUNNER_ID,
"expire-time": 0,
},
"local-agent": localAgentConfig,
runner_config: {
...runnerConfigs,
[RUNNER_ID]: localAgentConfig,
},
},
};
@@ -18,7 +18,7 @@ import {
writeResult,
} from "./lib/langbot-e2e.mjs";
const RUNNER_ID = "plugin:langbot/local-agent/default";
const RUNNER_ID = "plugin:langbot-team/LocalAgent/default";
const SPACE_PROVIDER_UUID = "00000000-0000-0000-0000-000000000000";
const DEFAULT_PIPELINE_NAME = "Agent QA Local Agent Debug Chat";
const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026";
@@ -31,7 +31,7 @@ await ensureEvidence(paths);
const backendUrl = (env.LANGBOT_BACKEND_URL || "").replace(/\/$/, "");
const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_LOCAL_AGENT_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || "";
const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || "";
const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot/local-agent/default";
const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot-team/LocalAgent/default";
const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "qa_steering_sentinel_6194";
const responseTimeoutMs = positiveInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, 240000);
const followupDelayMs = 1000;
@@ -446,7 +446,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex
}
const config = pipeline.config || {};
const runner = config.ai?.runner || {};
const runnerId = runner.id || runner.runner || "";
const runnerId = runner.id || "";
if (!runnerId) {
return {
status: "blocked",
@@ -455,7 +455,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex
pipeline_id: pipelineId,
pipeline_name: pipeline.name,
matched_by: matchedBy,
reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.",
reason: "Pipeline has no ai.runner.id.",
};
}
if (expectedRunnerId && runnerId !== expectedRunnerId) {
+2 -2
View File
@@ -429,7 +429,7 @@ async function inspectAndPatchPipelineConfig(page, {
const config = JSON.parse(JSON.stringify(pipeline.config || {}));
const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {};
const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {};
const runnerId = runner.id || runner.runner || "";
const runnerId = runner.id || "";
if (!runnerId) {
return {
status: "blocked",
@@ -438,7 +438,7 @@ async function inspectAndPatchPipelineConfig(page, {
pipeline_id: pipelineId,
pipeline_name: pipeline.name,
matched_by: matchedBy,
reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.",
reason: "Pipeline has no ai.runner.id.",
};
}
if (expectedRunnerId && runnerId !== expectedRunnerId) {
@@ -0,0 +1,280 @@
#!/usr/bin/env node
import {
apiJson,
createBrowser,
ensureAuthenticatedBrowser,
ensureEvidence,
evidencePaths,
exitCode,
loadEnvFiles,
localIsoWithOffset,
safeScreenshot,
scanBrowserDiagnostics,
writeResult,
} from "./lib/langbot-e2e.mjs";
const caseId = "wizard-runner-marketplace-catalog";
await loadEnvFiles();
const paths = evidencePaths(caseId);
await ensureEvidence(paths);
const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png");
const startedAt = new Date();
const frontendUrl = process.env.LANGBOT_FRONTEND_URL || "";
const backendUrl = process.env.LANGBOT_BACKEND_URL || "";
let browser;
let token = "";
let botId = "";
const result = {
source: "automation",
case_id: caseId,
run_id: paths.runId,
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: "",
finished_at_local: "",
status: "fail",
reason: "",
url: "",
visible_signals: [],
api: {},
marketplace_request: null,
diagnostics: null,
cleanup: {},
evidence: {
screenshot: paths.screenshot,
mobile_screenshot: mobileScreenshot,
console_log: paths.consoleLog,
network_log: paths.networkLog,
automation_result_json: paths.automationResultJson,
result_json: paths.resultJson,
},
evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"],
};
try {
if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
browser = await createBrowser(paths);
const { page } = browser;
page.on("request", (request) => {
if (
!/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test(
new URL(request.url()).pathname,
)
) {
return;
}
try {
const payload = request.postDataJSON();
if (payload?.component_filter === "AgentRunner") {
result.marketplace_request = {
endpoint: new URL(request.url()).pathname,
component_filter: payload.component_filter,
type_filter: payload.type_filter || null,
page_size: payload.page_size || null,
};
}
} catch {
// The assertion below reports a missing or malformed catalog request.
}
});
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
const auth = await ensureAuthenticatedBrowser(page, {
frontendUrl,
backendUrl,
});
if (auth.status !== "pass") {
result.status = auth.status;
throw new Error(auth.reason);
}
token = await page.evaluate(() => localStorage.getItem("token") || "");
if (!token) {
result.status = "blocked";
throw new Error("Authenticated browser token is unavailable.");
}
const [plugins, metadata, system] = await Promise.all([
apiJson(backendUrl, "/api/v1/plugins", { token }),
apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }),
apiJson(backendUrl, "/api/v1/system/info", { token }),
]);
const runnerStage = metadata.json.data?.configs
?.find((config) => config.name === "ai")
?.stages?.find((stage) => stage.name === "runner");
const runnerOptions =
runnerStage?.config?.find((item) => item.name === "id")?.options || [];
const installedPlugins = plugins.json.data?.plugins || [];
result.api.clean_state = {
plugin_count: installedPlugins.length,
runner_count: runnerOptions.length,
wizard_status: system.json.data?.wizard_status || null,
};
if (installedPlugins.length !== 0 || runnerOptions.length !== 0) {
result.status = "blocked";
throw new Error(
`This case requires zero plugins and zero runners; found ${installedPlugins.length} plugins and ${runnerOptions.length} runners.`,
);
}
if (system.json.data?.wizard_status !== "none") {
result.status = "blocked";
throw new Error(
`This case requires a first-run instance; wizard status is ${system.json.data?.wizard_status}.`,
);
}
const suffix = paths.runId.slice(-40);
const bot = await apiJson(backendUrl, "/api/v1/platform/bots", {
method: "POST",
token,
body: {
name: `Runner Catalog Wizard ${suffix}`,
description: "Temporary clean-runner catalog fixture",
adapter: "aiocqhttp",
adapter_config: {
host: "127.0.0.1",
port: 2280,
"access-token": "",
},
enable: false,
event_bindings: [],
},
});
botId = bot.json.data?.uuid || "";
if (bot.status >= 400 || bot.json.code !== 0 || !botId) {
throw new Error(bot.json.msg || "Failed to create the temporary Bot.");
}
const progress = await apiJson(backendUrl, "/api/v1/system/wizard/progress", {
method: "PUT",
token,
body: {
step: 2,
selected_scenario: "message_reply",
selected_adapter: "aiocqhttp",
created_bot_uuid: botId,
bot_saved: true,
selected_runner: null,
},
});
if (progress.status >= 400 || progress.json.code !== 0) {
throw new Error(progress.json.msg || "Failed to prepare Wizard progress.");
}
await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, {
waitUntil: "domcontentloaded",
});
result.url = page.url();
await page
.getByText(/Select an AI Engine|选择 AI 引擎|AIエンジンを選択/, {
exact: true,
})
.waitFor({ timeout: 15_000 });
await page
.getByText(
/No AgentRunner extensions are published yet|市场暂未发布 AgentRunner 扩展|AgentRunner 拡張機能はまだ公開されていません/,
{ exact: true },
)
.waitFor({ timeout: 15_000 });
const browseLink = page.getByRole("link", {
name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/,
});
await browseLink.waitFor();
const href = await browseLink.getAttribute("href");
if (href !== "/home/extensions?type=plugin&component=AgentRunner") {
throw new Error(`Unexpected Runner marketplace URL: ${href}`);
}
const nextButton = page.getByRole("button", {
name: /Create & Deploy|创建并部署|作成&デプロイ/,
});
if (!(await nextButton.isDisabled())) {
throw new Error("Wizard allowed continuing without an installed Runner.");
}
if (!result.marketplace_request) {
throw new Error(
"Wizard did not request the AgentRunner Marketplace catalog.",
);
}
result.visible_signals.push(
"first-run-ai-engine-step",
"empty-runner-catalog-state",
"runner-marketplace-link",
"next-disabled-without-runner",
);
await safeScreenshot(page, paths.screenshot);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(250);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
if (overflow > 1) {
throw new Error(`Runner catalog Wizard overflows mobile by ${overflow}px.`);
}
await browseLink.scrollIntoViewIfNeeded();
await safeScreenshot(page, mobileScreenshot);
result.visible_signals.push("mobile-layout");
result.diagnostics = await scanBrowserDiagnostics(paths);
if (result.diagnostics.status !== "pass") {
throw new Error(result.diagnostics.reason);
}
result.status = "pass";
result.reason =
"A clean first-run instance requested the AgentRunner catalog, showed a safe empty state, and prevented continuing without a Runner.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
if (browser?.page) await safeScreenshot(browser.page, paths.screenshot);
} finally {
const cleanup = {};
if (token && backendUrl) {
const resetProgress = await apiJson(
backendUrl,
"/api/v1/system/wizard/progress",
{
method: "PUT",
token,
body: {
step: 0,
selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
selected_runner: null,
},
},
).catch(() => ({ status: 0, json: {} }));
cleanup.progress_reset =
resetProgress.status < 400 && resetProgress.json.code === 0;
}
if (botId && token && backendUrl) {
const deletedBot = await apiJson(
backendUrl,
`/api/v1/platform/bots/${encodeURIComponent(botId)}`,
{ method: "DELETE", token },
).catch(() => ({ status: 0, json: {} }));
cleanup.bot_deleted = deletedBot.status < 400 && deletedBot.json.code === 0;
}
result.cleanup = cleanup;
if (
result.status === "pass" &&
(!cleanup.progress_reset || (botId && !cleanup.bot_deleted))
) {
result.status = "fail";
result.reason = "The clean Wizard fixtures were not fully reset.";
}
if (browser) await browser.close().catch(() => {});
const finishedAt = new Date();
result.finished_at = finishedAt.toISOString();
result.finished_at_local = localIsoWithOffset(finishedAt);
await writeResult(paths, result);
console.log(JSON.stringify(result, null, 2));
}
process.exit(exitCode(result.status));
+55
View File
@@ -143,6 +143,7 @@
"agent-runner-async-db-readiness",
"agent-runner-behavior-matrix",
"agent-runner-fixture-contract",
"agent-runner-health-visibility",
"agent-runner-ledger-concurrency",
"agent-runner-ledger-contention",
"agent-runner-ledger-invariants",
@@ -192,6 +193,7 @@
"skill-discovery-via-mcp-gateway",
"webui-login-state",
"wizard-onebot-agent-runtime",
"wizard-runner-marketplace-catalog",
"wizard-scenario-routing"
],
"case_summaries": [
@@ -291,6 +293,31 @@
"filesystem"
]
},
{
"id": "agent-runner-health-visibility",
"title": "Agent configuration shows whether its selected runner is usable",
"mode": "agent-browser",
"area": "agent",
"type": "feature",
"priority": "p0",
"risk": "medium",
"ci_eligible": false,
"tags": [
"agent-runner",
"agent",
"productization",
"release-gate"
],
"automation": "scripts/e2e/agent-runner-health-visibility.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"screenshot",
"console",
"api_diagnostic"
]
},
{
"id": "agent-runner-ledger-concurrency",
"title": "AgentRunner run ledger concurrency and auth pytest probe",
@@ -1676,6 +1703,32 @@
"api_diagnostic"
]
},
{
"id": "wizard-runner-marketplace-catalog",
"title": "Quick Start discovers AgentRunner extensions on a clean instance",
"mode": "agent-browser",
"area": "wizard",
"type": "feature",
"priority": "p0",
"risk": "high",
"ci_eligible": false,
"tags": [
"wizard",
"agent-runner",
"marketplace",
"clean-install",
"productization"
],
"automation": "scripts/e2e/wizard-runner-marketplace-catalog.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"screenshot",
"console",
"api_diagnostic"
]
},
{
"id": "wizard-scenario-routing",
"title": "Quick Start filters channels by the selected bot scenario",
@@ -1793,6 +1846,8 @@
],
"cases": [
"wizard-scenario-routing",
"wizard-runner-marketplace-catalog",
"agent-runner-health-visibility",
"bot-event-routing-product-flow",
"wizard-onebot-agent-runtime"
]
@@ -24,7 +24,7 @@ Healthy startup includes:
```text
Running on http://0.0.0.0:<backend-port>
Connected to plugin runtime.
Plugin langbot/local-agent initialized
Plugin langbot-team/LocalAgent initialized
```
Quick check:
+1 -1
View File
@@ -59,7 +59,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
| `get_system_info` | Version, edition, instance id |
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
| `list_agents` / `get_agent` / `create_agent` / `update_agent` / `delete_agent` | Manage the Agent product surface, including Agent orchestrations and Pipelines |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) |
+1 -1
View File
@@ -422,7 +422,7 @@ When a plugin doesn't work:
3. **Verify plugin loaded**: `GET /api/v1/plugins` — should list your plugin
4. **Test person mode first**: `session_type=person` always triggers pipeline, isolating trigger rule issues
5. **Check trigger rules**: Group mode requires @bot, prefix match, or random% to enter pipeline
6. **Verify model configured**: Pipeline's `config.ai.local-agent.model.primary` must point to a valid model UUID with working API keys
6. **Verify model configured**: Pipeline's `config.ai.runner_config[config.ai.runner.id].model.primary` must point to a valid model UUID with working API keys
## Publishing Plugins
@@ -34,7 +34,7 @@ automation_env:
- LANGBOT_E2E_RESPONSE_TIMEOUT_MS
automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/acp-agent-runner/default"
automation_expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default"
automation_prompt: "Use the injected LangBot MCP server tool langbot_get_current_event once. If the MCP call succeeds, reply with exactly ACP_AGENT_RUNNER_E2E_OK."
automation_expected_text: "ACP_AGENT_RUNNER_E2E_OK"
automation_response_timeout_ms: "300000"
@@ -49,7 +49,7 @@ preconditions:
steps:
- "Open LANGBOT_FRONTEND_URL."
- "Open the ACP AgentRunner QA pipeline."
- "Confirm the pipeline AI runner is plugin:langbot/acp-agent-runner/default."
- "Confirm the pipeline AI runner is plugin:langbot-team/ACPAgentRunner/default."
- "Open Debug Chat."
- "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_AGENT_RUNNER_E2E_OK exactly."
checks:
@@ -72,7 +72,7 @@ success_patterns:
failure_patterns:
- "acp.command_not_found"
- "acp.process_exited"
- "Agent runner plugin:langbot/acp-agent-runner/default execution failed"
- "Agent runner plugin:langbot-team/ACPAgentRunner/default execution failed"
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
@@ -0,0 +1,53 @@
id: agent-runner-health-visibility
title: "Agent configuration shows whether its selected runner is usable"
mode: agent-browser
area: agent
type: feature
priority: p0
risk: medium
ci_eligible: false
tags:
- agent-runner
- agent
- productization
- release-gate
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
automation: scripts/e2e/agent-runner-health-visibility.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
preconditions:
- "The plugin runtime is enabled and connected, with at least one AgentRunner registered."
- "The target is a local test instance where a temporary Agent may be created and deleted."
steps:
- "Read the live plugin runtime status and select a registered runner from Agent metadata."
- "Create a temporary Agent using that runner and open its Runner settings in the WebUI."
- "Confirm the UI reports that the runner is ready."
- "Replace the temporary Agent binding with a syntactically valid but unregistered runner id."
- "Reload Runner settings and confirm the UI reports that the selected runner is unavailable."
checks:
- "UI: A registered runner and connected plugin runtime produce a visible ready status."
- "UI: A stale runner binding produces a visible unavailable status with recovery actions."
- "API: Plugin runtime status is connected and Agent metadata contains the registered runner."
- "Console: No unexpected frontend errors appear during the flow."
- "Cleanup: The temporary Agent is deleted after evidence is collected."
evidence_required:
- ui
- screenshot
- console
- api_diagnostic
diagnostics:
- "A disconnected plugin runtime is env_issue, not a passing unavailable-runner product check."
- "A ready status inferred only from API metadata without opening the Runner UI is not a pass."
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
- proxy-env-mismatch
@@ -45,7 +45,7 @@ steps:
- "Run the local-agent primary model test endpoint unless LANGBOT_PREFLIGHT_TEST_MODELS=0."
checks:
- "API diagnostic: api-diagnostic.json has no blockers and no env_issues."
- "API diagnostic: required pipelines resolve to plugin:langbot/local-agent/default and plugin:langbot/acp-agent-runner/default."
- "API diagnostic: required pipelines resolve to plugin:langbot-team/LocalAgent/default and plugin:langbot-team/ACPAgentRunner/default."
- "API diagnostic: qa_plugin_echo is exposed by /api/v1/tools."
- "API diagnostic: local-agent model check catches invalid credentials or missing func_call/vision before release E2E starts."
- "Secret safety: token values, api keys, and provider secrets are not printed."
@@ -38,7 +38,7 @@ steps:
- "Open LANGBOT_FRONTEND_URL."
- "Navigate to Pipelines and open the target local-agent pipeline."
- "Open Configuration > AI."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model that is known to answer Debug Chat in the current environment."
- "Clear Knowledge Bases unless this run intentionally combines RAG with the smoke path."
- "Save the pipeline."
@@ -35,7 +35,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_RAG_KB_UUID
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":650,"context-reserve-tokens":180,"context-keep-recent-tokens":120,"context-summary-tokens":220,"max-tool-iterations":4,"tool-execution-mode":"serial"}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -59,7 +59,7 @@ preconditions:
steps:
- "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available."
- "Temporarily bind the LangRAG KB, shrink the runner context budget, and allow serial plugin tool execution."
- "Temporarily bind only langbot/local-agent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Reset the person Debug Chat session for the target pipeline."
- "Send the passcode memory prompt and wait for MEMORY_SET."
- "Send the long pressure prompt and wait for COMBO_CONTEXT_PRESSURE_READY."
@@ -69,7 +69,7 @@ checks:
- "UI: All three user messages appear in Debug Chat."
- "UI: The final Bot message contains COMBO_FINAL qa_combo_compaction_sentinel_2406 azalea-cobalt-7421 qa-plugin-smoke:combo-tool-ok-local-agent."
- "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, and tool-loop settings were patched."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot/local-agent and qa/plugin-smoke were bound."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound."
- "API diagnostic: restore diagnostics show the original runner config and extension bindings were restored."
- "Logs: Backend completes RAG retrieval, plugin tool call, and Debug Chat response without runner timeout or model setup failure."
- "Console: No unexpected frontend runtime errors appear during the run."
@@ -31,7 +31,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"context-window-tokens":225,"context-reserve-tokens":50,"context-keep-recent-tokens":30,"context-summary-tokens":105,"knowledge-bases":[]}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -51,7 +51,7 @@ preconditions:
steps:
- "Open the target local-agent pipeline through LANGBOT_FRONTEND_URL."
- "Use the authenticated browser token only inside automation to GET and PUT /api/v1/pipelines/{uuid}."
- "Assert the saved runner is plugin:langbot/local-agent/default."
- "Assert the saved runner is plugin:langbot-team/LocalAgent/default."
- "Temporarily set context-window-tokens, context-reserve-tokens, context-keep-recent-tokens, and context-summary-tokens to force compaction, and clear knowledge-bases so RAG does not answer the memory question."
- "Temporarily bind only the local-agent plugin, disable MCP servers, and disable skills so unrelated visible tools cannot answer or distract the memory question."
- "Reset the person Debug Chat session for the target pipeline."
@@ -64,7 +64,7 @@ checks:
- "UI: The final Bot message contains qa_compaction_sentinel_7391."
- "API diagnostic: pipeline-config-diagnostic.json shows patched=true and patch_keys include the four token context compaction fields plus knowledge-bases."
- "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored."
- "API diagnostic: pipeline-extensions-diagnostic.json shows enable_all_plugins=false, bound_plugins contains langbot/local-agent, enable_all_mcp_servers=false, and enable_all_skills=false."
- "API diagnostic: pipeline-extensions-diagnostic.json shows enable_all_plugins=false, bound_plugins contains langbot-team/LocalAgent, enable_all_mcp_servers=false, and enable_all_skills=false."
- "API diagnostic: pipeline-extensions-restore-diagnostic.json shows the original extension bindings were restored."
- "Logs: Backend completes the multi-turn Debug Chat path without runner timeout or model setup failure."
- "Console: No unexpected frontend runtime errors appear during the run."
@@ -44,7 +44,7 @@ steps:
- "Confirm the fixture plugin is bound to the target pipeline through Extensions."
- "Open the target local-agent pipeline."
- "Open Configuration > AI."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model that is known to follow system prompts in Debug Chat."
- "Save the pipeline."
- "Open Debug Chat."
@@ -42,7 +42,7 @@ steps:
- "Open LANGBOT_FRONTEND_URL."
- "Navigate to Pipelines and open the target local-agent pipeline."
- "Open Configuration > AI."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model that supports image input in the current environment, or use a known model that at least accepts the uploaded image payload."
- "Save the pipeline."
- "Open Debug Chat."
@@ -36,7 +36,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_RAG_KB_UUID
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":5,"tool-execution-mode":"serial"}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -60,7 +60,7 @@ preconditions:
steps:
- "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available."
- "Temporarily bind the LangRAG KB, shrink the runner context budget, and set max-tool-iterations high enough for two serial plugin tool calls."
- "Temporarily bind only langbot/local-agent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Reset the person Debug Chat session for the target pipeline."
- "Send the passcode memory prompt and wait for MEMORY_SET."
- "Send the long pressure prompt and wait for MULTITOOL_CONTEXT_PRESSURE_READY."
@@ -70,7 +70,7 @@ checks:
- "UI: All three user messages appear in Debug Chat."
- "UI: The final Bot message contains MULTITOOL_COMBO_FINAL with the compacted sentinel, azalea-cobalt-7421, and both qa-plugin-smoke tool results."
- "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, and max-tool-iterations were patched."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot/local-agent and qa/plugin-smoke were bound."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound."
- "Logs: Backend completes RAG retrieval, two plugin tool calls, and Debug Chat response without runner timeout or model setup failure."
- "Console: No unexpected frontend runtime errors appear during the run."
evidence_required:
@@ -39,7 +39,7 @@ steps:
- "Open LANGBOT_FRONTEND_URL."
- "Navigate to Pipelines and open the target local-agent pipeline."
- "Open Configuration > AI."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model that is known to answer Debug Chat in the current environment."
- "Save the pipeline."
- "Open Debug Chat."
@@ -36,7 +36,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_RAG_KB_UUID
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":3,"tool-execution-mode":"parallel"}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -60,7 +60,7 @@ preconditions:
steps:
- "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available."
- "Temporarily bind the LangRAG KB, shrink the runner context budget, and set tool-execution-mode to parallel."
- "Temporarily bind only langbot/local-agent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface."
- "Reset the person Debug Chat session for the target pipeline."
- "Send the passcode memory prompt and wait for MEMORY_SET."
- "Send the long pressure prompt and wait for PARALLEL_CONTEXT_PRESSURE_READY."
@@ -70,7 +70,7 @@ checks:
- "UI: All three user messages appear in Debug Chat."
- "UI: The final Bot message contains PARALLEL_COMBO_FINAL with the compacted sentinel, azalea-cobalt-7421, and both qa-plugin-smoke tool results."
- "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, max-tool-iterations, and tool-execution-mode=parallel were patched."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot/local-agent and qa/plugin-smoke were bound."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound."
- "Logs: Backend completes RAG retrieval, both plugin tool calls, and Debug Chat response without runner timeout or model setup failure."
- "Console: No unexpected frontend runtime errors appear during the run."
evidence_required:
@@ -46,7 +46,7 @@ steps:
- "Confirm /api/v1/tools or the plugin detail shows qa_plugin_echo."
- "Confirm the fixture plugin is bound to the target pipeline through Extensions, or that all plugins are enabled."
- "Open the target local-agent pipeline."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model with function-calling ability that is known to work with tools in the current environment."
- "Open Debug Chat."
- "Send: Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result."
@@ -31,7 +31,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_RAG_KB_UUID
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}'
automation_restore_runner_config: "1"
automation_reset_debug_chat: "1"
@@ -29,7 +29,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_reset_debug_chat: "1"
automation_stream_output: "1"
automation_prompt: "You are running the LangBot steering E2E test. First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor. Do not answer before the tool result is available. After the tool returns, answer the latest user follow-up. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP."
@@ -45,7 +45,7 @@ preconditions:
- "The selected model route supports function/tool calling and can call qa_plugin_sleep."
steps:
- "Open the target local-agent pipeline."
- "Assert the saved runner is plugin:langbot/local-agent/default."
- "Assert the saved runner is plugin:langbot-team/LocalAgent/default."
- "Reset the person Debug Chat session for the target pipeline."
- "Assert /api/v1/tools contains qa_plugin_sleep."
- "Send the first prompt that instructs the model to call qa_plugin_sleep for 8 seconds."
@@ -32,7 +32,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":3,"tool-execution-mode":"serial"}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -53,7 +53,7 @@ preconditions:
- "qa-plugin-smoke exposes qa_plugin_fail; the setup case reinstalls the fixture if an older installed package is missing it."
steps:
- "Ensure the local-agent pipeline and qa-plugin-smoke fixture plugin are available."
- "Temporarily bind only langbot/local-agent plus qa/plugin-smoke and clear RAG knowledge bases."
- "Temporarily bind only langbot-team/LocalAgent plus qa/plugin-smoke and clear RAG knowledge bases."
- "Reset the person Debug Chat session for the target pipeline."
- "Send the TOOL_ERROR_RECOVERY prompt."
- "Verify the failing plugin tool result is fed back into the model and the final answer contains the recovery sentinel."
@@ -61,7 +61,7 @@ steps:
checks:
- "UI: The final Bot message contains TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed."
- "API diagnostic: pipeline-config-diagnostic.json shows max-tool-iterations=3 and knowledge-bases cleared."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot/local-agent and qa/plugin-smoke were bound."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound."
- "Logs: Backend shows the request completed without runner.timeout, runner.tool_error run failure, or model setup failure."
- "Console: No unexpected frontend runtime errors appear during Debug Chat."
evidence_required:
@@ -33,7 +33,7 @@ automation_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":2,"tool-execution-mode":"serial"}'
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_runner_config: "1"
@@ -54,14 +54,14 @@ preconditions:
- "qa-plugin-smoke exposes qa_plugin_echo."
steps:
- "Ensure the local-agent pipeline and qa-plugin-smoke fixture plugin are available."
- "Temporarily set max-tool-iterations to 2 and bind only langbot/local-agent plus qa/plugin-smoke."
- "Temporarily set max-tool-iterations to 2 and bind only langbot-team/LocalAgent plus qa/plugin-smoke."
- "Reset the person Debug Chat session for the target pipeline."
- "Send the LOOP_LIMIT prompt and wait for the runner limit message."
- "Restore the original runner config and pipeline extension bindings."
checks:
- "UI: The final Bot message contains Tool call iteration limit reached. I stopped before calling more tools."
- "API diagnostic: pipeline-config-diagnostic.json shows max-tool-iterations was patched to 2."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot/local-agent and qa/plugin-smoke were bound."
- "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound."
- "Logs: Backend shows the request completed without infinite loop, runner timeout, or model setup failure."
- "Console: No unexpected frontend runtime errors appear during Debug Chat."
evidence_required:
@@ -31,7 +31,7 @@ automation_env:
- LANGBOT_MCP_QA_STDIO_SERVER_UUID
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot/local-agent/default"
automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default"
automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":["${LANGBOT_MCP_QA_STDIO_SERVER_UUID}"],"enable_all_skills":false,"bound_skills":[]}'
automation_restore_extensions: "1"
automation_reset_debug_chat: "1"
@@ -65,7 +65,7 @@ steps:
- "Submit the server."
- "Confirm the server detail page shows Tools: 1 and qa_mcp_echo."
- "Open the target local-agent pipeline."
- "Use runner Default or the pluginized langbot/local-agent runner."
- "Use runner Default or the pluginized langbot-team/LocalAgent runner."
- "Select a model with function-calling ability that is known to work with tools in the current environment."
- "Open Debug Chat."
- "Send: Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result."
@@ -0,0 +1,59 @@
id: wizard-runner-marketplace-catalog
title: "Quick Start discovers AgentRunner extensions on a clean instance"
mode: agent-browser
area: wizard
type: feature
priority: p0
risk: high
ci_eligible: false
tags:
- wizard
- agent-runner
- marketplace
- clean-install
- productization
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_E2E_LOGIN_USER
automation: scripts/e2e/wizard-runner-marketplace-catalog.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_E2E_LOGIN_USER
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
preconditions:
- "The target is a newly initialized local instance with wizard_status=none."
- "No plugins or AgentRunner components are installed."
steps:
- "Confirm the backend reports zero installed plugins and zero registered runners."
- "Resume Quick Start at the AI Engine step with a temporary disabled Bot."
- "Confirm the browser requests Marketplace plugins with component_filter=AgentRunner."
- "When Space has not published Runner plugins, confirm the safe empty state and Runner Extensions link."
- "Confirm Create & Deploy remains disabled until a Runner is installed and selected."
- "Verify the layout at desktop and mobile widths."
checks:
- "API: The instance has zero installed plugins and zero registered runners."
- "API: The instance wizard status is none."
- "Network: Marketplace search uses component_filter=AgentRunner and type_filter=plugin."
- "UI: The AI Engine step remains usable when the Runner catalog is empty."
- "UI: Browse Runner Extensions links to the AgentRunner-filtered market."
- "UI: Create & Deploy remains disabled without a selected Runner."
- "Console: No unexpected frontend errors appear during the flow."
- "Cleanup: Wizard progress and the temporary Bot are removed."
evidence_required:
- ui
- screenshot
- console
- api_diagnostic
diagnostics:
- "This case validates the clean-instance discovery and empty state. Run the install continuation after Space publishes at least one AgentRunner plugin."
- "A Marketplace API response alone is not a pass; the empty or installable Runner UI must be visible."
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
- proxy-env-mismatch
@@ -110,7 +110,7 @@ Each probe writes `automation-result.json` and probe logs under
| Generic Pipeline Debug Chat | `pipeline-debug-chat` | The WebUI Debug Chat path itself works before runner-specific failures are diagnosed. |
| Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` AgentRunner package can install and register a runner. |
| Deterministic QA runner Debug Chat | `agent-runner-qa-debug-chat` | The installed QA runner executes through WebUI Debug Chat without a model provider. |
| Required runner plugins | `agent-runner-release-preflight` | `langbot/local-agent` and `langbot/acp-agent-runner` are visible to the host. |
| Required runner plugins | `agent-runner-release-preflight` | `langbot-team/LocalAgent` and `langbot-team/ACPAgentRunner` are visible to the host. |
| Required QA plugin tools | `plugin-e2e-smoke`, `agent-runner-release-preflight`, `qa-plugin-smoke-live-install` | The deterministic `qa_plugin_echo` and `qa_plugin_fail` tools are exposed before tool-loop and tool-error cases start. |
| Knowledge base fixture | `langrag-kb-retrieve`, `local-agent-rag-debug-chat` | LangRAG data is queryable and the runner inserts retrieved context. |
| Effective prompt bridge | `local-agent-effective-prompt-debug-chat` | Host prompt preprocessing reaches the runner. |
@@ -1,6 +1,6 @@
# Dify AgentRunner
Use this reference when validating `langbot/dify-agent` through LangBot WebUI.
Use this reference when validating `langbot-team/DifyAgent` through LangBot WebUI.
## Prepare Dify
@@ -38,7 +38,7 @@ Pass only when:
## Diagnostics
- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot/dify-agent/default` and runner config contains `app-type`, `base-url`, and `api-key`.
- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot-team/DifyAgent/default` and runner config contains `app-type`, `base-url`, and `api-key`.
- Direct Dify streaming API calls are useful only to distinguish invalid Dify credentials from LangBot runner issues.
- If Debug Chat returns `Agent runner execution failed`, inspect backend logs before changing UI settings.
@@ -1,6 +1,6 @@
# Local Agent Runner Coverage
Use this matrix when judging whether the external `langbot/local-agent` plugin still behaves like the old built-in local-agent runner.
Use this matrix when judging whether the external `langbot-team/LocalAgent` plugin still behaves like the old built-in local-agent runner.
The QA target is end-to-end behavior. UI cases prove the host, SDK, plugin runtime, and WebUI work together. Unit or component tests are still needed for negative branches that are hard to trigger reliably through a live provider.
@@ -1,6 +1,6 @@
# Local Agent Runner
Use this reference when validating the pluginized `langbot/local-agent` runner through the WebUI.
Use this reference when validating the pluginized `langbot-team/LocalAgent` runner through the WebUI.
The goal is behavior parity with the old built-in local-agent runner. The code does not need to be identical, but the visible behavior should match: effective prompt, current input, history, model selection and fallback, tool calling, knowledge retrieval, multimodal input, streaming and non-streaming output all have to reach the runner through the host and SDK.
@@ -87,7 +87,7 @@ extension binding uses this UUID, not the human-readable server name.
1. Open the target pipeline.
2. Confirm `Extensions` allows the MCP server, or that all MCP servers are enabled.
3. Use runner `Default` or the pluginized `langbot/local-agent` runner.
3. Use runner `Default` or the pluginized `langbot-team/LocalAgent` runner.
4. Select a model with function-calling ability that is known to work with tools in the current environment.
5. Open `Debug Chat`.
6. Ask:
@@ -20,7 +20,7 @@ An old `langbot_plugin` runtime process survived a backend restart, or multiple
### Fix
Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot/local-agent`, and initializes the default agent runner.
Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot-team/LocalAgent`, and initializes the default agent runner.
### Verification
@@ -10,5 +10,7 @@ tags:
- release-gate
cases:
- wizard-scenario-routing
- wizard-runner-marketplace-catalog
- agent-runner-health-visibility
- bot-event-routing-product-flow
- wizard-onebot-agent-runtime
@@ -16,7 +16,7 @@ fix_steps:
- "Change metadata.label to the provider or runner family name, such as Dify, Local Agent, Coze, DashScope, Langflow, n8n, or Tbox."
- "Restart LangBot or plugin runtime to refresh runner metadata cache."
- "Check /api/v1/pipelines/_/metadata and the WebUI runner selector."
verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot/dify-agent/default."
verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot-team/DifyAgent/default."
related_cases:
- dify-agent-debug-chat
- local-agent-rag-debug-chat
@@ -22,7 +22,7 @@ fix_steps:
- "Stop the LangBot backend."
- "Stop orphaned langbot_plugin.cli runtime processes."
- "Confirm the configured backend URL is free or reachable as appropriate."
- "Start LangBot again and wait for Connected to plugin runtime plus langbot/local-agent initialization."
- "Start LangBot again and wait for Connected to plugin runtime plus langbot-team/LocalAgent initialization."
- "For disposable test data only, move suspected fixture plugins out of data/plugins and restart to isolate plugin discovery failures."
verification: "Open Installed Plugins and confirm the plugin list loads, then run pipeline-debug-chat and confirm the UI shows a Bot response while backend logs include Streaming completed."
related_cases:
+1 -1
View File
@@ -3578,7 +3578,7 @@ test("MCP stdio tool-call case setups pipeline and registered MCP server", () =>
);
assert.equal(
run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID,
"plugin:langbot/local-agent/default",
"plugin:langbot-team/LocalAgent/default",
);
assert.equal(run.automation.env_defaults.LANGBOT_E2E_RESET_DEBUG_CHAT, "1");
assert.equal(
+15 -122
View File
@@ -5,27 +5,13 @@ from __future__ import annotations
import typing
LEGACY_RUNNER_ID_MAP: dict[str, str] = {
'local-agent': 'plugin:langbot/local-agent/default',
'dify-service-api': 'plugin:langbot/dify-agent/default',
'n8n-service-api': 'plugin:langbot/n8n-agent/default',
'coze-api': 'plugin:langbot/coze-agent/default',
'dashscope-app-api': 'plugin:langbot/dashscope-agent/default',
'deerflow-api': 'plugin:langbot/deerflow-agent/default',
'langflow-api': 'plugin:langbot/langflow-agent/default',
'tbox-app-api': 'plugin:langbot/tbox-agent/default',
'weknora-api': 'plugin:langbot/weknora-agent/default',
}
class ConfigMigration:
"""Configuration helper for agent runner IDs.
"""Configuration helpers for the current AgentRunner shape.
Responsibilities:
- Resolve runner ID from ai.runner.id
- Migrate legacy ai.runner.runner + ai.<runner-name> blocks
- Extract current Agent/runner config from ai.runner_config
- Keep the current config container shape stable on save
- Extract Agent/runner config from ai.runner_config
- Read current conversation expiry settings
"""
@staticmethod
@@ -38,18 +24,10 @@ class ConfigMigration:
Returns:
Runner ID string, or None if not configured
"""
ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner', {})
runner_id = runner_config.get('id')
if runner_id:
return runner_id
legacy_runner = runner_config.get('runner')
if isinstance(legacy_runner, str):
return LEGACY_RUNNER_ID_MAP.get(legacy_runner)
return None
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
runner_id = runner.get('id') if isinstance(runner, dict) else None
return runner_id if isinstance(runner_id, str) and runner_id else None
@staticmethod
def resolve_runner_config(
@@ -65,20 +43,10 @@ class ConfigMigration:
Returns:
Runner configuration dict (empty if not found)
"""
ai_config = pipeline_config.get('ai', {})
runner_configs = ai_config.get('runner_config', {})
if runner_id in runner_configs:
return runner_configs[runner_id]
legacy_runner = ConfigMigration._legacy_runner_name_for_id(runner_id)
if legacy_runner and isinstance(ai_config.get(legacy_runner), dict):
return ConfigMigration._normalize_legacy_runner_config(
legacy_runner,
ai_config[legacy_runner],
)
return {}
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {}
runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {}
return runner_config if isinstance(runner_config, dict) else {}
@staticmethod
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
@@ -90,82 +58,7 @@ class ConfigMigration:
Returns:
Expire time in seconds (0 means no expiry)
"""
ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner', {})
return runner_config.get('expire-time', 0)
@staticmethod
def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""Normalize the current config container before saving.
Args:
pipeline_config: Original configuration
Returns:
Configuration with explicit ai.runner and ai.runner_config containers
"""
new_config = dict(pipeline_config)
if 'ai' not in new_config:
return new_config
ai_config = dict(new_config.get('ai', {}))
runner_config = dict(ai_config.get('runner', {}))
runner_configs = dict(ai_config.get('runner_config', {}))
legacy_runner = runner_config.get('runner')
mapped_runner_id = None
if isinstance(legacy_runner, str):
mapped_runner_id = LEGACY_RUNNER_ID_MAP.get(legacy_runner)
if mapped_runner_id and not runner_config.get('id'):
runner_config = {
key: value
for key, value in runner_config.items()
if key != 'runner'
}
runner_config['id'] = mapped_runner_id
if mapped_runner_id and mapped_runner_id not in runner_configs:
legacy_config = ai_config.get(legacy_runner)
if isinstance(legacy_config, dict):
runner_configs[mapped_runner_id] = ConfigMigration._normalize_legacy_runner_config(
legacy_runner,
legacy_config,
)
ai_config['runner'] = runner_config
ai_config['runner_config'] = runner_configs
if mapped_runner_id and legacy_runner in ai_config:
ai_config.pop(legacy_runner, None)
new_config['ai'] = ai_config
return new_config
@staticmethod
def _legacy_runner_name_for_id(runner_id: str) -> str | None:
for legacy_runner, mapped_runner_id in LEGACY_RUNNER_ID_MAP.items():
if mapped_runner_id == runner_id:
return legacy_runner
return None
@staticmethod
def _normalize_legacy_runner_config(
legacy_runner: str,
legacy_config: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Normalize legacy runner config blocks to current plugin schema quirks."""
normalized = dict(legacy_config)
if legacy_runner == 'local-agent':
model = normalized.get('model')
if isinstance(model, str):
normalized['model'] = {
'primary': model,
'fallbacks': [],
}
knowledge_base = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized and isinstance(knowledge_base, str):
normalized['knowledge-bases'] = [] if knowledge_base in {'', '__none__', '__none'} else [knowledge_base]
return normalized
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0
return expire_time if isinstance(expire_time, int) else 0
+5 -5
View File
@@ -139,9 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel):
class AgentConfig(pydantic.BaseModel):
"""Host-side Agent configuration.
Product-level Agent is the target replacement for Pipeline-owned agent
config. Current Pipeline entry paths can project their config into this
model during migration.
Product-level Agents are independent from Pipelines. A Pipeline entry path
can project its config into this runtime-only model without creating or
updating a persisted Agent.
"""
agent_id: str | None = None
@@ -175,8 +175,8 @@ class AgentConfig(pydantic.BaseModel):
class AgentBinding(pydantic.BaseModel):
"""Binding configuration for mapping events to runners.
This is Host-internal model for event-to-runner binding.
It replaces the old Pipeline runner config role.
This is a Host-internal, runtime-only model for event-to-runner binding.
Projecting Pipeline config into it is not a persistence migration.
"""
binding_id: str
+1 -1
View File
@@ -17,7 +17,7 @@ AGENT_DEFAULT_EVENT_PATTERNS = ['*']
class AgentService:
"""Unified product surface for Agent processors and Pipelines."""
"""Unified processor facade for the peer Agent and Pipeline types."""
ap: app.Application
@@ -200,16 +200,10 @@ class PipelineService:
return pipeline_data['uuid']
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
from ....agent.runner.config_migration import ConfigMigration
pipeline_data = pipeline_data.copy()
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
pipeline_data.pop(protected_field, None)
# Migrate config to new format before saving
if 'config' in pipeline_data:
pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
+15 -15
View File
@@ -164,32 +164,32 @@ class LangBotMCPServer:
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
return _dump({'ok': True})
# ----- Agents -------------------------------------------------- #
# ----- Processors ---------------------------------------------- #
@mcp.tool(description='List product-level processors, including Agents and Pipelines.')
async def list_agents() -> str:
async def list_processors() -> str:
return _dump(await ap.agent_service.get_agents())
@mcp.tool(description='Get a product-level Agent or Pipeline by UUID.')
async def get_agent(agent_uuid: str) -> str:
return _dump(await ap.agent_service.get_agent(agent_uuid))
@mcp.tool(description='Get an Agent or Pipeline processor by UUID.')
async def get_processor(processor_uuid: str) -> str:
return _dump(await ap.agent_service.get_agent(processor_uuid))
@mcp.tool(
description=(
'Create an Agent processor or Pipeline. `agent_data` matches '
'POST /api/v1/agents; set kind to `agent` or `pipeline`. Returns the new UUID and kind.'
'Create an Agent or Pipeline processor. Set `processor_data.kind` to '
'`agent` or `pipeline`. Returns the new UUID and kind.'
)
)
async def create_agent(agent_data: dict) -> str:
return _dump(await ap.agent_service.create_agent(agent_data))
async def create_processor(processor_data: dict) -> str:
return _dump(await ap.agent_service.create_agent(processor_data))
@mcp.tool(description='Update an Agent processor or Pipeline by UUID.')
async def update_agent(agent_uuid: str, agent_data: dict) -> str:
await ap.agent_service.update_agent(agent_uuid, agent_data)
@mcp.tool(description='Update an Agent or Pipeline processor by UUID.')
async def update_processor(processor_uuid: str, processor_data: dict) -> str:
await ap.agent_service.update_agent(processor_uuid, processor_data)
return _dump({'ok': True})
@mcp.tool(description='Delete an Agent processor or Pipeline by UUID.')
async def delete_agent(agent_uuid: str) -> str:
await ap.agent_service.delete_agent(agent_uuid)
@mcp.tool(description='Delete an Agent or Pipeline processor by UUID.')
async def delete_processor(processor_uuid: str) -> str:
await ap.agent_service.delete_agent(processor_uuid)
return _dump({'ok': True})
# ----- Models -------------------------------------------------- #
@@ -1,88 +0,0 @@
"""Normalize AgentRunner config containers
Revision ID: 0005_migrate_runner_config
Revises: 0005_add_llm_context_length
Create Date: 2026-05-10
"""
import json
import sqlalchemy as sa
from alembic import op
from langbot.pkg.agent.runner.config_migration import ConfigMigration
revision = '0005_migrate_runner_config'
down_revision = '0005_add_llm_context_length'
branch_labels = None
depends_on = None
def migrate_pipeline_config(config: dict) -> dict:
"""Migrate persisted pipeline config to the AgentRunner plugin shape."""
return ConfigMigration.migrate_pipeline_config(config)
def _load_config(config_value):
if isinstance(config_value, dict):
return config_value
if isinstance(config_value, str):
return json.loads(config_value)
return None
def _update_config(conn, table_name: str, pipeline_uuid: str, migrated_config: dict) -> None:
"""Write JSON config using a dialect-compatible bind."""
config_json = json.dumps(migrated_config)
if conn.dialect.name == 'postgresql':
conn.execute(
sa.text(
f'UPDATE {table_name} '
'SET config = CAST(:config AS JSON) '
'WHERE uuid = :uuid'
),
{'config': config_json, 'uuid': pipeline_uuid},
)
return
conn.execute(
sa.text(f'UPDATE {table_name} SET config = :config WHERE uuid = :uuid'),
{'config': config_json, 'uuid': pipeline_uuid},
)
def upgrade() -> None:
"""Normalize existing pipeline config containers."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_name = 'legacy_pipelines'
# Check if pipeline table exists (may not exist in fresh install)
if table_name not in inspector.get_table_names():
return
# Get all pipelines
result = conn.execute(sa.text(f'SELECT uuid, config FROM {table_name}'))
pipelines = result.fetchall()
for pipeline_uuid, config_json in pipelines:
if not config_json:
continue
try:
config = _load_config(config_json)
if not isinstance(config, dict):
continue
migrated_config = migrate_pipeline_config(config)
# Only update if config changed
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
_update_config(conn, table_name, pipeline_uuid, migrated_config)
except Exception:
# Skip invalid configs
continue
def downgrade() -> None:
"""Downgrade is not supported for data migration."""
# No downgrade - keep configs in new format
pass
@@ -1,4 +1,7 @@
"""migrate use_pipeline_uuid and pipeline_routing_rules into event_bindings
"""Migrate legacy Bot routes into Pipeline event bindings.
Bindings keep referencing the original Pipeline UUIDs. This migration never
creates Agent rows or copies Pipeline runner configuration.
Revision ID: 0009_migrate_routing_to_event_bindings
Revises: 0008_agent_product_surface
@@ -1,7 +1,7 @@
"""add_event_log_and_transcript_tables
Revision ID: 58846a8d7a81
Revises: 0005_migrate_runner_config
Revises: 0005_add_llm_context_length
Create Date: 2026-05-23 15:41:47.030841
"""
from alembic import op
@@ -9,7 +9,7 @@ import sqlalchemy as sa
# revision identifiers
revision = '58846a8d7a81'
down_revision = '0005_migrate_runner_config'
down_revision = '0005_add_llm_context_length'
branch_labels = None
depends_on = None
@@ -11,7 +11,6 @@ from ...entity.persistence import (
pipeline as persistence_pipeline,
bot as persistence_bot,
)
from ...agent.runner.config_migration import LEGACY_RUNNER_ID_MAP
@migration.migration_class(1)
@@ -114,47 +113,6 @@ class DBMigrateV3Config(migration.DBMigration):
pipeline_config = default_pipeline['config']
# ai
ai_config = pipeline_config.setdefault('ai', {})
runner_name = self.ap.provider_cfg.data['runner']
runner_id = LEGACY_RUNNER_ID_MAP.get(runner_name, '')
ai_config['runner'] = {
'id': runner_id,
}
runner_configs = ai_config.setdefault('runner_config', {})
local_agent_runner_id = LEGACY_RUNNER_ID_MAP['local-agent']
local_agent_config = runner_configs.setdefault(local_agent_runner_id, {})
local_agent_config['model'] = {
'primary': model_uuid,
'fallbacks': [],
}
local_agent_config['prompt'] = [
{
'role': 'system',
'content': self.ap.provider_cfg.data['prompt']['default'],
}
]
runner_configs[LEGACY_RUNNER_ID_MAP['dify-service-api']] = {
'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'],
'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'],
'api-key': self.ap.provider_cfg.data['dify-service-api'][
self.ap.provider_cfg.data['dify-service-api']['app-type']
]['api-key'],
'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'],
'timeout': self.ap.provider_cfg.data['dify-service-api'][
self.ap.provider_cfg.data['dify-service-api']['app-type']
]['timeout'],
}
runner_configs[LEGACY_RUNNER_ID_MAP['dashscope-app-api']] = {
'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'],
'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'],
'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][
self.ap.provider_cfg.data['dashscope-app-api']['app-type']
]['references_quote'],
}
# trigger
pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default']
pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control']
+11 -3
View File
@@ -14,6 +14,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
from .config_coercion import coerce_pipeline_config
from ..agent.runner.config_migration import ConfigMigration
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -94,12 +95,19 @@ class RuntimePipeline:
extensions_prefs = pipeline_entity.extensions_preferences or {}
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
self.mcp_resource_attachments = local_agent_config.get(
pipeline_config = pipeline_entity.config or {}
runner_config: dict[str, typing.Any] = {}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None
if runner_id:
resolved_runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
if isinstance(resolved_runner_config, dict):
runner_config = resolved_runner_config
self.mcp_resource_attachments = runner_config.get(
'mcp-resources',
extensions_prefs.get('mcp_resources', []),
)
self.mcp_resource_agent_read_enabled = local_agent_config.get(
self.mcp_resource_agent_read_enabled = runner_config.get(
'mcp-resource-agent-read-enabled',
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
)
+3 -3
View File
@@ -630,7 +630,7 @@ class RuntimeBot:
text=f'Test event {event_type} dispatched from control plane',
)
test_adapter = SyntheticRouteTestAdapter(self.adapter)
outcome = await self._dispatch_eba_event_to_agent(
outcome = await self._dispatch_eba_event_to_processor(
event,
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
)
@@ -1135,9 +1135,9 @@ class RuntimeBot:
except Exception:
await self.logger.error(f'Failed to dispatch platform event to plugins: {traceback.format_exc()}')
await self._dispatch_eba_event_to_agent(event, adapter)
await self._dispatch_eba_event_to_processor(event, adapter)
async def _dispatch_eba_event_to_agent(
async def _dispatch_eba_event_to_processor(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
@@ -27,7 +27,7 @@ from tests.e2e.utils.process_manager import find_project_root
pytestmark = pytest.mark.e2e
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot/local-agent/default'
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent'
+12 -8
View File
@@ -169,10 +169,12 @@ def _base_query(
'bot_uuid': 'test-bot-uuid',
'pipeline_config': {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
},
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
@@ -417,10 +419,12 @@ def query_with_config(
if pipeline_config is None:
pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
},
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
+36 -30
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import pytest
import asyncio
from unittest.mock import AsyncMock, Mock
import sys
from tests.factories import FakeApp, text_query, mock_platform_adapter
from tests.factories.provider import FakeProvider
@@ -48,10 +47,6 @@ def mock_circular_import_chain():
# Mock core.app - Application class is referenced but not instantiated
mock_core_app = Mock()
# Mock provider.runner with preregistered_runners list
mock_runner = Mock()
mock_runner.preregistered_runners = [] # Will be populated in tests
# Mock utils.importutil - prevents auto-import of runners
mock_importutil = Mock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
@@ -75,8 +70,7 @@ def mock_circular_import_chain():
mocks={
'langbot.pkg.core.entities': mock_core_entities,
'langbot.pkg.core.app': mock_core_app,
'langbot.pkg.provider.runner': mock_runner,
'langbot.pkg.utils.importutil': mock_importutil,
'langbot.pkg.utils.importutil': mock_importutil,
'langbot.pkg.pipeline.controller': Mock(),
'langbot.pkg.pipeline.pipelinemgr': Mock(),
},
@@ -108,8 +102,7 @@ def mock_circular_import_chain():
class FakeRunner:
"""Minimal fake runner class for pipeline integration tests.
Note: preregistered_runners expects a CLASS, not an instance.
The handler calls runner_cls(self.ap, query.pipeline_config) to instantiate.
The test orchestrator instantiates this class for each configured run.
"""
name = 'local-agent'
@@ -243,12 +236,23 @@ def fake_platform_adapter():
@pytest.fixture
def set_fake_runner():
"""Factory fixture to set a fake runner CLASS in preregistered_runners."""
def set_fake_runner(pipeline_app):
"""Attach a minimal AgentRunOrchestrator-compatible test double."""
def _set_runner(runner_cls):
# preregistered_runners expects a list of runner classes
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_cls]
runner = runner_cls()
orchestrator = Mock()
orchestrator.try_claim_steering_from_query = AsyncMock(return_value=False)
async def run_from_query(query):
async for result in runner.run(query):
yield result
orchestrator.run_from_query = run_from_query
orchestrator.resolve_runner_id_for_telemetry = Mock(
return_value='plugin:langbot-team/LocalAgent/default'
)
pipeline_app.agent_run_orchestrator = orchestrator
return _set_runner
@@ -260,11 +264,13 @@ def create_minimal_pipeline_config():
"""Create minimal pipeline configuration for tests."""
return {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': None},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'default',
'knowledge-bases': [],
'runner': {'id': 'plugin:langbot-team/LocalAgent/default', 'expire-time': 0},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'default',
'knowledge-bases': [],
},
},
},
'output': {
@@ -366,7 +372,7 @@ class TestPreProcessorStage:
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
assert result.result_type.value == entities.ResultType.CONTINUE.value
assert result.new_query.session is not None
assert result.new_query.user_message is not None
@@ -393,7 +399,7 @@ class TestPreProcessorStage:
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
assert result.result_type.value == entities.ResultType.CONTINUE.value
# Check user_message content
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
@@ -466,7 +472,7 @@ class TestProcessorStage:
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].result_type.value == entities.ResultType.INTERRUPT.value
@pytest.mark.asyncio
async def test_processor_prevent_default_with_reply_continues(self, pipeline_app, fake_platform_adapter):
@@ -501,7 +507,7 @@ class TestProcessorStage:
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert results[0].result_type.value == entities.ResultType.CONTINUE.value
assert len(query.resp_messages) == 1
assert query.resp_messages[0] == reply_chain
@@ -546,7 +552,7 @@ class TestRunnerExceptionFlow:
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].result_type.value == entities.ResultType.INTERRUPT.value
assert results[0].user_notice == 'Request failed.'
assert results[0].error_notice is not None
@@ -585,7 +591,7 @@ class TestRunnerExceptionFlow:
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].result_type.value == entities.ResultType.INTERRUPT.value
assert 'Custom runtime error' in results[0].user_notice
@pytest.mark.asyncio
@@ -623,7 +629,7 @@ class TestRunnerExceptionFlow:
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].result_type.value == entities.ResultType.INTERRUPT.value
assert results[0].user_notice is None
@@ -655,7 +661,7 @@ class TestSendResponseBackStage:
result = await respback_stage.process(query, 'SendResponseBackStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.result_type.value == entities.ResultType.CONTINUE.value
# Check that adapter was called
outbound = platform.get_outbound_messages()
@@ -815,7 +821,7 @@ class TestStageChainIntegration:
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
assert result1.result_type.value == entities.ResultType.CONTINUE.value
query = result1.new_query
# Run Processor
@@ -831,7 +837,7 @@ class TestStageChainIntegration:
# Run SendResponseBackStage
result3 = await respback_stage.process(query, 'SendResponseBackStage')
assert result3.result_type == entities.ResultType.CONTINUE
assert result3.result_type.value == entities.ResultType.CONTINUE.value
# Verify adapter was called
outbound = platform.get_outbound_messages()
@@ -879,14 +885,14 @@ class TestStageChainIntegration:
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
assert result1.result_type.value == entities.ResultType.CONTINUE.value
query = result1.new_query
# Run Processor - should INTERRUPT
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].result_type.value == entities.ResultType.INTERRUPT.value
# Chain stops here - no resp_messages
assert len(query.resp_messages) == 0
+16 -16
View File
@@ -69,7 +69,7 @@ class MockQuery:
self.pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'id': 'plugin:langbot-team/LocalAgent/default',
},
'runner_config': {},
},
@@ -133,7 +133,7 @@ class MockAgentRunOrchestrator:
return False
def resolve_runner_id_for_telemetry(self, query):
return 'plugin:langbot/local-agent/default'
return 'plugin:langbot-team/LocalAgent/default'
class MockApplication:
@@ -234,16 +234,16 @@ class TestConfigMigrationInChatHandler:
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_resolve_runner_id_from_old_format(self):
"""ConfigMigration resolves old runner aliases for compatibility."""
def test_old_runner_field_is_not_resolved(self):
"""The 4.x path requires ai.runner.id."""
pipeline_config = {
'ai': {
'runner': {
@@ -253,7 +253,7 @@ class TestConfigMigrationInChatHandler:
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id is None
class TestErrorHandling:
@@ -268,18 +268,18 @@ class TestErrorHandling:
def test_runner_execution_error_retryable(self):
"""RunnerExecutionError should have retryable property."""
error = RunnerExecutionError(
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
'Upstream timeout',
retryable=True,
)
assert error.runner_id == 'plugin:langbot/local-agent/default'
assert error.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert error.retryable is True
assert 'timeout' in str(error)
def test_runner_execution_error_not_retryable(self):
"""RunnerExecutionError can be non-retryable."""
error = RunnerExecutionError(
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
'Configuration error',
retryable=False,
)
@@ -288,11 +288,11 @@ class TestErrorHandling:
def test_runner_not_authorized_error_properties(self):
"""RunnerNotAuthorizedError should have bound_plugins property."""
error = RunnerNotAuthorizedError(
'plugin:langbot/local-agent/default',
['langbot/dify-agent'],
'plugin:langbot-team/LocalAgent/default',
['langbot-team/DifyAgent'],
)
assert error.runner_id == 'plugin:langbot/local-agent/default'
assert error.bound_plugins == ['langbot/dify-agent']
assert error.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert error.bound_plugins == ['langbot-team/DifyAgent']
class TestChatHandlerImports:
@@ -500,7 +500,7 @@ class TestChatHandlerAsyncBehavior:
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerNotAuthorizedError('plugin:langbot/local-agent/default', ['other/plugin'])
error=RunnerNotAuthorizedError('plugin:langbot-team/LocalAgent/default', ['other/plugin'])
)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
@@ -538,7 +538,7 @@ class TestChatHandlerAsyncBehavior:
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerExecutionError('plugin:langbot/local-agent/default', 'timeout', retryable=True)
error=RunnerExecutionError('plugin:langbot-team/LocalAgent/default', 'timeout', retryable=True)
)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
+10 -154
View File
@@ -12,15 +12,15 @@ class TestResolveRunnerId:
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'id': 'plugin:langbot-team/LocalAgent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_resolves_old_runner_field(self):
def test_does_not_resolve_legacy_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
@@ -30,33 +30,7 @@ class TestResolveRunnerId:
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolves_deerflow_and_weknora_legacy_runner_fields(self):
assert (
ConfigMigration.resolve_runner_id(
{
'ai': {
'runner': {
'runner': 'deerflow-api',
},
},
}
)
== 'plugin:langbot/deerflow-agent/default'
)
assert (
ConfigMigration.resolve_runner_id(
{
'ai': {
'runner': {
'runner': 'weknora-api',
},
},
}
)
== 'plugin:langbot/weknora-agent/default'
)
assert runner_id is None
def test_resolve_no_runner_config(self):
runner_id = ConfigMigration.resolve_runner_id({})
@@ -70,7 +44,7 @@ class TestResolveRunnerConfig:
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {
'plugin:langbot-team/LocalAgent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
@@ -80,11 +54,11 @@ class TestResolveRunnerConfig:
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_reads_old_runner_block(self):
def test_does_not_read_legacy_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
@@ -95,46 +69,14 @@ class TestResolveRunnerConfig:
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
)
assert config == {'model': {'primary': 'uuid-123', 'fallbacks': []}}
def test_reads_deerflow_and_weknora_legacy_runner_blocks(self):
pipeline_config = {
'ai': {
'deerflow-api': {
'api-base': 'http://127.0.0.1:2026',
'assistant-id': 'lead_agent',
},
'weknora-api': {
'base-url': 'http://localhost:8080/api/v1',
'app-type': 'agent',
},
},
}
deerflow_config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/deerflow-agent/default',
)
weknora_config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/weknora-agent/default',
)
assert deerflow_config == {
'api-base': 'http://127.0.0.1:2026',
'assistant-id': 'lead_agent',
}
assert weknora_config == {
'base-url': 'http://localhost:8080/api/v1',
'app-type': 'agent',
}
assert config == {}
def test_resolve_no_config(self):
config = ConfigMigration.resolve_runner_config(
{},
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
)
assert config == {}
@@ -169,89 +111,3 @@ class TestGetExpireTime:
def test_get_expire_time_default(self):
expire_time = ConfigMigration.get_expire_time({})
assert expire_time == 0
class TestNormalizePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config."""
def test_normalizes_current_containers(self):
config = {'ai': {}}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {'ai': {'runner': {}, 'runner_config': {}}}
def test_preserves_current_config(self):
config = {
'ai': {
'runner': {'id': 'plugin:test/my-runner/default'},
'runner_config': {
'plugin:test/my-runner/default': {'custom-option': 20},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default'
assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20
def test_migrates_old_runner_blocks(self):
config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': 'old-model', 'knowledge-base': 'kb_1'},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert 'runner' not in migrated['ai']['runner']
assert 'local-agent' not in migrated['ai']
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == {
'model': {'primary': 'old-model', 'fallbacks': []},
'knowledge-bases': ['kb_1'],
}
def test_migrates_deerflow_legacy_runner_block(self):
config = {
'ai': {
'runner': {'runner': 'deerflow-api'},
'deerflow-api': {
'api-base': 'http://127.0.0.1:2026',
'assistant-id': 'lead_agent',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/deerflow-agent/default'
assert 'runner' not in migrated['ai']['runner']
assert 'deerflow-api' not in migrated['ai']
assert migrated['ai']['runner_config']['plugin:langbot/deerflow-agent/default'] == {
'api-base': 'http://127.0.0.1:2026',
'assistant-id': 'lead_agent',
}
def test_migrates_weknora_legacy_runner_block(self):
config = {
'ai': {
'runner': {'runner': 'weknora-api'},
'weknora-api': {
'base-url': 'http://localhost:8080/api/v1',
'app-type': 'agent',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/weknora-agent/default'
assert 'runner' not in migrated['ai']['runner']
assert 'weknora-api' not in migrated['ai']
assert migrated['ai']['runner_config']['plugin:langbot/weknora-agent/default'] == {
'base-url': 'http://localhost:8080/api/v1',
'app-type': 'agent',
}
@@ -7,65 +7,6 @@ import json
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestMigratePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config."""
def test_current_format_config_stays_unchanged(self):
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'expire-time': 0,
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'model': {'primary': '', 'fallbacks': []},
'custom-option': 10,
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10
def test_old_runner_field_is_mapped(self):
config = {
'ai': {
'runner': {
'runner': 'local-agent',
'expire-time': 3600,
},
'local-agent': {
'model': 'old-model',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner'] == {
'expire-time': 3600,
'id': 'plugin:langbot/local-agent/default',
}
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == {
'model': {'primary': 'old-model', 'fallbacks': []},
}
assert 'local-agent' not in migrated['ai']
def test_empty_config_is_unchanged(self):
config = {}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {}
def test_config_without_ai_section_is_unchanged(self):
config = {'trigger': {}}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {'trigger': {}}
class TestDefaultPipelineConfig:
"""Tests for default-pipeline-config.json format."""
@@ -97,14 +38,14 @@ class TestResolveRunnerId:
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:test/my-runner/default'
def test_old_runner_field_is_mapped(self):
def test_old_runner_field_is_not_supported(self):
config = {
'ai': {
'runner': {'runner': 'local-agent'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id is None
class TestResolveRunnerConfig:
@@ -114,18 +55,18 @@ class TestResolveRunnerConfig:
config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {'custom-option': 20},
'plugin:langbot-team/LocalAgent/default': {'custom-option': 20},
},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config['custom-option'] == 20
def test_old_runner_block_is_read(self):
def test_old_runner_block_is_not_supported(self):
config = {
'ai': {
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {'custom-option': 20}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
assert runner_config == {}
@@ -158,27 +158,26 @@ class TestQueryConfigToAgentConfig:
assert agent_config.runner_id == "plugin:author/plugin/runner"
def test_config_to_agent_config_uses_legacy_runner_config_migration(self, mock_query):
"""Temporary query adapter must share the normal runner config resolver."""
def test_config_to_agent_config_uses_current_runner_config(self, mock_query):
"""Temporary query adapters use the current runner config container."""
mock_query.pipeline_config = {
"ai": {
"runner": {"runner": "local-agent"},
"local-agent": {
"model": "model-primary",
"knowledge-base": "kb-001",
"runner": {"id": "plugin:langbot-team/LocalAgent/default"},
"runner_config": {
"plugin:langbot-team/LocalAgent/default": {
"model": {"primary": "model-primary", "fallbacks": []},
"knowledge-bases": ["kb-001"],
},
},
}
}
agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query,
"plugin:langbot/local-agent/default",
"plugin:langbot-team/LocalAgent/default",
)
assert agent_config.runner_config["model"] == {
"primary": "model-primary",
"fallbacks": [],
}
assert agent_config.runner_config["model"] == {"primary": "model-primary", "fallbacks": []}
assert agent_config.runner_config["knowledge-bases"] == ["kb-001"]
def test_resolver_projects_agent_scope(self, mock_query):
+6 -6
View File
@@ -544,10 +544,10 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'id': 'plugin:langbot-team/LocalAgent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'plugin:langbot-team/LocalAgent/default': {
'knowledge-bases': ['kb_001'],
},
},
@@ -583,8 +583,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
assert 'kb_custom' in allowed_kbs
def test_retrieve_kb_reads_old_runner_format(self):
"""Old runner format is resolved for migration compatibility."""
def test_retrieve_kb_does_not_read_old_runner_format(self):
"""The 4.x authorization path ignores legacy Pipeline runner fields."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
pipeline_config = {
@@ -600,8 +600,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_config.get('knowledge-bases') == ['kb_legacy']
assert runner_id is None
assert runner_config == {}
class TestHandlerActionAuthorization:
+15 -15
View File
@@ -16,12 +16,12 @@ class TestRunnerIdParsing:
def test_parse_plugin_runner_id(self):
"""Parse valid plugin runner ID."""
runner_id = 'plugin:langbot/local-agent/default'
runner_id = 'plugin:langbot-team/LocalAgent/default'
parts = parse_runner_id(runner_id)
assert parts.source == 'plugin'
assert parts.plugin_author == 'langbot'
assert parts.plugin_name == 'local-agent'
assert parts.plugin_author == 'langbot-team'
assert parts.plugin_name == 'LocalAgent'
assert parts.runner_name == 'default'
def test_parse_plugin_runner_id_complex_names(self):
@@ -36,7 +36,7 @@ class TestRunnerIdParsing:
def test_parse_invalid_plugin_runner_id_missing_parts(self):
"""Parse invalid plugin runner ID with missing parts."""
runner_id = 'plugin:langbot/local-agent'
runner_id = 'plugin:langbot-team/LocalAgent'
with pytest.raises(ValueError) as exc_info:
parse_runner_id(runner_id)
@@ -76,20 +76,20 @@ class TestRunnerIdFormatting:
"""Format plugin runner ID."""
runner_id = format_runner_id(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
)
assert runner_id == 'plugin:langbot/local-agent/default'
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
def test_format_invalid_source(self):
"""Format runner ID with invalid source."""
with pytest.raises(ValueError) as exc_info:
format_runner_id(
source='builtin',
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
)
@@ -103,19 +103,19 @@ class TestRunnerIdParts:
"""Get plugin ID from parts."""
parts = RunnerIdParts(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
)
assert parts.to_plugin_id() == 'langbot/local-agent'
assert parts.to_plugin_id() == 'langbot-team/LocalAgent'
def test_frozen_dataclass(self):
"""RunnerIdParts should be immutable."""
parts = RunnerIdParts(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
)
@@ -128,7 +128,7 @@ class TestIsPluginRunnerId:
def test_is_plugin_runner_id_true(self):
"""Check plugin runner ID returns True."""
assert is_plugin_runner_id('plugin:langbot/local-agent/default') is True
assert is_plugin_runner_id('plugin:langbot-team/LocalAgent/default') is True
def test_is_plugin_runner_id_false(self):
"""Check non-plugin runner ID returns False."""
@@ -26,7 +26,7 @@ from langbot_plugin.api.entities.builtin.provider import session as provider_ses
from langbot_plugin.api.entities.builtin.resource import tool as resource_tool
RUNNER_ID = 'plugin:langbot/local-agent/default'
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
class FakeLogger:
@@ -153,8 +153,8 @@ def make_descriptor() -> AgentRunnerDescriptor:
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
capabilities={
'streaming': True,
@@ -231,7 +231,7 @@ def make_query():
],
),
variables={
'_pipeline_bound_plugins': ['langbot/local-agent'],
'_pipeline_bound_plugins': ['langbot-team/LocalAgent'],
'_fallback_model_uuids': ['model_fallback'],
'_pipeline_bound_skills': ['demo'],
'public_param': 'visible',
@@ -354,8 +354,8 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
assert messages[0].content == 'fake response'
assert plugin_connector.calls == [
{
'plugin_author': 'langbot',
'plugin_name': 'local-agent',
'plugin_author': 'langbot-team',
'plugin_name': 'LocalAgent',
'runner_name': 'default',
}
]
@@ -400,7 +400,7 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run['plugin_identity'] == 'langbot/local-agent'
assert session_during_run['plugin_identity'] == 'langbot-team/LocalAgent'
assert session_during_run['authorization']['authorized_ids']['tool'] == {'langbot/test-tool/search'}
assert session_during_run['authorization']['authorized_ids']['skill'] == {'demo'}
assert await get_session_registry().get(context['run_id']) is None
+17 -17
View File
@@ -35,11 +35,11 @@ class FakeApplication:
# Return sample runner data
return [
{
'plugin_author': 'langbot',
'plugin_name': 'local-agent',
'plugin_author': 'langbot-team',
'plugin_name': 'LocalAgent',
'runner_name': 'default',
'manifest': {
'id': 'plugin:langbot/local-agent/default',
'id': 'plugin:langbot-team/LocalAgent/default',
'name': 'default',
'label': {'en_US': 'Local Agent'},
'capabilities': {'streaming': True},
@@ -98,11 +98,11 @@ class TestRegistryDiscovery:
runners = await registry.list_runners(use_cache=False)
# Should find 2 valid runners (langbot/local-agent and alice/my-agent)
# Should find 2 valid runners (langbot-team/LocalAgent and alice/my-agent)
assert len(runners) == 2
ids = [r.id for r in runners]
assert 'plugin:langbot/local-agent/default' in ids
assert 'plugin:langbot-team/LocalAgent/default' in ids
assert 'plugin:alice/my-agent/custom' in ids
@pytest.mark.asyncio
@@ -143,15 +143,15 @@ class TestRegistryDiscovery:
# First: get with bound_plugins filter (should not pollute cache)
descriptor = await registry.get(
'plugin:langbot/local-agent/default',
bound_plugins=['langbot/local-agent'],
'plugin:langbot-team/LocalAgent/default',
bound_plugins=['langbot-team/LocalAgent'],
)
assert descriptor.id == 'plugin:langbot/local-agent/default'
assert descriptor.id == 'plugin:langbot-team/LocalAgent/default'
# Cache should contain ALL runners (both langbot and alice)
assert registry._cache is not None
assert len(registry._cache) == 2 # Both runners in cache
assert 'plugin:langbot/local-agent/default' in registry._cache
assert 'plugin:langbot-team/LocalAgent/default' in registry._cache
assert 'plugin:alice/my-agent/custom' in registry._cache
# Second: list_runners without filter should return ALL runners
@@ -173,11 +173,11 @@ class TestRegistryGet:
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
descriptor = await registry.get('plugin:langbot/local-agent/default')
descriptor = await registry.get('plugin:langbot-team/LocalAgent/default')
assert descriptor.id == 'plugin:langbot/local-agent/default'
assert descriptor.plugin_author == 'langbot'
assert descriptor.plugin_name == 'local-agent'
assert descriptor.id == 'plugin:langbot-team/LocalAgent/default'
assert descriptor.plugin_author == 'langbot-team'
assert descriptor.plugin_name == 'LocalAgent'
assert descriptor.runner_name == 'default'
@pytest.mark.asyncio
@@ -199,8 +199,8 @@ class TestRegistryGet:
# Authorized - langbot plugin in bound list
descriptor = await registry.get(
'plugin:langbot/local-agent/default',
bound_plugins=['langbot/local-agent'],
'plugin:langbot-team/LocalAgent/default',
bound_plugins=['langbot-team/LocalAgent'],
)
assert descriptor is not None
@@ -208,7 +208,7 @@ class TestRegistryGet:
with pytest.raises(RunnerNotAuthorizedError):
await registry.get(
'plugin:alice/my-agent/custom',
bound_plugins=['langbot/local-agent'],
bound_plugins=['langbot-team/LocalAgent'],
)
@@ -226,7 +226,7 @@ class TestRegistryMetadataForPipeline:
# Should have options for each runner
assert len(options) == 2
option_ids = [o['name'] for o in options]
assert 'plugin:langbot/local-agent/default' in option_ids
assert 'plugin:langbot-team/LocalAgent/default' in option_ids
assert 'plugin:alice/my-agent/custom' in option_ids
# Config comes from the typed manifest.
@@ -32,11 +32,11 @@ class FakeApplication:
def make_descriptor():
"""Create a test descriptor."""
return AgentRunnerDescriptor(
id='plugin:langbot/local-agent/default',
id='plugin:langbot-team/LocalAgent/default',
source='plugin',
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
capabilities={'streaming': True},
)
@@ -190,7 +190,7 @@ class TestNormalizeRunFailed:
with pytest.raises(RunnerExecutionError) as exc_info:
await normalizer.normalize(result_dict, descriptor)
assert exc_info.value.runner_id == 'plugin:langbot/local-agent/default'
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert exc_info.value.retryable is True
assert 'timeout' in str(exc_info.value)
@@ -206,7 +206,7 @@ class TestAgentServiceCreateUpdateDelete:
async def test_create_agent_uses_default_runner_config_from_registry(self):
app = _make_app()
runner = SimpleNamespace(
id='plugin:langbot/local-agent/default',
id='plugin:langbot-team/LocalAgent/default',
config_schema=[
{'name': 'model', 'default': 'gpt-4.1'},
{'name': 'temperature', 'default': 0.2},
@@ -353,19 +353,6 @@ class TestPipelineServiceCreatePipeline:
}
class _MockResultWithBots:
"""Helper class to mock SQLAlchemy result with iterable .all() method."""
def __init__(self, bots_list):
self._bots_list = bots_list
def all(self):
return self._bots_list
def first(self):
return self._bots_list[0] if self._bots_list else None
class TestPipelineServiceUpdatePipeline:
"""Tests for update_pipeline method."""
@@ -379,20 +366,18 @@ class TestPipelineServiceUpdatePipeline:
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
ap.bot_service = None # No bot_service when not updating name
ap.persistence_mgr.execute_async = AsyncMock()
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'})
# Execute with protected fields - no name change, so no bot sync
# Execute with protected fields.
pipeline_data = {
'uuid': 'should-be-removed',
'for_version': 'should-be-removed',
'stages': ['should-be-removed'],
'is_default': True,
'description': 'New description', # Not name change, so no bot_service needed
'description': 'New description',
}
await service.update_pipeline('test-uuid', pipeline_data)
@@ -402,8 +387,8 @@ class TestPipelineServiceUpdatePipeline:
assert ['should-be-removed'] not in update_params.values()
assert not any(value is True for value in update_params.values())
async def test_update_pipeline_syncs_bot_names(self):
"""Updates bot use_pipeline_name when pipeline name changes."""
async def test_update_pipeline_name_does_not_rewrite_bot_routes(self):
"""Bot event bindings remain independent from pipeline display names."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
@@ -415,45 +400,14 @@ class TestPipelineServiceUpdatePipeline:
ap.bot_service = SimpleNamespace()
ap.bot_service.update_bot = AsyncMock()
# Create proper mock Bot entities with uuid attribute
mock_bot1 = Mock()
mock_bot1.uuid = 'bot-uuid-1'
mock_bot2 = Mock()
mock_bot2.uuid = 'bot-uuid-2'
# Create bot list
bot_list = [mock_bot1, mock_bot2]
# Create mock result using helper class
bot_result = _MockResultWithBots(bot_list)
# The order of calls in update_pipeline:
# 1. UPDATE (line 125) - returns Mock (no result needed)
# 2. SELECT bots (line 136) - returns bot_result with .all()
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
# First call is the UPDATE - just return a Mock
return Mock()
elif call_count == 2:
# Second call is the SELECT bots - return proper result
return bot_result
return Mock() # Any additional calls
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(return_value={})
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})
# Execute with name change
await service.update_pipeline('test-uuid', {'name': 'New Name'})
# Verify - bot_service.update_bot was called for each bot
assert ap.bot_service.update_bot.call_count == 2
ap.bot_service.update_bot.assert_not_awaited()
async def test_update_pipeline_clears_conversations(self):
"""Clears session conversations using this pipeline."""
+2
View File
@@ -76,6 +76,8 @@ async def test_mcp_server_exposes_bot_event_route_tools():
assert 'list_bot_event_route_statuses' in tool_names
assert 'test_bot_event_route' in tool_names
assert 'list_processors' in tool_names
assert 'list_agents' not in tool_names
@pytest.mark.asyncio
@@ -39,7 +39,7 @@ def make_runner(runner_id: str, config_schema: list[dict]):
@pytest.mark.asyncio
async def test_default_pipeline_config_uses_first_installed_runner_schema():
local_agent = make_runner(
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
[
{'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}},
{'name': 'prompt', 'type': 'prompt-editor', 'default': [{'role': 'system', 'content': 'Hello'}]},
+20 -4
View File
@@ -185,9 +185,9 @@ def test_resolve_box_session_id_reads_current_runner_config():
query = make_query(101)
query.pipeline_config = {
'ai': {
'runner': {'id': 'plugin:langbot/local-agent/default'},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot/local-agent/default': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': 'bot-{launcher_id}-{sender_id}',
},
},
@@ -450,7 +450,14 @@ def test_box_service_forced_template_ignores_pipeline_config():
launcher_id='test_user',
sender_id='test_user',
pipeline_config={
'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}}
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'
}
},
}
},
)
@@ -469,7 +476,16 @@ def test_box_service_empty_forced_template_respects_pipeline_config():
query_id=7,
launcher_type='group',
launcher_id='room-1',
pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}},
pipeline_config={
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'box-session-id-template': '{launcher_type}_{launcher_id}'
}
},
}
},
)
assert service.resolve_box_session_id(query) == 'group_room-1'
+1 -1
View File
@@ -27,7 +27,7 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.pipeline import entities as pipeline_entities
DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
DEFAULT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
class MockApplication:
+21 -6
View File
@@ -337,8 +337,13 @@ class TestChatHandlerExceptions:
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}},
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'prompt': 'default',
'model': {'primary': 'test'},
},
},
},
}
@@ -385,8 +390,13 @@ class TestChatHandlerExceptions:
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-error'}},
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'prompt': 'default',
'model': {'primary': 'test'},
},
},
},
}
@@ -430,8 +440,13 @@ class TestChatHandlerExceptions:
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'hide'}},
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'prompt': 'default',
'model': {'primary': 'test'},
},
},
},
}
@@ -50,8 +50,13 @@ async def _run_preprocessor(mock_app, sample_query, conversation):
sample_query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': 60},
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default', 'expire-time': 60},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'model': {'primary': '', 'fallbacks': []},
'prompt': [],
},
},
},
'trigger': {'misc': {'combine-quote-message': False}},
'output': {'misc': {'exception-handling': 'show-hint'}},
@@ -11,10 +11,7 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
loaded_pipeline = Mock()
service.get_pipeline = AsyncMock(return_value=loaded_pipeline)
bot = Mock(uuid='bot-uuid')
bot_result = Mock(all=Mock(return_value=[bot]))
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=[None, bot_result])
mock_app.bot_service = Mock(update_bot=AsyncMock())
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=None)
mock_app.pipeline_mgr = Mock(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock())
mock_app.sess_mgr.session_list = []
@@ -35,9 +32,5 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
updated_fields = {getattr(field, 'key', str(field)) for field in update_stmt._values}
assert updated_fields == {'name'}
mock_app.bot_service.update_bot.assert_awaited_once_with(
'bot-uuid',
{'use_pipeline_name': 'Updated pipeline'},
)
mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('pipeline-uuid')
mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with(loaded_pipeline)
+15 -7
View File
@@ -164,17 +164,20 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
mock_stage.process.assert_called_once()
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
"""Local Agent resource selection should override legacy extension prefs."""
def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
"""Runner resource selection should override extension preferences."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {
'ai': {
'local-agent': {
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
'mcp-resource-agent-read-enabled': False,
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot-team/LocalAgent/default': {
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
'mcp-resource-agent-read-enabled': False,
},
}
}
}
@@ -190,12 +193,17 @@ def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
"""Existing extension prefs remain compatible until a Local Agent value exists."""
"""Extension preferences apply when the current runner has no override."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {'ai': {'local-agent': {}}}
pipeline_entity.config = {
'ai': {
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {'plugin:langbot-team/LocalAgent/default': {}},
}
}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': False,
+13 -16
View File
@@ -35,7 +35,7 @@ def get_entities_module():
return import_module('langbot.pkg.pipeline.entities')
RUNNER_ID = 'plugin:langbot/local-agent/default'
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def attach_agent_runner_descriptor(app, *, multimodal_input=True, tool_calling=True):
@@ -46,8 +46,8 @@ def attach_agent_runner_descriptor(app, *, multimodal_input=True, tool_calling=T
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector'},
@@ -499,22 +499,19 @@ class TestPreProcessorToolSelection:
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
attach_agent_runner_descriptor(app)
stage = preproc.PreProcessor(app)
query = text_query('hello')
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
'prompt': 'default',
'enable-all-tools': False,
'tools': ['plugin_tool'],
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
query.pipeline_config = agent_runner_pipeline_config(
{'primary': 'primary-model-uuid', 'fallbacks': []},
)
query.pipeline_config['ai']['runner_config'][RUNNER_ID].update(
{
'enable-all-tools': False,
'tools': ['plugin_tool'],
}
)
result = await stage.process(query, 'PreProcessor')
@@ -9,7 +9,7 @@ import pytest
from langbot_plugin.api.entities.builtin.platform import message as platform_message
RUNNER_ID = 'plugin:langbot/local-agent/default'
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def _attach_agent_runner_descriptor(app):
@@ -19,8 +19,8 @@ def _attach_agent_runner_descriptor(app):
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector'},
@@ -27,7 +27,7 @@ class TestEventRouteTrace:
"""A route miss is visible as structured route trace metadata."""
bot = self._make_bot([])
await bot._dispatch_eba_event_to_agent(SimpleNamespace(type='platform.member.joined'), Mock())
await bot._dispatch_eba_event_to_processor(SimpleNamespace(type='platform.member.joined'), Mock())
bot.logger.info.assert_awaited_once()
_, kwargs = bot.logger.info.await_args
@@ -21,7 +21,7 @@ from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr.token import TokenManager
DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
DEFAULT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
class FakeAgentRunnerRegistry:
@@ -30,8 +30,8 @@ class FakeAgentRunnerRegistry:
id=runner_id,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot',
plugin_name='local-agent',
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
config_schema=[
{'name': 'model', 'type': 'model-fallback-selector'},
@@ -193,7 +193,7 @@ class TestPersistActivatedSkill:
query = SimpleNamespace(variables={ACTIVATED_SKILLS_KEY: {'pdf': {'name': 'pdf'}}})
query._agent_run_session = {
'runner_id': 'plugin:langbot/local-agent/default',
'runner_id': 'plugin:langbot-team/LocalAgent/default',
'state_context': {
'scope_keys': {'conversation': 'conv-scope-key'},
'binding_identity': 'binding-1',
@@ -214,7 +214,7 @@ class TestPersistActivatedSkill:
assert kwargs['state_key'] == ACTIVATED_SKILL_NAMES_STATE_KEY
assert kwargs['value'] == ['pdf']
assert kwargs['scope'] == 'conversation'
assert kwargs['runner_id'] == 'plugin:langbot/local-agent/default'
assert kwargs['runner_id'] == 'plugin:langbot-team/LocalAgent/default'
assert kwargs['binding_identity'] == 'binding-1'
@pytest.mark.asyncio
+3 -3
View File
@@ -64,9 +64,9 @@ def _make_query() -> Query:
pipeline_uuid='pipe-1',
pipeline_config={
'ai': {
'runner': {'id': 'plugin:langbot/local-agent/default'},
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
'runner_config': {
'plugin:langbot/local-agent/default': {
'plugin:langbot-team/LocalAgent/default': {
'model': {'primary': 'model-1', 'fallbacks': []},
'prompt': [],
'knowledge-bases': [],
@@ -307,7 +307,7 @@ async def test_preproc_uses_transcript_history_view_when_available():
assert result.result_type == entities_module.ResultType.CONTINUE
assert query.messages == transcript_messages
stage._load_agent_runner_history_messages.assert_awaited_once_with(
'plugin:langbot/local-agent/default',
'plugin:langbot-team/LocalAgent/default',
'conv-1',
bot_id='bot-1',
workspace_id=None,
@@ -1,13 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type React from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Brain, FileJson2, Info, Power, Trash2 } from 'lucide-react';
import {
Brain,
CircleAlert,
CircleCheck,
FileJson2,
Info,
LoaderCircle,
Power,
RefreshCw,
Trash2,
Unplug,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent } from '@/app/infra/entities/api';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import {
PipelineConfigStage,
PipelineConfigTab,
@@ -43,6 +55,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface AgentFormComponentProps {
agentId: string;
@@ -68,6 +81,10 @@ export default function AgentFormComponent({
useState<SectionItem['name']>('basic');
const [runnerConfigSchema, setRunnerConfigSchema] =
useState<PipelineConfigTab | null>(null);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const formSchema = z.object({
@@ -145,6 +162,23 @@ export default function AgentFormComponent({
};
}, [agentId, form, t]);
const loadPluginSystemStatus = useCallback(async () => {
setPluginStatusLoading(true);
setPluginStatusError(false);
try {
setPluginSystemStatus(await httpClient.getPluginSystemStatus());
} catch {
setPluginSystemStatus(null);
setPluginStatusError(true);
} finally {
setPluginStatusLoading(false);
}
}, []);
useEffect(() => {
void loadPluginSystemStatus();
}, [loadPluginSystemStatus]);
const sections: SectionItem[] = [
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
@@ -152,6 +186,128 @@ export default function AgentFormComponent({
];
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
const runnerOptions = useMemo(() => {
const runnerStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === 'runner',
);
return (
runnerStage?.config.find((item) => item.name === 'id')?.options ?? []
);
}, [runnerConfigSchema]);
const selectedRunnerOption = runnerOptions.find(
(option) => option.name === currentRunner,
);
function renderRunnerStatusActions(showRetry = true) {
return (
<div className="mt-3 flex flex-wrap gap-2">
{showRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadPluginSystemStatus()}
>
<RefreshCw className="size-4" />
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" size="sm" asChild>
<Link to="/home/extensions">{t('plugins.title')}</Link>
</Button>
</div>
);
}
function renderRunnerStatus() {
if (pluginStatusLoading) {
return (
<Alert>
<LoaderCircle className="animate-spin" />
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
</Alert>
);
}
if (pluginStatusError || !pluginSystemStatus) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
<AlertDescription>
{t('agents.runnerStatusCheckFailedDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
}
if (!pluginSystemStatus.is_enable) {
return (
<Alert variant="destructive">
<Power />
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
<AlertDescription>
{t('plugins.systemDisabledDesc')}
{renderRunnerStatusActions(false)}
</AlertDescription>
</Alert>
);
}
if (!pluginSystemStatus.is_connected) {
return (
<Alert variant="destructive">
<Unplug />
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
<AlertDescription>
{t('plugins.connectionErrorDesc')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
}
if (runnerOptions.length === 0) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
<AlertDescription>
{t('agents.noRunnersAvailableDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
}
if (!currentRunner || !selectedRunnerOption) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
<AlertDescription>
{t('agents.selectedRunnerUnavailableDescription', {
runner: currentRunner || t('agents.noRunnerSelected'),
})}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
}
return (
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
<CircleCheck className="text-emerald-600" />
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
<AlertDescription>
{t('agents.runnerReadyDescription', {
runner: extractI18nObject(selectedRunnerOption.label),
})}
</AlertDescription>
</Alert>
);
}
function updateSnapshotIfInitial(stageKey: string) {
if (!initializedStagesRef.current.has(stageKey)) {
@@ -431,6 +587,7 @@ export default function AgentFormComponent({
{activeSection === 'runner' && (
<div className="space-y-6">
{renderRunnerStatus()}
{runnerConfigSchema?.stages.map((stage) =>
renderDynamicStage(stage),
)}
@@ -111,6 +111,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
case DynamicFormItemType.BOOLEAN:
return z.boolean();
case DynamicFormItemType.STRING:
case DynamicFormItemType.SECRET:
return z.string();
case DynamicFormItemType.STRING_ARRAY:
return z.array(z.string());
@@ -43,6 +43,7 @@ import {
Plus,
X,
Eye,
EyeOff,
Wrench,
Trash2,
Sparkles,
@@ -137,6 +138,7 @@ export default function DynamicFormItemComponent({
const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [secretVisible, setSecretVisible] = useState(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
@@ -385,6 +387,44 @@ export default function DynamicFormItemComponent({
/>
);
case DynamicFormItemType.SECRET:
return (
<div className="relative w-full max-w-md">
<Input
type={secretVisible ? 'text' : 'password'}
autoComplete="new-password"
className="pr-10"
{...field}
value={field.value ?? ''}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-8 -translate-y-1/2 text-muted-foreground"
aria-label={
secretVisible
? t('common.hideSecret')
: t('common.showSecret')
}
onClick={() => setSecretVisible((visible) => !visible)}
>
{secretVisible ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{secretVisible ? t('common.hideSecret') : t('common.showSecret')}
</TooltipContent>
</Tooltip>
</div>
);
case DynamicFormItemType.TEXT:
return (
<Textarea
@@ -9,6 +9,10 @@ interface MessageDetailsCardProps {
export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
const { t } = useTranslation();
const isLocalAgent = [
'local-agent',
'plugin:langbot-team/LocalAgent/default',
].includes(details.message?.runnerName ?? '');
// Parse query variables JSON string
const queryVariables = useMemo(() => {
@@ -204,7 +208,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* Query Variables Section - Only show for non-local-agent runners */}
{queryVariables &&
Object.keys(queryVariables).length > 0 &&
details.message?.runnerName !== 'local-agent' && (
!isLocalAgent && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Braces className="w-4 h-4 mr-2" />
@@ -241,7 +245,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* No data message */}
{(!details.llmCalls || details.llmCalls.length === 0) &&
(!details.errors || details.errors.length === 0) &&
(details.message?.runnerName === 'local-agent' ||
(isLocalAgent ||
!queryVariables ||
Object.keys(queryVariables).length === 0) && (
<div className="text-sm text-muted-foreground text-center py-4">
@@ -51,6 +51,7 @@ export enum DynamicFormItemType {
FLOAT = 'float',
BOOLEAN = 'boolean',
STRING = 'string',
SECRET = 'secret',
TEXT = 'text',
STRING_ARRAY = 'array[string]',
FILE = 'file',

Some files were not shown because too many files have changed in this diff Show More