refactor(agent-runner): simplify event-first entry path

This commit is contained in:
huanghuoguoguo
2026-06-03 17:33:47 +08:00
parent 4d0a2b117a
commit a850127893
32 changed files with 743 additions and 2653 deletions
@@ -1,21 +1,8 @@
# Agent-owned Context 协议设计 # Agent-owned Context 协议设计
本文档描述插件化 AgentRunner 场景下的上下文边界。结论先行:LangBot 不应成为最终 agentic context managerLangBot 应提供 context substrateAgentRunner 或其背后的 agent runtime 自己决定如何管理历史、压缩、召回和 KV cache。 本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager提供 context substrateAgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。
## 当前状态 > 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。实现进度见 [PROGRESS.md](./PROGRESS.md)。
**当前分支已落地**
-`AgentRunContext` — event-first context 模型
-`ContextAccess` — cursor、inline policy、available APIs
-`AgentRunAPIProxy.history` — page/search API
-`AgentRunAPIProxy.events` — get/page API
-`AgentRunAPIProxy.artifacts` — metadata/read_range API
-`AgentRunAPIProxy.state` — get/set/delete API
- ✅ EventLog / Transcript / ArtifactStore — host 事实源
- ✅ PersistentStateStore — 持久化状态存储
- ✅ Host-side history window 已从 LangBot Host 语义中移除;runner 自己管理 working context
- ✅ 外部 harness context projection 已用 Claude Code runner 做 MVP 验证:context 文件、skill 投影、MCP 配置和 host-owned resume state
## 1. 设计原则 ## 1. 设计原则
@@ -24,25 +11,16 @@
不同 runner 背后的 runtime 差异很大: 不同 runner 背后的 runtime 差异很大:
- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。 - 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。
- Claude Code SDK / Codex 类 runtime 可能有自己的 session、transcript、tool loop 和上下文压缩。 - Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。
- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。 - Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。
因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供: 因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / artifact / state API、可投影给外部 harness 的 scoped context / MCP / skill / resource refs、payload hard cap 和权限 guardrail。
- 当前事件的完整结构化信息。
- 稳定身份和会话引用。
- 可授权读取的 history / event / artifact / state API。
- 可投影给外部 harness 的 scoped context、MCP、skill 和 resource refs。
- payload hard cap 和权限 guardrail。
### 1.2 Host 不定义通用历史窗口 ### 1.2 Host 不定义通用历史窗口
历史窗口策略不应继续作为 AgentRunner 协议或 Pipeline adapter 的核心概念。 历史窗口策略不 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。
Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。
当前 official local-agent 方向是通过 Host history API 拉取 transcript,并由 runner 自己管理模型上下文。它不依赖 Pipeline adapter 下发历史窗口。 正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是:
新协议不应该问“LangBot 每轮裁几轮历史给 agent”,而应该问:
- 这类 runner 是否自管 context - 这类 runner 是否自管 context
- 事件到来时 host 应 inline 哪些最小信息? - 事件到来时 host 应 inline 哪些最小信息?
@@ -57,108 +35,44 @@ Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自
- `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 - `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
- `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。 - `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。
LangBot 不提供 host-side bootstrap window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。 LangBot 不提供 host-side inline history window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。
## 2. Event 到来时传什么 ## 2. Event 到来时传什么
默认 `AgentRunContext` 应尽量小且稳定: 默认 `AgentRunContext`PROTOCOL_V1 §5.2应尽量小且稳定。默认规则
```python
class AgentRunContext(BaseModel):
run_id: str
trigger: AgentTrigger
event: AgentEventContext
conversation: ConversationContext | None
actor: ActorContext | None
subject: SubjectContext | None
input: AgentInput
delivery: DeliveryContext
resources: AgentResources
context: ContextAccess
state: AgentRunState
runtime: AgentRuntimeContext
config: dict[str, Any]
```
默认规则:
- Host MUST NOT inline full history by default. - Host MUST NOT inline full history by default.
- Host SHOULD inline only current event / input and context handles. - Host SHOULD inline only current event / input and context handles.
- Runner owns working-context assembly. - Runner owns working-context assembly.
- Runner MAY use Host history / event / artifact / state / storage APIs when authorized. - Runner MAY use Host history / event / artifact / state / storage API when authorized.
- Official runners MUST consume Host infrastructure through the same public APIs as third-party runners. - Official runners MUST consume Host infrastructure through the same public API as third-party runners.
### 2.1 必须 inline 的内容 ### 2.1 必须 inline 的内容
每次 run 必须 inline 当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata 和 artifact refactor / subject / conversation / thread / bot / workspacedelivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。
- 当前 event 的稳定类型、id、时间、source。
- 当前输入文本和结构化内容。
- 附件 / 文件 / 图片的 metadata 和 artifact ref。
- actor、subject、conversation、thread、bot、workspace。
- delivery 能力,例如是否支持 streaming、reply target、平台限制。
- 已授权资源列表。
- context cursors 和可用 API 能力。
- Agent/runner config。
这些是 agent 决定下一步需要的最低信息。
### 2.2 默认不 inline 的内容 ### 2.2 默认不 inline 的内容
默认不要 inline 完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。
- 完整历史消息。 ### 2.3 不提供 Host Inline History Window
- 大文件全文。
- 大工具结果。
- 全量知识库内容。
- 平台原始 payload 大对象。
- 每轮重新生成的大段 summary。
这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略 `AgentRunContext` 不包含 `bootstrap` 字段。Host 不下发历史窗口,也不通过 Pipeline 配置决定窗口大小。runner 若需要类似 `recent_tail` 的策略,应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 history API 读取、裁剪和压缩。Host 只负责权限、分页、hard cap 和事实源
### 2.3 不提供 Host Bootstrap Window ## 3. ContextAccess 的作用
`AgentRunContext.bootstrap` 可以作为协议里的可选扩展字段保留,但 LangBot Host 默认不填历史窗口,也不通过 Pipeline 配置决定窗口大小 `ContextAccess`PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent:当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限
如果 runner 需要类似 `recent_tail` 的策略,它应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 `history_page` / `history_search` 读取、裁剪和压缩历史。Host 只负责权限、分页、hard cap 和事实源。
## 3. ContextAccess
`ContextAccess` 是 host 交给 agent 的上下文读取入口描述:
```python
class ContextAccess(BaseModel):
conversation_id: str | None
thread_id: str | None
latest_cursor: str | None
event_seq: int | None
transcript_seq: int | None
has_history_before: bool
inline_policy: InlineContextPolicy
available_apis: ContextAPICapabilities
```
它告诉 agent
- 当前事件位于哪条 conversation / thread。
- 若需要更多历史,从哪个 cursor 开始拉。
- host inline 了什么,没 inline 什么。
- 当前 run 有哪些 context API 权限。
## 4. Agent 如何获取更多上下文 ## 4. Agent 如何获取更多上下文
所有 API 都必须`AgentRunAPIProxy`由 host 用 `run_id` 校验。 所有 API 都走 `AgentRunAPIProxy`PROTOCOL_V1 §8,由 host 用 `run_id` 校验。
### 4.1 History API ### 4.1 History
```python ```python
await api.history.page( await api.history.page(conversation_id=ctx.context.conversation_id,
conversation_id=ctx.context.conversation_id, before_cursor=ctx.context.latest_cursor,
before_cursor=ctx.context.latest_cursor, limit=50, direction="backward", include_artifacts=False)
limit=50,
direction="backward",
include_artifacts=False,
)
``` ```
返回: 返回:
@@ -171,164 +85,65 @@ class HistoryPage(BaseModel):
has_more: bool has_more: bool
``` ```
约束: 约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 manifest permission + binding policy;返回 artifact ref,不默认返回大文件内容。
- `limit` 有 host hard cap。 ### 4.2 Search
- 默认只能读当前 conversation / thread。
- 跨会话读取必须有 manifest permission + binding policy。
- 返回 artifact ref,不默认返回大文件内容。
### 4.2 Search API
```python ```python
await api.history.search( await api.history.search(query="用户之前提到的数据库连接信息",
query="用户之前提到的数据库连接信息", filters={"conversation_id": ..., "event_types": ["message.received"]},
filters={ top_k=10)
"conversation_id": ctx.context.conversation_id,
"event_types": ["message.received"],
},
top_k=10,
)
``` ```
Search 可先用数据库全文索引,后续接 embedding recall。它是 host 提供的检索能力,不等于 agent 的长期记忆策略。 Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。
### 4.3 Event API ### 4.3 Event / Artifact / State
```python - Event API`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。
await api.events.get(event_id) - Artifact API`artifacts.metadata` / `read_range` / `open_stream`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/stream 读取,工具大结果也应 artifact 化。
await api.events.page(before_cursor=..., limit=...) - State API`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id``summary.checkpoint`
```
Event API 用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。 ### 4.4 大文件与工具协作
### 4.4 Artifact API 大文件、多模态输入和工具产物不要内联进 prompt 或 tool resultmessage/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref`artifact_id``mime_type``size``digest``summary``expires_at``permissions`)。工具之间传递大结果时传 artifact ref,不传完整 blob。Host 校验 artifact 是否属于当前 run / scope,默认不允许插件直接读任意本地路径;临时文件应有 TTL 和清理机制。
```python ### 4.5 External harness context projection
await api.artifacts.metadata(artifact_id)
await api.artifacts.read_range(artifact_id, offset=0, length=65536)
await api.artifacts.open_stream(artifact_id)
```
约束 Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态
- 校验 artifact 所属 conversation / run / binding。
- 校验 MIME、大小、过期时间和权限。
- 大文件按 range/stream 读取。
- 工具大结果也应 artifact 化。
### 4.5 State API
```python
await api.state.get(scope="conversation", key="external.session_id")
await api.state.set(scope="conversation", key="summary.checkpoint", value=...)
```
State 是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用。
### 4.6 External harness context projection
Claude Code、Codex、Kimi Code 这类 runtime 通常已经有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把这类 runner 强行改造成“host prompt assembler”,而应提供可审计的事件和资源投影。
推荐 projection 形态:
- `agent-context.json`:结构化 JSON,包含 `run_id``event``actor``subject``input``delivery``resources``context``state``runtime` - `agent-context.json`:结构化 JSON,包含 `run_id``event``actor``subject``input``delivery``resources``context``state``runtime`
- `LANGBOT_CONTEXT.md`:人类可读摘要,用于 code-agent harness 快速理解当前 IM 事件 - `LANGBOT_CONTEXT.md`:人类可读摘要。
- `resources`:只包含本次 run 授权后的模型、工具、知识库、artifact、state/storage 句柄,不暴露 Host 内部私有对象。 - `resources`:只包含本次 run 授权后的句柄,不暴露 Host 内部私有对象。
- `skills`Host 或 binding 把已授权 skill 投影为目标 harness 可读目录,例如 Claude Code 的 `.claude/skills/<name>/SKILL.md` - `skills`:已授权 skill 投影为目标 harness 可读目录如 Claude Code 的 `.claude/skills/<name>/SKILL.md`
- `MCP config`Host 或 binding 提供 scoped MCP 配置,runner adapter 转成目标 harness 的配置文件或 CLI 参数。 - `MCP config`scoped MCP 配置,runner adapter 转成目标 harness 的配置文件或 CLI 参数。
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存,例如 `external.session_id``external.working_directory` - `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。
当前 Claude Code runner MVP 使用 schema `langbot.agent_runner.external_harness_context.v1`,并已通过 WebUI Debug Chat 验证 context 文件、skill 文件、MCP config 和 resume state 的基本链路 当前 Claude Code runner 使用 schema `langbot.agent_runner.external_harness_context.v1`(现状见 OFFICIAL_RUNNER_PLUGINS §8)。这类 projection 是"把 LangBot 事实源和授权资源交给 harness",不是"由 LangBot 决定最终模型上下文"
这类 projection 是“把 LangBot 事实源和授权资源交给 harness”,不是“由 LangBot 决定最终模型上下文”。外部 harness 可以继续使用自己的 transcript、工具权限和压缩策略。
## 5. Runner manifest 中的上下文声明 ## 5. Runner manifest 中的上下文声明
建议增加: `AgentRunnerContextPolicy`PROTOCOL_V1 §4.5)声明 runner 的上下文能力:`supports_history_pull` / `supports_history_search` / `supports_artifact_pull` / `owns_compaction` / `wants_static_context_refs`。它表示 Host 只给当前事件和 context handlesrunner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。
```yaml
context:
ownership: self_managed | host_bootstrap | hybrid
bootstrap: none | current_event | recent_tail | summary_tail
max_inline_events: 0
max_inline_bytes: 0
supports_history_pull: true
supports_history_search: true
supports_artifact_pull: true
owns_compaction: true
wants_static_context_refs: true
```
语义:
- `self_managed`: Host 不主动 inline 历史,只提供 event 和 handles。
- `host_bootstrap`: Host 为简单 runner inline 一个小窗口。
- `hybrid`: Host inline summary/tailrunner 仍可按需拉更多。
- `owns_compaction`: runner 负责压缩,host 不做语义摘要。
- `wants_static_context_refs`: host 用 ref/hash 描述静态内容,减少重复 payload。
## 6. KV cache 友好的上下文管理 ## 6. KV cache 友好的上下文管理
如果目标是支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime,必须避免每轮由 LangBot 重组大块 prompt 支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime,必须避免每轮由 LangBot 重组大块 prompt
建议:
- 稳定 session key`workspace/bot/binding/runner/conversation/thread` - 稳定 session key`workspace/bot/binding/runner/conversation/thread`
- 静态内容使用 `ref + version/hash`system prompt、resource manifest、tool schema、platform policy。 - 静态内容使用 `ref + version/hash``ctx.runtime.static_refs`system prompt、resource manifest、tool schema、platform policy。
- 每轮只传 delta:当前 event、artifact refs、少量 runtime metadata。 - 每轮只传 delta:当前 event、artifact refs、少量 runtime metadata。
- 历史 append-only:不要每轮改写同一段 history 文本。 - 历史 append-only:不要每轮改写同一段 history 文本。
- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint,不要每轮微调 - Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。
- 大文件和工具结果 artifact 化。 - 大文件和工具结果 artifact 化。
- Tool/context API schema 稳定,数据通过 API 拉取,而不是塞入 prompt。 - Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。
- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。 - 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。
## 7. Host guardrail ## 7. Host guardrail
Agent 自管 context 不代表无限制访问。LangBot 仍必须控制: Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / artifact read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。
- 每次 run 的 active `run_id`
- runner identity。
- 当前 binding 的 resource policy。
- conversation / actor / subject scope。
- page size、artifact read size、API rate limit。
- 跨会话读取权限。
- 数据脱敏和敏感变量过滤。
- 审计日志。
Host 不负责“最佳上下文策略”,但负责“不越权、不爆内存、不不可审计”。
## 8. 官方 runner 与业务编排边界 ## 8. 官方 runner 与业务编排边界
官方 runner 插件可以选择把状态寄宿在 LangBot,但它们必须和第三方 runner 一样通过公开 Host APIs 消费这些能力 官方 runner 插件可以把状态寄宿在 LangBot,但必须和第三方 runner 一样通过公开 Host API 消费。LangBot core 不内置官方 agent 的业务流程(prompt 组装、tool loop、RAG 编排、summary/compaction、"local-agent 专用"状态字段)
LangBot core 不应内置官方 agent 的业务流程: 官方 local-agent 应作为"依附 LangBot 基础设施的复杂 runner 参考实现"transcript/history 通过 `api.history` 读取,summary/checkpoint/外部 session id/用户偏好通过 `api.state``api.storage` 保存,图片/文件/工具大结果通过 `api.artifacts` 读取,模型/工具/知识库通过 `api.models` / `api.tools` / `api.knowledge` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
- 不内置 prompt 组装策略。
- 不内置 tool loop。
- 不内置 RAG 编排策略。
- 不内置 summary / compaction 策略。
- 不内置“local-agent 专用”的状态字段。
官方 local-agent 应作为“依附 LangBot 基础设施的复杂 runner 参考实现”存在:
- transcript / history 通过 `api.history.page()``api.history.search()` 读取。
- summary、checkpoint、外部 session id、用户偏好通过 `api.state``api.storage` 保存。
- 图片、文件、工具大结果通过 `api.artifacts` 读取。
- 模型、工具、知识库通过 `api.models``api.tools``api.knowledge` 调用。
这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。
## 9. 当前实现需要调整
**已完成(当前分支)**
- ✅ Host 不再定义通用历史窗口字段或策略
- ✅ 新 runner 默认不收到历史窗口
-`AgentRunContext` 增加 `context` / cursor / access capabilities
-`AgentRunAPIProxy` 增加 history / events / artifacts / state API
- ✅ Host 增加持久 EventLog / Transcript / ArtifactStore / PersistentStateStore
-`run_from_query()` 委托到 event-first `run(event, binding)`
- ✅ Claude Code external harness smokecontext JSON / Markdown、skill、MCP config、`external.session_id` / `external.working_directory`
这样 LangBot 既能服务依附 host 基础设施的官方 runner,也能服务自带 memory/session/cache 的外部 agent runtime。
@@ -1,34 +1,22 @@
# Event Based Agent 预留设计 # Event Based Agent 预留设计
> **注意**:本文档是 future design note,不是当前分支实现范围。 > **future design note**,不是当前分支实现范围。EventGateway、EventRouter、Event subscription/notification 由其他分支实现;本分支只预留 event-first 入口和 envelope/binding models。实现进度见 [PROGRESS.md](./PROGRESS.md)。
> >
> EventGateway、EventRouter、Event subscription/notification 由其他分支实现 > 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)Host 内部模型);本文只讲 EBA 语义,不重抄 schema
> 本分支只预留 event-first 入口和 envelope/binding models。
> 2026-05-29 的 local-agent / Claude Code runner smoke 只验证本分支的 `run(event, binding)` 调度边界,不表示 EBA 分支已经完成联调。
本文描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。 本文描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本阶段不实现完整 EventBus / EventRouter / Platform API,目标是把协议边界设计对,避免当前消息入口继续绑死 Pipeline 和用户文本消息。
本阶段不实现完整 EventBus / EventRouter / Platform API。本阶段要做的是把协议边界设计对,避免当前消息入口继续绑死 Pipeline 和用户文本消息。
## 1. 设计目标 ## 1. 设计目标
- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。 - 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 AgentBinding。 - EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`
- AgentRunner 通过同一套 orchestrator 被调用。 - AgentRunner 通过同一套 orchestrator 被调用。
- 非消息事件不伪造成用户文本消息。 - 非消息事件不伪造成用户文本消息。
- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。 - 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。
## 2. 事件不是消息 ## 2. 事件不是消息
`message.received` 只是事件的一种。协议不应假设: `message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。
- 一定有用户文本。
- 一定有 conversation history。
- 一定要返回一条聊天消息。
- actor 一定等于 sender。
- subject 一定等于当前消息。
例如:
| event_type | actor | subject | input | | event_type | actor | subject | input |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -39,73 +27,27 @@
| `schedule.triggered` | 系统 | 定时任务 | 任务 payload | | `schedule.triggered` | 系统 | 定时任务 | 任务 payload |
| `api.invoked` | API caller | API request | request payload | | `api.invoked` | API caller | API request | request payload |
## 3. Event Envelope ## 3. 稳定事件名
建议事件 envelope 先保留的稳定事件名(作为插件协议的一部分保持稳定)
```python - `message.received`
class AgentEventEnvelope(BaseModel): - `message.recalled`
event_id: str - `group.member_joined`
event_type: str - `friend.request_received`
event_time: int | None
source: EventSource
workspace_id: str | None
bot_id: str | None
conversation_id: str | None
thread_id: str | None
actor: ActorRef | None
subject: SubjectRef | None
input: AgentInput
delivery: DeliveryContext
raw_ref: RawEventRef | None
metadata: dict[str, Any] = {}
```
顶层字段使用 LangBot 稳定协议名。平台原始事件名和原始 payload 放到 `metadata` `raw_ref`,不直接成为 runner 的稳定依赖 平台原始事件名只能进入 `ctx.event.source_event_type` / `raw_ref`,不成为 `ctx.event.event_type` 的公共契约
## 4. Event Source ## 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` 决定哪些事件触发哪个 runner。
- `platform_adapter`: 飞书、QQ、微信、Telegram 等 IM 平台 Binding scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。旧 Pipeline 可迁移为 `message.received` 的 binding source,但不是唯一 binding source
- `webui`: Debug Chat、控制台操作。
- `http_api`: 外部系统调用 LangBot。
- `scheduler`: 定时任务。
- `system`: runtime、plugin、maintenance 事件。
同一个 event source 可以产生多个 event type。EventRouter 不应写死平台 adapter 的类名。 Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui``http_api``scheduler``system`。EventRouter 不应写死平台 adapter 的类名。
## 5. Event Binding ## 5. EventRouter 调用链
EBA 中,AgentBinding 取代 Pipeline runner 配置成为触发关系:
```python
class AgentBinding(BaseModel):
binding_id: str
enabled: bool
event_types: list[str]
scope: BindingScope
filters: list[EventFilter]
runner_id: str
runner_config: dict[str, Any]
resource_policy: ResourcePolicy
state_policy: StatePolicy
delivery_policy: DeliveryPolicy
```
Binding scope 示例:
- workspace 全局。
- bot 级别。
- platform channel 级别。
- conversation / group / thread 级别。
- user / actor 级别。
旧 Pipeline 可以迁移为 `message.received` 的 binding source,但不是唯一 binding source。
## 6. EventRouter 调用链
目标调用链:
```text ```text
Platform Adapter / WebUI / API Platform Adapter / WebUI / API
@@ -119,119 +61,29 @@ Platform Adapter / WebUI / API
-> DeliveryController render / platform action -> DeliveryController render / platform action
``` ```
约束: 约束:必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorizationdelivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。
- `run_from_event()` 必须复用现有 orchestrator 能力。 ## 6. 平台动作执行
- 不能为 EBA 单独实现另一套 plugin runner 调用协议。
- 不能让非消息事件绕过 resource authorization。
- Delivery 和 platform action 要走统一权限模型。
- 外部 harness runner 也应通过同一套 envelope/binding/context/result 协议接入;EBA 不应为 Claude Code / Codex / Kimi Code 单独发明队列协议。
## 7. Delivery Context EBA 后 `action.requested`PROTOCOL_V1 §7.2,当前仅 telemetry 不执行)将用于请求 host 执行平台动作:
Event 不一定回复到当前聊天窗口。需要显式 delivery:
```python
class DeliveryContext(BaseModel):
surface: str
reply_target: ReplyTarget | None
supports_streaming: bool
supports_edit: bool
supports_reaction: bool
max_message_size: int | None
platform_capabilities: dict[str, Any] = {}
```
消息事件通常带 reply target。系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置。
## 8. AgentRunResult 与平台动作
当前消息路径主要消费:
- `message.delta`
- `message.completed`
- `run.completed`
- `run.failed`
EBA 后需要预留:
- `action.requested`: 请求 host 执行平台动作。
- `artifact.created`: runner 生成文件或大结果。
- `delivery.requested`: 请求投递到某个 surface。
示例:
```json ```json
{ { "type": "action.requested",
"type": "action.requested", "data": { "action": "friend.request.accept",
"data": { "target": {"platform": "wechat", "request_id": "..."},
"action": "friend.request.accept", "reason": "policy matched" } }
"target": {"platform": "wechat", "request_id": "..."},
"reason": "policy matched"
}
}
``` ```
Host 必须校验: Host 必须校验:runner manifest 是否声明 `platform_api` capability、binding 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。
- runner manifest 是否声明 platform_api capability Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7
- binding 是否授权该 action。
- actor / bot / workspace 是否允许。
- 是否需要人工审批。
本阶段如收到 `action.requested`,可以只记录 telemetry,不执行。 ## 7. 与 Context 协议的关系
## 9. 与 Context 协议的关系 EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)inline 当前事件、大 payload 用 raw/artifact ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user messageAgentRunner 根据 event type 自己决定是否纳入模型上下文。
EBA 事件进入 AgentRunner 时仍使用 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的原则: ## 8. 未来 EBA 完整落地需要
- inline 当前事件 EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入
- 大 payload 用 raw/artifact ref。
- 不默认 inline 完整 history。
- agent 按需通过 API 拉 history/event/artifact/state。
- Host 保留 EventLog 和权限 guardrail。
非消息事件可以被投影进 Transcript,但不能强制伪装为 user message。AgentRunner 可以根据 event type 自己决定是否把它纳入模型上下文 落地顺序:① 把当前 Pipeline 消息入口适配成 `message.received` event(已完成)→ ② 增加 `AgentBinding` 抽象,先由 current config 生成(已完成)→ ③ context builder 改为从 event + binding 构造(已完成)→ ④ 引入 EventLog / Transcript(已完成)→ ⑤ 增加非消息事件的协议测试,不接真实平台 → ⑥ 接入真实 EventRouter 和 platform action
## 10. 当前实现与目标差距
**当前分支已落地(Event-first 基础设施)**
-`AgentRunOrchestrator` — event-first `run(event, binding)` 入口
-`AgentRunContextBuilder` — event-first context 构建
-`AgentEventEnvelope` 模型
-`AgentBinding` 模型
-`AgentRunResult` 基础消息流
-`ctx.event` 的最小消息事件封装
-`PipelineAdapter` — Query → Event + Binding 转换
-`run_from_query()``run(event, binding)` 委托
- ✅ EventLog / Transcript / ArtifactStore
- ✅ History / Event / Artifact / State pull APIs
- ✅ 当前消息事件 path 已用 `local-agent` 与 Claude Code external harness runner 做本地 smoke
**其他分支负责(非本分支范围)**
- EventGateway 实现
- EventRouter 实现
- Event subscription / notification
- EventLog 持久化管理 UI
- AgentBinding 持久化 UI
- 平台动作执行 (`action.requested` 执行器)
**未来 EBA 完整落地需要**
- EventGateway 完整实现
- EventRouter 与 BindingResolver 集成
- AgentBinding 持久模型和 UI
- DeliveryContext 完整实现
- platform action permission model 和执行器
- 真实平台事件接入
## 11. 落地顺序
1. 先把当前 Pipeline 消息入口适配成 `message.received` event。
2. 增加 `AgentBinding` 抽象,先由 Pipeline config 生成。
3. `AgentRunContextBuilder` 改为从 event + binding 构造 context。
4. 引入 EventLog / Transcript。
5. 增加非消息事件的协议测试,不接真实平台。
6. 再接入真实 EventRouter 和 platform action。
@@ -1,6 +1,10 @@
# LangBot Host 与 SDK 基础设施设计 # LangBot Host 与 SDK 基础设施设计
本文档描述 LangBot 和 SDK 为插件化 AgentRunner 共同提供的基础设施。它不以 Pipeline 为中心,也不以官方 local-agent 的实现方式为前提 本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型
- SDK ↔ Host 的协议数据结构(`AgentRunContext``AgentRunnerManifest``AgentRunResult``AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。
- 实现进度见 [PROGRESS.md](./PROGRESS.md)。
- 本文定义的 Host 内部模型(`AgentEventEnvelope``AgentBinding``AgentRunnerDescriptor`)不属于 SDK 协议字段。
## 1. 目标 ## 1. 目标
@@ -10,15 +14,7 @@ LangBot 要转为 agent host,而不是内置 runner 容器:
- 根据事件、bot、workspace、scope 解析应该调用的 agent binding。 - 根据事件、bot、workspace、scope 解析应该调用的 agent binding。
- 发现、校验和调用插件提供的 AgentRunner。 - 发现、校验和调用插件提供的 AgentRunner。
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。 - 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。 - 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。
SDK 要提供稳定协议:
- `AgentRunner` 组件定义。
- runner manifest / capabilities / permissions / config schema。
- `AgentRunContext` 输入 envelope。
- `AgentRunResult` 输出事件流。
- `AgentRunAPIProxy` 运行期受限 API。
## 2. 非目标 ## 2. 非目标
@@ -26,13 +22,11 @@ SDK 要提供稳定协议:
- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。 - 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。 - 不要求官方 local-agent 的旧行为反向塑造 host 协议。
- 不在 host 中实现通用 agentic prompt assembler。 - 不在 host 中实现通用 agentic prompt assembler。
- 不强制 runner 使用 LangBot state / storageLangBot 只提供可选、受控的寄宿能力。 - 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。
- **不实现 EventGateway**EventGateway 是 future integration point,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 - 不实现 EventGateway:它是 future integration point,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。
## 3. 分层架构 ## 3. 分层架构
目标结构:
```text ```text
IM / WebUI / API / EventRouter (future) IM / WebUI / API / EventRouter (future)
| |
@@ -59,29 +53,15 @@ AgentRunResult stream
Delivery / Renderer / Platform API Delivery / Renderer / Platform API
``` ```
**当前状态** 当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway 由外部 event branch 实现。
- `PipelineAdapter` 作为当前入口 adapter,将 Pipeline Query 转换为 `AgentEventEnvelope` + `AgentBinding`
- `run_from_query()` 内部委托到 `run(event, binding)`
- EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
- `local-agent` 与 Claude Code runner 已通过本地 WebUI smoke,验证同一条 `run(event, binding)` path 可服务 host-infra runner 与外部 harness runner
- EventGateway 由外部 event branch 实现
当前 Pipeline 只应接入在 Pipeline adapter 位置。它可以继续产生 `message.received`,但不应继续拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。
## 4. LangBot 侧能力 ## 4. LangBot 侧能力
### 4.1 Event GatewayFuture Integration Point ### 4.1 Event GatewayFuture Integration Point
> **注意**EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。 > EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。
Event Gateway 将负责把入口统一成 host event Event Gateway 将把入口统一成 host eventIM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`Host 内部模型)
- IM 平台消息。
- WebUI debug chat 消息。
- API 触发。
- 后续非消息事件,例如入群、撤回、好友申请。
输出应是稳定 envelope,而不是 Pipeline Query 私有结构:
```python ```python
class AgentEventEnvelope(BaseModel): class AgentEventEnvelope(BaseModel):
@@ -95,53 +75,39 @@ class AgentEventEnvelope(BaseModel):
thread_id: str | None thread_id: str | None
actor: ActorRef | None actor: ActorRef | None
subject: SubjectRef | None subject: SubjectRef | None
input: AgentInput input: AgentInput # 见 PROTOCOL_V1 §5.6
delivery: DeliveryContext delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7
raw_ref: RawEventRef | None raw_ref: RawEventRef | None
metadata: dict[str, Any] = {}
``` ```
**当前 adapter source**`PipelineAdapter.query_to_event(query)` 从 Pipeline Query 生成 `AgentEventEnvelope` `AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 artifact ref,不扩散到 runner 协议顶层
原始平台 payload 可以存为 raw event 或 artifact ref;不要把平台私有字段直接扩散到 AgentRunner 顶层协议 **当前 adapter source**`QueryEntryAdapter.query_to_event(query)` 从 Query 生成 `AgentEventEnvelope`
### 4.2 Agent Binding ### 4.2 AgentBinding
Agent binding 是什么事件调用哪个 runner、带什么绑定配置的持久配置。它替代长期依赖 Pipeline runner config 的角色。 `AgentBinding`"什么事件调用哪个 runner、带什么绑定配置"的持久配置,是 Host 内部模型(不暴露给 SDK),替代长期依赖 Pipeline runner config 的角色。
建议模型:
```python ```python
class AgentBinding(BaseModel): class AgentBinding(BaseModel):
binding_id: str binding_id: str
enabled: bool
scope: BindingScope scope: BindingScope
event_types: list[str] event_types: list[str]
filters: list[EventFilter] = [] # EBA 阶段使用,见 EVENT_BASED_AGENT
runner_id: str runner_id: str
runner_config: dict[str, Any] runner_config: dict[str, Any]
resource_policy: ResourcePolicy resource_policy: ResourcePolicy
state_policy: StatePolicy state_policy: StatePolicy
delivery_policy: DeliveryPolicy delivery_policy: DeliveryPolicy
enabled: bool
``` ```
**当前 adapter source**`PipelineAdapter.pipeline_config_to_binding(query, runner_id)`Pipeline config 生成临时 `AgentBinding` **当前 adapter source**`QueryEntryAdapter.config_to_binding(query, runner_id)`current config 生成临时 `AgentBinding`Pipeline 当前作为一种 binding sourceAI runner config → binding、extension preference → resource_policy、output settings → delivery_policy),但新设计不再把这些字段命名为 Pipeline 专属概念。
Pipeline 当前可以被迁移为一种 binding source
- Pipeline AI runner config -> `AgentBinding`
- Pipeline extension preference -> `resource_policy`
- Pipeline output settings -> `delivery_policy`
但新设计不应再把这些字段命名为 Pipeline 专属概念。
### 4.3 AgentRunnerRegistry ### 4.3 AgentRunnerRegistry
Registry 负责收集 runner descriptor Registry 收集 runner descriptor(来自插件 runtime、可能的 host adapter runner、开发期本地插件)
- 插件 runtime 提供的 `AgentRunner`
- 可能存在的 host adapter runner。
- 开发期本地插件 runner。
Descriptor 必须包含:
```python ```python
class AgentRunnerDescriptor(BaseModel): class AgentRunnerDescriptor(BaseModel):
@@ -149,13 +115,16 @@ class AgentRunnerDescriptor(BaseModel):
source: Literal["plugin", "host_adapter"] source: Literal["plugin", "host_adapter"]
label: I18nObject label: I18nObject
description: I18nObject | None = None description: I18nObject | None = None
capabilities: AgentRunnerCapabilities protocol_version: str = "1"
permissions: AgentRunnerPermissions capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3
permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4
config_schema: list[DynamicFormItemSchema] config_schema: list[DynamicFormItemSchema]
plugin: PluginRef | None = None plugin: PluginRef | None = None
``` ```
`plugin:author/name/runner` 仍可作为稳定 id 格式多个 binding 指向同一 runner id 时不创建多个插件实例。 职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 manifest`kind == AgentRunner``metadata.name/label` 存在、`protocol_version` 兼容、`spec.*` 类型正确)、输出 descriptor、缓存 discovery 结果并提供 `refresh()`。单个插件 manifest 失败只记 warning,不影响其它 runner。`plugin:author/name/runner` 稳定 id 格式多个 binding 指向同一 runner id 时**不创建多个插件实例**
刷新触发点:插件安装/卸载/升级/重启后;Pipeline metadata 请求时发现缓存为空;可选 TTL(优先保证正确性)。
### 4.4 AgentRunOrchestrator ### 4.4 AgentRunOrchestrator
@@ -173,255 +142,70 @@ run(event, binding)
-> unregister run session -> unregister run session
``` ```
它负责: 它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。
- `run_id` 生成和生命周期 `run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;插件是无状态执行单元,跨请求持久化状态必须走授权 storage / 外部服务,不能隐式存在 per-pipeline 插件对象里
- timeout / deadline / cancellation。
- 插件异常隔离。
- result schema 校验和大小限制。
- state.updated 处理。
- delivery backpressure 和 telemetry。
`run_from_query()` 这类 API 可以保留为 Pipeline adapter 入口,但内部应转换成 event + binding 后走统一 `run()` ### 4.5 Resource Authorization(三层裁剪)
### 4.5 Resource Authorization LangBot 在每次 run 前生成 `ctx.resources`PROTOCOL_V1 §6),来自三层约束:
LangBot 在每次 run 前生成 `ctx.resources`。资源来自三层约束: 1. runner manifest 声明的 `permissions`(最大能力)。
2. binding / resource policy 允许的资源范围。
3. 当前 event / actor / bot / workspace 的实际权限。
- runner manifest 声明的 permissions 运行期每个 proxy action 必须再次通过 `run_id` 校验。SDK 侧本地校验只用于开发体验,host 侧校验才是安全边界
- binding/resource policy 允许的资源范围。
- 当前 event / actor / bot / workspace 的实际权限。
资源类型包括: 资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
- models 执行/文件/skill/MCP 等能力的接入方向:先由 Host 封装成普通 tool,再通过 `ctx.resources.tools` 进入 runner;runner 不应识别或硬编码执行环境 provider。
- tools
- knowledge bases
- files / artifacts
- storage
- platform capabilities
- history / transcript access
运行期 action 必须再次通过 `run_id` 校验。SDK 侧本地校验只用于开发体验,host 侧校验才是安全边界。 ### 4.6 State / Storage
### 4.6 State 与 Storage LangBot 可提供 host-owned state 让 runner 寄宿状态(conversation / actor / subject / runner / binding / workspace state),但**不是强制**。Host 只需提供:授权开关、scope key、get/set/list/delete API(见 PROTOCOL_V1 §8)、持久化 backend、审计和清理策略。外部 agent runtime 可维护自己的 session 和 memory。进程内 state store 只能作为过渡实现,不能作为正式生产语义。
LangBot 可以提供 host-owned state,让 AgentRunner 把状态寄宿在 LangBot ### 4.7 EventLog / Transcript / Artifact(事实源)
- conversation state - `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。
- actor state - `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
- subject state
- runner/binding state
- workspace state
但这不是强制。外部 agent runtime 可以维护自己的 session 和 memory。LangBot 只需要提供:
- 授权开关。
- scope key。
- get/set/list/delete API。
- 持久化 backend。
- 审计和清理策略。
当前进程内 state store 只能作为过渡实现,不能作为正式生产语义。
### 4.7 EventLog / Transcript / Artifact
LangBot 应提供事实源能力:
- `EventLog`: 保存原始事件、系统事件、工具调用、投递结果、错误。
- `Transcript`: 面向对话 UI / agent history 的消息投影。
- `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。 - `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。
AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。 三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。
### 4.8 Prompt / Instruction Package(占位) ### 4.8 Prompt / Instruction Package(占位)
旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata 当前 Query 入口不把 preprocessing 后的有效 prompt 放进 adapter metadata。目标形态是 Host 保存或生成一个 run-scoped instruction packagerunner 通过 Host API 拉取:
这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个
run-scoped instruction packagerunner 通过 Host API 拉取:
- Host 负责记录静态绑定 prompt、host hook / user plugin 产生的 instruction - Host 记录静态绑定 prompt、host hook / user plugin 产生的 instruction fragment、来源和审计信息。
fragment、来源和审计信息 - `ctx.context.available_apis` 增加 `prompt_get` 能力位表示拉取是否可用
- `ctx.context.available_apis.prompt_get` 只表示拉取能力是否可用 - Runner 拉取后仍由自己决定如何与 history、RAG、tool 结果、memory 和当前输入组装最终 prompt
- Runner 拉取 instruction package 后,仍由 runner 自己决定如何与 history、RAG、 - Host 不实现通用 agentic prompt assembler,也不把 Query entry adapter prompt 作为长期业务输入契约。
tool 结果、memory 和当前输入组装最终模型 prompt。
- Host 不实现通用 agentic prompt assembler,也不把 Pipeline adapter prompt 作为
长期业务输入契约。
### 4.9 External harness resource projection ### 4.9 External harness resource projection
Claude Code、Codex、Kimi Code 等外部 harness runner 可能不直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源投影到自己的 harness 执行。Host 侧仍保持统一边界: Claude Code、Codex、Kimi Code 等外部 harness runner 可能不直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源投影到自己的 harness 执行。Host 侧仍保持统一边界:Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计;Host 或 binding policy 决定哪些 MCP server、skill、artifact、history/state 句柄可投影给 runnerrunner plugin 把 scoped projection 转成目标 harness 可消费形式;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume。
- Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计 投影的具体形态(context 文件、skill 目录、MCP config、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5Claude Code / Codex 当前实现见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING
- Host 或 binding policy 负责决定哪些 MCP server、skill、artifact、history/state 句柄可以投影给 runner。
- Runner plugin 负责把 scoped projection 转成目标 harness 可消费的形式,例如 context JSON/Markdown、MCP config、skill 目录、环境变量或 CLI 参数。
- 外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume 机制。
当前 Claude Code runner MVP 已验证:
- LangBot event-first context 可以写入 `agent-context.json` / `LANGBOT_CONTEXT.md`
- binding 中的 skill / MCP 配置可以投影到 Claude Code 原生目录和 CLI 参数。
- `external.session_id``external.working_directory` 可以通过 Host state 保存并用于 resume。
发布级路径隔离、secret 过滤、MCP allowlist、工具白名单、资源配额和 workspace 清理不属于当前协议闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
## 5. SDK 侧协议 ## 5. SDK 侧协议
### 5.1 AgentRunner 组件 SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。
```python ```python
class AgentRunner(BaseComponent): class AgentRunner(BaseComponent):
__kind__ = "AgentRunner" __kind__ = "AgentRunner"
@classmethod @classmethod
def get_capabilities(cls) -> AgentRunnerCapabilities: def get_capabilities(cls) -> AgentRunnerCapabilities: ... # PROTOCOL_V1 §4.3
...
@classmethod @classmethod
def get_config_schema(cls) -> list[dict]: def get_config_schema(cls) -> list[dict]: ...
...
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ...
... # ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7
``` ```
### 5.2 Capabilities - Manifest / capabilities / permissions / context policyPROTOCOL_V1 §4。
- `AgentRunContext`PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
建议能力声明: - `AgentRunResult`PROTOCOL_V1 §7。
- `AgentRunAPIProxy`PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`
```yaml
capabilities:
streaming: true
tool_calling: true
knowledge_retrieval: true
multimodal_input: true
event_context: true
platform_api: false
interrupt: true
stateful_session: true
self_managed_context: true
host_state: optional
```
`self_managed_context` 表示 runner 或外部 runtime 自己管理上下文。Host 不应给它强塞历史窗口,只提供当前事件和 context handles。
### 5.3 Permissions
```yaml
permissions:
models: ["invoke", "stream", "rerank"]
tools: ["detail", "call"]
knowledge_bases: ["list", "retrieve"]
history: ["page", "search"]
events: ["get", "page"]
artifacts: ["metadata", "read"]
storage: ["plugin", "workspace", "binding"]
files: ["config", "knowledge"]
platform_api: []
```
权限声明是 runner 需要的最大能力,实际可用资源仍由 binding 和当前运行上下文裁剪。
### 5.4 AgentRunContext
Context 顶层应是 event-first,而不是 Query-first
```python
class AgentRunContext(BaseModel):
run_id: str
trigger: AgentTrigger
event: AgentEventContext
conversation: ConversationContext | None = None
actor: ActorContext | None = None
subject: SubjectContext | None = None
input: AgentInput
resources: AgentResources
context: ContextAccess
state: AgentRunState
runtime: AgentRuntimeContext
config: dict[str, Any]
```
`messages` 可以作为兼容字段或 bootstrap 字段,但不应继续是协议核心。
### 5.5 AgentRunResult
输出应是事件流:
```python
class AgentRunResult(BaseModel):
type: Literal[
"message.delta",
"message.completed",
"tool.call.started",
"tool.call.completed",
"state.updated",
"artifact.created",
"action.requested",
"run.completed",
"run.failed",
]
data: dict[str, Any] = {}
```
当前消息回复只消费 `message.delta` / `message.completed` / `run.failed`。平台动作执行等 EBA 和 platform API 权限落地后再启用。
### 5.6 AgentRunAPIProxy
Proxy 是 runner 访问 host 能力的唯一入口:
- model APIs
- tool APIs
- knowledge APIs
- state / storage APIs
- history / event APIs
- artifact APIs
- platform APIs
所有请求必须带 `run_id`host 侧按 active run session 验证 runner identity 和 resource ACL。
## 6. 当前实现与目标差距
**已落地(当前分支)**
-`AgentRunnerRegistry`
-`AgentRunOrchestrator` — event-first `run(event, binding)`
-`AgentRunContextBuilder` — event-first context
-`AgentResourceBuilder`
-`AgentRunSessionRegistry`
-`AgentRunAPIProxy` — model / tool / knowledge / history / event / artifact / state APIs
-`PipelineAdapter` — Query → Event + Binding
-`AgentBinding` 抽象
-`AgentEventEnvelope` 抽象
- ✅ Host 不定义通用历史窗口字段或策略;runner 自己管理 working context
-`PersistentStateStore` — 持久化状态存储
-`EventLogStore` / `TranscriptStore` / `ArtifactStore`
- ✅ history / artifact / event 的受限拉取 API
- ✅ Claude Code external harness MVPcontext/resource projection 与 host-owned resume state smoke
**其他分支负责(非本分支范围)**
- EventGateway 实现
- EventRouter 实现
- AgentBinding 持久化 UI
- platform API 动作执行
- 发布级 security hardening
## 7. 落地顺序
**已完成**
1. ✅ 固化 README 路由和专题文档边界。
2. ✅ 在 Host 中抽象 `AgentBinding`,由 Pipeline adapter 生成。
3. ✅ 将 `AgentRunContextBuilder` 改为 event-first。
4. ✅ 增加持久 transcript/event log/artifact/state 存储模型。
5. ✅ 扩展 `AgentRunAPIProxy` 的 history / artifact / state API。
6. ✅ 将 Pipeline-only 字段下沉到 Pipeline adapter。
7. ✅ 官方 runner 插件迁移完成(7 个插件)。
8. ✅ Claude Code runner MVP smoke:外部 harness context 投影和 state handoff。
**后续工作(其他分支)**
- EventGateway 实现
- EventRouter 与 BindingResolver 集成
- 平台动作执行器
@@ -1,552 +0,0 @@
# Agent Runner 插件化当前实现与收尾计划
> 2026-05-29 状态说明:本文档是实现推进计划和历史上下文,不是最新验收结论的唯一来源。当前设计入口见 [README.md](./README.md),协议边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md),进度见 [PROGRESS.md](./PROGRESS.md),下一轮测试入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。
本文档面向实现 agent,用来把当前 AgentRunner 插件化实现推进到可迁移状态。
当前代码已经不是从零开始的 PoC。LangBot 已经具备 registry、orchestrator、context/resource builder、result normalizer 和插件 runtime action。本计划重点描述剩余工作:补齐宿主通用能力、对齐旧内置 runner 行为、完成官方 runner 插件迁移验收。
## 1. 最终状态
LangBot 最终只保留 Agent Runner 的宿主能力:
- 发现 runner`AgentRunnerRegistry`
- 选择 runnerPipeline 配置和未来事件绑定配置
- 构造上下文:`AgentRunContext`
- 裁剪资源:模型、工具、知识库、文件、存储、平台能力
- 调度执行:`AgentRunOrchestrator`
- 归一结果:`AgentRunResult` -> 当前 Pipeline 的 `Message` / `MessageChunk`
- 隔离错误:插件异常、协议错误、超时、结果过大不能破坏主流程
- 迁移旧配置:把旧内置 runner 配置迁到官方 AgentRunner 插件配置
- 转发调用:插件 runtime 只维护已安装插件本身的运行实例,Pipeline 不创建插件实例或 runner 实例
LangBot 不再长期维护内置业务 runner 分支。`local-agent`、Dify、n8n、Coze、DashScope、Langflow、Tbox 等都迁到官方 AgentRunner 插件。
迁移期间允许旧 `RequestRunner` 文件继续存在,作为行为对齐基准和回退分析材料。它们不影响当前进度;真正的最终条件是主聊天执行路径不再依赖旧 runner。
## 1.1 当前状态快照
已完成或基本完成:
- `AgentRunnerDescriptor`、runner id 解析、registry。
- `AgentRunOrchestrator` 替换 `ChatMessageHandler` 内部 runner 调度。
- `AgentRunContextBuilder``AgentResourceBuilder``AgentResultNormalizer`
- `ai.runner.id` + `ai.runner_config[id]` 的读取与旧配置映射。
- AgentRunner runtime action`LIST_AGENT_RUNNERS``RUN_AGENT`
- run-scoped proxy authorization:模型、工具、知识库、存储、文件。
- EventLog / Transcript / ArtifactStore / PersistentStateStore。
- Pipeline adapter 已委托到 event-first `run(event, binding)`
- `local-agent` 与 Claude Code runner 已通过本地 WebUI smoke。
仍需收尾:
- Docs final QA 与安装/发布文档整理。
- timeout/deadline、取消、插件无输出、协议错误的端到端保护。
- 官方 runner 插件安装/预装/迁移缺失处理。
- 安全发布级 hardening:路径隔离、权限边界、secret、MCP/skill 投影策略、资源配额、审计。此项不阻塞当前协议闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
- Codex / Kimi runner 全量接入、issue-centric 队列、复杂 workflow engine 和 EBA 分支完整联调。
## 2. 高层架构
```text
Pipeline MessageProcessor / future EventRouter
|
v
AgentRunOrchestrator
|
+--> AgentRunnerRegistry
| +--> plugin runtime LIST_AGENT_RUNNERS
| +--> descriptor cache / validation
|
+--> AgentRunContextBuilder
+--> AgentResourceBuilder
+--> AgentResultNormalizer
|
v
PluginRuntimeConnector.run_agent()
|
v
SDK Runtime RUN_AGENT -> plugin AgentRunner.run()
```
关键约束:
- `ChatMessageHandler` 不解析 `plugin:*`,不实例化 wrapper,不知道 runner 组件细节。
- `PipelineService.get_pipeline_metadata()` 不直接访问插件 runtime,而是读取 registry。
-`RequestRunner` 只作为迁移参考,不作为最终运行路径。
- `AgentRunOrchestrator` 是 LangBot 侧运行编排层:负责 runner 绑定解析、资源授权、context envelope provisioning、run scope 注册、插件调用和结果归一化;不负责决定 Agent 的最终 prompt/window/压缩策略。
- 插件是无状态执行单元:多个 Agent 可以绑定同一个 runner id,并分别保存自己的 `ai.runner_config[id]`;运行时 LangBot 只把当前 Agent/runner config 放入 `ctx.config` 转发给同一个插件 runner。
- 禁止按 Pipeline 或 runner config 创建多个插件实例。需要跨请求持久化的状态必须走明确授权的 plugin storage / workspace storage / 外部服务,不能隐式保存在 per-pipeline 插件对象里。
- EBA 只做字段预留,不在本轮实现 EventBus、EventRouter、平台动作执行。
## 3. 新增 LangBot 模块
建议新增:
```text
src/langbot/pkg/agent/
__init__.py
runner/
__init__.py
descriptor.py
errors.py
id.py
registry.py
context_builder.py
resource_builder.py
orchestrator.py
result_normalizer.py
config_migration.py
```
### 3.1 descriptor.py
定义 LangBot 内部使用的 descriptor
```python
class AgentRunnerDescriptor(BaseModel):
id: str
source: Literal["plugin"]
label: dict[str, str]
description: dict[str, str] | None = None
plugin_author: str
plugin_name: str
runner_name: str
plugin_version: str | None = None
protocol_version: str = "1"
config_schema: list[dict[str, Any]] = []
capabilities: dict[str, bool] = {}
permissions: dict[str, list[str]] = {}
raw_manifest: dict[str, Any] = {}
```
`source == "builtin"` 不作为最终目标。如果实现阶段需要临时 adapter,必须标记为测试过渡代码,并在官方插件跑通后删除。
### 3.2 id.py
统一 runner id 解析和生成:
- 插件 runner id`plugin:{author}/{plugin_name}/{runner_name}`
- `parse_runner_id(id)` 返回结构化对象
- 禁止业务代码手写字符串 split
- PoC 已存在的 `plugin:author/name/runner` 继续作为合法 id
### 3.3 registry.py
职责:
- 调用 `ap.plugin_connector.list_agent_runners(bound_plugins=None)` 拉取插件 runner
- 校验 manifest
- `kind == AgentRunner`
- `metadata.name` 存在
- `metadata.label` 存在
- `spec.protocol_version` 兼容,默认 `1`
- `spec.config` 是 list,默认空
- `spec.capabilities` 是 dict,默认空
- `spec.permissions` 是 dict,默认空
- 输出 `AgentRunnerDescriptor`
- 缓存 discovery 结果,提供 `refresh()`
- 单个插件 manifest 失败只记录 warning,不影响其它 runner
刷新触发点:
- 插件安装、卸载、升级、重启后
- Pipeline metadata 请求时发现缓存为空
- 可选 TTL,优先保证正确性
### 3.4 context_builder.py / pipeline_adapter.py
`context_builder.py` 只负责从 `AgentEventEnvelope + AgentBinding` 构造 SDK v1 `AgentRunContext`。Pipeline Query 的读取、参数过滤和 prompt 提取属于 `PipelineAdapter`,但 PipelineAdapter 不再做历史窗口裁剪或 bootstrap 打包。
当前消息 Pipeline 进入 agent runner 的路径:
```text
Query
-> PipelineAdapter.query_to_event(query)
-> PipelineAdapter.pipeline_config_to_binding(query, runner_id)
-> PipelineAdapter.build_adapter_context(query, binding)
-> AgentRunOrchestrator.run(event, binding, adapter_context=...)
-> AgentRunContextBuilder.build_context_from_event(...)
```
Protocol v1 context 的稳定字段:
- `run_id`: 新 UUID,不使用 query id 作为全局 run id
- `trigger.type`: 事件触发类型,例如 `message.received`
- `conversation`: conversation/thread/launcher/sender/bot/pipeline 投影
- `event`: 稳定事件上下文
- `actor`: 触发者
- `subject`: 当前消息、群、频道或其它事件主体
- `input`: 当前事件输入,不是历史消息窗口
- `delivery`: 输出 surface 和平台投递能力
- `resources`: 由 `resource_builder` 基于 binding policy 注入
- `state`: `PersistentStateStore` 读取的 host-managed scoped state snapshot
- `runtime`: host/version/workspace/bot/query/trace/deadline
- `config`: 当前 binding 对该 runner id 的配置,即 `runner_config`
- `bootstrap`: 可选扩展字段;LangBot Host 默认不填历史窗口
- `adapter`: Pipeline 或其它入口 adapter 的元数据
Pipeline adapter 的 `prompt` 和公开业务变量不进入顶层协议字段:
- filtered params -> `ctx.adapter.extra["params"]`
- legacy/effective prompt 可以暂存到 `ctx.adapter.extra["prompt"]`,但 official
runner 不应把它当作行为契约
- LangBot Host 不生成 bootstrap history payload 或 context packaging 元数据
现阶段不要把新的压缩或 token-budget 裁剪塞回 Pipeline stage。Pipeline 只负责入口适配;完整历史和长期上下文由 EventLog / Transcript / pull APIs / future ContextCompressor 支撑。
### 3.4.1 Agentic context plan
EventLog / Transcript / Host pull APIs 已落地,`ContextCompressor` 仍是设计预留。
目标是让 Pipeline 逐步退化为入口 adapter,让 AgentRunner 层拥有上下文打包职责。
建议 Host 保持三类事实源和受限 API:
```text
ConversationStore / EventLog
-> durable append-only raw messages, events, tool results, artifact refs
ConversationProjection
-> converts events into agent-readable conversation history
ContextCompressor
-> future optional service for summaries/checkpoints, requested and consumed by runners
```
关键原则:
- 完整历史属于 LangBot host,不属于插件实例。插件仍是 singleton/stateless。
- `ctx.bootstrap.messages` 不是 Host 默认下发的 working context。
- 每轮不能全量复制/序列化完整历史给插件 runtime;否则长会话会产生 O(n) 成本和跨进程 payload 膨胀。
- 通用历史窗口规则不属于 LangBot Host 语义。
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。
- `ContextCompressor` 生成的是派生 summary/checkpoint,不能覆盖或删除 raw history。
- 重启恢复依赖持久化 store 和 summary checkpoint,不依赖 `SessionManager` 里的进程内 conversation list。
未来需要的受限 API
```python
api.get_conversation_messages(cursor: str | None, limit: int) -> HistoryPage
api.get_context_summary(scope: str = "conversation") -> ContextSummary | None
api.request_context_compaction(policy: dict) -> CompactionResult
```
这些 API 必须绑定 `run_id`、runner id、actor/subject scope 和资源权限;Host 需要限制
page size、总字节数、deadline 和可访问 conversation。
### 3.4.2 Large artifacts and tool collaboration
大文件、多模态输入和工具产物不要内联进 prompt、bootstrap 或 tool result。后续统一用
artifact/resource ref 协作:
- message/content 里只放小文本和必要摘要。
- 大文件、图片、音频、长工具输出返回 `artifact_id``mime_type``size``digest`
`summary``expires_at``permissions`
- `/tmp` 只能作为单次 run 的临时 staging,用于插件或工具短时间读写;它不是 durable store
也不能作为重启恢复依据。
- box/object storage 是长期 artifact 的目标位置。当前分支尚未合并 box 能力,因此本轮只写文档预留,不实现 API。
- 工具之间传递大结果时应传 artifact ref,不传完整 blob。Agent 需要读取时走受限 proxy。
未来建议 API
```python
api.get_artifact_metadata(artifact_id: str) -> ArtifactMetadata
api.open_artifact_stream(artifact_id: str) -> AsyncIterator[bytes]
api.read_artifact_range(artifact_id: str, offset: int, length: int) -> bytes
api.create_temp_artifact(name: str, content_type: str, ttl_seconds: int) -> ArtifactWriter
```
安全约束:
- Host 校验 artifact 是否属于当前 run、conversation、actor/subject scope 或授权资源。
- 默认不允许插件直接读任意本地路径,包括 `/tmp` 任意路径。
- 临时文件应有 TTL 和清理机制;box artifact 应有 retention policy。
- 多模态文件进入模型前,由 runner/context packager 决定传引用、摘要、缩略图还是实际 bytes。
### 3.5 resource_builder.py
执行前做三层裁剪:
1. runner manifest 声明的 `spec.permissions`
2. Pipeline 的 `extensions_preferences`
3. 当前 Pipeline runner 绑定配置中选择的资源范围
输出写入 `ctx.resources`,至少覆盖:
- models:可调用模型 UUID、类型、能力摘要。包括 LLM、fallback LLM、rerank 等 runner config schema 中选择的模型类资源。
- tools:可见工具 manifest,使用当前 bound plugins / MCP server 范围
- knowledge_bases:可检索知识库列表
- storageplugin storage / workspace storage 权限摘要
- files:允许读取的配置文件、知识文件摘要
- platform_capabilities:本阶段只声明,不执行平台动作
注意:旧的 unrestricted proxy action 必须二次校验,不能只靠 context 声明。AgentRunner 可用资源应来自 `ctx.resources`,不是插件 runtime 的全局能力。
本阶段不接入 sandbox/skills,也不预留 runner 可见字段。后续相关分支合并后,
执行、文件、skill、MCP 等能力应先由 Host 侧封装成普通 tool,再通过
`ctx.resources.tools` 进入 runner;runner 不应识别或硬编码执行环境 provider。
资源裁剪要尽量通用,不应只写死 local-agent
- `model-fallback-selector` 授权 primary/fallback LLM。
- `llm-model-selector` 授权 LLM。
- `rerank-model-selector` 授权 rerank 模型。
- `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)
```
EBA 落地后,`ConversationStore` 不应只保存聊天消息,而应从 `EventLog` 投影生成:
```text
Platform Adapter
-> EventLog append raw event
-> ConversationProjection update message/history view when applicable
-> EventRouter resolve binding
-> AgentRunOrchestrator.run_from_event(event_request)
-> Context packager builds working context from projection + state + artifacts
```
这样消息事件、工具事件、群成员事件、好友申请事件可以共用同一套 run/session/state/resource
边界;非消息事件也不需要伪造成一条用户文本消息。
`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
- `message.delta`
- `message.completed`
- `tool.call.started`
- `tool.call.completed`
- `state.updated`
- `run.completed`
- `run.failed`
- `action.requested` 允许实验性返回,但本阶段只记录 telemetry,不执行
映射:
- `message.delta.data.chunk` -> `provider_message.MessageChunk`
- `message.completed.data.message` -> `provider_message.Message`
- `run.completed.data.message` -> `provider_message.Message`
- `run.failed` -> 抛出受控异常,让 `ChatMessageHandler` 使用现有错误策略
- 工具和状态事件默认不 yield 到 Pipeline,只记录 debug/telemetry
防护:
- 未知 type warning 后忽略
- 单 result 序列化大小限制
- provider message schema 校验失败转 `run.failed`
- 插件没有输出任何消息时,按 runner failed 处理
### 3.7 orchestrator.py
核心入口:
```python
async def run_from_query(query: pipeline_query.Query) -> AsyncGenerator[Message | MessageChunk, None]:
runner_id = resolve_runner_id(query.pipeline_config)
descriptor = await registry.get(runner_id, bound_plugins=query.variables.get("_pipeline_bound_plugins"))
ctx = await context_builder.from_query(query, descriptor)
async for raw in plugin_connector.run_agent(...):
async for message in result_normalizer.normalize(raw):
yield message
```
必须覆盖:
- runner id 不存在
- 插件系统关闭
- runner 不在 bound plugins 范围内
- 插件 runtime 断连
- runner 协议版本不兼容
- run 超时
- task cancellation
## 4. 配置模型直接切换
配置模型表达的是 Pipeline 到 runner id 的绑定,不表达插件实例。插件安装后由 plugin runtime 管理单个插件运行实例;不同 Pipeline 选择同一个 runner id 时,只是保存不同的 `runner_config[id]`,调用时随 `AgentRunContext.config` 传入。
目标格式:
```json
{
"ai": {
"runner": {
"id": "plugin:langbot/local-agent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot/local-agent/default": {}
}
}
}
```
兼容读取:
- 优先读 `ai.runner.id`
- 没有 `id` 时读旧 `ai.runner.runner`
- 旧内置 runner 名通过迁移表映射:
- `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`
- `langflow-api` -> `plugin:langbot/langflow-agent/default`
- `tbox-app-api` -> `plugin:langbot/tbox-agent/default`
写入策略:
- 新 UI 只写 `ai.runner.id``ai.runner_config`
- 后端 update 接口接受旧字段,但保存时归一成新格式
- migration 最后统一落库
## 5. 需要修改的 LangBot 范围
必须修改:
- `src/langbot/pkg/core/app.py`
- 增加 `agent_runner_registry` / `agent_run_orchestrator` 属性
- `src/langbot/pkg/core/stages/build_app.py`
- 初始化 Agent 子系统
- `src/langbot/pkg/pipeline/process/handlers/chat.py`
- 删除 `PluginAgentRunnerWrapper`
- 删除内置 runner 查找逻辑
- 调用 orchestrator
- `src/langbot/pkg/api/http/service/pipeline.py`
- metadata 从 registry 生成
- `src/langbot/pkg/plugin/connector.py`
- `list_agent_runners()` / `run_agent()` 增加协议校验和 bound plugin 参数
- `src/langbot/pkg/plugin/handler.py`
- proxy action 二次权限校验
- `src/langbot/pkg/pipeline/preproc/preproc.py`
- 不再只为 `local-agent` 构造工具、知识库、模型
- 对所有 agent runner 保留 multimodal input
- `src/langbot/pkg/pipeline/pipelinemgr.py`
- runner name 监控改读 `runner.id`
- `src/langbot/templates/metadata/pipeline/ai.yaml`
- runner 字段从 `runner` 迁到 `id`
- `src/langbot/templates/default-pipeline-config.json`
- 默认 runner 改为官方 local-agent 插件 id
- `web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx`
- 当前 runner 改读 `ai.runner.id`
- runner 配置区改写入 `ai.runner_config[id]`
最终删除或停用:
- `src/langbot/pkg/provider/runner.py` 的业务注册路径
- `src/langbot/pkg/provider/runners/*` 的运行入口
可以暂时保留文件作为官方插件迁移参考,但不应被运行时引用。
## 6. 收尾实现顺序
### Step 1:补齐宿主上下文
- SDK `AgentRunContext` 保持 event-first`event/input/delivery/resources/context/state/runtime/config/bootstrap/adapter`
- LangBot context builder 只从 `AgentEventEnvelope + AgentBinding` 写入稳定协议字段。
- Pipeline adapter 可以把公开业务变量写入 `ctx.adapter.extra["params"]`legacy/effective prompt 若保留在 `ctx.adapter.extra["prompt"]`,也只属于 adapter metadata。
- 保持 `ctx.config` 只表达静态 Agent/runner config。
### Step 2:增强宿主 AgentRun proxy action
- `invoke_llm` / `invoke_llm_stream` 通过 `run_id/query_id` 找回当前 Query。
- 自动合并 model persisted `extra_args` 与 action-level override。
- 自动应用 pipeline `remove-think`,并允许 action 显式 override。
- `call_tool` 传回当前 Query,恢复旧工具调用上下文。
- `retrieve_knowledge` 保持 `bot_uuid``sender_id``session_name` 等 settings。
- `invoke_rerank` 使用 run-scoped model authorization。
### Step 3:泛化资源构建
- 按 manifest permissions + bound plugins/MCP + runner config schema 构造资源。
- 支持 primary/fallback LLM、rerank model、KB selector。
- 不把 local-agent 特例扩散到通用资源层。
### Step 4local-agent parity
- 使用静态 Agent/runner config `ctx.config["prompt"]`,不读取 `ctx.adapter.extra["prompt"]`
- 通过 Host history API 拉取 transcript,不读取 `ctx.bootstrap.messages` 或 adapter window 字段。
- 当前 user message 从 `ctx.input.contents` 构造,保留多模态内容。
- RAG 只替换/插入文本部分,不丢图片/文件。
- streaming/non-streaming 默认跟随 `runtime.metadata.streaming_supported`
- 首轮 fallback 成功后,tool loop 固定使用 committed model。
- tool loop 继续传可用 tools,支持多步工具调用。
- rerank 通过授权模型资源调用。
### Step 5:端到端保护和测试
- 插件无输出时按 runner failed 处理。
- timeout/deadline 覆盖 plugin runtime、模型调用和外部 runner 调用。
- runner 协议错误转受控错误。
- 覆盖 local-agent 用户可见行为:普通回复、流式、工具、多步工具、KB、rerank、多模态、绑定 prompt、history API。
### Step 6:官方 runner 迁移
- 官方插件 ready 后移除内置 runner registry
- 删除或隔离 provider runners 的运行引用
- 测试旧 runner 名只能通过 migration 映射到插件 id
### Step 7:历史配置迁移
- 写 persistence migration
- 更新 default pipeline config
- 对已存在 Pipeline 执行旧字段到新字段迁移
- 对监控/日志里的 runner 字段改用新 id
## 7. 测试要求
单测:
- runner id parse / format
- registry manifest 校验、失败隔离、bound plugins 过滤
- context builder 从 query 生成完整 v1 context
- resource builder 三层裁剪
- result normalizer 对每种 result type 的映射
- 旧配置 resolve 和 migration
集成测试:
- fake AgentRunner 插件可被 Pipeline 选择
- streaming 输出仍能更新 message card
- 插件异常返回用户可理解错误,不中断 runtime
- runner 不在 bound plugins 时不可执行
- 未授权工具 / 知识库 / 模型 proxy 调用被拒绝
-`local-agent` Pipeline 配置迁到官方插件 id
## 8. 验收标准
- LangBot Pipeline 可以选择插件 AgentRunner 并完成非流式和流式回复。
- `ChatMessageHandler` 不包含插件 runner 解析和 wrapper。
- `PipelineService` 不直接拼插件 runner metadata。
- 所有 runner 配置使用 `ai.runner.id` + `ai.runner_config`
- 插件 runtime 不为每个 Agent 或 runner 配置创建插件实例;`runner_config` 只作为 Agent/runner config 随 `ctx.config` 传入。
- 主聊天路径不再通过旧内置 runner 执行业务 runner。迁移期间旧文件可以保留。
- 插件只能访问 `ctx.resources` 授权的模型、工具、知识库和文件。
- 宿主 action 能为 AgentRunner 调用恢复必要 Query 语义,插件不需要拿裸 Query。
- 官方 `local-agent` 插件对外行为与旧内置 local-agent 对齐。
- EBA 相关字段只作为 context/result 预留,不执行平台动作。
@@ -1,63 +1,27 @@
# 官方 AgentRunner 插件迁移计划 # 官方 AgentRunner 插件迁移计划
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。 本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。验收状态见 [PROGRESS.md](./PROGRESS.md)QA 入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。
它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和
[AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot
宿主协议的设计前提。
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构, 官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。
而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot 的
host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管
context/runtime 的 runner,不能被官方插件的实现细节绑死。
当前实现已经进入过渡阶段: ## 1. 仓库组织
- LangBot 主聊天路径通过 `AgentRunOrchestrator` 调用插件化 `AgentRunner` 官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配
-`src/langbot/pkg/provider/runners/*` 仍保留,作为迁移参考和回退分析材料;在官方插件迁移完成前不要求删除。
- 官方 runner 当前以独立插件目录/仓库推进,例如 `langbot-local-agent/``langbot-agent-runner/*-agent/`。不再要求先落地单一 monorepo。
- `claude-code-agent``codex-agent` 已作为外部 harness runner MVP 接入,用来验证 Claude Code / Codex / Kimi Code 这类自管 runtime 的边界。
## 1. 为什么新仓库 当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局:
官方 runner 插件会和 LangBot 主仓库、SDK 仓库以不同节奏迭代:
- LangBot 主仓库只维护宿主协议和调度。
- SDK 仓库维护 AgentRunner 组件和 runtime 协议。
- 官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。
不要把官方 runner 插件重新绑死在 LangBot 主仓库内。允许开发期使用本地路径插件,但运行边界必须保持为:
- LangBot 提供通用宿主能力:当前事件、context handles、资源授权、状态/存储、历史、artifact、模型/工具/知识库调用代理、结果归一。
- 插件消费这些公开能力,实现具体 runner 行为。
- LangBot 默认不把全量历史消息 inline 给 runnerrunner 按需通过授权 API 拉取历史和 artifact。
- 旧内置 runner 只作为行为对齐的基准,不作为长期运行路径。
## 2. 仓库结构
当前推荐策略是“官方插件可独立发布,必要时共享 SDK helper”。开发期可以采用本地多目录布局:
```text ```text
langbot-app/ langbot-app/
langbot-local-agent/ langbot-local-agent/ # plugin:langbot/local-agent/default
manifest.yaml manifest.yaml
components/agent_runner/default.yaml components/agent_runner/default.{yaml,py}
components/agent_runner/default.py langbot-agent-runner/ # 外部服务 runner 仓库
pkg/ claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ...
tests/
langbot-agent-runner/
claude-code-agent/
codex-agent/
n8n-agent/
...
``` ```
后续可以把多个官方 runner 聚合进 monorepo,也可继续独立发布这个选择不影响协议设计;协议边界由 SDK 和 LangBot 宿主保证 后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 在官方插件迁移完成前保留作为行为对齐基准,不作为长期运行路径
如果多个 runner 出现重复逻辑,优先沉淀到 SDK 或一个明确的共享 helper 包,不要把宿主私有结构泄漏给插件。 ## 2. 插件命名和 runner id
## 3. 插件命名和 runner id
固定映射:
| 旧 runner | 官方插件 | runner id | | 旧 runner | 官方插件 | runner id |
| --- | --- | --- | | --- | --- | --- |
@@ -71,259 +35,109 @@ langbot-app/
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` | | `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` | | `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default` 每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`
## 4. 迁移优先级 ## 3. 迁移批次
### Batch 1打通协议 - **Batch 1打通协议**`local-agent`(能力最完整基准)、`claude-code-agent` / `codex-agent`(外部 code-agent harness 边界)、`dify-agent`(传统 service API runner)。
- **Batch 2(外部 workflow**`n8n-agent``langflow-agent`webhook/workflow 输入输出、timeout、外部 conversation id)。
- **Batch 3(平台 Agent API**`coze-agent``dashscope-agent``tbox-agent`(平台特有响应格式、引用资料、文件/图片输入)。
1. `local-agent` ## 4. 每个官方插件的组件要求
2. `claude-code-agent`
3. `codex-agent`
4. `dify-agent`
原因 每个插件至少包含一个 `AgentRunner` 组件,manifest 示例
- `local-agent` 覆盖模型、工具、知识库、流式、会话历史,是能力最完整的基准。
- `claude-code-agent` / `codex-agent` 代表 Claude Code / Codex / Kimi Code 这类本地或外部 code-agent harness:它们通常自带 session、tool loop、上下文压缩和权限模型,LangBot 主要提供 IM 事件、资源投影、审计和状态指针。
- `dify-agent` 代表外部 Agent 平台调用,配置和错误处理能验证传统 service API runner 的迁移方式。
### Batch 2:迁移外部 workflow runner
1. `n8n-agent`
2. `langflow-agent`
这批主要验证 webhook/workflow 输入输出、timeout、外部 conversation id。
### Batch 3:迁移平台 Agent API
1. `coze-agent`
2. `dashscope-agent`
3. `tbox-agent`
这批主要验证平台特有响应格式、引用资料、文件/图片输入。
## 5. 每个官方插件的组件要求
每个插件至少包含:
```yaml ```yaml
apiVersion: langbot/v1 apiVersion: langbot/v1
kind: AgentRunner kind: AgentRunner
metadata: metadata:
name: default name: default
label: label: { en_US: Dify Agent, zh_Hans: Dify Agent }
en_US: Dify Agent
zh_Hans: Dify Agent
description: description:
en_US: Run a Dify application as a LangBot AgentRunner. en_US: Run a Dify application as a LangBot AgentRunner.
zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。 zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。
spec: spec:
protocol_version: "1"
config: [] config: []
capabilities: capabilities: # 字段语义见 PROTOCOL_V1 §4.3
streaming: true streaming: true
tool_calling: false
knowledge_retrieval: false
multimodal_input: false
event_context: true event_context: true
platform_api: false
interrupt: false
stateful_session: true stateful_session: true
permissions: permissions: # 字段语义见 PROTOCOL_V1 §4.4
models: []
tools: []
knowledge_bases: []
storage: ["plugin"] storage: ["plugin"]
files: [] context: # 字段语义见 PROTOCOL_V1 §4.5
platform_api: [] supports_history_pull: true
owns_compaction: true
execution: execution:
python: python: { path: ./main.py, attr: DefaultAgentRunner }
path: ./main.py
attr: DefaultAgentRunner
``` ```
## 6. local-agent 插件方向 ## 5. local-agent 插件方向
`local-agent` 是官方插件中的重要消费者,但不是宿主协议的设计中心。它可以选择复用 `local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
旧实现,也可以完全重写。它需要证明:一个主要依附 LangBot host 能力的 agent runner
可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
LangBot core 不应为了 local-agent 保留业务编排逻辑。local-agent 的 prompt 组装、history 迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。
拉取、summary/checkpoint、tool loop、RAG 编排、fallback、多模态处理都应在插件内完成。
迁移或重写时需要覆盖旧内置 runner 的用户可见能力 责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束
- model primary/fallback 选择 - `ctx.config` 读取静态绑定 `prompt`**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。
- prompt - 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。
- knowledge-bases - `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。
- rerank-model - 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。
- rerank-top-k - manifest 声明自管上下文能力(`context.supports_history_pull/search``owns_compaction` 等)。
- function calling
- streaming
- multimodal input
- conversation history
- monitoring metadata
与 LangBot 主仓库的责任边界: ### 5.1 Native Execution / Skills 后续接入
- LangBot 构造当前事件、结构化输入、资源授权、context handles、state/storage 能力和 delivery 能力 本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host 封装成 scoped tools,再通过 `ctx.resources.tools` 暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力
- LangBot 不默认 inline 全量历史,不替插件组装最终模型上下文
- 插件负责选择模型、拼请求、调用 LLM、处理 tool call loop、输出 result stream
- 插件不能绕过 `ctx.resources` 调用未授权模型、工具或知识库
为了保持旧内置 runner 的用户可见行为,`local-agent` 插件应消费宿主处理后的有效输入和 ## 6. 外部 runner 插件要求
受限 API,而不是读取宿主内部私有结构:
- `ctx.event` / `ctx.input`:当前结构化输入,必须保留图片、文件等多模态内容 外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state,不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`
- `ctx.context`history cursor、inline policy、可用 context API。
- `AgentRunAPIProxy.history`:按需读取 transcript,而不是依赖 host 每轮强塞历史窗口。
- `AgentRunAPIProxy.artifacts`:按需读取图片、文件、工具大结果。
- `AgentRunAPIProxy.state` / storage:保存 summary、外部 conversation id、用户偏好等可选状态。
- `ctx.resources`:已授权模型、工具、知识库、文件和 storage。
- `ctx.runtime.metadata.streaming_supported`:当前 adapter 是否能消费流式输出。
- 宿主代理 action:模型、工具、知识库、rerank 调用必须通过 `run_id` 校验资源权限。
`local-agent` 不应消费 Pipeline adapter 生成的历史窗口,也不应读取 ### 6.1 Code-agent harness runner
`ctx.adapter.extra.prompt`。它应从绑定配置读取静态 `prompt`,并通过 Host
history API 拉取 transcript。Pipeline adapter 不保留 Host-side window 兼容逻辑。
建议 local-agent manifest 使用 hybrid 或 self-managed context Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行,可以依赖自己的 harness,但仍必须遵守 Host 边界:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;授权资源投影为 harness 可读的 context 文件、MCP 配置、skill 目录、环境变量或 CLI 参数(投影形态见 AGENT_CONTEXT_PROTOCOL §4.5);外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage,插件实例保持无状态;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射;harness 的 permission mode / allow-deny / MCP 配置只是一层执行约束,Host 仍负责调用前的资源授权、路径策略、secret 过滤和审计(发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
```yaml ### 6.2 SDK-owned LangBot MCP bridge
context:
ownership: hybrid
bootstrap: current_event
max_inline_events: 0
max_inline_bytes: 0
supports_history_pull: true
supports_history_search: true
supports_artifact_pull: true
owns_compaction: true
wants_static_context_refs: true
```
这表示:LangBot 只给当前事件和 context handleslocal-agent 自己决定是否拉取历史、是否搜索、 外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,因此不能像 `local-agent` 一样直接调用 `AgentRunAPIProxy`。当前轻量方案是由 SDK 提供一层 per-run MCP bridge
何时摘要、如何构造最终 prompt。
### 6.1 Native Execution / Skills 后续接入
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段,也不预留 runner 可见字段。
后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process
等能力应先由 LangBot Host 封装成 scoped tools,再通过 `ctx.resources.tools`
暴露给 runner。
这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。
Claude Code / Codex 这类外部 harness runner 仍可先保留自己的执行模型,但要在文档和
配置中明确它们是否使用 LangBot 提供的工具投影。
## 7. 外部 runner 插件要求
外部平台 runner 迁移时遵循:
- 旧配置字段尽量保持同名,便于 migration 复制
- 输出统一转换为 `AgentRunResult`
- 外部 API timeout 从 runner config 读取
- 平台 conversation id 存 plugin storage 或 context runtime state,不能依赖 LangBot 内置 conversation uuid 私有结构
- 流式支持按平台能力声明,没有流式就只发 `message.completed`
### 7.1 Code-agent harness runner 要求
Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行。它们可以依赖自己的 harness,但仍必须遵守 LangBot 的宿主边界:
- 输入来自 `ctx.event` / `ctx.input`,不能直接依赖 Pipeline 私有 `Query`
- LangBot 授权后的资源应被投影为 harness 可读的 context 文件、MCP 配置、skill 目录、环境变量或 CLI 参数。
- 外部 session id、workspace、checkpoint 等跨轮次指针应写入 Host state 或 plugin storage;插件实例本身保持无状态。
- CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。
- 如果外部 harness 选择使用 LangBot 托管执行能力,它应通过 scoped MCP/tool
投影消费 Host 授权资源;否则它属于 external harness mode,不能声称具备
LangBot-managed 执行隔离。
- 外部 harness 的 permission mode、allowed/disallowed tools、MCP 配置只是一层执行约束;LangBot 仍负责调用前的资源授权、路径策略、secret 过滤和审计。发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
### 7.2 SDK-owned LangBot MCP bridge
Claude Code / Codex 这类外部 harness 不能直接持有 Python 进程内的
`plugin_runtime_handler`,因此不能像 `local-agent` 一样直接调用
`AgentRunAPIProxy`。当前轻量方案是由 SDK 提供一层 per-run MCP bridge
- `AgentRunner.create_external_mcp_bridge(ctx)` 是 runner 父类入口。 - `AgentRunner.create_external_mcp_bridge(ctx)` 是 runner 父类入口。
- Bridge 由 `AgentRunAPIProxy``AgentRunContext` 构造,生命周期只覆盖当前 run。 - Bridge 由 `AgentRunAPIProxy``AgentRunContext` 构造,生命周期只覆盖当前 run。
- Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是扫描或导出全部 SDK action。 - Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是导出全部 SDK actionMCP tool schema 由注解和 Pydantic args model 生成
- MCP tool schema 由注解和 Pydantic args model 生成;runner 插件不各自手写 LangBot tool schema - stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridgerun 结束后 bridge 关闭
- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge。
- run 结束后 bridge 关闭;这不是 LangBot 主程序全局 MCP server。
第一批工具保持很小:当前事件快照、history page、knowledge retrieve、authorized tool call。后续新增工具必须先进入 SDK-owned annotated surface,再由 MCP adapter 自动投影。 第一批工具保持很小:当前事件快照、history page、knowledge retrieve、authorized tool call。新增工具必须先进入 SDK-owned annotated surface,再由 MCP adapter 自动投影。
## 8. Claude Code runner 当前形态 ## 7. Claude Code / Codex runner 当前形态
当前 `claude-code-agent` 是最小可运行 MVP,用来证明外部 harness runner 可以接入同一套 AgentRunner 协议。 `claude-code-agent``codex-agent` 是最小可运行 MVP,用来证明外部 harness runner 可以接入同一套 AgentRunner 协议。本地 smoke 验收记录见 [PROGRESS.md](./PROGRESS.md) 与 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。
### 8.1 基本行为 ### 7.1 Claude Code runner
- Runner ID`plugin:langbot/claude-code-agent/default` - Runner ID`plugin:langbot/claude-code-agent/default`,执行方式:本地 Claude Code CLI print mode(默认 `claude -p`)。
- 执行方式:本地 Claude Code CLI print mode,默认命令为 `claude -p` - 默认输出 `message.completed` + `run.completed`;默认权限 `permission-mode=plan``max-turns=1``disallowedTools=AskUserQuestion`
- 默认输出:`message.completed` + `run.completed` - 投影:写入 `agent-context.json`schema `langbot.agent_runner.external_harness_context.v1`)和 `LANGBOT_CONTEXT.md`;可把 `skills-json` 投影到 `.claude/skills/<name>/SKILL.md`;可把 `mcp-config-json` 写成每次 run 的 MCP config 经 `--mcp-config` / `--strict-mcp-config` 传入;可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge。
- 默认权限:`permission-mode=plan``max-turns=1``disallowedTools=AskUserQuestion` - 状态:Claude Code 返回 `session_id` 时通过 `state.updated` 写回 `external.session_id`;工作目录优先用 config 的 `working-directory`,其次用 Host state 的 `external.working_directory`
- 默认状态:如果 Claude Code 返回 `session_id`runner 通过 `state.updated` 写回 `external.session_id`
- 工作目录:优先使用 Agent/runner config 的 `working-directory`,其次使用 Host state 中的 `external.working_directory`
### 8.2 Context / skill / MCP 投影 ### 7.2 Codex runner
Claude Code runner 当前把 LangBot event-first context 投影给外部 harness - Runner ID`plugin:langbot/codex-agent/default`,执行方式:本地 Codex CLI,读取 LangBot event context
- Codex `thread_id` 写回 host-owned state;支持 SDK-owned per-run LangBot MCP bridge;需要代理的本地环境可通过 config 的 `environment-json` 显式传递非 secret 环境变量。
- 写入 `agent-context.json`schema 为 `langbot.agent_runner.external_harness_context.v1` ### 7.3 当前限制
- 写入 `LANGBOT_CONTEXT.md`,作为人类可读摘要
- 将 prompt prefix 指向 context 文件路径
- 可把 Agent/runner config 提供的 `skills-json` 写入 Claude Code 原生 `.claude/skills/<name>/SKILL.md`
- 可把 Agent/runner config 提供的 `mcp-config-json` 写成每次 run 的 MCP config,并通过 `--mcp-config` / `--strict-mcp-config` 传给 Claude Code
- 可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge,使 Claude Code 通过 MCP 调用受限的 `AgentRunAPIProxy` 能力
这些投影目前由 runner adapter 完成;长期更理想的形态是 LangBot Host 负责生成 scoped resource projectionrunner 只负责适配 Claude Code 的原生目录和 CLI 参数 不是发布级安全边界实现;默认只做本地 CLI 调用,不实现完整执行隔离或 workspace 生命周期;不实现 issue-centric 队列、复杂 workflow engine 或长期任务调度;Codex 仅验证协议形态,不代表 Codex 发布级能力或 Kimi runner 已完成。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)
### 8.3 已验证能力 ## 8. 发布和安装策略
2026-05-29 本地验证: 最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 历史配置 migration 只在官方插件可用时执行 → 迁移期间保留旧内置 runner 文件,直到对应官方插件通过 parity 验收。
- WebUI Debug Chat 能通过 Pipeline adapter 调用 `claude-code-agent` ## 9. 验收标准
- Claude Code 能读取 LangBot context 文件并按指令输出 sentinel
- Skill 文件可以投影到 `.claude/skills/`
- MCP config 可以通过 Agent/runner config 投影为 Claude Code CLI 参数
- SDK-owned per-run LangBot MCP bridge 可以被真实 Claude Code CLI 调用,并通过 `langbot_get_current_event` 读取当前 run_id
- `external.session_id``external.working_directory` 可以写入 host-owned state,用于后续 resume
- `codex-agent` 可通过 WebUI Debug Chat 调用本机 Codex CLI,读取 LangBot event context,并把 Codex `thread_id` 写入 host-owned state
- SDK-owned per-run LangBot MCP bridge 可以被真实 Codex CLI 调用,并通过 `langbot_get_current_event` 读取当前 run_id
- 对需要代理的本地运行环境,`codex-agent` 可通过 Agent/runner config 的 `environment-json` 显式传递非 secret 环境变量
下一轮测试入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) - 每个旧 runner 都有对应官方 AgentRunner 插件,旧配置能无损复制到新 `runner_config[id]`
### 8.4 当前限制
- 不是发布级安全边界实现。
- 默认只做本地 CLI 调用,不实现完整执行隔离或 workspace 生命周期。
- 不实现 issue-centric 队列、复杂 workflow engine 或长期任务调度。
- 不代表 Codex 发布级能力或 Kimi runner 已完成;当前只验证外部 harness runner 的协议形态。
## 9. 发布和安装策略
最终 LangBot 安装或升级时需要保证官方 runner 插件可用。可选方案:
1. 首次启动检测缺失官方 runner 插件并提示安装。
2. 打包发行版时预装官方 runner 插件。
3. 在 migration 前检查对应插件是否存在,不存在则自动安装或阻止迁移。
建议实现顺序:
- 开发阶段使用本地路径插件。
- 发布前支持 marketplace 安装。
- 历史配置 migration 只在官方插件可用时执行。
- 迁移期间保留旧内置 runner 文件,直到对应官方插件通过 parity 验收。
## 10. 验收标准
- 每个旧 runner 都有对应官方 AgentRunner 插件。
- 旧 runner 配置能无损复制到新 `runner_config[id]`
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 - LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。 - 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
- `local-agent` 插件能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。 - `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。
- `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。 - `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。
- 对外行为与旧内置 local-agent runner 保持一致;代码结构不需要相同。 - 对外行为与旧内置 local-agent runner 一致;代码结构不需要相同。
@@ -14,7 +14,7 @@ event -> binding -> runner.run(ctx) -> result stream
本指南验证: 本指南验证:
- Host 能通过当前 Pipeline adapter 进入 event-first `run(event, binding)` 主链路。 - Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。
- Runner 来自插件 registry,而不是旧内置 runner 分支。 - Runner 来自插件 registry,而不是旧内置 runner 分支。
- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。 - `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。
- 外部 harness runnerClaude Code / Codex)能消费 event-first context,并把 session / working directory 等指针写回 host-owned state。 - 外部 harness runnerClaude Code / Codex)能消费 event-first context,并把 session / working directory 等指针写回 host-owned state。
@@ -136,7 +136,7 @@ bin/lbs case list
| ID | 场景 | 操作 | 通过条件 | | ID | 场景 | 操作 | 通过条件 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 | | LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 |
| LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 bootstrap window。 | | LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 inline history window。 |
| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 | | LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 |
| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 | | LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 |
| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 | | LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 |
+6 -3
View File
@@ -2,6 +2,8 @@
本文档跟踪 Agent Runner 插件化的实现状态,便于快速了解当前进度。 本文档跟踪 Agent Runner 插件化的实现状态,便于快速了解当前进度。
> 本文是 agent-runner 插件化**实现状态的唯一事实源**。协议规范见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)Host 架构见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。规范类文档不再各自维护"当前状态/✅"段落,状态一律以本文为准。
## 总体进度 ## 总体进度
**当前阶段**: Phase 3.5 已完成,Event-first 基础设施已完成;2026-05-29 已通过本地 `local-agent` 与 Claude Code runner smoke。 **当前阶段**: Phase 3.5 已完成,Event-first 基础设施已完成;2026-05-29 已通过本地 `local-agent` 与 Claude Code runner smoke。
@@ -47,7 +49,7 @@
| `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context | | `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context |
| `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.py` | | `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.py` |
| `ConfigMigration` | ✅ | `pkg/agent/runner/config_migration.py` | | `ConfigMigration` | ✅ | `pkg/agent/runner/config_migration.py` |
| `PipelineAdapter` | ✅ | `pkg/agent/runner/pipeline_adapter.py` - Query → Event + Binding | | `QueryEntryAdapter` | ✅ | `pkg/agent/runner/query_entry_adapter.py` - Query → Event + Binding |
| `run_from_query()``run(event, binding)` | ✅ | Pipeline 路径委托到 event-first path | | `run_from_query()``run(event, binding)` | ✅ | Pipeline 路径委托到 event-first path |
| `ChatMessageHandler` 集成 | ✅ | 使用 orchestrator 替代 wrapper | | `ChatMessageHandler` 集成 | ✅ | 使用 orchestrator 替代 wrapper |
| `PipelineService` 集成 | ✅ | 从 registry 获取 runner metadata | | `PipelineService` 集成 | ✅ | 从 registry 获取 runner metadata |
@@ -89,6 +91,7 @@
| 2026-05-29 | `claude-code-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-18-03-31-169-08-00-pipeline-debug-chat.md` | | 2026-05-29 | `claude-code-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-18-03-31-169-08-00-pipeline-debug-chat.md` |
| 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` | | 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` |
| 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` | | 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` |
| 2026-05-29 | `codex-agent` Debug Chat + thread_id resume state | ✅ PASS | 见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) §10 / `langbot-skills/reports/` |
--- ---
@@ -150,8 +153,8 @@
## 相关文档 ## 相关文档
- [README.md](./README.md) — 总体设计 - [README.md](./README.md) — 总体设计与路由
- [PROTOCOL_V1.md](./PROTOCOL_V1.md) — 协议规范(唯一 schema 事实源)
- [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) — Agent Runner QA 指南和下一轮测试入口 - [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) — Agent Runner QA 指南和下一轮测试入口
- [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划 - [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划
- [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛 - [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛
- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) — 具体实施细节
+117 -304
View File
@@ -1,38 +1,27 @@
# LangBot AgentRunner Protocol v1 # LangBot AgentRunner Protocol v1
本文档定义 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同。它优先描述”稳定接口应是什么”,不描述具体落地任务 本文档 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth**
## 当前状态 - 本文件描述"稳定接口应是什么",是 normative spec,不混入实现进度。实现状态见 [PROGRESS.md](./PROGRESS.md)。
- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。
**Protocol v1 已在当前分支落地** - Host 内部模型(`AgentEventEnvelope``AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
- ✅ SDK 定义 `AgentRunnerManifest``AgentRunContext``AgentRunResult``AgentRunAPIProxy`
- ✅ Runtime 支持 `LIST_AGENT_RUNNERS``RUN_AGENT`
- ✅ Host 支持 `run_id` session authorization
- ✅ Host 能从当前 Pipeline 入口生成 event-first context
-`messages` 降级为 optional bootstrap
- ✅ Host 不定义通用历史窗口字段或策略;runner 自己管理 working context
- ✅ Proxy 覆盖 model、tool、knowledge、state/storage
- ✅ History / Event / Artifact / State API 已落地
- ✅ EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
-`local-agent` 与 Claude Code runner 已通过本地 WebUI smoke,验证 host-infra runner 与外部 harness runner 共享同一协议路径
## 1. 协议目标 ## 1. 协议目标
Protocol v1 解决四件事: Protocol v1 解决四件事:
- LangBot 如何发现插件提供的 AgentRunner。 - LangBot 如何发现插件提供的 AgentRunner。
- LangBot 如何把一次事件调用封装成 `AgentRunContext` - LangBot 如何把一次事件调用封装成 `AgentRunContext`
- AgentRunner 如何以事件流形式返回运行结果。 - AgentRunner 如何以事件流形式返回运行结果。
- AgentRunner 如何通过受限 API 访问 LangBot host 能力。 - AgentRunner 如何通过受限 API 访问 LangBot host 能力。
Protocol v1 不定义 Protocol v1 **不定义**
- LangBot 内部如何持久化 AgentBinding。 - LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK
- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory。 - AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)
- 官方 local-agent 的具体实现 - 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)
- Pipeline 的长期配置模型。 - Pipeline 的长期配置模型。
- 发布级安全 hardening 的完整实现;当前只定义 Host 侧资源、权限、状态和审计边界,release gate 见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 - 发布级安全 hardening 的完整实现见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)
## 2. 参与方 ## 2. 参与方
@@ -42,26 +31,32 @@ Protocol v1 不定义:
| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 | | Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 |
| AgentRunner | 插件提供的 agent 执行组件。 | | AgentRunner | 插件提供的 agent 执行组件。 |
| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 | | AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 |
| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK。 | | AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2。 |
`AgentBinding` 只影响 Host 构造出的 `ctx.config``ctx.resources``ctx.context``ctx.delivery`。SDK 不需要知道 binding 的持久化形态。 `AgentBinding` 只影响 Host 构造出的 `ctx.config``ctx.resources``ctx.context``ctx.delivery`。SDK 不需要知道 binding 的持久化形态。
外部 harness runnerClaude Code、Codex、Kimi Code 等)仍然`AgentRunner`。Protocol v1 只要求它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact APIs 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 外部 harness runnerClaude Code、Codex、Kimi Code 等)`AgentRunner`它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact API 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。
## 3. Discovery 协议 ## 3. 版本协商
### 3.1 LIST_AGENT_RUNNERS - `AgentRunnerManifest.protocol_version` 声明 runner 实现的协议大版本,当前为 `"1"`
- `AgentRuntimeContext.protocol_version``ctx.runtime.protocol_version`)声明 Host 下发的协议大版本。
- Host 发现 runner 时校验 `protocol_version` 兼容性;不兼容的 runner 不进入可用列表,只记 warning。
- 字段级演进规则:新增可选字段不提升大版本;删除或改语义需要提升大版本。
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。新增 result type 不提升大版本。
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表。该请求不需要额外 payload。 ## 4. Discovery 协议
Runtime 返回: ### 4.1 LIST_AGENT_RUNNERS
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回:
```python ```python
class ListAgentRunnersResponse(BaseModel): class ListAgentRunnersResponse(BaseModel):
runners: list[AgentRunnerManifest] runners: list[AgentRunnerManifest]
``` ```
### 3.2 AgentRunnerManifest ### 4.2 AgentRunnerManifest
```python ```python
class AgentRunnerManifest(BaseModel): class AgentRunnerManifest(BaseModel):
@@ -69,6 +64,7 @@ class AgentRunnerManifest(BaseModel):
name: str name: str
label: I18nObject label: I18nObject
description: I18nObject | None = None description: I18nObject | None = None
protocol_version: str = "1"
capabilities: AgentRunnerCapabilities capabilities: AgentRunnerCapabilities
permissions: AgentRunnerPermissions permissions: AgentRunnerPermissions
context: AgentRunnerContextPolicy context: AgentRunnerContextPolicy
@@ -76,14 +72,12 @@ class AgentRunnerManifest(BaseModel):
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
``` ```
字段要求: - `id` 必须稳定,格式 `plugin:author/name/runner`
- `id` 必须稳定,推荐 `plugin:author/name/runner`
- `name` 是插件内 runner 名称,例如 `default` - `name` 是插件内 runner 名称,例如 `default`
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。 - `config_schema` 只描述绑定配置表单,不代表插件实例状态。
- `metadata`放展示、诊断、非稳定扩展信息。 - `metadata` 只放展示、诊断、非稳定扩展信息。
### 3.3 Capabilities ### 4.3 Capabilities
```python ```python
class AgentRunnerCapabilities(BaseModel): class AgentRunnerCapabilities(BaseModel):
@@ -101,8 +95,8 @@ class AgentRunnerCapabilities(BaseModel):
语义: 语义:
- `streaming`: runner 可以返回 `message.delta` - `streaming`: runner 可以返回 `message.delta`
- `tool_calling`: runner 可能调用 Host tool APIs - `tool_calling`: runner 可能调用 Host tool API。
- `knowledge_retrieval`: runner 可能调用 Host knowledge APIs - `knowledge_retrieval`: runner 可能调用 Host knowledge API。
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。 - `multimodal_input`: runner 可以处理非纯文本 input / artifact。
- `event_context`: runner 理解 event-first 输入。 - `event_context`: runner 理解 event-first 输入。
- `platform_api`: runner 可能请求平台动作。 - `platform_api`: runner 可能请求平台动作。
@@ -110,7 +104,9 @@ class AgentRunnerCapabilities(BaseModel):
- `stateful_session`: runner 可能维护跨 run 会话状态。 - `stateful_session`: runner 可能维护跨 run 会话状态。
- `self_managed_context`: runner 自己管理 working contextHost 不应默认 inline 历史。 - `self_managed_context`: runner 自己管理 working contextHost 不应默认 inline 历史。
### 3.4 Permissions > Capabilities 字段全部是 `bool`。runner 是否寄宿 host-owned state **不在 capabilities 表达**,而通过 `permissions.storage` 声明(见 §4.4),避免出现非 bool 取值。
### 4.4 Permissions
```python ```python
class AgentRunnerPermissions(BaseModel): class AgentRunnerPermissions(BaseModel):
@@ -125,16 +121,12 @@ class AgentRunnerPermissions(BaseModel):
platform_api: list[str] = [] platform_api: list[str] = []
``` ```
Manifest permissions 是 runner 需要的最大能力。实际可用资源还要经过 Host binding policy 和当前 run scope 裁剪。 Manifest permissions 是 runner 需要的**最大能力**。实际可用资源还要经过 Host binding policy 和当前 run scope 裁剪(三层裁剪见 HOST_SDK §4.5
### 3.5 Context Policy ### 4.5 Context Policy
```python ```python
class AgentRunnerContextPolicy(BaseModel): class AgentRunnerContextPolicy(BaseModel):
ownership: Literal["self_managed", "host_bootstrap", "hybrid"] = "self_managed"
bootstrap: Literal["none", "current_event", "recent_tail", "summary_tail"] = "current_event"
max_inline_events: int = 0
max_inline_bytes: int = 0
supports_history_pull: bool = True supports_history_pull: bool = True
supports_history_search: bool = False supports_history_search: bool = False
supports_artifact_pull: bool = True supports_artifact_pull: bool = True
@@ -147,12 +139,14 @@ Host 不使用该声明给 runner inline 历史窗口。默认原则:
- Host 不得默认 inline 全量历史。 - Host 不得默认 inline 全量历史。
- Host 只 inline 当前 event / input 和 context handles。 - Host 只 inline 当前 event / input 和 context handles。
- Runner 拥有 working context assembly。 - Runner 拥有 working context assembly。
- Runner 可在授权后通过 Host history / event / artifact / state APIs 拉取更多上下文。 - Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。
- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。 - 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。
## 4. Run 协议 context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
### 4.1 RUN_AGENT ## 5. Run 协议
### 5.1 RUN_AGENT
Host 调用 Runtime Host 调用 Runtime
@@ -163,11 +157,11 @@ class AgentRunRequest(BaseModel):
context: AgentRunContext context: AgentRunContext
``` ```
Runtime 返回 `AgentRunResult` 异步流。 Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id``context` 为准。
插件运行时可以继续在底层 transport 中使用 `plugin_author``plugin_name``runner_name` 定位组件,但协议语义以 `runner_id``context` 为准。 ### 5.2 AgentRunContext
### 4.2 AgentRunContext 这是 SDK 看到的**唯一权威 context 定义**。
```python ```python
class AgentRunContext(BaseModel): class AgentRunContext(BaseModel):
@@ -184,7 +178,6 @@ class AgentRunContext(BaseModel):
state: AgentRunState state: AgentRunState
runtime: AgentRuntimeContext runtime: AgentRuntimeContext
config: dict[str, Any] = {} config: dict[str, Any] = {}
bootstrap: BootstrapContext | None = None
adapter: AdapterContext | None = None adapter: AdapterContext | None = None
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
``` ```
@@ -193,29 +186,26 @@ class AgentRunContext(BaseModel):
- `event` 是必选字段,Protocol v1 是 event-first。 - `event` 是必选字段,Protocol v1 是 event-first。
- `input` 表示当前事件的主输入,不等于历史消息。 - `input` 表示当前事件的主输入,不等于历史消息。
- `bootstrap` 是可选字段;LangBot Host 默认不填历史窗口。 - `bootstrap` / `messages` **不是协议字段**Host 不内联历史窗口。
- `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。 - `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。
- `config` 是 Agent/runner config,不是插件实例状态。 - `config` 是 Agent/runner config,不是插件实例状态。
### 4.3 AgentTrigger ### 5.3 AgentTrigger
```python ```python
class AgentTrigger(BaseModel): class AgentTrigger(BaseModel):
type: str type: str
source: Literal["platform", "webui", "api", "scheduler", "system", "pipeline_adapter"] source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"]
timestamp: int | None = None timestamp: int | None = None
``` ```
`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如 Pipeline 兼容入口触发消息时: `trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时:
```json ```json
{ { "type": "message.received", "source": "host_adapter" }
"type": "message.received",
"source": "pipeline_adapter"
}
``` ```
### 4.4 AgentEventContext ### 5.4 AgentEventContext
```python ```python
class AgentEventContext(BaseModel): class AgentEventContext(BaseModel):
@@ -228,13 +218,11 @@ class AgentEventContext(BaseModel):
data: dict[str, Any] = {} data: dict[str, Any] = {}
``` ```
要求: - `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`
- 平台原始事件名放入 `source_event_type` - 平台原始事件名放入 `source_event_type`
- 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data` - 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`
### 4.5 Actor / Subject / Conversation ### 5.5 Conversation / Actor / Subject
```python ```python
class ConversationContext(BaseModel): class ConversationContext(BaseModel):
@@ -263,7 +251,7 @@ class SubjectContext(BaseModel):
- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。 - 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。
- 定时事件:actor 可以是 systemsubject 是 schedule。 - 定时事件:actor 可以是 systemsubject 是 schedule。
### 4.6 AgentInput ### 5.6 AgentInput
```python ```python
class AgentInput(BaseModel): class AgentInput(BaseModel):
@@ -273,13 +261,11 @@ class AgentInput(BaseModel):
message_chain: dict[str, Any] | None = None message_chain: dict[str, Any] | None = None
``` ```
要求:
- 文本、多模态、附件都属于当前 event input。 - 文本、多模态、附件都属于当前 event input。
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。 - 大文件、图片、音频、工具大结果应以 artifact ref 传递。
- `message_chain` 是平台兼容字段,不应成为长期稳定依赖。 - `message_chain` 是平台兼容字段,不应成为长期稳定依赖。
### 4.7 DeliveryContext ### 5.7 DeliveryContext
```python ```python
class DeliveryContext(BaseModel): class DeliveryContext(BaseModel):
@@ -292,9 +278,9 @@ class DeliveryContext(BaseModel):
platform_capabilities: dict[str, Any] = {} platform_capabilities: dict[str, Any] = {}
``` ```
Runner 可参考 delivery 能力决定返回 `message.delta``message.completed``action.requested` Runner 可参考 delivery 能力决定返回 `message.delta``message.completed``action.requested`
### 4.8 ContextAccess ### 5.8 ContextAccess
```python ```python
class ContextAccess(BaseModel): class ContextAccess(BaseModel):
@@ -306,12 +292,7 @@ class ContextAccess(BaseModel):
has_history_before: bool = False has_history_before: bool = False
inline_policy: InlineContextPolicy inline_policy: InlineContextPolicy
available_apis: ContextAPICapabilities available_apis: ContextAPICapabilities
```
`ContextAccess` 告诉 runnerHost inline 了什么、没有 inline 什么、如果需要更多上下文应该通过哪些 API 拉取。
它不是 Host 的业务上下文编排策略,而是 runner 按需读取上下文的入口说明。
```python
class InlineContextPolicy(BaseModel): class InlineContextPolicy(BaseModel):
mode: Literal["none", "current_event", "recent_tail", "summary_tail"] mode: Literal["none", "current_event", "recent_tail", "summary_tail"]
delivered_count: int = 0 delivered_count: int = 0
@@ -330,28 +311,14 @@ class ContextAPICapabilities(BaseModel):
storage: bool = False storage: bool = False
``` ```
### 4.9 BootstrapContext `ContextAccess` 告诉 runnerHost inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。
```python ### 5.9 AgentRuntimeContext
class BootstrapContext(BaseModel):
messages: list[Message] = []
summary: str | None = None
artifacts: list[ArtifactRef] = []
metadata: dict[str, Any] = {}
```
约束:
- `bootstrap.messages` 不是 LangBot Host 的默认行为。
- 自管 context runner 默认应收到空 bootstrap。
- Host 不应为了”帮 agent 更聪明”而自动拼接完整 transcript。
- 历史窗口策略由 runner 自己管理,并通过 Host history API 按需拉取历史;new/official runners 不应依赖入口 adapter 下发历史窗口。
### 4.10 RuntimeContext
```python ```python
class AgentRuntimeContext(BaseModel): class AgentRuntimeContext(BaseModel):
host: str = "langbot" host: str = "langbot"
protocol_version: str = "1"
langbot_version: str | None = None langbot_version: str | None = None
trace_id: str trace_id: str
deadline_at: float | None = None deadline_at: float | None = None
@@ -361,9 +328,9 @@ class AgentRuntimeContext(BaseModel):
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
``` ```
`static_refs` 用于 KV cache 友好的静态上下文引用,例如 system policy、tool schema、resource manifest 的 hash/version。 `static_refs` 用于 KV cache 友好的静态上下文引用system policy、tool schema、resource manifest 的 hash/version)。理由见 AGENT_CONTEXT_PROTOCOL §6
### 4.11 State ### 5.10 AgentRunState
```python ```python
class AgentRunState(BaseModel): class AgentRunState(BaseModel):
@@ -375,7 +342,7 @@ class AgentRunState(BaseModel):
State 是可选 host-owned snapshot。Runner 也可以完全自管状态。 State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
## 5. Resources ## 6. Resources
```python ```python
class AgentResources(BaseModel): class AgentResources(BaseModel):
@@ -389,9 +356,9 @@ class AgentResources(BaseModel):
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。 资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
## 6. Result Stream ## 7. Result Stream
### 6.1 AgentRunResult ### 7.1 AgentRunResult
```python ```python
class AgentRunResult(BaseModel): class AgentRunResult(BaseModel):
@@ -402,180 +369,78 @@ class AgentRunResult(BaseModel):
timestamp: int | None = None timestamp: int | None = None
``` ```
### 6.2 稳定 result types ### 7.2 稳定 result types
| type | 说明 | | type | 说明 | 当前消费 |
| --- | --- | | --- | --- | --- |
| `message.delta` | 流式消息片段。 | | `message.delta` | 流式消息片段。 | ✅ |
| `message.completed` | 完整消息。 | | `message.completed` | 完整消息。 | ✅ |
| `tool.call.started` | runner 开始工具调用的可观测事件。 | | `tool.call.started` | 工具调用开始的可观测事件。 | telemetry |
| `tool.call.completed` | runner 完成工具调用的可观测事件。 | | `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry |
| `artifact.created` | runner 生成 artifact。 | | `artifact.created` | runner 生成 artifact。 | ✅ |
| `state.updated` | runner 请求更新 host-owned state。 | | `state.updated` | runner 请求更新 host-owned state。 | ✅ |
| `action.requested` | runner 请求 Host 执行平台动作。 | | `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** |
| `run.completed` | run 正常结束。 | | `run.completed` | run 正常结束。 | ✅ |
| `run.failed` | run 失败。 | | `run.failed` | run 失败。 | ✅ |
Host 必须忽略未知 result type 并记录 warning,除非该 type 明确要求强校验 `action.requested` 是为 EBA 和 platform API 预留的协议表面:当前阶段 Host 收到后只记 telemetry**不执行**,runner 作者不应依赖其副作用。执行模型见 EVENT_BASED_AGENT §6
### 6.3 message.delta ### 7.3 示例
```json ```json
{ { "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } }
"type": "message.delta", { "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } }
"data": { { "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } }
"chunk": { { "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } }
"role": "assistant",
"content": "hel"
}
}
}
``` ```
### 6.4 message.completed Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。
```json ## 8. AgentRunAPIProxy
{
"type": "message.completed",
"data": {
"message": {
"role": "assistant",
"content": "hello"
}
}
}
```
### 6.5 state.updated 所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。
```json
{
"type": "state.updated",
"data": {
"scope": "conversation",
"key": "external.session_id",
"value": "abc"
}
}
```
Host 必须校验 scope、key、value 大小和 JSON 可序列化性。
### 6.6 action.requested
```json
{
"type": "action.requested",
"data": {
"action": "message.edit",
"target": {"message_id": "..."},
"payload": {"text": "..."}
}
}
```
Protocol v1 只定义表达方式。Host 是否执行 action 取决于 platform API 能力、binding policy、审批策略和实现阶段。
## 7. AgentRunAPIProxy
所有 proxy action 必须携带 `run_id`。Host 必须校验:
- active run session 存在。
- caller plugin identity 匹配。
- resource 在本次 `ctx.resources` 中授权。
- scope 不越界。
- payload size / rate limit / deadline 合法。
### 7.1 Model APIs
```python ```python
# Model
await api.models.invoke(model_id, messages, tools=None, extra_args=None) await api.models.invoke(model_id, messages, tools=None, extra_args=None)
await api.models.stream(model_id, messages, tools=None, extra_args=None) await api.models.stream(model_id, messages, tools=None, extra_args=None)
await api.models.rerank(model_id, query, documents, top_k=None) await api.models.rerank(model_id, query, documents, top_k=None)
```
### 7.2 Tool APIs # Tool
```python
await api.tools.get_detail(tool_name) await api.tools.get_detail(tool_name)
await api.tools.call(tool_name, parameters) await api.tools.call(tool_name, parameters)
```
### 7.3 Knowledge APIs # Knowledge
```python
await api.knowledge.retrieve(kb_id, query_text, top_k=5, filters=None) await api.knowledge.retrieve(kb_id, query_text, top_k=5, filters=None)
```
### 7.4 History APIs # History(返回 Transcript projection,不返回原始平台 payload
await api.history.page(conversation_id=None, before_cursor=None, after_cursor=None,
limit=50, direction="backward", include_artifacts=False)
await api.history.search(query, filters=None, top_k=10)
```python # Event(返回稳定 event envelope 或受限 raw ref,不默认返回大 payload
await api.history.page(
conversation_id=None,
before_cursor=None,
after_cursor=None,
limit=50,
direction="backward",
include_artifacts=False,
)
await api.history.search(
query,
filters=None,
top_k=10,
)
```
History API 返回 Transcript projection,不返回原始平台 payload。
### 7.5 Event APIs
```python
await api.events.get(event_id) await api.events.get(event_id)
await api.events.page(before_cursor=None, limit=50) await api.events.page(before_cursor=None, limit=50)
```
Event API 返回稳定 event envelope 或受限 raw ref,不默认返回大 payload。 # Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围)
### 7.6 Artifact APIs
```python
await api.artifacts.metadata(artifact_id) await api.artifacts.metadata(artifact_id)
await api.artifacts.read_range(artifact_id, offset=0, length=65536) await api.artifacts.read_range(artifact_id, offset=0, length=65536)
await api.artifacts.open_stream(artifact_id) await api.artifacts.open_stream(artifact_id)
```
Artifact API 必须支持大小限制、MIME 校验、过期时间和授权范围。 # State / Storage
await api.state.get(scope, key); await api.state.set(scope, key, value); await api.state.delete(scope, key)
await api.storage.get(area, key); await api.storage.set(area, key, value)
await api.storage.delete(area, key); await api.storage.list(area, prefix=None)
### 7.7 State / Storage APIs # Platform(受限能力,默认不开放,需 manifest + binding policy + 用户审批同时允许)
```python
await api.state.get(scope, key)
await api.state.set(scope, key, value)
await api.state.delete(scope, key)
await api.storage.get(area, key)
await api.storage.set(area, key, value)
await api.storage.delete(area, key)
await api.storage.list(area, prefix=None)
```
建议区分:
- `state`: 小型 JSON 状态,适合 conversation / actor / runner / binding。
- `storage`: blob 或较大数据,适合插件私有数据、workspace 数据、checkpoint。
### 7.8 Platform APIs
```python
await api.platform.request_action(action, target, payload) await api.platform.request_action(action, target, payload)
``` ```
平台 API 是受限能力。默认不开放。需要 runner manifest、binding policy、用户审批策略同时允许 `state``storage` 的建议边界:`state` 放小型 JSONconversation / actor / runner / binding),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint
## 8. 错误模型 返回数据结构(如 `HistoryPage`、artifact metadata)见 AGENT_CONTEXT_PROTOCOL §4。
Host API 错误统一返回: ## 9. 错误模型
```python ```python
class AgentAPIError(BaseModel): class AgentAPIError(BaseModel):
@@ -585,8 +450,6 @@ class AgentAPIError(BaseModel):
details: dict[str, Any] = {} details: dict[str, Any] = {}
``` ```
建议 code
| code | 说明 | | code | 说明 |
| --- | --- | | --- | --- |
| `unauthorized` | 未授权访问资源或 scope。 | | `unauthorized` | 未授权访问资源或 scope。 |
@@ -600,96 +463,46 @@ class AgentAPIError(BaseModel):
Runner 失败使用 `run.failed` Runner 失败使用 `run.failed`
```json ```json
{ { "type": "run.failed", "data": { "code": "runner.error", "message": "failed to call external agent", "retryable": false } }
"type": "run.failed",
"data": {
"code": "runner.error",
"message": "failed to call external agent",
"retryable": false
}
}
``` ```
## 9. Timeout 与 Cancellation ## 10. Timeout 与 Cancellation
Host 在 `ctx.runtime.deadline_at` 下发总 deadlineSDK proxy 必须用该 deadline 限制单次 action timeout。 - Host 在 `ctx.runtime.deadline_at` 下发总 deadlineSDK proxy 必须用该 deadline 限制单次 action timeout。
- Host 可以取消 active runRuntime 应尽力中断 runner。
取消语义:
- Host 可以取消 active run。
- Runtime 应尽力中断 runner。
- Runner 支持中断时应返回或触发 `run.failed`code 为 `cancelled` - Runner 支持中断时应返回或触发 `run.failed`code 为 `cancelled`
- Host 必须 unregister active run session。 - Host 必须 unregister active run session。
## 10. Security 与 Guardrail ## 11. Security 与 Guardrail(协议层)
Protocol v1 的安全边界在 Host Protocol v1 的安全边界在 Host
- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。 - Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。
- SDK 本地校验只提升开发体验,不能替代 Host 校验。 - SDK 本地校验只提升开发体验,不能替代 Host 校验。
- 所有 resource id 对 runner 来说都是 opaque。 - 所有 resource id 对 runner 来说都是 opaque。
- 默认只能访问当前 conversation / thread 的 history。 - 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权
- 跨会话、workspace 级 history 或 storage 必须额外授权。
- 大 payload 必须 artifact 化。 - 大 payload 必须 artifact 化。
- Host 必须记录 run_id、runner_id、action、resource、scope、result。 - Host 必须记录 run_id、runner_id、action、resource、scope、result。
对外部 harness runner,边界进一步拆分为: Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。
- Host 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计记录 对外部 harness runnerHost 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计runner plugin 把授权后的 context/resource projection 适配为目标 harness 的形式;harness 的 native permission mode、allowed/disallowed tools 只是额外执行约束,不能替代 Host 授权
- Runner plugin 把授权后的 context/resource projection 适配为目标 harness 的 context 文件、MCP 配置、skill 目录、环境变量或 CLI 参数。
- Claude Code / Codex / Kimi Code 等外部 harness 的 native permission mode、allowed/disallowed tools 和执行隔离策略只是额外执行约束,不能替代 Host 侧授权。
- 外部 session id、working directory、checkpoint 等跨轮次指针应作为小型 JSON state 保存,例如 `external.session_id``external.working_directory`
完整路径隔离、MCP allowlist、secret redaction、配额、workspace 清理和发布级安全测试不属于当前 Protocol v1 smoke 闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 > 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
Host 不负责业务编排: ## 12. Pipeline Adapter 边界
- 不拼接全量历史。 Pipeline 是当前入口 adapter,不是协议中心。Query entry adapter 负责:
- 不替 runner 做业务 prompt assembly。
- 不内置 agent memory 策略。
- 不内置 tool loop 业务流程。
- 不内置上下文压缩策略。
这些能力可以由官方或第三方 AgentRunner 插件实现,并通过公开 Host APIs 消费 LangBot 的状态、历史、存储、artifact、模型、工具和知识库能力 -`Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2
## 11. Pipeline Adapter
Pipeline 是当前入口 adapter,不是协议中心。
**当前分支已实现**
-`PipelineAdapter.query_to_event(query)` — 从 `Query` 构造 `AgentEventEnvelope`
-`PipelineAdapter.pipeline_config_to_binding(query, runner_id)` — 从 Pipeline config 构造临时 AgentBinding
-`run_from_query()` 委托到 `run(event, binding)`
- ✅ Agent/runner config 从当前配置容器透传到 `AgentBinding.runner_config` / `ctx.config`
- ✅ Query-only 字段放入 `adapter` context
Pipeline adapter 负责:
-`Query` 构造 `AgentEventContext`
- 从当前配置容器构造临时 AgentBinding。
- 从当前 Agent/runner config 构造 `ctx.config` - 从当前 Agent/runner config 构造 `ctx.config`
- 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略 - 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`
- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction
package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`
- 将 Query-only 字段放入 `adapter`
Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。 约束:
## 12. 最小 v1 完成标准 - adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。
- preprocessing / hook 后的有效指令不通过 `ctx.adapter.extra` 主动推送;后续应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。
Protocol v1 已在当前分支完成: - 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。
- ✅ SDK 定义 `AgentRunnerManifest``AgentRunContext``AgentRunResult``AgentRunAPIProxy`
- ✅ Runtime 支持 `LIST_AGENT_RUNNERS``RUN_AGENT`
- ✅ Host 支持 `run_id` session authorization
- ✅ Host 能从当前 Pipeline 入口生成 event-first context
-`messages` 降级为 optional bootstrap
- ✅ Host 不定义通用历史窗口字段或策略
- ✅ Proxy 至少覆盖 model、tool、knowledge、state/storage
- ✅ History / event / artifact API 已落地
- ✅ EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
- ✅ 外部 harness runner 最小 smoke 已落地:Claude Code runner 能消费 event-first context、返回消息、写回 `external.session_id` / `external.working_directory`
## 13. 开放问题 ## 13. 开放问题
+14 -15
View File
@@ -2,6 +2,13 @@
本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 预留和官方 runner 迁移混在同一份 README 里。 本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 预留和官方 runner 迁移混在同一份 README 里。
## 文档维护原则(单一事实源)
- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。
- **实现状态唯一记录在 [PROGRESS.md](./PROGRESS.md)。** 规范类文档不维护"当前状态/✅"段落。
- Host 内部模型(`AgentEventEnvelope``AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。
- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。
## 本分支目标 ## 本分支目标
**本分支目标:AgentRunner 外化 / 插件化基础设施** **本分支目标:AgentRunner 外化 / 插件化基础设施**
@@ -11,7 +18,7 @@
- LangBot 与 SDK 的稳定协议合同(Protocol v1 - LangBot 与 SDK 的稳定协议合同(Protocol v1
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型 - Host-side `AgentEventEnvelope` / `AgentBinding` 模型
- `run(event, binding)` event-first 入口 - `run(event, binding)` event-first 入口
- `PipelineAdapter`Pipeline Query → AgentEventEnvelope + AgentBinding - `QueryEntryAdapter`Query → AgentEventEnvelope + AgentBinding
- EventLog / Transcript / ArtifactStore / PersistentStateStore - EventLog / Transcript / ArtifactStore / PersistentStateStore
- History / Event / Artifact / State pull APIs - History / Event / Artifact / State pull APIs
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 - SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
@@ -32,31 +39,23 @@ EventGateway 在本文档中描述为 **future integration point**,由外部 e
**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。** **当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。**
当前主入口仍可由 Pipeline 触发,但内部已转换成 event-first path 主入口仍可由 Pipeline 触发,但内部已转换成 event-first path`run_from_query()``QueryEntryAdapter``Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Pipeline path 因此获得了 event-first host capabilitiesEventLog / Transcript / ArtifactStore / PersistentStateStore 写入,History / Event / Artifact / State pull API 可用)。
1. `run_from_query()` 使用 `PipelineAdapter.query_to_event(query)` 转换为 `AgentEventEnvelope` 详细实现进度、已验收能力和未完成收尾见 [PROGRESS.md](./PROGRESS.md)。
2. `run_from_query()` 使用 `PipelineAdapter.pipeline_config_to_binding(query, runner_id)` 转换为 `AgentBinding`
3. `run_from_query()` 委托到 `run(event, binding, bound_plugins, adapter_context)`
Pipeline path 已获得 event-first host capabilities
- EventLog / Transcript 写入
- ArtifactStore 注册
- PersistentStateStore 状态持久化
- History / Event / Artifact / State pull APIs 可用
## 设计文档 ## 设计文档
| 文档 | 关注点 | | 文档 | 关注点 |
| --- | --- | | --- | --- |
| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:run context、result stream、proxy actions、错误和 adapter 边界。 | | [PROTOCOL_V1.md](./PROTOCOL_V1.md) | **🔒 唯一 schema 事实源**。LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:版本协商、discovery、run context、result stream、proxy actions、错误和 adapter 边界。 |
| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力、SDK 协议、runner 发现、绑定、权、状态、存储、生命周期和调用链。 | | [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 |
| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 | | [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 |
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 预留:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度。**标注为 future design note**。 | | [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 预留:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度。**标注为 future design note**。 |
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面预留:Host 新增 runtime registry、heartbeat、task queue、daemon 执行和 audit;管理插件构建在这些 Host 能力之上。**标注为 future design note**。 | | [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面预留:Host 新增 runtime registry、heartbeat、task queue、daemon 执行和 audit;管理插件构建在这些 Host 能力之上。**标注为 future design note**。 |
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 | | [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
| [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | | [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 | | [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
| [PROGRESS.md](./PROGRESS.md) | 当前实现进度、已验收能力、未完成收尾和非本分支范围。 | | [PROGRESS.md](./PROGRESS.md) | **🔒 唯一状态事实源**。当前实现进度、已验收能力、未完成收尾和非本分支范围。 |
## 工作拆分 ## 工作拆分
@@ -92,7 +91,7 @@ Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API
- event-first envelope (`AgentEventEnvelope`) - event-first envelope (`AgentEventEnvelope`)
- AgentBinding model - AgentBinding model
- `run(event, binding)` 入口 - `run(event, binding)` 入口
- PipelineAdapter(当前 AgentEventEnvelope / AgentBinding 的 Pipeline adapter source - QueryEntryAdapter(当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source
详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
@@ -2,6 +2,8 @@
本文档记录后续 Agent Platform / runtime 管控面的设计方向。它是当前讨论中的 **v2 文档**,但这里的 v2 指 Host capability layer / runtime control plane,不是 `AgentRunner Protocol v2`,也不属于当前 AgentRunner Protocol v1 插件化主线的交付范围。 本文档记录后续 Agent Platform / runtime 管控面的设计方向。它是当前讨论中的 **v2 文档**,但这里的 v2 指 Host capability layer / runtime control plane,不是 `AgentRunner Protocol v2`,也不属于当前 AgentRunner Protocol v1 插件化主线的交付范围。
> **future design note**。协议数据结构见 [PROTOCOL_V1.md](./PROTOCOL_V1.md),实现进度见 [PROGRESS.md](./PROGRESS.md)。本文只讲 v2 管控面方向,不重抄 schema。
## 1. 结论 ## 1. 结论
当前主线应继续收口 AgentRunner v1 当前主线应继续收口 AgentRunner v1
@@ -10,6 +10,8 @@
安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。 安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。
> **硬规则**:能执行代码 / 访问工作目录的外部 harness runnerClaude Code、Codex、Kimi Code 等)在本文 Release Gate Checklist 完成前,**不得在生产环境默认启用**。本地 smoke 通过不等于可生产默认开启。
## 责任边界 ## 责任边界
### LangBot Host 负责 ### LangBot Host 负责
+17 -149
View File
@@ -1,44 +1,25 @@
"""Configuration migration for agent runner IDs.""" """Helpers for the current AgentRunner config shape."""
from __future__ import annotations from __future__ import annotations
import typing import typing
from .id import is_plugin_runner_id
# Mapping from old built-in runner names to official plugin runner IDs
OLD_RUNNER_TO_PLUGIN_RUNNER_ID = {
'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',
'langflow-api': 'plugin:langbot/langflow-agent/default',
'tbox-app-api': 'plugin:langbot/tbox-agent/default',
}
class ConfigMigration: class ConfigMigration:
"""Configuration migration helper for agent runner IDs. """Configuration helper for agent runner IDs.
Responsibilities: Responsibilities:
- Resolve runner ID from new ai.runner.id or old ai.runner.runner - Resolve runner ID from ai.runner.id
- Map old built-in runner names to official plugin runner IDs
- Extract current Agent/runner config from ai.runner_config - Extract current Agent/runner config from ai.runner_config
- Migrate old ai.<runner-name> blocks into ai.runner_config - Keep the current config container shape stable on save
""" """
@staticmethod @staticmethod
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
"""Resolve runner ID from pipeline configuration. """Resolve runner ID from current configuration.
Priority:
1. New format: ai.runner.id (must be plugin:* format)
2. Old format: ai.runner.runner (mapped to plugin:* if built-in)
Args: Args:
pipeline_config: Pipeline configuration dict pipeline_config: Current configuration container
Returns: Returns:
Runner ID string, or None if not configured Runner ID string, or None if not configured
@@ -46,26 +27,9 @@ class ConfigMigration:
ai_config = pipeline_config.get('ai', {}) ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner', {}) runner_config = ai_config.get('runner', {})
# Check new format first
runner_id = runner_config.get('id') runner_id = runner_config.get('id')
if runner_id: if runner_id:
if is_plugin_runner_id(runner_id): return runner_id
return runner_id
# If it's not a plugin ID, try to map it as old runner name
return OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(runner_id, runner_id)
# Check old format
old_runner_name = runner_config.get('runner')
if old_runner_name:
# If already plugin:* format, return directly
if is_plugin_runner_id(old_runner_name):
return old_runner_name
# Map old built-in runner to official plugin ID
mapped_id = OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(old_runner_name)
if mapped_id:
return mapped_id
# Return old name if no mapping exists (will error in registry)
return old_runner_name
return None return None
@@ -74,14 +38,10 @@ class ConfigMigration:
pipeline_config: dict[str, typing.Any], pipeline_config: dict[str, typing.Any],
runner_id: str, runner_id: str,
) -> dict[str, typing.Any]: ) -> dict[str, typing.Any]:
"""Resolve Agent/runner configuration from pipeline configuration. """Resolve Agent/runner configuration from the current container.
Runtime code should only read the migrated format. Legacy
ai.<runner-name> blocks are handled by migration helpers, not by the
hot path.
Args: Args:
pipeline_config: Pipeline configuration dict pipeline_config: Current configuration container
runner_id: Resolved runner ID runner_id: Resolved runner ID
Returns: Returns:
@@ -89,79 +49,18 @@ class ConfigMigration:
""" """
ai_config = pipeline_config.get('ai', {}) ai_config = pipeline_config.get('ai', {})
# Check new format
runner_configs = ai_config.get('runner_config', {}) runner_configs = ai_config.get('runner_config', {})
if runner_id in runner_configs: if runner_id in runner_configs:
return runner_configs[runner_id] return runner_configs[runner_id]
return {} return {}
@staticmethod
def resolve_legacy_runner_config(
pipeline_config: dict[str, typing.Any],
runner_id: str,
) -> dict[str, typing.Any]:
"""Resolve old ai.<runner-name> config for migration only."""
ai_config = pipeline_config.get('ai', {})
# Try to find old runner name from runner_id
old_runner_name = None
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id:
old_runner_name = old_name
break
if old_runner_name:
old_config = ai_config.get(old_runner_name, {})
if old_config:
return ConfigMigration.normalize_runner_config_for_migration(runner_id, old_config)
return {}
@staticmethod
def normalize_runner_config_for_migration(
runner_id: str,
runner_config: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Normalize released legacy runner config before storing Agent/runner config.
Runtime code should not carry aliases. This helper is intentionally used
only by config migration so AgentRunner implementations can consume the
current manifest-defined field names.
"""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
@staticmethod
def get_old_runner_name(runner_id: str) -> str | None:
"""Get old runner name from mapped runner ID.
Args:
runner_id: Plugin runner ID
Returns:
Old runner name if mapped, None otherwise
"""
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id:
return old_name
return None
@staticmethod @staticmethod
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
"""Get conversation expire time from configuration. """Get conversation expire time from configuration.
Args: Args:
pipeline_config: Pipeline configuration dict pipeline_config: Current configuration container
Returns: Returns:
Expire time in seconds (0 means no expiry) Expire time in seconds (0 means no expiry)
@@ -172,54 +71,23 @@ class ConfigMigration:
@staticmethod @staticmethod
def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]: def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""Migrate pipeline config to new format. """Normalize the current config container before saving.
This converts old ai.runner.runner and ai.<runner-name> to
new ai.runner.id and ai.runner_config format.
Args: Args:
pipeline_config: Original pipeline configuration pipeline_config: Original configuration
Returns: Returns:
Migrated pipeline configuration Configuration with explicit ai.runner and ai.runner_config containers
""" """
# Create copy
new_config = dict(pipeline_config) new_config = dict(pipeline_config)
ai_config = new_config.get('ai', {}) if 'ai' not in new_config:
if not ai_config:
return new_config return new_config
runner_config = ai_config.get('runner', {}) ai_config = dict(new_config.get('ai', {}))
runner_configs = ai_config.get('runner_config', {})
# Resolve runner ID runner_config = dict(ai_config.get('runner', {}))
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) runner_configs = dict(ai_config.get('runner_config', {}))
if runner_id:
# Set new format
runner_config['id'] = runner_id
# Remove old runner field if present
if 'runner' in runner_config and is_plugin_runner_id(runner_config['runner']):
# Already migrated plugin:* format, keep as id
pass
elif 'runner' in runner_config:
# Old built-in runner name, remove after migration
old_name = runner_config['runner']
if old_name in OLD_RUNNER_TO_PLUGIN_RUNNER_ID:
del runner_config['runner']
# Migrate runner config
resolved_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
if not resolved_config:
resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id)
if resolved_config:
resolved_config = ConfigMigration.normalize_runner_config_for_migration(runner_id, resolved_config)
runner_configs[runner_id] = resolved_config
# Remove old runner config block
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id and old_name in ai_config:
del ai_config[old_name]
# Update configs
ai_config['runner'] = runner_config ai_config['runner'] = runner_config
ai_config['runner_config'] = runner_configs ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config new_config['ai'] = ai_config
@@ -116,7 +116,6 @@ class AgentRuntimeContext(typing.TypedDict):
langbot_version: str | None langbot_version: str | None
sdk_protocol_version: str sdk_protocol_version: str
query_id: int | None
trace_id: str | None trace_id: str | None
deadline_at: float | None deadline_at: float | None
metadata: dict[str, typing.Any] metadata: dict[str, typing.Any]
@@ -128,8 +127,8 @@ class AgentRunContextPayload(typing.TypedDict):
Protocol v1 structure - matches SDK AgentRunContext. Protocol v1 structure - matches SDK AgentRunContext.
Note: The 'config' field contains the current Agent/runner config Note: The 'config' field contains the current Agent/runner config
from ai.runner_config[runner_id] while Pipeline remains the temporary from ai.runner_config[runner_id] while the current Query entry remains
configuration container. It is not plugin instance config. a temporary configuration container. It is not plugin instance config.
""" """
run_id: str run_id: str
@@ -145,7 +144,6 @@ class AgentRunContextPayload(typing.TypedDict):
state: AgentRunState state: AgentRunState
runtime: AgentRuntimeContext runtime: AgentRuntimeContext
config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id] config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id]
bootstrap: dict[str, typing.Any] | None # Optional bootstrap context
adapter: dict[str, typing.Any] | None # Entry adapter context adapter: dict[str, typing.Any] | None # Entry adapter context
metadata: dict[str, typing.Any] # Additional metadata metadata: dict[str, typing.Any] # Additional metadata
@@ -162,7 +160,7 @@ class AgentRunContextBuilder:
- Build runtime context with host info, trace_id, deadline - Build runtime context with host info, trace_id, deadline
- Set config from current Agent/runner configuration. - Set config from current Agent/runner configuration.
Pipeline Query adaptation belongs to PipelineAdapter, not this builder. Query adaptation belongs to QueryEntryAdapter, not this builder.
""" """
ap: app.Application ap: app.Application
@@ -266,7 +264,6 @@ class AgentRunContextBuilder:
runtime: AgentRuntimeContext = { runtime: AgentRuntimeContext = {
'langbot_version': self.ap.ver_mgr.get_current_version(), 'langbot_version': self.ap.ver_mgr.get_current_version(),
'sdk_protocol_version': descriptor.protocol_version, 'sdk_protocol_version': descriptor.protocol_version,
'query_id': None, # No query_id in event-first mode
'trace_id': run_id, 'trace_id': run_id,
'deadline_at': self._build_deadline_from_binding(binding), 'deadline_at': self._build_deadline_from_binding(binding),
'metadata': { 'metadata': {
@@ -293,7 +290,6 @@ class AgentRunContextBuilder:
# Build adapter context (empty for event-first) # Build adapter context (empty for event-first)
adapter_context = { adapter_context = {
'query_id': None,
'extra': {}, 'extra': {},
} }
@@ -312,7 +308,6 @@ class AgentRunContextBuilder:
'state': state, 'state': state,
'runtime': runtime, 'runtime': runtime,
'config': binding.runner_config, 'config': binding.runner_config,
'bootstrap': None,
'adapter': adapter_context, 'adapter': adapter_context,
'metadata': {}, # Additional metadata 'metadata': {}, # Additional metadata
} }
+13 -17
View File
@@ -20,7 +20,7 @@ from .persistent_state_store import get_persistent_state_store, PersistentStateS
from .session_registry import get_session_registry, AgentRunSessionRegistry from .session_registry import get_session_registry, AgentRunSessionRegistry
from .config_migration import ConfigMigration from .config_migration import ConfigMigration
from .host_models import AgentEventEnvelope, AgentBinding from .host_models import AgentEventEnvelope, AgentBinding
from .pipeline_adapter import PipelineAdapter from .query_entry_adapter import QueryEntryAdapter
from .state_scope import build_state_context from .state_scope import build_state_context
from .errors import ( from .errors import (
RunnerNotFoundError, RunnerNotFoundError,
@@ -37,7 +37,7 @@ class AgentRunOrchestrator:
"""Orchestrator for agent runner execution. """Orchestrator for agent runner execution.
Responsibilities: Responsibilities:
- Resolve runner ID from pipeline config (new or old format) - Resolve runner ID from current Agent/runner config
- Get runner descriptor from registry - Get runner descriptor from registry
- Provision AgentRunContext envelope from Query - Provision AgentRunContext envelope from Query
- Build AgentResources with permission filtering - Build AgentResources with permission filtering
@@ -48,7 +48,7 @@ class AgentRunOrchestrator:
Entry points: Entry points:
- run(event, binding): Main entry for event-first Protocol v1 - run(event, binding): Main entry for event-first Protocol v1
- run_from_query(query): Pipeline adapter wrapper - run_from_query(query): current Query entry adapter wrapper
""" """
ap: app.Application ap: app.Application
@@ -125,28 +125,24 @@ class AgentRunOrchestrator:
resources=resources, resources=resources,
) )
session_query_id = None
# Merge adapter context if provided # Merge adapter context if provided
if adapter_context: if adapter_context:
session_query_id = adapter_context.get('query_id')
# Merge params into adapter.extra # Merge params into adapter.extra
if 'params' in adapter_context: if 'params' in adapter_context:
context['adapter']['extra']['params'] = adapter_context['params'] context['adapter']['extra']['params'] = adapter_context['params']
# Merge prompt into adapter.extra for transitional adapter consumers.
if 'prompt' in adapter_context:
context['adapter']['extra']['prompt'] = adapter_context['prompt']
# Set query_id if provided
if adapter_context.get('query_id'):
context['runtime']['query_id'] = adapter_context['query_id']
# Build state context for State API handlers # Build state context for State API handlers
state_context = build_state_context(event, binding, descriptor) state_context = build_state_context(event, binding, descriptor)
# Register session for proxy action permission validation # Register session for proxy action permission validation
run_id = context['run_id'] run_id = context['run_id']
query_id = context['runtime'].get('query_id') # May be None for pure event-first mode
await self._session_registry.register( await self._session_registry.register(
run_id=run_id, run_id=run_id,
runner_id=descriptor.id, runner_id=descriptor.id,
query_id=query_id, query_id=session_query_id,
plugin_identity=descriptor.get_plugin_id(), plugin_identity=descriptor.get_plugin_id(),
resources=resources, resources=resources,
permissions=descriptor.permissions or {}, permissions=descriptor.permissions or {},
@@ -238,7 +234,7 @@ class AgentRunOrchestrator:
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""Run agent runner from pipeline query. """Run agent runner from pipeline query.
This is the Pipeline adapter wrapper for the Query-based flow. This is the Query entry adapter wrapper for the query-based flow.
It delegates to the event-first run(event, binding) method. It delegates to the event-first run(event, binding) method.
For the new event-first Protocol v1, use run(event, binding) instead. For the new event-first Protocol v1, use run(event, binding) instead.
@@ -260,16 +256,16 @@ class AgentRunOrchestrator:
raise RunnerNotFoundError('no runner configured') raise RunnerNotFoundError('no runner configured')
# Convert Query to event-first envelope # Convert Query to event-first envelope
event = PipelineAdapter.query_to_event(query) event = QueryEntryAdapter.query_to_event(query)
# Convert Pipeline config to binding # Convert current config to binding
binding = PipelineAdapter.pipeline_config_to_binding(query, runner_id) binding = QueryEntryAdapter.config_to_binding(query, runner_id)
# Extract bound plugins for authorization # Extract bound plugins for authorization
bound_plugins = query.variables.get('_pipeline_bound_plugins') bound_plugins = query.variables.get('_pipeline_bound_plugins')
# Build adapter context for Pipeline-specific fields # Build adapter context for Query-specific fields
adapter_context = PipelineAdapter.build_adapter_context(query, binding) adapter_context = QueryEntryAdapter.build_adapter_context(query, binding)
# Delegate to event-first run() # Delegate to event-first run()
async for result in self.run( async for result in self.run(
@@ -1,7 +1,7 @@
"""Pipeline adapter for converting Query to event-first envelope. """Query entry adapter for converting Query to event-first envelope.
This adapter bridges the Query/Pipeline entry point with the event-first This adapter bridges the current Query entry point with the event-first
Protocol v1 architecture. Protocol v1 architecture without exposing Query internals to runners.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,12 +31,12 @@ from .host_models import (
from . import events as runner_events from . import events as runner_events
class PipelineAdapter: class QueryEntryAdapter:
"""Adapter for converting Pipeline Query to event-first envelope. """Adapter for converting Query to event-first envelope.
This adapter is responsible for: This adapter is responsible for:
- Converting Query to AgentEventEnvelope - Converting Query to AgentEventEnvelope
- Converting Pipeline config to temporary AgentBinding - Converting current Agent/runner config to temporary AgentBinding
- Putting Query-only fields into adapter context - Putting Query-only fields into adapter context
""" """
@@ -49,10 +49,10 @@ class PipelineAdapter:
cls, cls,
query: pipeline_query.Query, query: pipeline_query.Query,
) -> AgentEventEnvelope: ) -> AgentEventEnvelope:
"""Convert Pipeline Query to AgentEventEnvelope. """Convert Query to AgentEventEnvelope.
Args: Args:
query: Pipeline query query: Current entry query
Returns: Returns:
AgentEventEnvelope for event-first processing AgentEventEnvelope for event-first processing
@@ -82,7 +82,7 @@ class PipelineAdapter:
event_id=event.event_id or str(query.query_id), event_id=event.event_id or str(query.query_id),
event_type=event.event_type or runner_events.MESSAGE_RECEIVED, event_type=event.event_type or runner_events.MESSAGE_RECEIVED,
event_time=event.event_time, event_time=event.event_time,
source="pipeline_adapter", source="host_adapter",
source_event_type=event.source_event_type, source_event_type=event.source_event_type,
bot_id=query.bot_uuid, bot_id=query.bot_uuid,
workspace_id=None, # Not available in Query workspace_id=None, # Not available in Query
@@ -97,15 +97,15 @@ class PipelineAdapter:
) )
@classmethod @classmethod
def pipeline_config_to_binding( def config_to_binding(
cls, cls,
query: pipeline_query.Query, query: pipeline_query.Query,
runner_id: str, runner_id: str,
) -> AgentBinding: ) -> AgentBinding:
"""Convert Pipeline config to temporary AgentBinding. """Convert current config container to temporary AgentBinding.
Args: Args:
query: Pipeline query query: Current entry query
runner_id: Resolved runner ID runner_id: Resolved runner ID
Returns: Returns:
@@ -121,7 +121,7 @@ class PipelineAdapter:
scope_id=agent_id, scope_id=agent_id,
) )
# Build resource policy from pipeline config # Build resource policy from current config
resource_policy = ResourcePolicy( resource_policy = ResourcePolicy(
allowed_model_uuids=cls._extract_allowed_models(query), allowed_model_uuids=cls._extract_allowed_models(query),
allowed_tool_names=cls._extract_allowed_tools(query), allowed_tool_names=cls._extract_allowed_tools(query),
@@ -159,10 +159,9 @@ class PipelineAdapter:
query: pipeline_query.Query, query: pipeline_query.Query,
binding: AgentBinding, binding: AgentBinding,
) -> dict[str, typing.Any]: ) -> dict[str, typing.Any]:
"""Build Query-derived fields for the Pipeline adapter entry.""" """Build Query-derived fields for the current entry adapter."""
return { return {
'params': cls.build_params(query), 'params': cls.build_params(query),
'prompt': cls.build_prompt(query),
'query_id': getattr(query, 'query_id', None), 'query_id': getattr(query, 'query_id', None),
} }
@@ -187,15 +186,6 @@ class PipelineAdapter:
return params return params
@classmethod
def build_prompt(cls, query: pipeline_query.Query) -> list[dict[str, typing.Any]]:
"""Build effective prompt messages from Pipeline preprocessing output."""
prompt = getattr(query, 'prompt', None)
messages = getattr(prompt, 'messages', None)
if not messages:
return []
return [cls._dump_message(msg) for msg in messages]
@classmethod @classmethod
def is_json_serializable(cls, value: typing.Any) -> bool: def is_json_serializable(cls, value: typing.Any) -> bool:
"""Return whether a value can safely cross the adapter boundary as JSON.""" """Return whether a value can safely cross the adapter boundary as JSON."""
@@ -210,18 +200,6 @@ class PipelineAdapter:
) )
return False return False
@staticmethod
def _dump_message(message: typing.Any) -> dict[str, typing.Any]:
"""Serialize a provider message-like object."""
if hasattr(message, 'model_dump'):
return message.model_dump(mode='json')
if isinstance(message, dict):
return message
return {
'role': getattr(message, 'role', None),
'content': getattr(message, 'content', None),
}
# Private helper methods # Private helper methods
@classmethod @classmethod
@@ -262,7 +240,7 @@ class PipelineAdapter:
event_id=cls._build_scoped_event_id(query, source_event_id, event_time), event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
event_type=runner_events.MESSAGE_RECEIVED, event_type=runner_events.MESSAGE_RECEIVED,
event_time=event_time, event_time=event_time,
source="pipeline_adapter", source="host_adapter",
source_event_type=source_event_type, source_event_type=source_event_type,
data=event_data, data=event_data,
) )
@@ -278,7 +256,7 @@ class PipelineAdapter:
launcher_type = getattr(query, 'launcher_type', None) launcher_type = getattr(query, 'launcher_type', None)
launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None
scope_parts = [ scope_parts = [
'pipeline_adapter', 'host_adapter',
getattr(query, 'pipeline_uuid', None), getattr(query, 'pipeline_uuid', None),
getattr(query, 'bot_uuid', None), getattr(query, 'bot_uuid', None),
launcher_type_value, launcher_type_value,
@@ -289,7 +267,7 @@ class PipelineAdapter:
] ]
scoped = '|'.join('' if part is None else str(part) for part in scope_parts) scoped = '|'.join('' if part is None else str(part) for part in scope_parts)
digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32] digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32]
return f'pipeline:{digest}' return f'host:{digest}'
@classmethod @classmethod
def _build_conversation_context( def _build_conversation_context(
@@ -23,7 +23,7 @@ class AgentRunSession(typing.TypedDict):
Fields: Fields:
run_id: Unique run identifier (UUID from AgentRunContext) run_id: Unique run identifier (UUID from AgentRunContext)
runner_id: Runner descriptor ID (plugin:author/name/runner) runner_id: Runner descriptor ID (plugin:author/name/runner)
query_id: Pipeline query ID query_id: Host entry query ID, only present for query-based adapters
plugin_identity: Plugin identifier (author/name) of the runner plugin_identity: Plugin identifier (author/name) of the runner
conversation_id: Conversation ID for history/event access conversation_id: Conversation ID for history/event access
resources: Authorized resources for this run (from AgentResources) resources: Authorized resources for this run (from AgentResources)
@@ -82,7 +82,7 @@ class AgentRunSessionRegistry:
Args: Args:
run_id: Unique run identifier run_id: Unique run identifier
runner_id: Runner descriptor ID runner_id: Runner descriptor ID
query_id: Pipeline query ID query_id: Host entry query ID, only present for query-based adapters
plugin_identity: Plugin identifier (author/name) plugin_identity: Plugin identifier (author/name)
resources: Authorized resources for this run resources: Authorized resources for this run
conversation_id: Conversation ID for history/event access conversation_id: Conversation ID for history/event access
@@ -247,4 +247,4 @@ def get_session_registry() -> AgentRunSessionRegistry:
with _global_registry_lock: with _global_registry_lock:
if _global_registry is None: if _global_registry is None:
_global_registry = AgentRunSessionRegistry() _global_registry = AgentRunSessionRegistry()
return _global_registry return _global_registry
@@ -1,4 +1,4 @@
"""Migrate pipeline config to new runner format """Normalize AgentRunner config containers
Revision ID: 0004_migrate_runner_config Revision ID: 0004_migrate_runner_config
Revises: 0003_add_rerank_models Revises: 0003_add_rerank_models
@@ -14,101 +14,23 @@ down_revision = '0003_add_rerank_models'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
# Mapping from old built-in runner names to official plugin runner IDs
OLD_RUNNER_TO_PLUGIN_RUNNER_ID = {
'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',
'langflow-api': 'plugin:langbot/langflow-agent/default',
'tbox-app-api': 'plugin:langbot/tbox-agent/default',
}
def is_plugin_runner_id(runner_id: str) -> bool:
"""Check if runner ID is in plugin:* format."""
return runner_id.startswith('plugin:')
def normalize_runner_config_for_migration(runner_id: str, runner_config: dict) -> dict:
"""Normalize released legacy runner fields before storing binding config."""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
def migrate_pipeline_config(config: dict) -> dict: def migrate_pipeline_config(config: dict) -> dict:
"""Migrate pipeline config to new format.""" """Keep current AgentRunner config containers explicit."""
new_config = dict(config) new_config = dict(config)
ai_config = new_config.get('ai', {}) if 'ai' not in new_config:
if not ai_config:
return new_config return new_config
runner_config = ai_config.get('runner', {}) ai_config = dict(new_config.get('ai', {}))
runner_configs = ai_config.get('runner_config', {})
# Check for new format first ai_config['runner'] = dict(ai_config.get('runner', {}))
runner_id = runner_config.get('id') ai_config['runner_config'] = dict(ai_config.get('runner_config', {}))
if runner_id and is_plugin_runner_id(runner_id):
if runner_id in runner_configs:
runner_configs[runner_id] = normalize_runner_config_for_migration(
runner_id,
runner_configs[runner_id],
)
ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config
return new_config
# Check for old format
old_runner_name = runner_config.get('runner')
if old_runner_name:
# Map to new runner ID
if is_plugin_runner_id(old_runner_name):
runner_id = old_runner_name
else:
runner_id = OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(old_runner_name, old_runner_name)
# Set new format
runner_config['id'] = runner_id
# Remove old runner field if it's a mapped built-in runner
if old_runner_name in OLD_RUNNER_TO_PLUGIN_RUNNER_ID:
del runner_config['runner']
# Migrate runner-specific config and remove old config blocks
if old_runner_name in ai_config:
old_runner_config = ai_config[old_runner_name]
if old_runner_config:
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, old_runner_config)
# Remove old config block after migration
del ai_config[old_runner_name]
# Also check if runner_id has config under other old name formats
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id and old_name in ai_config:
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, ai_config[old_name])
# Remove old config block after migration
del ai_config[old_name]
# Update configs
ai_config['runner'] = runner_config
ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config new_config['ai'] = ai_config
return new_config return new_config
def upgrade() -> None: def upgrade() -> None:
"""Migrate existing pipeline configs to new runner format.""" """Normalize existing pipeline config containers."""
conn = op.get_bind() conn = op.get_bind()
inspector = sa.inspect(conn) inspector = sa.inspect(conn)
@@ -11,12 +11,19 @@ from .. import handler
from ... import entities from ... import entities
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
from ....agent.runner.config_migration import ConfigMigration
from ....agent.runner import config_schema
from ....utils import constants, runner as runner_utils from ....utils import constants, runner as runner_utils
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
DEFAULT_PROMPT_CONFIG = [
{'role': 'system', 'content': 'You are a helpful assistant.'},
]
class ChatMessageHandler(handler.MessageHandler): class ChatMessageHandler(handler.MessageHandler):
"""Chat message handler using AgentRunOrchestrator. """Chat message handler using AgentRunOrchestrator.
@@ -140,8 +147,9 @@ class ChatMessageHandler(handler.MessageHandler):
) )
# Update conversation history # Update conversation history
query.session.using_conversation.messages.append(query.user_message) conversation = await self._ensure_conversation_for_history(query)
query.session.using_conversation.messages.extend(query.resp_messages) conversation.messages.append(query.user_message)
conversation.messages.extend(query.resp_messages)
except Exception as e: except Exception as e:
# Import orchestrator errors for specific handling # Import orchestrator errors for specific handling
@@ -234,3 +242,69 @@ class ChatMessageHandler(handler.MessageHandler):
await self.ap.survey.trigger_event('first_bot_response_success') await self.ap.survey.trigger_event('first_bot_response_success')
except Exception as ex: except Exception as ex:
self.ap.logger.warning(f'Failed to send telemetry: {ex}') self.ap.logger.warning(f'Failed to send telemetry: {ex}')
async def _ensure_conversation_for_history(
self,
query: pipeline_query.Query,
) -> provider_session.Conversation:
session = getattr(query, 'session', None)
conversation = getattr(session, 'using_conversation', None)
if conversation is not None:
return conversation
if session is None or getattr(self.ap, 'sess_mgr', None) is None:
raise RuntimeError('Conversation is not available for history update')
prompt_config = await self._build_history_prompt_config(query)
conversation = await self.ap.sess_mgr.get_conversation(
query,
session,
prompt_config,
query.pipeline_uuid,
query.bot_uuid,
)
if conversation is None:
raise RuntimeError('Conversation manager did not return a conversation')
if getattr(session, 'using_conversation', None) is None:
session.using_conversation = conversation
return conversation
async def _build_history_prompt_config(
self,
query: pipeline_query.Query,
) -> list[dict[str, typing.Any]]:
prompt_messages = getattr(getattr(query, 'prompt', None), 'messages', None)
if prompt_messages:
prompt_config = []
for message in prompt_messages:
if hasattr(message, 'model_dump'):
prompt_config.append(message.model_dump(mode='python'))
elif isinstance(message, dict):
prompt_config.append(message)
if prompt_config:
return prompt_config
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG)
async def _get_runner_descriptor(
self,
runner_id: str | None,
bound_plugins: list[str] | None,
) -> typing.Any | None:
if not runner_id:
return None
registry = getattr(self.ap, 'agent_runner_registry', None)
if registry is None:
return None
try:
return await registry.get(runner_id, bound_plugins)
except Exception as e:
self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}')
return None
+29 -22
View File
@@ -239,7 +239,7 @@ async def _get_pipeline_knowledge_base_uuids(ap: app.Application, query: Any) ->
try: try:
descriptor = await registry.get(runner_id, bound_plugins) descriptor = await registry.get(runner_id, bound_plugins)
except Exception as e: except Exception as e:
ap.logger.warning(f'Failed to load AgentRunner descriptor for pipeline knowledge-base scope: {e}') ap.logger.warning(f'Failed to load AgentRunner descriptor for knowledge-base scope: {e}')
return [] return []
return config_schema.extract_knowledge_base_uuids(descriptor, runner_config) return config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
@@ -302,7 +302,7 @@ async def _validate_run_authorization(
def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None: def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None:
"""Return a cached pipeline Query for runtime actions when available.""" """Return a cached Query for query-based runtime actions when available."""
if query_id is None: if query_id is None:
return None return None
@@ -313,7 +313,7 @@ def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None:
def _resolve_action_query(data: dict[str, Any], session: Any | None, ap: app.Application) -> Any | None: def _resolve_action_query(data: dict[str, Any], session: Any | None, ap: app.Application) -> Any | None:
"""Resolve the current Query from an AgentRunner session or action payload.""" """Resolve the current Query from internal run state or query-based action payload."""
query_id = None query_id = None
if session: if session:
query_id = session.get('query_id') query_id = session.get('query_id')
@@ -762,8 +762,6 @@ class RuntimeConnectionHandler(handler.Handler):
parameters = data.get('tool_parameters') or data.get('parameters', {}) parameters = data.get('tool_parameters') or data.get('parameters', {})
run_id = data.get('run_id') # Optional: present for AgentRunner calls run_id = data.get('run_id') # Optional: present for AgentRunner calls
caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation
# session_data = data['session']
# query_id = data['query_id']
session = None session = None
# Permission validation for AgentRunner calls # Permission validation for AgentRunner calls
@@ -1322,7 +1320,7 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse: async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from a knowledge base within the pipeline's scope. """Retrieve documents from a knowledge base within the current run or query scope.
For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases. For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases.
For regular plugin calls: no run_id, validates against pipeline's configured knowledge bases. For regular plugin calls: no run_id, validates against pipeline's configured knowledge bases.
@@ -1331,20 +1329,14 @@ class RuntimeConnectionHandler(handler.Handler):
- AgentRunner: uses session_registry for permission check - AgentRunner: uses session_registry for permission check
- Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check - Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check
""" """
query_id = data['query_id']
kb_id = data['kb_id'] kb_id = data['kb_id']
query_text = data['query_text'] query_text = data['query_text']
top_k = data.get('top_k', 5) top_k = data.get('top_k', 5)
filters = data.get('filters') or {} filters = data.get('filters') or {}
run_id = data.get('run_id') # Optional: present for AgentRunner calls run_id = data.get('run_id') # Optional: present for AgentRunner calls
caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation
session = None
if query_id not in self.ap.query_pool.cached_queries: query = None
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query = self.ap.query_pool.cached_queries[query_id]
# Permission validation for AgentRunner calls # Permission validation for AgentRunner calls
if run_id: if run_id:
@@ -1353,7 +1345,16 @@ class RuntimeConnectionHandler(handler.Handler):
) )
if error: if error:
return error return error
query = _resolve_action_query(data, session, self.ap)
else: else:
query_id = data['query_id']
if query_id not in self.ap.query_pool.cached_queries:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query = self.ap.query_pool.cached_queries[query_id]
# Regular plugin call: validate against the runner binding's # Regular plugin call: validate against the runner binding's
# schema-defined KB selectors or the preprocessed query scope. # schema-defined KB selectors or the preprocessed query scope.
allowed_kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) allowed_kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query)
@@ -1370,16 +1371,22 @@ class RuntimeConnectionHandler(handler.Handler):
) )
try: try:
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}' settings: dict[str, Any] = {
'top_k': top_k,
'filters': filters,
}
if query is not None:
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
settings.update(
{
'session_name': session_name,
'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id),
}
)
entries = await kb.retrieve( entries = await kb.retrieve(
query_text, query_text,
settings={ settings=settings,
'top_k': top_k,
'filters': filters,
'session_name': session_name,
'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id),
},
) )
results = [entry.model_dump(mode='json') for entry in entries] results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results}) return handler.ActionResponse.success(data={'results': results})
@@ -540,7 +540,7 @@ class MCPLoader(loader.ToolLoader):
return function return function
return None return None
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query | None) -> typing.Any:
"""执行工具调用""" """执行工具调用"""
for session in self.sessions.values(): for session in self.sessions.values():
for function in session.get_tools(): for function in session.get_tools():
@@ -45,7 +45,12 @@ class PluginToolLoader(loader.ToolLoader):
return tool return tool
return None return None
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query | None) -> typing.Any:
if query is None:
raise ValueError(
f'Plugin tool {name} requires a query-based host context. '
'Use MCP tools or provide a Host tool implementation that is run-scoped.'
)
try: try:
return await self.ap.plugin_connector.call_tool( return await self.ap.plugin_connector.call_tool(
name, parameters, session=query.session, query_id=query.query_id name, parameters, session=query.session, query_id=query.query_id
+3 -1
View File
@@ -117,7 +117,9 @@ class ToolManager:
return tools return tools
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query | None) -> typing.Any:
"""执行函数调用"""
if await self.native_tool_loader.has_tool(name): if await self.native_tool_loader.has_tool(name):
return await self.native_tool_loader.invoke_tool(name, parameters, query) return await self.native_tool_loader.invoke_tool(name, parameters, query)
if await self.plugin_tool_loader.has_tool(name): if await self.plugin_tool_loader.has_tool(name):
+1 -1
View File
@@ -45,7 +45,7 @@ def make_session(
Args: Args:
run_id: Unique run identifier run_id: Unique run identifier
runner_id: Runner descriptor ID runner_id: Runner descriptor ID
query_id: Pipeline query ID query_id: Host entry query ID
plugin_identity: Plugin identifier (author/name) plugin_identity: Plugin identifier (author/name)
resources: AgentResources dict (uses make_resources() default if None) resources: AgentResources dict (uses make_resources() default if None)
+57 -6
View File
@@ -29,8 +29,9 @@ class MockLauncherType:
class MockConversation: class MockConversation:
uuid = 'conv-uuid' def __init__(self):
messages = [] self.uuid = 'conv-uuid'
self.messages = []
class MockMessage: class MockMessage:
@@ -51,7 +52,9 @@ class MockAdapter:
class MockSession: class MockSession:
launcher_type = MockLauncherType() launcher_type = MockLauncherType()
launcher_id = 'user123' launcher_id = 'user123'
using_conversation = MockConversation()
def __init__(self):
self.using_conversation = MockConversation()
class MockQuery: class MockQuery:
@@ -155,6 +158,10 @@ class MockApplication:
self.model_mgr = MagicMock() self.model_mgr = MagicMock()
self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
# Mock sess_mgr
self.sess_mgr = MagicMock()
self.sess_mgr.get_conversation = AsyncMock()
class TestStreamingBehavior: class TestStreamingBehavior:
"""Tests for streaming mode behavior.""" """Tests for streaming mode behavior."""
@@ -232,7 +239,7 @@ class TestConfigMigrationInChatHandler:
assert runner_id == 'plugin:langbot/local-agent/default' assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_runner_id_from_old_format(self): def test_resolve_runner_id_from_old_format(self):
"""ConfigMigration should handle old runner format.""" """ConfigMigration should not resolve removed runner aliases."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -242,7 +249,7 @@ class TestConfigMigrationInChatHandler:
} }
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default' assert runner_id is None
class TestErrorHandling: class TestErrorHandling:
@@ -399,6 +406,50 @@ class TestChatHandlerAsyncBehavior:
assert query.resp_messages[0].content == 'Response 1' assert query.resp_messages[0].content == 'Response 1'
assert query.resp_messages[1].content == 'Response 2' assert query.resp_messages[1].content == 'Response 2'
@pytest.mark.asyncio
async def test_history_update_recreates_conversation_if_tool_resets_it(self):
"""History update should tolerate CREATE_NEW_CONVERSATION during runner execution."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
response = MockMessageChunk('Tool response')
new_conversation = MockConversation()
class ResetConversationOrchestrator(MockAgentRunOrchestrator):
async def run_from_query(self, query):
query.session.using_conversation = None
yield response
mock_ap = MockApplication(orchestrator=ResetConversationOrchestrator())
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
mock_ap.sess_mgr.get_conversation = AsyncMock(return_value=new_conversation)
query = MockQuery()
query.adapter.is_stream = False
handler = ChatMessageHandler(mock_ap)
mock_event = MagicMock()
mock_event.return_value = MagicMock()
def make_result(*args, **kwargs):
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
mock_events_module.PersonNormalMessageReceived = mock_event
mock_events_module.GroupNormalMessageReceived = mock_event
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
mock_ap.sess_mgr.get_conversation.assert_awaited_once()
assert query.session.using_conversation is new_conversation
assert new_conversation.messages == [query.user_message, response]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_not_found_error(self): async def test_runner_not_found_error(self):
"""Handler should catch RunnerNotFoundError and return INTERRUPT.""" """Handler should catch RunnerNotFoundError and return INTERRUPT."""
@@ -550,4 +601,4 @@ class TestChatHandlerAsyncBehavior:
# Should return CONTINUE with reply message # Should return CONTINUE with reply message
assert len(results) == 1 assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1 assert len(query.resp_messages) == 1
+46 -145
View File
@@ -1,56 +1,14 @@
"""Tests for agent runner config migration.""" """Tests for current AgentRunner config helpers."""
from __future__ import annotations from __future__ import annotations
from langbot.pkg.agent.runner.config_migration import ConfigMigration
from langbot.pkg.agent.runner.config_migration import (
ConfigMigration,
OLD_RUNNER_TO_PLUGIN_RUNNER_ID,
)
class TestOldRunnerMapping:
"""Tests for OLD_RUNNER_TO_PLUGIN_RUNNER_ID mapping."""
def test_local_agent_mapping(self):
"""Local-agent should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent'] == 'plugin:langbot/local-agent/default'
def test_dify_mapping(self):
"""Dify should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['dify-service-api'] == 'plugin:langbot/dify-agent/default'
def test_n8n_mapping(self):
"""n8n should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['n8n-service-api'] == 'plugin:langbot/n8n-agent/default'
def test_coze_mapping(self):
"""Coze should map to official plugin."""
assert OLD_RUNNER_TO_PLUGIN_RUNNER_ID['coze-api'] == 'plugin:langbot/coze-agent/default'
def test_all_runners_mapped(self):
"""All old runners should have mapping."""
expected_runners = [
'local-agent',
'dify-service-api',
'n8n-service-api',
'coze-api',
'dashscope-app-api',
'langflow-api',
'tbox-app-api',
]
for runner in expected_runners:
assert runner in OLD_RUNNER_TO_PLUGIN_RUNNER_ID
mapped = OLD_RUNNER_TO_PLUGIN_RUNNER_ID[runner]
assert mapped.startswith('plugin:langbot/')
assert mapped.endswith('/default')
class TestResolveRunnerId: class TestResolveRunnerId:
"""Tests for ConfigMigration.resolve_runner_id.""" """Tests for ConfigMigration.resolve_runner_id."""
def test_resolve_new_format_runner_id(self): def test_resolve_current_runner_id(self):
"""Resolve runner ID from new format."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -62,8 +20,7 @@ class TestResolveRunnerId:
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default' assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_old_format_runner_name(self): def test_does_not_resolve_old_runner_field(self):
"""Resolve runner ID from old format."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -72,49 +29,18 @@ class TestResolveRunnerId:
}, },
} }
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_old_format_plugin_runner(self):
"""Resolve already migrated plugin:* runner."""
pipeline_config = {
'ai': {
'runner': {
'runner': 'plugin:alice/my-agent/custom',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:alice/my-agent/custom'
def test_resolve_no_runner_config(self):
"""Resolve runner ID when not configured."""
pipeline_config = {}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None assert runner_id is None
def test_resolve_priority_new_over_old(self): def test_resolve_no_runner_config(self):
"""New format takes priority over old format.""" runner_id = ConfigMigration.resolve_runner_id({})
pipeline_config = { assert runner_id is None
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'runner': 'dify-service-api', # This should be ignored
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
class TestResolveRunnerConfig: class TestResolveRunnerConfig:
"""Tests for ConfigMigration.resolve_runner_config.""" """Tests for ConfigMigration.resolve_runner_config."""
def test_resolve_new_format_config(self): def test_resolve_current_config(self):
"""Resolve runner config from new format."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner_config': { 'runner_config': {
@@ -132,13 +58,11 @@ class TestResolveRunnerConfig:
) )
assert config == {'model': 'uuid-123', 'custom_option': 10} assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_resolve_old_format_config(self): def test_does_not_read_old_runner_block(self):
"""Runtime config resolver should not read old format."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'local-agent': { 'local-agent': {
'model': 'uuid-123', 'model': 'uuid-123',
'custom_option': 10,
}, },
}, },
} }
@@ -149,62 +73,18 @@ class TestResolveRunnerConfig:
) )
assert config == {} assert config == {}
def test_resolve_legacy_config_for_migration(self):
"""Migration helper should read old format."""
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
'custom_option': 10,
'knowledge-base': 'kb-123',
},
},
}
config = ConfigMigration.resolve_legacy_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10, 'knowledge-bases': ['kb-123']}
assert 'knowledge-base' not in config
def test_resolve_no_config(self): def test_resolve_no_config(self):
"""Resolve runner config when not found."""
pipeline_config = {}
config = ConfigMigration.resolve_runner_config( config = ConfigMigration.resolve_runner_config(
pipeline_config, {},
'plugin:langbot/local-agent/default', 'plugin:langbot/local-agent/default',
) )
assert config == {} assert config == {}
def test_resolve_priority_new_over_old(self):
"""New format config takes priority."""
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {
'model': 'new-uuid',
},
},
'local-agent': {
'model': 'old-uuid',
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'new-uuid'}
class TestGetExpireTime: class TestGetExpireTime:
"""Tests for ConfigMigration.get_expire_time.""" """Tests for ConfigMigration.get_expire_time."""
def test_get_expire_time_zero(self): def test_get_expire_time_zero(self):
"""Get expire time when zero."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -217,7 +97,6 @@ class TestGetExpireTime:
assert expire_time == 0 assert expire_time == 0
def test_get_expire_time_positive(self): def test_get_expire_time_positive(self):
"""Get expire time when positive."""
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -230,22 +109,44 @@ class TestGetExpireTime:
assert expire_time == 3600 assert expire_time == 3600
def test_get_expire_time_default(self): def test_get_expire_time_default(self):
"""Get expire time when not configured.""" expire_time = ConfigMigration.get_expire_time({})
pipeline_config = {}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 0 assert expire_time == 0
class TestGetOldRunnerName: class TestNormalizePipelineConfig:
"""Tests for ConfigMigration.get_old_runner_name.""" """Tests for ConfigMigration.migrate_pipeline_config."""
def test_get_old_runner_name_mapped(self): def test_normalizes_current_containers(self):
"""Get old runner name for mapped runner ID.""" config = {'ai': {}}
old_name = ConfigMigration.get_old_runner_name('plugin:langbot/local-agent/default')
assert old_name == 'local-agent'
def test_get_old_runner_name_not_mapped(self): migrated = ConfigMigration.migrate_pipeline_config(config)
"""Get old runner name for unmapped runner ID."""
old_name = ConfigMigration.get_old_runner_name('plugin:alice/my-agent/custom') assert migrated == {'ai': {'runner': {}, 'runner_config': {}}}
assert old_name is None
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_does_not_migrate_old_runner_blocks(self):
config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': 'old-model'},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert 'id' not in migrated['ai']['runner']
assert migrated['ai']['local-agent'] == {'model': 'old-model'}
@@ -1,4 +1,4 @@
"""Tests for pipeline config migration to new runner format.""" """Tests for persisted AgentRunner config shape."""
from __future__ import annotations from __future__ import annotations
@@ -10,62 +10,8 @@ from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestMigratePipelineConfig: class TestMigratePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config.""" """Tests for ConfigMigration.migrate_pipeline_config."""
def test_migrate_old_local_agent_config(self): def test_current_format_config_stays_unchanged(self):
"""Old local-agent config should migrate to plugin format.""" config = {
old_config = {
'ai': {
'runner': {
'runner': 'local-agent',
'expire-time': 0,
},
'local-agent': {
'model': {'primary': 'model-uuid', 'fallbacks': []},
'knowledge-base': 'kb-uuid',
'prompt': [{'role': 'system', 'content': 'Hello'}],
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(old_config)
# Should have new format
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert 'runner' not in migrated['ai']['runner'] or migrated['ai']['runner'].get('runner') != 'local-agent'
# Config should be in runner_config
assert 'plugin:langbot/local-agent/default' in migrated['ai']['runner_config']
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['knowledge-bases'] == ['kb-uuid']
assert 'knowledge-base' not in migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
# Expire-time preserved
assert migrated['ai']['runner']['expire-time'] == 0
def test_migrate_old_dify_service_api_config(self):
"""Old dify-service-api config should migrate to dify-agent plugin."""
old_config = {
'ai': {
'runner': {
'runner': 'dify-service-api',
'expire-time': 300,
},
'dify-service-api': {
'base-url': 'https://api.dify.ai/v1',
'api-key': 'test-key',
'app-type': 'chat',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(old_config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/dify-agent/default'
assert 'plugin:langbot/dify-agent/default' in migrated['ai']['runner_config']
assert migrated['ai']['runner_config']['plugin:langbot/dify-agent/default']['api-key'] == 'test-key'
assert migrated['ai']['runner']['expire-time'] == 300
def test_new_format_config_stays_unchanged(self):
"""New format config should not change."""
new_config = {
'ai': { 'ai': {
'runner': { 'runner': {
'id': 'plugin:langbot/local-agent/default', 'id': 'plugin:langbot/local-agent/default',
@@ -80,134 +26,67 @@ class TestMigratePipelineConfig:
}, },
} }
migrated = ConfigMigration.migrate_pipeline_config(new_config) migrated = ConfigMigration.migrate_pipeline_config(config)
# Should remain unchanged
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default' assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10 assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10
def test_new_format_local_agent_config_normalizes_legacy_kb_key(self): def test_old_runner_field_is_not_mapped(self):
"""Migration should normalize legacy KB aliases before runtime."""
config = { config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb-legacy',
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
runner_config = migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
assert runner_config == {'knowledge-bases': ['kb-legacy']}
def test_migrate_all_old_runners(self):
"""All old runner names should be migrated."""
old_runners = [
'local-agent',
'dify-service-api',
'n8n-service-api',
'coze-api',
'dashscope-app-api',
'langflow-api',
'tbox-app-api',
]
expected_ids = [
'plugin:langbot/local-agent/default',
'plugin:langbot/dify-agent/default',
'plugin:langbot/n8n-agent/default',
'plugin:langbot/coze-agent/default',
'plugin:langbot/dashscope-agent/default',
'plugin:langbot/langflow-agent/default',
'plugin:langbot/tbox-agent/default',
]
for old_runner, expected_id in zip(old_runners, expected_ids):
config = {
'ai': {
'runner': {'runner': old_runner, 'expire-time': 0},
old_runner: {'test-key': 'test-value'},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == expected_id
assert expected_id in migrated['ai']['runner_config']
def test_migrate_empty_config(self):
"""Empty config should not break."""
config = {}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {}
def test_migrate_config_without_ai_section(self):
"""Config without ai section should not break."""
config = {'trigger': {}}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert 'trigger' in migrated
def test_expire_time_preserved(self):
"""expire-time should be preserved during migration."""
old_config = {
'ai': { 'ai': {
'runner': { 'runner': {
'runner': 'local-agent', 'runner': 'local-agent',
'expire-time': 3600, 'expire-time': 3600,
}, },
'local-agent': {}, 'local-agent': {
'model': 'old-model',
},
}, },
} }
migrated = ConfigMigration.migrate_pipeline_config(old_config) migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['expire-time'] == 3600
assert migrated['ai']['runner'] == {
'runner': 'local-agent',
'expire-time': 3600,
}
assert migrated['ai']['runner_config'] == {}
assert migrated['ai']['local-agent'] == {'model': 'old-model'}
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: class TestDefaultPipelineConfig:
"""Tests for default-pipeline-config.json format.""" """Tests for default-pipeline-config.json format."""
def test_default_config_is_new_format(self): def test_default_config_is_current_format(self):
"""Default pipeline template should use the new runner config shape."""
from langbot.pkg.utils import paths as path_utils from langbot.pkg.utils import paths as path_utils
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
with open(template_path, 'r', encoding='utf-8') as f: with open(template_path, 'r', encoding='utf-8') as f:
config = json.load(f) config = json.load(f)
# Should have new format
assert 'ai' in config assert 'ai' in config
assert 'runner' in config['ai'] assert 'runner' in config['ai']
assert 'id' in config['ai']['runner'] assert 'id' in config['ai']['runner']
assert config['ai']['runner']['id'] == '' assert config['ai']['runner']['id'] == ''
# Plugin runner selection and config defaults are rendered at creation
# time from installed AgentRunner metadata.
assert 'runner_config' in config['ai'] assert 'runner_config' in config['ai']
assert config['ai']['runner_config'] == {} assert config['ai']['runner_config'] == {}
# Should NOT have old local-agent key
assert 'local-agent' not in config['ai'] assert 'local-agent' not in config['ai']
def test_default_config_does_not_hardcode_plugin_schema(self):
"""Default template should not duplicate plugin-provided config schema."""
from langbot.pkg.utils import paths as path_utils
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') class TestResolveRunnerId:
with open(template_path, 'r', encoding='utf-8') as f: """Tests for current runner id resolution."""
config = json.load(f)
assert config['ai']['runner_config'] == {} def test_resolve_current_id(self):
class TestResolveRunnerIdAliases:
"""Tests for runner id alias resolution."""
def test_resolve_new_format_id(self):
"""resolve_runner_id should work with new format."""
config = { config = {
'ai': { 'ai': {
'runner': {'id': 'plugin:test/my-runner/default'}, 'runner': {'id': 'plugin:test/my-runner/default'},
@@ -216,45 +95,20 @@ class TestResolveRunnerIdAliases:
runner_id = ConfigMigration.resolve_runner_id(config) runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:test/my-runner/default' assert runner_id == 'plugin:test/my-runner/default'
def test_resolve_old_format_runner(self): def test_old_runner_field_is_ignored(self):
"""resolve_runner_id should map old format to plugin ID."""
config = { config = {
'ai': { 'ai': {
'runner': {'runner': 'local-agent'}, 'runner': {'runner': 'local-agent'},
}, },
} }
runner_id = ConfigMigration.resolve_runner_id(config) runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:langbot/local-agent/default' assert runner_id is None
def test_resolve_plugin_format_in_runner_field(self):
"""resolve_runner_id should handle plugin:* in runner field."""
config = {
'ai': {
'runner': {'runner': 'plugin:langbot/local-agent/default'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_new_format_priority(self):
"""New format id should take priority over old runner field."""
config = {
'ai': {
'runner': {
'id': 'plugin:new-runner/default',
'runner': 'local-agent', # Old field, should be ignored
},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:new-runner/default'
class TestResolveRunnerConfig: class TestResolveRunnerConfig:
"""Tests for runtime runner config resolution.""" """Tests for runtime runner config resolution."""
def test_resolve_new_format_config(self): def test_resolve_current_config(self):
"""resolve_runner_config should read from runner_config."""
config = { config = {
'ai': { 'ai': {
'runner_config': { 'runner_config': {
@@ -265,8 +119,7 @@ class TestResolveRunnerConfig:
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['custom-option'] == 20 assert runner_config['custom-option'] == 20
def test_resolve_old_format_config(self): def test_old_runner_block_is_ignored(self):
"""resolve_runner_config should not read old ai.local-agent at runtime."""
config = { config = {
'ai': { 'ai': {
'local-agent': {'custom-option': 20}, 'local-agent': {'custom-option': 20},
@@ -274,26 +127,3 @@ class TestResolveRunnerConfig:
} }
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {} assert runner_config == {}
def test_resolve_legacy_runner_config_for_migration(self):
"""resolve_legacy_runner_config should read old ai.local-agent for migration."""
config = {
'ai': {
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_legacy_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {'custom-option': 20}
def test_resolve_new_format_priority(self):
"""New format runner_config should take priority."""
config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {'custom-option': 25},
},
'local-agent': {'custom-option': 10}, # Old, should be ignored
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['custom-option'] == 25
@@ -1,31 +1,15 @@
"""Tests for Pipeline adapter params and prompt packaging.""" """Tests for Query entry adapter params packaging."""
from __future__ import annotations from __future__ import annotations
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
class FakeMessage:
"""Fake prompt/history message."""
def __init__(self, content='Hello'):
self.content = content
self.role = 'user'
def model_dump(self, mode='json'):
return {'role': self.role, 'content': self.content}
class FakePrompt:
"""Fake prompt container."""
def __init__(self, messages=None):
self.messages = messages or []
class TestBuildParams: class TestBuildParams:
"""Tests for PipelineAdapter.build_params filtering.""" """Tests for QueryEntryAdapter.build_params filtering."""
def test_params_empty_when_no_variables(self): def test_params_empty_when_no_variables(self):
query = type('Query', (), {'variables': None})() query = type('Query', (), {'variables': None})()
assert PipelineAdapter.build_params(query) == {} assert QueryEntryAdapter.build_params(query) == {}
def test_params_filters_underscore_prefix(self): def test_params_filters_underscore_prefix(self):
query = type('Query', (), { query = type('Query', (), {
@@ -37,7 +21,7 @@ class TestBuildParams:
}, },
})() })()
params = PipelineAdapter.build_params(query) params = QueryEntryAdapter.build_params(query)
assert '_internal_var' not in params assert '_internal_var' not in params
assert '_pipeline_bound_plugins' not in params assert '_pipeline_bound_plugins' not in params
assert '_monitoring_bot_name' not in params assert '_monitoring_bot_name' not in params
@@ -61,7 +45,7 @@ class TestBuildParams:
}, },
})() })()
params = PipelineAdapter.build_params(query) params = QueryEntryAdapter.build_params(query)
assert 'api_key' not in params assert 'api_key' not in params
assert 'API_KEY' not in params assert 'API_KEY' not in params
assert 'token' not in params assert 'token' not in params
@@ -89,7 +73,7 @@ class TestBuildParams:
}, },
})() })()
params = PipelineAdapter.build_params(query) params = QueryEntryAdapter.build_params(query)
assert params['launcher_type'] == 'telegram' assert params['launcher_type'] == 'telegram'
assert params['launcher_id'] == 'group_123' assert params['launcher_id'] == 'group_123'
assert params['sender_id'] == 'user_001' assert params['sender_id'] == 'user_001'
@@ -116,7 +100,7 @@ class TestBuildParams:
}, },
})() })()
params = PipelineAdapter.build_params(query) params = QueryEntryAdapter.build_params(query)
assert 'string_value' in params assert 'string_value' in params
assert 'int_value' in params assert 'int_value' in params
assert 'float_value' in params assert 'float_value' in params
@@ -139,41 +123,40 @@ class TestBuildParams:
}, },
})() })()
params = PipelineAdapter.build_params(query) params = QueryEntryAdapter.build_params(query)
assert 'nested_list_with_bad' not in params assert 'nested_list_with_bad' not in params
assert 'nested_dict_with_bad' not in params assert 'nested_dict_with_bad' not in params
assert 'good_nested_list' in params assert 'good_nested_list' in params
assert 'good_nested_dict' in params assert 'good_nested_dict' in params
def test_is_json_serializable_primitives_and_collections(self): def test_is_json_serializable_primitives_and_collections(self):
assert PipelineAdapter.is_json_serializable(None) is True assert QueryEntryAdapter.is_json_serializable(None) is True
assert PipelineAdapter.is_json_serializable('string') is True assert QueryEntryAdapter.is_json_serializable('string') is True
assert PipelineAdapter.is_json_serializable(42) is True assert QueryEntryAdapter.is_json_serializable(42) is True
assert PipelineAdapter.is_json_serializable(['a', 'b']) is True assert QueryEntryAdapter.is_json_serializable(['a', 'b']) is True
assert PipelineAdapter.is_json_serializable({'key': 'value'}) is True assert QueryEntryAdapter.is_json_serializable({'key': 'value'}) is True
assert PipelineAdapter.is_json_serializable((1, 2, 3)) is True assert QueryEntryAdapter.is_json_serializable((1, 2, 3)) is True
def test_is_json_serializable_rejects_sets_and_objects(self): def test_is_json_serializable_rejects_sets_and_objects(self):
class CustomObject: class CustomObject:
pass pass
assert PipelineAdapter.is_json_serializable(CustomObject()) is False assert QueryEntryAdapter.is_json_serializable(CustomObject()) is False
assert PipelineAdapter.is_json_serializable({1, 2, 3}) is False assert QueryEntryAdapter.is_json_serializable({1, 2, 3}) is False
assert PipelineAdapter.is_json_serializable([1, {2, 3}]) is False assert QueryEntryAdapter.is_json_serializable([1, {2, 3}]) is False
assert PipelineAdapter.is_json_serializable({'key': {1, 2}}) is False assert QueryEntryAdapter.is_json_serializable({'key': {1, 2}}) is False
class TestBuildPrompt: class TestBuildAdapterContext:
"""Tests for PipelineAdapter.build_prompt.""" """Tests for QueryEntryAdapter.build_adapter_context."""
def test_prompt_empty_when_missing(self): def test_adapter_context_does_not_push_prompt(self):
query = type('Query', (), {})()
assert PipelineAdapter.build_prompt(query) == []
def test_prompt_serializes_messages(self):
query = type('Query', (), { query = type('Query', (), {
'prompt': FakePrompt([FakeMessage('Effective prompt')]), 'variables': {},
'query_id': 123,
'prompt': object(),
})() })()
prompt = PipelineAdapter.build_prompt(query) context = QueryEntryAdapter.build_adapter_context(query, binding=None)
assert prompt == [{'role': 'user', 'content': 'Effective prompt'}]
assert context == {'params': {}, 'query_id': 123}
@@ -259,7 +259,7 @@ class TestContextValidation:
# Protocol v1 DOES have these # Protocol v1 DOES have these
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1" assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1"
assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1" assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1"
assert 'bootstrap' in context_dict, "bootstrap should exist (can be None)" assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows"
assert 'adapter' in context_dict, "adapter should exist" assert 'adapter' in context_dict, "adapter should exist"
assert 'metadata' in context_dict, "metadata should exist" assert 'metadata' in context_dict, "metadata should exist"
@@ -1,8 +1,8 @@
"""Tests for event-first Protocol v1 entities and Pipeline adapter. """Tests for event-first Protocol v1 entities and Query entry adapter.
Tests cover: Tests cover:
1. Pipeline Query -> AgentEventEnvelope conversion 1. Query -> AgentEventEnvelope conversion
2. Pipeline config -> AgentBinding conversion 2. Current config -> AgentBinding conversion
3. AgentRunContext not inlining full history by default 3. AgentRunContext not inlining full history by default
4. LangBot Host not defining context-window controls 4. LangBot Host not defining context-window controls
5. Event-first run() entry point 5. Event-first run() entry point
@@ -31,32 +31,32 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
) )
# Import LangBot host models # Import LangBot host models
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
class TestPipelineQueryToEventEnvelope: class TestQueryToEventEnvelope:
"""Test Pipeline Query -> AgentEventEnvelope conversion.""" """Test Query -> AgentEventEnvelope conversion."""
def test_query_to_event_basic_fields(self, mock_query): def test_query_to_event_basic_fields(self, mock_query):
"""Test basic field conversion from Query to Event envelope.""" """Test basic field conversion from Query to Event envelope."""
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.event_type == "message.received" assert event.event_type == "message.received"
assert event.source == "pipeline_adapter" assert event.source == "host_adapter"
assert event.bot_id == mock_query.bot_uuid assert event.bot_id == mock_query.bot_uuid
assert event.actor is not None assert event.actor is not None
assert event.actor.actor_type == "user" assert event.actor.actor_type == "user"
def test_query_to_event_input(self, mock_query): def test_query_to_event_input(self, mock_query):
"""Test input conversion from Query.""" """Test input conversion from Query."""
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.input is not None assert event.input is not None
assert event.input.text == "Hello world" assert event.input.text == "Hello world"
def test_query_to_event_conversation(self, mock_query): def test_query_to_event_conversation(self, mock_query):
"""Test conversation context extraction.""" """Test conversation context extraction."""
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-uuid-123" assert event.conversation_id == "conv-uuid-123"
@@ -65,7 +65,7 @@ class TestPipelineQueryToEventEnvelope:
mock_query.session.using_conversation.uuid = None mock_query.session.using_conversation.uuid = None
mock_query.variables["conversation_id"] = "conv-from-vars" mock_query.variables["conversation_id"] = "conv-from-vars"
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-from-vars" assert event.conversation_id == "conv-from-vars"
@@ -73,13 +73,13 @@ class TestPipelineQueryToEventEnvelope:
"""Debug Chat and legacy pipeline runs may not have a conversation UUID.""" """Debug Chat and legacy pipeline runs may not have a conversation UUID."""
mock_query.session.using_conversation.uuid = None mock_query.session.using_conversation.uuid = None
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "person_launcher-123" assert event.conversation_id == "person_launcher-123"
def test_query_to_event_delivery_context(self, mock_query): def test_query_to_event_delivery_context(self, mock_query):
"""Test delivery context extraction.""" """Test delivery context extraction."""
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery is not None assert event.delivery is not None
assert event.delivery.surface == "platform" assert event.delivery.surface == "platform"
@@ -98,7 +98,7 @@ class TestPipelineQueryToEventEnvelope:
}) })
mock_query.message_event = source_event mock_query.message_event = source_event
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.source_event_type == "platform.message.created" assert event.source_event_type == "platform.message.created"
assert event.event_time == 1700000000 assert event.event_time == 1700000000
@@ -111,28 +111,28 @@ class TestPipelineQueryToEventEnvelope:
"""Test delivery context building when Query has no message_chain.""" """Test delivery context building when Query has no message_chain."""
delattr(mock_query, "message_chain") delattr(mock_query, "message_chain")
event = PipelineAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery.reply_target == {"message_id": None} assert event.delivery.reply_target == {"message_id": None}
def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query): def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query):
"""Pipeline-local message IDs must not become global audit IDs.""" """Pipeline-local message IDs must not become global audit IDs."""
first = PipelineAdapter.query_to_event(mock_query) first = QueryEntryAdapter.query_to_event(mock_query)
mock_query.launcher_id = "launcher-456" mock_query.launcher_id = "launcher-456"
second = PipelineAdapter.query_to_event(mock_query) second = QueryEntryAdapter.query_to_event(mock_query)
assert first.event_id.startswith("pipeline:") assert first.event_id.startswith("host:")
assert first.event_id != "789" assert first.event_id != "789"
assert second.event_id != first.event_id assert second.event_id != first.event_id
class TestPipelineConfigToBinding: class TestQueryConfigToBinding:
"""Test Pipeline config -> AgentBinding conversion.""" """Test current config -> AgentBinding conversion."""
def test_config_to_binding_runner_id(self, mock_query): def test_config_to_binding_runner_id(self, mock_query):
"""Test binding runner_id extraction.""" """Test binding runner_id extraction."""
binding = PipelineAdapter.pipeline_config_to_binding( binding = QueryEntryAdapter.config_to_binding(
mock_query, "plugin:author/plugin/runner" mock_query, "plugin:author/plugin/runner"
) )
@@ -140,7 +140,7 @@ class TestPipelineConfigToBinding:
def test_config_to_binding_scope(self, mock_query): def test_config_to_binding_scope(self, mock_query):
"""Test binding scope extraction.""" """Test binding scope extraction."""
binding = PipelineAdapter.pipeline_config_to_binding( binding = QueryEntryAdapter.config_to_binding(
mock_query, "plugin:test/plugin/runner" mock_query, "plugin:test/plugin/runner"
) )
@@ -177,8 +177,8 @@ class TestAgentRunContextProtocolV1:
assert ctx.event is not None assert ctx.event is not None
assert ctx.event.event_type == "message.received" assert ctx.event.event_type == "message.received"
def test_sdk_context_messages_default_empty(self): def test_sdk_context_has_no_history_message_fields(self):
"""Test that messages default to empty (not full history).""" """AgentRunContext should not expose inline history message fields."""
trigger = AgentTrigger(type="message.received") trigger = AgentTrigger(type="message.received")
event = AgentEventContext( event = AgentEventContext(
event_id="evt_1", event_id="evt_1",
@@ -200,34 +200,9 @@ class TestAgentRunContextProtocolV1:
runtime=AgentRuntimeContext(), runtime=AgentRuntimeContext(),
) )
# messages is now in bootstrap, not top-level assert "messages" not in AgentRunContext.model_fields
assert ctx.bootstrap is None or ctx.bootstrap.messages == [] assert "bootstrap" not in AgentRunContext.model_fields
assert not hasattr(ctx, "bootstrap")
def test_sdk_context_bootstrap_optional(self):
"""Test that bootstrap is optional."""
trigger = AgentTrigger(type="message.received")
event = AgentEventContext(
event_id="evt_1",
event_type="message.received",
source="platform",
)
input = AgentInput(text="Hello")
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
ctx = AgentRunContext(
run_id="run_1",
trigger=trigger,
event=event,
input=input,
delivery=DeliveryContext(surface="platform"),
resources=AgentResources(),
runtime=AgentRuntimeContext(),
)
# bootstrap is optional
assert ctx.bootstrap is None or isinstance(ctx.bootstrap.messages, list)
class TestHostManagedHistoryNotInProtocol: class TestHostManagedHistoryNotInProtocol:
@@ -306,7 +281,7 @@ class TestSDKResultProtocolV1:
# Fixtures # Fixtures
@pytest.fixture @pytest.fixture
def mock_query(): def mock_query():
"""Create a mock Pipeline Query for testing.""" """Create a mock query for testing."""
query = Mock() query = Mock()
query.query_id = 123 query.query_id = 123
query.bot_uuid = "bot-uuid-123" query.bot_uuid = "bot-uuid-123"
+4 -29
View File
@@ -576,11 +576,10 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
assert 'kb_custom' in allowed_kbs assert 'kb_custom' in allowed_kbs
def test_retrieve_kb_fix_old_format(self): def test_retrieve_kb_ignores_old_runner_format(self):
"""Fix should work for old format pipeline config.""" """Old runner format is not resolved by current AgentRunner helpers."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration from langbot.pkg.agent.runner.config_migration import ConfigMigration
# Old format: ai.runner.runner = 'local-agent'
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'runner': { 'runner': {
@@ -590,31 +589,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
} }
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
# Should resolve to plugin:langbot/local-agent/default assert runner_id is None
assert 'local-agent' in runner_id
def test_retrieve_kb_legacy_single_key_is_migration_only(self):
"""Old singular knowledge-base config is normalized before runtime."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb_single', # Old singular field
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(pipeline_config)
runner_id = ConfigMigration.resolve_runner_id(migrated)
runner_config = ConfigMigration.resolve_runner_config(migrated, runner_id)
assert runner_config == {'knowledge-bases': ['kb_single']}
class TestHandlerActionAuthorization: class TestHandlerActionAuthorization:
@@ -850,7 +825,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
"""CALL_TOOL: SDK includes 'run_id' field.""" """CALL_TOOL: SDK includes 'run_id' field."""
# SDK agent_run_api.py line 144: "run_id": self.run_id # SDK agent_run_api.py line 144: "run_id": self.run_id
# Host handler.py line 458: run_id = data.get('run_id') # Host handler.py line 458: run_id = data.get('run_id')
sdk_fields = ['run_id', 'tool_name', 'parameters', 'session', 'query_id'] sdk_fields = ['run_id', 'tool_name', 'parameters']
host_expected_fields = ['tool_name', 'parameters', 'run_id'] host_expected_fields = ['tool_name', 'parameters', 'run_id']
for field in host_expected_fields: for field in host_expected_fields:
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -239,7 +239,7 @@ def test_context_builder_includes_consumable_base64_attachments():
[platform_message.Image(base64="data:image/jpeg;base64,aGVsbG8=")] [platform_message.Image(base64="data:image/jpeg;base64,aGVsbG8=")]
) )
input_data = PipelineAdapter._build_input(query) input_data = QueryEntryAdapter._build_input(query)
assert input_data.contents[0].text == "see attached" assert input_data.contents[0].text == "see attached"
assert input_data.contents[1].image_base64 == "data:image/png;base64,aGVsbG8=" assert input_data.contents[1].image_base64 == "data:image/png;base64,aGVsbG8="
@@ -362,8 +362,8 @@ async def test_orchestrator_does_not_package_query_messages_into_context(clean_a
assert len(messages) == 1 assert len(messages) == 1
context = plugin_connector.contexts[0] context = plugin_connector.contexts[0]
assert context["config"]["custom-option"] == 2 assert context["config"]["custom-option"] == 2
assert context["bootstrap"] is None assert "bootstrap" not in context
assert set(context["adapter"]) == {"query_id", "extra"} assert set(context["adapter"]) == {"extra"}
assert "context_packaging" not in context["runtime"]["metadata"] assert "context_packaging" not in context["runtime"]["metadata"]
assert [message.content for message in query.messages] == [ assert [message.content for message in query.messages] == [
"message 1", "message 1",
@@ -473,12 +473,12 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
assert await get_session_registry().list_active_runs() == [] assert await get_session_registry().list_active_runs() == []
class TestPipelineCompatibilityQueryIdInSession: class TestQueryEntrySessionQueryId:
"""Tests for query_id entering session registry.""" """Tests for internal query_id entering session registry."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_id_registered_in_session_for_pipeline_flow(self, clean_agent_state): async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
"""query_id from Pipeline flow is registered in session.""" """query_id from Query entry flow is registered internally in session."""
db_engine = clean_agent_state db_engine = clean_agent_state
descriptor = make_descriptor() descriptor = make_descriptor()
plugin_connector = FakePluginConnector( plugin_connector = FakePluginConnector(
@@ -557,12 +557,12 @@ class TestPipelineCompatibilityQueryIdInSession:
assert session_during_run["query_id"] is None assert session_during_run["query_id"] is None
class TestPipelineAdapterPromptAndParams: class TestQueryEntryAdapterParams:
"""Tests for prompt and params handling in Pipeline adapter.""" """Tests for params handling in Query entry adapter."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_in_adapter_extra(self, clean_agent_state): async def test_prompt_not_pushed_into_adapter_extra(self, clean_agent_state):
"""Pipeline prompt is placed in adapter.extra.prompt.""" """Pipeline prompt is not pushed into adapter.extra."""
from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt
db_engine = clean_agent_state db_engine = clean_agent_state
@@ -590,12 +590,8 @@ class TestPipelineAdapterPromptAndParams:
_messages = [message async for message in orchestrator.run_from_query(query)] _messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0] context = plugin_connector.contexts[0]
# Prompt should be in adapter.extra
assert "prompt" in context["adapter"]["extra"]
assert len(context["adapter"]["extra"]["prompt"]) == 1
assert context["adapter"]["extra"]["prompt"][0]["role"] == "system"
# Top-level should NOT have prompt
assert "prompt" not in context assert "prompt" not in context
assert "prompt" not in context["adapter"]["extra"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_params_filtering_keeps_public_param(self, clean_agent_state): async def test_params_filtering_keeps_public_param(self, clean_agent_state):
@@ -721,8 +717,8 @@ class TestPipelineAdapterPromptAndParams:
assert "a_lambda" not in params assert "a_lambda" not in params
class TestPipelineAdapterHostCapabilities: class TestQueryEntryAdapterHostCapabilities:
"""Tests for event-first host capabilities via Pipeline adapter path.""" """Tests for event-first host capabilities via Query entry adapter path."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_state_updated_writes_to_persistent_store(self, clean_agent_state): async def test_state_updated_writes_to_persistent_store(self, clean_agent_state):
@@ -760,9 +756,9 @@ class TestPipelineAdapterHostCapabilities:
persistent_store = get_persistent_state_store(db_engine) persistent_store = get_persistent_state_store(db_engine)
# Build snapshot to check if state was written # Build snapshot to check if state was written
# Note: We need to rebuild the event and binding to query the store # Note: We need to rebuild the event and binding to query the store
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
event = PipelineAdapter.query_to_event(query) event = QueryEntryAdapter.query_to_event(query)
binding = PipelineAdapter.pipeline_config_to_binding(query, RUNNER_ID) binding = QueryEntryAdapter.config_to_binding(query, RUNNER_ID)
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot["conversation"]["external.test_key"] == "test_value" assert snapshot["conversation"]["external.test_key"] == "test_value"
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest import pytest
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
@@ -62,7 +62,7 @@ def make_query(
async def build_resources(app, query, descriptor): async def build_resources(app, query, descriptor):
binding = PipelineAdapter.pipeline_config_to_binding(query, descriptor.id) binding = QueryEntryAdapter.config_to_binding(query, descriptor.id)
return await AgentResourceBuilder(app).build_resources_from_binding( return await AgentResourceBuilder(app).build_resources_from_binding(
event=Mock(), event=Mock(),
binding=binding, binding=binding,