feat(agent): reserve stable runner event names

This commit is contained in:
huanghuoguoguo
2026-05-19 10:15:00 +08:00
parent 760baa24a3
commit 927388c1f7
8 changed files with 288 additions and 8 deletions

View File

@@ -158,7 +158,7 @@ class AgentRunnerDescriptor(BaseModel):
- `run_id`: 新 UUID不使用 query id 作为全局 run id
- `trigger.type`: `message.received`
- `conversation`: launcher、sender、bot、pipeline、历史消息
- `event`: message event envelope 子集
- `event`: message event envelope 子集`event_type` 使用稳定协议名,平台/SDK 原始事件名放入 `event_data.source_event_type`
- `actor`: sender
- `subject`: 当前消息或 launcher
- `prompt`: 宿主已处理的有效 prompt`query.prompt.messages`
@@ -211,6 +211,34 @@ ctx.prompt + ctx.messages + [current_user_message_from_ctx.input]
- `knowledge-base-multi-selector` 授权知识库。
- 后续新增 selector 时应在 resource builder 中统一扩展。
### 3.5.1 future EventRouter 预留
当前分支不实现 EBA EventRouter但 AgentRunner 协议必须从现在开始兼容非消息事件。未来不要为消息撤回、群成员加入、好友申请各写一套 runner wrapper统一入口应是
```text
EventRouter -> AgentRunOrchestrator.run_from_event(event_request)
```
`event_request` 至少需要包含:
- `event_type`: 稳定协议名,例如 `message.recalled``group.member_joined``friend.request_received`
- `event_id` / `event_timestamp`
- `event_data`: 平台原始 payload 摘要和 source event type
- `actor`: 触发者,例如撤回操作者、新成员、好友申请人
- `subject`: 事件作用对象,例如被撤回消息、群/成员关系、好友申请
- `conversation`: 可选。群事件有 launcher 语义,好友申请可能还没有 conversation
- `input`: 可选结构化输入。非消息事件允许 `text=None``contents=[]`
- `binding`: 事件绑定解析出的 runner id、runner config、资源范围
先保留的稳定事件名:
- `message.received`
- `message.recalled`
- `group.member_joined`
- `friend.request_received`
这些事件名应作为插件协议的一部分保持稳定。平台原始事件名只能进入 `event_data`,不能成为 `ctx.event.event_type` 的公共契约。
### 3.6 result_normalizer.py
只接受 SDK v1 result

View File

@@ -0,0 +1,165 @@
# Agent Runner 插件化 Phase 1 QA 验收矩阵
本文档用于指导测试 agent 验收 Phase 1Agent Runner 插件化是否已经达到旧内置 runner 的对外效果。
Phase 1 的目标是让当前聊天 Pipeline 在选择插件化 AgentRunner 后,用户可感知行为与旧内置 runner 保持一致。Phase 2/EBA 不纳入本轮验收。
## 1. 验收边界
本轮必须验收:
- Pipeline 仍按现有消息入口运行。
- Runner 由插件提供,并通过 `AgentRunOrchestrator` 调用。
- `local-agent` 插件达到旧内置 local-agent 的主要行为 parity。
- 官方外部 runner 插件至少完成 smoke 验收。
- 旧 Pipeline 配置兼容,新配置可保存并生效。
- 权限裁剪、错误隔离、运行状态更新不破坏主流程。
本轮不验收:
- EBA EventBus。
- EBA EventRouter。
- 消息撤回、群成员加入、好友申请等非消息事件的真实接入。
- `action.requested` 平台动作执行。
- 新平台 API 权限模型。
上述非目标只允许检查协议预留是否存在,不允许作为 Phase 1 阻塞项。
## 2. 状态定义
测试 agent 只能使用以下状态:
| 状态 | 含义 |
| --- | --- |
| PASS | 按本矩阵步骤执行,所有通过条件满足,并记录证据。 |
| FAIL | 环境可用,但功能行为不满足通过条件。 |
| BLOCKED | 因缺少密钥、外部服务不可用、账号/OAuth 未完成、测试数据缺失等环境问题无法执行。必须写清阻塞原因。 |
| N/A | 当前插件或平台明确不支持该能力。必须引用 manifest capability、文档或配置说明。 |
不能使用“看起来正常”“大概通过”“未完全测试”等模糊状态。
## 3. 总体验收条件
Phase 1 可以关闭的最低条件:
- 所有 P0 case 必须 PASS。
- `local-agent` 的 P1 parity case 必须 PASS除非该能力旧内置 runner 也不支持,此时可标 N/A。
- 官方外部 runner smoke case 至少对已具备凭据和服务的插件 PASS缺凭据的插件可标 BLOCKED但必须保留配置页面截图或日志说明。
- 没有会导致主聊天路径不可用、插件 runtime 崩溃、Pipeline 配置丢失、权限绕过的未解决 FAIL。
- 所有 FAIL/BLOCKED 都必须记录复现步骤、日志位置、截图或请求/响应摘要。
推荐测试前先运行:
```bash
uv run --frozen pytest tests/unit_tests/agent
```
Host 侧 agent runner 单测不通过时,不应进入 UI parity QA。
## 4. 证据要求
每个 case 至少记录:
- LangBot commit、SDK commit、相关 runner 插件 commit。
- Pipeline UUID/name、runner id、runner config 摘要。
- WebUI 截图或浏览器操作记录。
- 后端日志中对应 query id/run id 的关键行。
- 对外部 runner记录外部服务响应摘要或错误码。
用户可见流程必须通过 WebUI 或真实消息平台验证。API/curl 只能作为诊断证据,不能单独让 UI case PASS。
## 5. P0 环境与主链路
| ID | 场景 | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P0-ENV-01 | LangBot 服务可用 | 启动后端和前端,打开 WebUI。 | WebUI 可登录/访问;后端无启动异常;插件系统按配置启用。 |
| P0-ENV-02 | 插件 runtime 可用 | 查看插件列表或后端日志。 | runtime 已启动;官方 runner 插件处于可用状态;无循环重启。 |
| P0-ENV-03 | Runner registry 可发现插件 runner | 打开 Pipeline AI runner 配置。 | runner 下拉列表来自插件 registry至少能看到 `plugin:langbot/local-agent/default`。 |
| P0-ENV-04 | 默认 Pipeline 可创建 | 新建 Pipeline 或读取默认 Pipeline。 | 默认配置使用 `ai.runner.id``ai.runner_config`;默认 runner 可保存。 |
| P0-ENV-05 | 主聊天路径调用插件 runner | 使用默认 `local-agent` Pipeline 发送一条普通消息。 | 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`;用户收到正常回复;旧内置 runner 不应作为主路径执行。 |
| P0-ENV-06 | 单测基线 | 运行 `uv run --frozen pytest tests/unit_tests/agent`。 | 全部通过;若失败,必须先修复或记录为 P0 FAIL。 |
## 6. P1 local-agent parity
`local-agent` 是 Phase 1 的主验收对象。以下 case 需要和旧内置 local-agent 的用户可见行为对齐。
| ID | 场景 | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P1-LA-01 | 普通文本对话 | 绑定 `plugin:langbot/local-agent/default`,发送普通文本。 | 回复正常生成conversation history 写入用户消息和助手消息。 |
| P1-LA-02 | 有效 prompt | 配置 system prompt并通过 PromptPreProcessing 插件或现有预处理改变 prompt。 | runner 使用 host 处理后的 `ctx.prompt`,不是只读取静态 `ctx.config.prompt`;回复体现有效 prompt。 |
| P1-LA-03 | 历史消息 | 连续多轮对话,第二轮引用第一轮内容。 | runner 可读到历史 `ctx.messages`;第二轮能基于上下文回答。 |
| P1-LA-04 | 流式输出 | 使用支持流式的 adapter/WebUI开启流式模型或流式 runner。 | UI 逐步更新;后端接收 `message.delta`;最终没有重复消息或空白卡片。 |
| P1-LA-05 | 非流式输出 | 使用不支持流式或关闭流式的路径。 | 只输出最终消息;不会创建异常流式卡片。 |
| P1-LA-06 | 工具调用 | 绑定一个可调用工具,提问触发工具。 | `ctx.resources.tools` 只包含授权工具runner 能获取工具详情并调用;最终回复包含工具结果。 |
| P1-LA-07 | 工具权限裁剪 | 不绑定某工具,但让 runner 尝试调用。 | 调用被拒绝错误不泄露未授权工具详情Pipeline 不崩溃。 |
| P1-LA-08 | RAG 检索 | 绑定知识库并提问命中文档。 | `ctx.resources.knowledge_bases` 包含所选知识库runner 可检索;回复引用或使用检索内容。 |
| P1-LA-09 | RAG 权限裁剪 | 不绑定知识库或绑定另一个知识库。 | 未授权知识库不可检索;错误可控。 |
| P1-LA-10 | rerank | 绑定 rerank 模型并启用知识库检索排序。 | runner 可通过授权 rerank 模型排序;无权限时不允许调用。 |
| P1-LA-11 | fallback model | 配置 primary 和 fallback模拟 primary 失败。 | fallback 被调用;用户得到可用回复或明确失败提示;日志能区分 primary/fallback。 |
| P1-LA-12 | remove-think | 开启输出 `remove-think`,使用会产生 think 内容的模型。 | 用户最终回复不包含被移除的 think 内容;插件 runner 走 runtime metadata 或 API 参数保持旧行为。 |
| P1-LA-13 | 多模态图片 | 发送图片输入。 | `ctx.input.contents` / `ctx.input.attachments` 保留图片;支持视觉模型时可正常处理;不支持时错误提示可控。 |
| P1-LA-14 | 文件输入 | 发送文件或文件 URL。 | runner 可看到文件 attachment 摘要;支持文件处理时正常处理;不支持时不崩溃。 |
| P1-LA-15 | 会话状态 | runner 返回 `state.updated`,下一轮继续对话。 | state 被 host 接收并作用于下一轮conversation id 等兼容旧行为。 |
| P1-LA-16 | 异常处理 | 让 runner 返回 `run.failed` 或抛异常。 | ChatMessageHandler 使用 Pipeline 的异常策略用户提示符合配置runtime 和后续请求不受影响。 |
| P1-LA-17 | 无输出保护 | runner 完成但不返回消息。 | 不产生空白成功回复;应按受控失败处理或明确记录缺陷。 |
## 7. P1 配置兼容与迁移
| ID | 场景 | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P1-CFG-01 | 读取旧配置 | 使用只包含 `ai.runner.runner = local-agent` 和旧 `ai.local-agent` 配置的 Pipeline。 | 能解析为 `plugin:langbot/local-agent/default`;旧配置值生效。 |
| P1-CFG-02 | 保存新配置 | 在 WebUI 修改 runner 和 runner config 后保存。 | 数据库存储 `ai.runner.id``ai.runner_config[id]`;刷新页面后不丢失。 |
| P1-CFG-03 | runner 切换 | 同一 Pipeline 从 local-agent 切到另一个官方 runner再切回。 | 每个 runner 的绑定配置独立保存;切换不污染其它 runner config。 |
| P1-CFG-04 | 插件缺失 | 配置引用一个未安装或未启动的 runner。 | WebUI/后端给出可理解错误Pipeline 不因 metadata 加载失败整体不可用。 |
| P1-CFG-05 | bound plugin 授权 | Pipeline 只绑定部分插件。 | 未绑定插件的 runner 不能执行;已绑定插件正常执行。 |
## 8. P1 权限与隔离
| ID | 场景 | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P1-AUTH-01 | 模型权限 | runner 尝试调用不在 `ctx.resources.models` 的模型。 | Host action 拒绝;错误包含 run/session 维度信息;不会调用实际模型。 |
| P1-AUTH-02 | 工具权限 | runner 尝试调用不在 `ctx.resources.tools` 的工具。 | Host action 拒绝;不会越权执行工具。 |
| P1-AUTH-03 | 知识库权限 | runner 尝试检索不在 `ctx.resources.knowledge_bases` 的知识库。 | Host action 拒绝;不会返回未授权知识库内容。 |
| P1-AUTH-04 | 存储权限 | manifest 未声明 storage 权限时访问 plugin/workspace storage。 | 访问被拒绝;普通插件非 AgentRunner 的兼容路径不受影响。 |
| P1-AUTH-05 | run_id 生命周期 | runner 结束后继续使用旧 run_id 调 host action。 | session 已注销;请求被拒绝。 |
| P1-AUTH-06 | 插件身份隔离 | A 插件 runner 的 run_id 被 B 插件使用。 | Host 拒绝 identity mismatch。 |
## 9. P2 官方外部 runner smoke
以下 case 是 smoke不要求和 local-agent 一样覆盖全部能力。若缺少外部服务凭据,状态标 BLOCKED并记录缺失项。
| ID | Runner | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P2-EXT-01 | `dify-agent` | 配置 chat/agent/workflow 中至少一种可用应用并发送消息。 | runner 可选、配置可保存、请求成功或外部服务错误被清晰返回。 |
| P2-EXT-02 | `n8n-agent` | 配置 webhook 和认证方式并发送消息。 | webhook 被调用;返回内容进入 LangBot 回复;认证失败时提示明确。 |
| P2-EXT-03 | `coze-agent` | 配置 Coze 应用并发送文本,若可用再测图片。 | 文本回复正常;多模态能力按 manifest/配置表现;思维链处理不污染最终回复。 |
| P2-EXT-04 | `dashscope-agent` | 配置 agent 或 workflow 并发送消息。 | 调用成功;失败时错误可控且不影响后续请求。 |
| P2-EXT-05 | `langflow-agent` | 配置 flow endpoint 并发送消息。 | 普通或 SSE 流式响应能归一为 LangBot 消息。 |
| P2-EXT-06 | `tbox-agent` | 配置 Tbox 应用并发送消息。 | 回复正常;多模态输入按插件能力处理。 |
## 10. P2 事件预留检查
这些只检查协议预留,不要求真实平台事件接入。
| ID | 场景 | 步骤 | 通过条件 |
| --- | --- | --- | --- |
| P2-EVT-01 | 消息事件名稳定 | 触发普通消息 runner。 | `ctx.trigger.type``ctx.event.event_type``message.received`;平台原始类型保存在 `ctx.event.event_data.source_event_type`。 |
| P2-EVT-02 | 非消息事件名预留 | 检查 host 侧保留事件名。 | `message.recalled``group.member_joined``friend.request_received` 作为稳定协议名存在。 |
| P2-EVT-03 | action.requested 预留 | 让测试 runner 返回 `action.requested`。 | Host 只记录日志,不执行平台动作,不影响主流程。 |
## 11. 退出标准
QA agent 完成后应输出一份报告,至少包含:
- 总状态PASS / FAIL / BLOCKED。
- 每个 case 的状态表。
- 所有 FAIL 的复现步骤和建议归属仓库。
- 所有 BLOCKED 的环境缺口。
- 是否建议关闭 Phase 1进入 Phase 2/EBA。
建议关闭 Phase 1 的条件:
- P0 全 PASS。
- P1 全 PASS或只有旧内置 runner 同样不支持的 N/A。
- P2 外部 runner smoke 对可用凭据全部 PASS。
- 剩余问题均为 EBA 预留、外部服务凭据、或非阻塞体验问题。

View File

@@ -12,7 +12,7 @@
| Phase 1 | 核心架构Registry、Orchestrator、上下文模型 | ✅ 完成 |
| Phase 2 | 权限、能力声明、资源注入 | ✅ 完成 |
| Phase 3 | 内置 runner 迁移到插件 | ✅ 完成7/7 |
| Phase 4 | EBA 事件支持 | 🔲 未开始message event/actor/subject 上下文已预填充) |
| Phase 4 | EBA 事件支持 | 🔲 未开始(已预留稳定事件名,message event/actor/subject 上下文已预填充) |
---
@@ -74,7 +74,7 @@
### 低优先级 / 未来
- [ ] EBA 完整集成 — message event/actor/subject 上下文已填充,完整事件路由与非消息事件仍待实现
- [ ] EBA 完整集成 — 稳定事件名与 message event/actor/subject 上下文已预留,完整事件路由与非消息事件仍待实现
- [ ] 平台 API 动作执行 — `action.requested` 结果类型存在但未执行
---
@@ -91,5 +91,6 @@
## 相关文档
- [README.md](./README.md) — 总体设计
- [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) — Phase 1 agent QA 验收矩阵
- [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划
- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) — 具体实施细节

View File

@@ -374,6 +374,40 @@ LangBot 执行前做三层裁剪:
因此文档和代码命名应避免把当前任务称为 EBA 实现。推荐使用 `agent-runner-pluginization``AgentRunContext``AgentRunResult` 等命名。
### 7.1 现在必须预留的事件适配方式
后续消息撤回、群成员加入、新好友申请等事件不要再走“伪造一条用户文本消息”的方式接入 AgentRunner。正确方向是让未来 `EventRouter` 构造同一份 `AgentRunContext`,然后复用当前 `AgentRunOrchestrator` 的 registry、resource builder、result normalizer 和插件调用协议。
当前先固定这些公共协议约束:
- 顶层 `ctx.event.event_type` 使用稳定协议名,不暴露 SDK 类名或平台原始事件名。
- 平台原始事件名、平台 payload、适配器细节放进 `ctx.event.event_data`
- `ctx.input.text` 可以为空runner 不能假设所有触发都是一段用户文本。
- `ctx.actor` 表示触发动作的主体,`ctx.subject` 表示被操作或被关注的对象。
- 需要平台动作时runner 只能返回 `action.requested`;当前阶段只记录,真正执行等统一平台 API 和权限模型落地。
已预留的事件类型:
| event_type | actor | subject | input |
| --- | --- | --- | --- |
| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等消息内容 |
| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空,原消息摘要放 `event_data` |
| `group.member_joined` | 新成员或邀请人,按平台 payload 标明 | 群/成员关系 | 通常为空,可把欢迎上下文放 `event_data` |
| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 |
未来 EventRouter 的最小调用链应是:
```text
Platform Adapter
-> EventRouter normalize platform payload
-> resolve event binding: event_type + bot/workspace/scope -> runner id + config
-> AgentRunOrchestrator.run_from_event(event_request)
-> AgentRunContextBuilder.build_context_from_event(event_request)
-> PluginRuntimeConnector.run_agent()
```
`run_from_event()` 不能重新实现一套 runner 调用逻辑,只能复用当前 `run_from_query()` 已经使用的 registry、资源裁剪、session registry、状态更新和结果归一化能力。这样 Pipeline 消息入口和 EBA 非消息入口不会分裂成两套协议。
## 8. 分阶段落地
### Phase 1整理当前分支
@@ -441,3 +475,7 @@ SDK
- 当前 runner 配置先跟 Pipeline 绑定,仍然在 Pipeline 的 AI runner stage 中执行;后续需要支持直接与 Bot 的事件触发器绑定。
- Pipeline/Event 绑定只保存 runner id 和绑定配置,不创建插件实例或 runner 实例;插件 runner 按无状态转发调用处理,跨请求状态必须显式存储。
- 内置 `RequestRunner` 最终强制迁移为插件形态,避免长期保留“内置 runner 分支”和“插件 runner 分支”两套执行体系。
## 12. QA 验收
Phase 1 收尾进入 agent QA 时,使用 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) 作为验收标准。该矩阵只验收 Agent Runner 插件化 parity不验收 EBA EventBus、EventRouter 或平台动作执行。

View File

@@ -17,6 +17,13 @@ from .result_normalizer import AgentResultNormalizer
from .orchestrator import AgentRunOrchestrator
from .config_migration import ConfigMigration
from .session_registry import AgentRunSessionRegistry, AgentRunSession, get_session_registry
from .events import (
MESSAGE_RECEIVED,
MESSAGE_RECALLED,
GROUP_MEMBER_JOINED,
FRIEND_REQUEST_RECEIVED,
RESERVED_EVENT_TYPES,
)
__all__ = [
'AgentRunnerDescriptor',
@@ -37,4 +44,9 @@ __all__ = [
'AgentRunSessionRegistry',
'AgentRunSession',
'get_session_registry',
]
'MESSAGE_RECEIVED',
'MESSAGE_RECALLED',
'GROUP_MEMBER_JOINED',
'FRIEND_REQUEST_RECEIVED',
'RESERVED_EVENT_TYPES',
]

View File

@@ -12,6 +12,7 @@ from ...core import app
from .descriptor import AgentRunnerDescriptor
from .config_migration import ConfigMigration
from .state_store import get_state_store
from . import events as runner_events
# Internal models for SDK v1 context protocol matching SDK v1 resources.py
@@ -183,7 +184,7 @@ class AgentRunContextBuilder:
# Build trigger
trigger: AgentTrigger = {
'type': 'message.received',
'type': runner_events.MESSAGE_RECEIVED,
'source': 'pipeline',
'timestamp': int(time.time()),
}
@@ -407,7 +408,12 @@ class AgentRunContextBuilder:
return default
def _build_event(self, query: pipeline_query.Query) -> dict[str, typing.Any]:
"""Build a minimal event envelope from the platform message event."""
"""Build a minimal EBA-compatible event envelope from the message query.
The public event_type must be a stable AgentRunner protocol name. Keep
platform or SDK class names inside event_data so future EventRouter
events can share the same top-level naming contract.
"""
message_event = getattr(query, 'message_event', None)
event_data: dict[str, typing.Any] = {}
@@ -420,6 +426,10 @@ class AgentRunContextBuilder:
event_data = {}
event_data.pop('source_platform_object', None)
source_event_type = getattr(message_event, 'type', None) if message_event else None
if source_event_type:
event_data.setdefault('source_event_type', source_event_type)
message_chain = getattr(query, 'message_chain', None)
message_id = getattr(message_chain, 'message_id', None)
if message_id == -1:
@@ -429,7 +439,7 @@ class AgentRunContextBuilder:
event_timestamp = int(event_time) if isinstance(event_time, (int, float)) else None
return {
'event_type': getattr(message_event, 'type', None) or 'message.received',
'event_type': runner_events.MESSAGE_RECEIVED,
'event_id': str(message_id or getattr(query, 'query_id', '')),
'event_timestamp': event_timestamp,
'event_data': event_data,

View File

@@ -0,0 +1,25 @@
"""Canonical AgentRunner event names reserved for future EBA integration."""
from __future__ import annotations
MESSAGE_RECEIVED = 'message.received'
"""A normal message entered the current Pipeline."""
MESSAGE_RECALLED = 'message.recalled'
"""A platform message was recalled or deleted."""
GROUP_MEMBER_JOINED = 'group.member_joined'
"""A new member joined a group/channel conversation."""
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
"""A new friend/contact request was received."""
RESERVED_EVENT_TYPES = frozenset(
{
MESSAGE_RECEIVED,
MESSAGE_RECALLED,
GROUP_MEMBER_JOINED,
FRIEND_REQUEST_RECEIVED,
}
)

View File

@@ -281,7 +281,8 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context():
assert context["config"]["timeout"] == 30
assert context["runtime"]["deadline_at"] is not None
assert context["params"] == {"public_param": "visible"}
assert context["event"]["event_type"] == "FriendMessage"
assert context["event"]["event_type"] == "message.received"
assert context["event"]["event_data"]["source_event_type"] == "FriendMessage"
assert context["actor"]["actor_id"] == "user_001"
assert context["actor"]["actor_name"] == "Alice"
assert context["subject"]["subject_id"] == "msg_001"