From d0169e28880a2ec1a2bd515fba3e72dc29059cc9 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 3 Jun 2026 17:33:47 +0800 Subject: [PATCH] refactor(agent-runner): simplify event-first entry path --- .../AGENT_CONTEXT_PROTOCOL.md | 285 ++------- .../EVENT_BASED_AGENT.md | 212 +------ .../HOST_SDK_INFRASTRUCTURE.md | 336 ++--------- .../IMPLEMENTATION_PLAN.md | 552 ------------------ .../OFFICIAL_RUNNER_PLUGINS.md | 314 ++-------- .../PHASE1_QA_ACCEPTANCE_MATRIX.md | 4 +- docs/agent-runner-pluginization/PROGRESS.md | 9 +- .../agent-runner-pluginization/PROTOCOL_V1.md | 421 ++++--------- docs/agent-runner-pluginization/README.md | 29 +- .../RUNTIME_CONTROL_PLANE_V2.md | 2 + .../SECURITY_HARDENING.md | 2 + .../pkg/agent/runner/config_migration.py | 166 +----- .../pkg/agent/runner/context_builder.py | 11 +- src/langbot/pkg/agent/runner/orchestrator.py | 30 +- ...line_adapter.py => query_entry_adapter.py} | 56 +- .../pkg/agent/runner/session_registry.py | 6 +- .../versions/0004_migrate_runner_config.py | 92 +-- .../pkg/pipeline/process/handlers/chat.py | 78 ++- src/langbot/pkg/plugin/handler.py | 51 +- src/langbot/pkg/provider/tools/loaders/mcp.py | 2 +- .../pkg/provider/tools/loaders/plugin.py | 7 +- src/langbot/pkg/provider/tools/toolmgr.py | 4 +- tests/unit_tests/agent/conftest.py | 2 +- tests/unit_tests/agent/test_chat_handler.py | 63 +- .../unit_tests/agent/test_config_migration.py | 191 ++---- .../agent/test_config_migration_full.py | 238 ++------ .../test_context_builder_params_state.py | 73 +-- .../agent/test_context_validation.py | 2 +- .../agent/test_event_first_protocol.py | 81 +-- tests/unit_tests/agent/test_handler_auth.py | 33 +- .../agent/test_orchestrator_integration.py | 40 +- .../unit_tests/agent/test_resource_builder.py | 4 +- 32 files changed, 743 insertions(+), 2653 deletions(-) delete mode 100644 docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md rename src/langbot/pkg/agent/runner/{pipeline_adapter.py => query_entry_adapter.py} (92%) diff --git a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md index 9be0e463..99968e46 100644 --- a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md +++ b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md @@ -1,21 +1,8 @@ # Agent-owned Context 协议设计 -本文档描述插件化 AgentRunner 场景下的上下文边界。结论先行:LangBot 不应成为最终 agentic context manager;LangBot 应提供 context substrate,AgentRunner 或其背后的 agent runtime 自己决定如何管理历史、压缩、召回和 KV cache。 +本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。 -## 当前状态 - -**当前分支已落地**: - -- ✅ `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 +> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。实现进度见 [PROGRESS.md](./PROGRESS.md)。 ## 1. 设计原则 @@ -24,25 +11,16 @@ 不同 runner 背后的 runtime 差异很大: - 官方 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。 -因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供: - -- 当前事件的完整结构化信息。 -- 稳定身份和会话引用。 -- 可授权读取的 history / event / artifact / state API。 -- 可投影给外部 harness 的 scoped context、MCP、skill 和 resource refs。 -- payload hard cap 和权限 guardrail。 +因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / artifact / state API、可投影给外部 harness 的 scoped context / MCP / skill / resource refs、payload hard cap 和权限 guardrail。 ### 1.2 Host 不定义通用历史窗口 -历史窗口策略不应继续作为 AgentRunner 协议或 Pipeline adapter 的核心概念。 -Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。 +历史窗口策略不是 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。 -当前 official local-agent 方向是通过 Host history API 拉取 transcript,并由 runner 自己管理模型上下文。它不依赖 Pipeline adapter 下发历史窗口。 - -新协议不应该问“LangBot 每轮裁几轮历史给 agent”,而应该问: +正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是: - 这类 runner 是否自管 context? - 事件到来时 host 应 inline 哪些最小信息? @@ -57,108 +35,44 @@ Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自 - `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 - `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 到来时传什么 -默认 `AgentRunContext` 应尽量小且稳定: - -```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] -``` - -默认规则: +默认 `AgentRunContext`(PROTOCOL_V1 §5.2)应尽量小且稳定。默认规则: - Host MUST NOT inline full history by default. - Host SHOULD inline only current event / input and context handles. - Runner owns working-context assembly. -- Runner MAY use Host history / event / artifact / state / storage APIs when authorized. -- Official runners MUST consume Host infrastructure through the same public APIs as third-party runners. +- Runner MAY use Host history / event / artifact / state / storage API when authorized. +- Official runners MUST consume Host infrastructure through the same public API as third-party runners. ### 2.1 必须 inline 的内容 -每次 run 必须 inline: - -- 当前 event 的稳定类型、id、时间、source。 -- 当前输入文本和结构化内容。 -- 附件 / 文件 / 图片的 metadata 和 artifact ref。 -- actor、subject、conversation、thread、bot、workspace。 -- delivery 能力,例如是否支持 streaming、reply target、平台限制。 -- 已授权资源列表。 -- context cursors 和可用 API 能力。 -- Agent/runner config。 - -这些是 agent 决定下一步需要的最低信息。 +当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata 和 artifact ref;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。 ### 2.2 默认不 inline 的内容 -默认不要 inline: +完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。 -- 完整历史消息。 -- 大文件全文。 -- 大工具结果。 -- 全量知识库内容。 -- 平台原始 payload 大对象。 -- 每轮重新生成的大段 summary。 +### 2.3 不提供 Host Inline History Window -这些会破坏跨进程序列化成本、泄露范围、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 配置决定窗口大小。 - -如果 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 权限。 +`ContextAccess`(PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent:当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限。 ## 4. Agent 如何获取更多上下文 -所有 API 都必须走 `AgentRunAPIProxy`,并由 host 用 `run_id` 校验。 +所有 API 都走 `AgentRunAPIProxy`(PROTOCOL_V1 §8),由 host 用 `run_id` 校验。 -### 4.1 History API +### 4.1 History ```python -await api.history.page( - conversation_id=ctx.context.conversation_id, - before_cursor=ctx.context.latest_cursor, - limit=50, - direction="backward", - include_artifacts=False, -) +await api.history.page(conversation_id=ctx.context.conversation_id, + before_cursor=ctx.context.latest_cursor, + limit=50, direction="backward", include_artifacts=False) ``` 返回: @@ -171,164 +85,65 @@ class HistoryPage(BaseModel): has_more: bool ``` -约束: +约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 manifest permission + binding policy;返回 artifact ref,不默认返回大文件内容。 -- `limit` 有 host hard cap。 -- 默认只能读当前 conversation / thread。 -- 跨会话读取必须有 manifest permission + binding policy。 -- 返回 artifact ref,不默认返回大文件内容。 - -### 4.2 Search API +### 4.2 Search ```python -await api.history.search( - query="用户之前提到的数据库连接信息", - filters={ - "conversation_id": ctx.context.conversation_id, - "event_types": ["message.received"], - }, - top_k=10, -) +await api.history.search(query="用户之前提到的数据库连接信息", + filters={"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 -await api.events.get(event_id) -await api.events.page(before_cursor=..., limit=...) -``` +- Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。 +- Artifact API(`artifacts.metadata` / `read_range` / `open_stream`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/stream 读取,工具大结果也应 artifact 化。 +- 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 result:message/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref(`artifact_id`、`mime_type`、`size`、`digest`、`summary`、`expires_at`、`permissions`)。工具之间传递大结果时传 artifact ref,不传完整 blob。Host 校验 artifact 是否属于当前 run / scope,默认不允许插件直接读任意本地路径;临时文件应有 TTL 和清理机制。 -```python -await api.artifacts.metadata(artifact_id) -await api.artifacts.read_range(artifact_id, offset=0, length=65536) -await api.artifacts.open_stream(artifact_id) -``` +### 4.5 External harness context 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 形态: +Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态: - `agent-context.json`:结构化 JSON,包含 `run_id`、`event`、`actor`、`subject`、`input`、`delivery`、`resources`、`context`、`state`、`runtime`。 -- `LANGBOT_CONTEXT.md`:人类可读摘要,用于 code-agent harness 快速理解当前 IM 事件。 -- `resources`:只包含本次 run 授权后的模型、工具、知识库、artifact、state/storage 句柄,不暴露 Host 内部私有对象。 -- `skills`:Host 或 binding 把已授权 skill 投影为目标 harness 可读目录,例如 Claude Code 的 `.claude/skills//SKILL.md`。 -- `MCP config`:Host 或 binding 提供 scoped MCP 配置,runner adapter 转成目标 harness 的配置文件或 CLI 参数。 -- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存,例如 `external.session_id`、`external.working_directory`。 +- `LANGBOT_CONTEXT.md`:人类可读摘要。 +- `resources`:只包含本次 run 授权后的句柄,不暴露 Host 内部私有对象。 +- `skills`:已授权 skill 投影为目标 harness 可读目录(如 Claude Code 的 `.claude/skills//SKILL.md`)。 +- `MCP config`:scoped MCP 配置,runner adapter 转成目标 harness 的配置文件或 CLI 参数。 +- `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 的基本链路。 - -这类 projection 是“把 LangBot 事实源和授权资源交给 harness”,不是“由 LangBot 决定最终模型上下文”。外部 harness 可以继续使用自己的 transcript、工具权限和压缩策略。 +当前 Claude Code runner 使用 schema `langbot.agent_runner.external_harness_context.v1`(现状见 OFFICIAL_RUNNER_PLUGINS §8)。这类 projection 是"把 LangBot 事实源和授权资源交给 harness",不是"由 LangBot 决定最终模型上下文"。 ## 5. Runner manifest 中的上下文声明 -建议增加: - -```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/tail,runner 仍可按需拉更多。 -- `owns_compaction`: runner 负责压缩,host 不做语义摘要。 -- `wants_static_context_refs`: host 用 ref/hash 描述静态内容,减少重复 payload。 +`AgentRunnerContextPolicy`(PROTOCOL_V1 §4.5)声明 runner 的上下文能力:`supports_history_pull` / `supports_history_search` / `supports_artifact_pull` / `owns_compaction` / `wants_static_context_refs`。它表示 Host 只给当前事件和 context handles;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。 ## 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`。 -- 静态内容使用 `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。 - 历史 append-only:不要每轮改写同一段 history 文本。 -- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint,不要每轮微调。 +- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。 - 大文件和工具结果 artifact 化。 -- Tool/context API schema 稳定,数据通过 API 拉取,而不是塞入 prompt。 +- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。 - 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。 +- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。 ## 7. Host guardrail -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 不负责“最佳上下文策略”,但负责“不越权、不爆内存、不不可审计”。 +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 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。 ## 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 的业务流程: - -- 不内置 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 smoke:context JSON / Markdown、skill、MCP config、`external.session_id` / `external.working_directory` - -这样 LangBot 既能服务依附 host 基础设施的官方 runner,也能服务自带 memory/session/cache 的外部 agent runtime。 +官方 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)。 diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md index 99c7013d..d86683b9 100644 --- a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -1,34 +1,22 @@ # 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 由其他分支实现。 -> 本分支只预留 event-first 入口和 envelope/binding models。 -> 2026-05-29 的 local-agent / Claude Code runner smoke 只验证本分支的 `run(event, binding)` 调度边界,不表示 EBA 分支已经完成联调。 +> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。 -本文档描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。 - -本阶段不实现完整 EventBus / EventRouter / Platform API。本阶段要做的是把协议边界设计对,避免当前消息入口继续绑死 Pipeline 和用户文本消息。 +本文描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本阶段不实现完整 EventBus / EventRouter / Platform API,目标是把协议边界设计对,避免当前消息入口继续绑死 Pipeline 和用户文本消息。 ## 1. 设计目标 - 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。 -- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 AgentBinding。 +- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`。 - AgentRunner 通过同一套 orchestrator 被调用。 - 非消息事件不伪造成用户文本消息。 - 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。 ## 2. 事件不是消息 -`message.received` 只是事件的一种。协议不应假设: - -- 一定有用户文本。 -- 一定有 conversation history。 -- 一定要返回一条聊天消息。 -- actor 一定等于 sender。 -- subject 一定等于当前消息。 - -例如: +`message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。 | event_type | actor | subject | input | | --- | --- | --- | --- | @@ -39,73 +27,27 @@ | `schedule.triggered` | 系统 | 定时任务 | 任务 payload | | `api.invoked` | API caller | API request | request payload | -## 3. Event Envelope +## 3. 稳定事件名 -建议事件 envelope: +先保留的稳定事件名(作为插件协议的一部分保持稳定): -```python -class AgentEventEnvelope(BaseModel): - event_id: str - event_type: str - 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] = {} -``` +- `message.received` +- `message.recalled` +- `group.member_joined` +- `friend.request_received` -顶层字段使用 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 平台。 -- `webui`: Debug Chat、控制台操作。 -- `http_api`: 外部系统调用 LangBot。 -- `scheduler`: 定时任务。 -- `system`: runtime、plugin、maintenance 事件。 +Binding scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。旧 Pipeline 可迁移为 `message.received` 的 binding source,但不是唯一 binding source。 -同一个 event source 可以产生多个 event type。EventRouter 不应该写死平台 adapter 的类名。 +Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui`、`http_api`、`scheduler`、`system`。EventRouter 不应写死平台 adapter 的类名。 -## 5. Event Binding - -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 调用链 - -目标调用链: +## 5. EventRouter 调用链 ```text Platform Adapter / WebUI / API @@ -119,119 +61,29 @@ Platform Adapter / WebUI / API -> DeliveryController render / platform action ``` -约束: +约束:必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。 -- `run_from_event()` 必须复用现有 orchestrator 能力。 -- 不能为 EBA 单独实现另一套 plugin runner 调用协议。 -- 不能让非消息事件绕过 resource authorization。 -- Delivery 和 platform action 要走统一权限模型。 -- 外部 harness runner 也应通过同一套 envelope/binding/context/result 协议接入;EBA 不应为 Claude Code / Codex / Kimi Code 单独发明队列协议。 +## 6. 平台动作执行 -## 7. Delivery Context - -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。 - -示例: +EBA 后 `action.requested`(PROTOCOL_V1 §7.2,当前仅 telemetry 不执行)将用于请求 host 执行平台动作: ```json -{ - "type": "action.requested", - "data": { - "action": "friend.request.accept", - "target": {"platform": "wechat", "request_id": "..."}, - "reason": "policy matched" - } -} +{ "type": "action.requested", + "data": { "action": "friend.request.accept", + "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。 -- binding 是否授权该 action。 -- actor / bot / workspace 是否允许。 -- 是否需要人工审批。 +Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 -本阶段如收到 `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 message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。 -EBA 事件进入 AgentRunner 时仍使用 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的原则: +## 8. 未来 EBA 完整落地需要 -- inline 当前事件。 -- 大 payload 用 raw/artifact ref。 -- 不默认 inline 完整 history。 -- agent 按需通过 API 拉 history/event/artifact/state。 -- Host 保留 EventLog 和权限 guardrail。 +EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。 -非消息事件可以被投影进 Transcript,但不能强制伪装为 user message。AgentRunner 可以根据 event type 自己决定是否把它纳入模型上下文。 - -## 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。 +落地顺序:① 把当前 Pipeline 消息入口适配成 `message.received` event(已完成)→ ② 增加 `AgentBinding` 抽象,先由 current config 生成(已完成)→ ③ context builder 改为从 event + binding 构造(已完成)→ ④ 引入 EventLog / Transcript(已完成)→ ⑤ 增加非消息事件的协议测试,不接真实平台 → ⑥ 接入真实 EventRouter 和 platform action。 diff --git a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md index b58571ef..bd90c914 100644 --- a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -1,6 +1,10 @@ # 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. 目标 @@ -10,15 +14,7 @@ LangBot 要转为 agent host,而不是内置 runner 容器: - 根据事件、bot、workspace、scope 解析应该调用的 agent binding。 - 发现、校验和调用插件提供的 AgentRunner。 - 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。 -- 接收 AgentRunner 返回的事件流,并投递到 IM、WebUI 或其他 output surface。 - -SDK 要提供稳定协议: - -- `AgentRunner` 组件定义。 -- runner manifest / capabilities / permissions / config schema。 -- `AgentRunContext` 输入 envelope。 -- `AgentRunResult` 输出事件流。 -- `AgentRunAPIProxy` 运行期受限 API。 +- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。 ## 2. 非目标 @@ -26,13 +22,11 @@ SDK 要提供稳定协议: - 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。 - 不要求官方 local-agent 的旧行为反向塑造 host 协议。 - 不在 host 中实现通用 agentic prompt assembler。 -- 不强制 runner 使用 LangBot state / storage;LangBot 只提供可选、受控的寄宿能力。 -- **不实现 EventGateway**:EventGateway 是 future integration point,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 +- 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。 +- 不实现 EventGateway:它是 future integration point,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 ## 3. 分层架构 -目标结构: - ```text IM / WebUI / API / EventRouter (future) | @@ -59,29 +53,15 @@ AgentRunResult stream Delivery / Renderer / Platform API ``` -**当前状态**: -- `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 执行的核心语义。 +当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway 由外部 event branch 实现。 ## 4. LangBot 侧能力 ### 4.1 Event Gateway(Future Integration Point) -> **注意**:EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。 +> EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。 -Event Gateway 将负责把入口统一成 host event: - -- IM 平台消息。 -- WebUI debug chat 消息。 -- API 触发。 -- 后续非消息事件,例如入群、撤回、好友申请。 - -输出应是稳定 envelope,而不是 Pipeline Query 私有结构: +Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型): ```python class AgentEventEnvelope(BaseModel): @@ -95,53 +75,39 @@ class AgentEventEnvelope(BaseModel): thread_id: str | None actor: ActorRef | None subject: SubjectRef | None - input: AgentInput - delivery: DeliveryContext + input: AgentInput # 见 PROTOCOL_V1 §5.6 + delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7 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 class AgentBinding(BaseModel): binding_id: str + enabled: bool scope: BindingScope event_types: list[str] + filters: list[EventFilter] = [] # EBA 阶段使用,见 EVENT_BASED_AGENT runner_id: str runner_config: dict[str, Any] resource_policy: ResourcePolicy state_policy: StatePolicy delivery_policy: DeliveryPolicy - enabled: bool ``` -**当前 adapter source**:`PipelineAdapter.pipeline_config_to_binding(query, runner_id)` 从 Pipeline config 生成临时 `AgentBinding`。 - -Pipeline 当前可以被迁移为一种 binding source: - -- Pipeline AI runner config -> `AgentBinding` -- Pipeline extension preference -> `resource_policy` -- Pipeline output settings -> `delivery_policy` - -但新设计不应再把这些字段命名为 Pipeline 专属概念。 +**当前 adapter source**:`QueryEntryAdapter.config_to_binding(query, runner_id)` 从 current config 生成临时 `AgentBinding`。Pipeline 当前作为一种 binding source(AI runner config → binding、extension preference → resource_policy、output settings → delivery_policy),但新设计不再把这些字段命名为 Pipeline 专属概念。 ### 4.3 AgentRunnerRegistry -Registry 负责收集 runner descriptor: - -- 插件 runtime 提供的 `AgentRunner`。 -- 可能存在的 host adapter runner。 -- 开发期本地插件 runner。 - -Descriptor 必须包含: +Registry 收集 runner descriptor(来自插件 runtime、可能的 host adapter runner、开发期本地插件): ```python class AgentRunnerDescriptor(BaseModel): @@ -149,13 +115,16 @@ class AgentRunnerDescriptor(BaseModel): source: Literal["plugin", "host_adapter"] label: I18nObject description: I18nObject | None = None - capabilities: AgentRunnerCapabilities - permissions: AgentRunnerPermissions + protocol_version: str = "1" + capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3 + permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4 config_schema: list[DynamicFormItemSchema] 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 @@ -173,255 +142,70 @@ run(event, binding) -> unregister run session ``` -它负责: +它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。 -- `run_id` 生成和生命周期。 -- timeout / deadline / cancellation。 -- 插件异常隔离。 -- result schema 校验和大小限制。 -- state.updated 处理。 -- delivery backpressure 和 telemetry。 +`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;插件是无状态执行单元,跨请求持久化状态必须走授权 storage / 外部服务,不能隐式存在 per-pipeline 插件对象里。 -`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。 -- binding/resource policy 允许的资源范围。 -- 当前 event / actor / bot / workspace 的实际权限。 +运行期每个 proxy action 必须再次通过 `run_id` 校验。SDK 侧本地校验只用于开发体验,host 侧校验才是安全边界。 -资源类型包括: +资源裁剪应通用,不写死 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 -- tools -- knowledge bases -- files / artifacts -- storage -- platform capabilities -- history / transcript access +执行/文件/skill/MCP 等能力的接入方向:先由 Host 封装成普通 tool,再通过 `ctx.resources.tools` 进入 runner;runner 不应识别或硬编码执行环境 provider。 -运行期 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 -- actor state -- 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 的消息投影。 +- `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。 +- `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 - `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。 -AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。 +三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。 ### 4.8 Prompt / Instruction Package(占位) -旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata, -这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个 -run-scoped instruction package,runner 通过 Host API 拉取: +当前 Query 入口不把 preprocessing 后的有效 prompt 放进 adapter metadata。目标形态是 Host 保存或生成一个 run-scoped instruction package,runner 通过 Host API 拉取: -- Host 负责记录静态绑定 prompt、host hook / user plugin 产生的 instruction - fragment、来源和审计信息。 -- `ctx.context.available_apis.prompt_get` 只表示拉取能力是否可用。 -- Runner 拉取 instruction package 后,仍由 runner 自己决定如何与 history、RAG、 - tool 结果、memory 和当前输入组装最终模型 prompt。 -- Host 不实现通用 agentic prompt assembler,也不把 Pipeline adapter prompt 作为 - 长期业务输入契约。 +- Host 记录静态绑定 prompt、host hook / user plugin 产生的 instruction fragment、来源和审计信息。 +- `ctx.context.available_apis` 增加 `prompt_get` 能力位表示拉取是否可用。 +- Runner 拉取后仍由自己决定如何与 history、RAG、tool 结果、memory 和当前输入组装最终 prompt。 +- Host 不实现通用 agentic prompt assembler,也不把 Query entry adapter prompt 作为长期业务输入契约。 ### 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 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume。 -- Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计。 -- 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)。 +投影的具体形态(context 文件、skill 目录、MCP config、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;Claude Code / Codex 当前实现见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。 ## 5. SDK 侧协议 -### 5.1 AgentRunner 组件 +SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。 ```python class AgentRunner(BaseComponent): __kind__ = "AgentRunner" @classmethod - def get_capabilities(cls) -> AgentRunnerCapabilities: - ... + def get_capabilities(cls) -> AgentRunnerCapabilities: ... # PROTOCOL_V1 §4.3 @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 - -建议能力声明: - -```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 MVP:context/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 集成 -- 平台动作执行器 +- Manifest / capabilities / permissions / context policy:PROTOCOL_V1 §4。 +- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。 +- `AgentRunResult`:PROTOCOL_V1 §7。 +- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。 diff --git a/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md b/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md deleted file mode 100644 index d80ca750..00000000 --- a/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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` -- 选择 runner:Pipeline 配置和未来事件绑定配置 -- 构造上下文:`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:可检索知识库列表 -- storage:plugin 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 4:local-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 预留,不执行平台动作。 diff --git a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md index f48b3e00..9569c6ba 100644 --- a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md +++ b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -1,63 +1,27 @@ # 官方 AgentRunner 插件迁移计划 -本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。 -它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 -[AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot -宿主协议的设计前提。 +本文档描述内置 `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)。 -官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构, -而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot 的 -host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 -context/runtime 的 runner,不能被官方插件的实现细节绑死。 +官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。 -当前实现已经进入过渡阶段: +## 1. 仓库组织 -- LangBot 主聊天路径通过 `AgentRunOrchestrator` 调用插件化 `AgentRunner`。 -- 旧 `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 的边界。 +官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。 -## 1. 为什么新仓库 - -官方 runner 插件会和 LangBot 主仓库、SDK 仓库以不同节奏迭代: - -- LangBot 主仓库只维护宿主协议和调度。 -- SDK 仓库维护 AgentRunner 组件和 runtime 协议。 -- 官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。 - -不要把官方 runner 插件重新绑死在 LangBot 主仓库内。允许开发期使用本地路径插件,但运行边界必须保持为: - -- LangBot 提供通用宿主能力:当前事件、context handles、资源授权、状态/存储、历史、artifact、模型/工具/知识库调用代理、结果归一。 -- 插件消费这些公开能力,实现具体 runner 行为。 -- LangBot 默认不把全量历史消息 inline 给 runner;runner 按需通过授权 API 拉取历史和 artifact。 -- 旧内置 runner 只作为行为对齐的基准,不作为长期运行路径。 - -## 2. 仓库结构 - -当前推荐策略是“官方插件可独立发布,必要时共享 SDK helper”。开发期可以采用本地多目录布局: +当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局: ```text langbot-app/ - langbot-local-agent/ + langbot-local-agent/ # plugin:langbot/local-agent/default manifest.yaml - components/agent_runner/default.yaml - components/agent_runner/default.py - pkg/ - tests/ - langbot-agent-runner/ - claude-code-agent/ - codex-agent/ - n8n-agent/ - ... + components/agent_runner/default.{yaml,py} + langbot-agent-runner/ # 外部服务 runner 仓库 + claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ... ``` -后续可以把多个官方 runner 聚合进 monorepo,也可以继续独立发布。这个选择不影响协议设计;协议边界由 SDK 和 LangBot 宿主保证。 +后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 在官方插件迁移完成前保留作为行为对齐基准,不作为长期运行路径。 -如果多个 runner 出现重复逻辑,优先沉淀到 SDK 或一个明确的共享 helper 包,不要把宿主私有结构泄漏给插件。 - -## 3. 插件命名和 runner id - -固定映射: +## 2. 插件命名和 runner id | 旧 runner | 官方插件 | runner id | | --- | --- | --- | @@ -71,259 +35,109 @@ langbot-app/ | `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-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` -2. `claude-code-agent` -3. `codex-agent` -4. `dify-agent` +## 4. 每个官方插件的组件要求 -原因: - -- `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. 每个官方插件的组件要求 - -每个插件至少包含: +每个插件至少包含一个 `AgentRunner` 组件,manifest 示例: ```yaml apiVersion: langbot/v1 kind: AgentRunner metadata: name: default - label: - en_US: Dify Agent - zh_Hans: Dify Agent + label: { en_US: Dify Agent, zh_Hans: Dify Agent } description: en_US: Run a Dify application as a LangBot AgentRunner. zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。 spec: + protocol_version: "1" config: [] - capabilities: + capabilities: # 字段语义见 PROTOCOL_V1 §4.3 streaming: true - tool_calling: false - knowledge_retrieval: false - multimodal_input: false event_context: true - platform_api: false - interrupt: false stateful_session: true - permissions: - models: [] - tools: [] - knowledge_bases: [] + permissions: # 字段语义见 PROTOCOL_V1 §4.4 storage: ["plugin"] - files: [] - platform_api: [] + context: # 字段语义见 PROTOCOL_V1 §4.5 + supports_history_pull: true + owns_compaction: true execution: - python: - path: ./main.py - attr: DefaultAgentRunner + python: { path: ./main.py, attr: DefaultAgentRunner } ``` -## 6. local-agent 插件方向 +## 5. local-agent 插件方向 -`local-agent` 是官方插件中的重要消费者,但不是宿主协议的设计中心。它可以选择复用 -旧实现,也可以完全重写。它需要证明:一个主要依附 LangBot host 能力的 agent runner -可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。 +`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。 -LangBot core 不应为了 local-agent 保留业务编排逻辑。local-agent 的 prompt 组装、history -拉取、summary/checkpoint、tool loop、RAG 编排、fallback、多模态处理都应在插件内完成。 +迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。 -迁移或重写时需要覆盖旧内置 runner 的用户可见能力: +责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束: -- model primary/fallback 选择 -- prompt -- knowledge-bases -- rerank-model -- rerank-top-k -- function calling -- streaming -- multimodal input -- conversation history -- monitoring metadata +- 从 `ctx.config` 读取静态绑定 `prompt`,**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。 +- 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。 +- `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。 +- 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。 +- manifest 声明自管上下文能力(`context.supports_history_pull/search`、`owns_compaction` 等)。 -与 LangBot 主仓库的责任边界: +### 5.1 Native Execution / Skills 后续接入 -- LangBot 构造当前事件、结构化输入、资源授权、context handles、state/storage 能力和 delivery 能力 -- LangBot 不默认 inline 全量历史,不替插件组装最终模型上下文 -- 插件负责选择模型、拼请求、调用 LLM、处理 tool call loop、输出 result stream -- 插件不能绕过 `ctx.resources` 调用未授权模型、工具或知识库 +本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host 封装成 scoped tools,再通过 `ctx.resources.tools` 暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。 -为了保持旧内置 runner 的用户可见行为,`local-agent` 插件应消费宿主处理后的有效输入和 -受限 API,而不是读取宿主内部私有结构: +## 6. 外部 runner 插件要求 -- `ctx.event` / `ctx.input`:当前结构化输入,必须保留图片、文件等多模态内容。 -- `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` 校验资源权限。 +外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state,不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`。 -`local-agent` 不应消费 Pipeline adapter 生成的历史窗口,也不应读取 -`ctx.adapter.extra.prompt`。它应从绑定配置读取静态 `prompt`,并通过 Host -history API 拉取 transcript。Pipeline adapter 不保留 Host-side window 兼容逻辑。 +### 6.1 Code-agent harness runner -建议 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 -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 -``` +### 6.2 SDK-owned LangBot MCP bridge -这表示:LangBot 只给当前事件和 context handles;local-agent 自己决定是否拉取历史、是否搜索、 -何时摘要、如何构造最终 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: +外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,因此不能像 `local-agent` 一样直接调用 `AgentRunAPIProxy`。当前轻量方案是由 SDK 提供一层 per-run MCP bridge: - `AgentRunner.create_external_mcp_bridge(ctx)` 是 runner 父类入口。 - Bridge 由 `AgentRunAPIProxy` 和 `AgentRunContext` 构造,生命周期只覆盖当前 run。 -- Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是扫描或导出全部 SDK action。 -- MCP tool schema 由注解和 Pydantic args model 生成;runner 插件不各自手写 LangBot tool schema。 -- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge。 -- run 结束后 bridge 关闭;这不是 LangBot 主程序全局 MCP server。 +- Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是导出全部 SDK action;MCP tool schema 由注解和 Pydantic args model 生成。 +- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge;run 结束后 bridge 关闭。 -第一批工具保持很小:当前事件快照、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` -- 执行方式:本地 Claude Code CLI print mode,默认命令为 `claude -p` -- 默认输出:`message.completed` + `run.completed` -- 默认权限:`permission-mode=plan`、`max-turns=1`、`disallowedTools=AskUserQuestion` -- 默认状态:如果 Claude Code 返回 `session_id`,runner 通过 `state.updated` 写回 `external.session_id` -- 工作目录:优先使用 Agent/runner config 的 `working-directory`,其次使用 Host state 中的 `external.working_directory` +- Runner ID:`plugin:langbot/claude-code-agent/default`,执行方式:本地 Claude Code CLI print mode(默认 `claude -p`)。 +- 默认输出 `message.completed` + `run.completed`;默认权限 `permission-mode=plan`、`max-turns=1`、`disallowedTools=AskUserQuestion`。 +- 投影:写入 `agent-context.json`(schema `langbot.agent_runner.external_harness_context.v1`)和 `LANGBOT_CONTEXT.md`;可把 `skills-json` 投影到 `.claude/skills//SKILL.md`;可把 `mcp-config-json` 写成每次 run 的 MCP config 经 `--mcp-config` / `--strict-mcp-config` 传入;可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge。 +- 状态:Claude Code 返回 `session_id` 时通过 `state.updated` 写回 `external.session_id`;工作目录优先用 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` -- 写入 `LANGBOT_CONTEXT.md`,作为人类可读摘要 -- 将 prompt prefix 指向 context 文件路径 -- 可把 Agent/runner config 提供的 `skills-json` 写入 Claude Code 原生 `.claude/skills//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` 能力 +### 7.3 当前限制 -这些投影目前由 runner adapter 完成;长期更理想的形态是 LangBot Host 负责生成 scoped resource projection,runner 只负责适配 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` -- 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 环境变量 +## 9. 验收标准 -下一轮测试入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。 - -### 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]`。 +- 每个旧 runner 都有对应官方 AgentRunner 插件,旧配置能无损复制到新 `runner_config[id]`。 - LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 - 官方插件测试覆盖非流式、流式、错误、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。 -- 对外行为与旧内置 local-agent runner 保持一致;代码结构不需要相同。 +- 对外行为与旧内置 local-agent runner 一致;代码结构不需要相同。 diff --git a/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md b/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md index bc8db4bb..44997235 100644 --- a/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md +++ b/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md @@ -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 分支。 - `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。 - 外部 harness runner(Claude Code / Codex)能消费 event-first context,并把 session / working directory 等指针写回 host-owned state。 @@ -136,7 +136,7 @@ bin/lbs case list | ID | 场景 | 操作 | 通过条件 | | --- | --- | --- | --- | | 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-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 | | LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 | diff --git a/docs/agent-runner-pluginization/PROGRESS.md b/docs/agent-runner-pluginization/PROGRESS.md index cf57441e..7640e3cb 100644 --- a/docs/agent-runner-pluginization/PROGRESS.md +++ b/docs/agent-runner-pluginization/PROGRESS.md @@ -2,6 +2,8 @@ 本文档跟踪 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。 @@ -47,7 +49,7 @@ | `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context | | `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.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 | | `ChatMessageHandler` 集成 | ✅ | 使用 orchestrator 替代 wrapper | | `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 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 | `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 指南和下一轮测试入口 - [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划 - [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛 -- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) — 具体实施细节 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index 9dfc786f..c2cb004c 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -1,38 +1,27 @@ # LangBot AgentRunner Protocol v1 -本文档定义 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间的协议合同。它优先描述”稳定接口应是什么”,不描述具体落地任务。 +本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。 -## 当前状态 - -**Protocol v1 已在当前分支落地**: - -- ✅ 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 共享同一协议路径 +- 本文件描述"稳定接口应是什么",是 normative spec,不混入实现进度。实现状态见 [PROGRESS.md](./PROGRESS.md)。 +- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。 +- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 ## 1. 协议目标 -Protocol v1 要解决四件事: +Protocol v1 只解决四件事: - LangBot 如何发现插件提供的 AgentRunner。 - LangBot 如何把一次事件调用封装成 `AgentRunContext`。 - AgentRunner 如何以事件流形式返回运行结果。 - AgentRunner 如何通过受限 API 访问 LangBot host 能力。 -Protocol v1 不定义: +Protocol v1 **不定义**: -- LangBot 内部如何持久化 AgentBinding。 -- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory。 -- 官方 local-agent 的具体实现。 +- LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK)。 +- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md))。 +- 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md))。 - Pipeline 的长期配置模型。 -- 发布级安全 hardening 的完整实现;当前只定义 Host 侧资源、权限、状态和审计边界,release gate 见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 发布级安全 hardening 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。 ## 2. 参与方 @@ -42,26 +31,32 @@ Protocol v1 不定义: | Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 | | AgentRunner | 插件提供的 agent 执行组件。 | | 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 的持久化形态。 -外部 harness runner(Claude Code、Codex、Kimi Code 等)仍然是 `AgentRunner`。Protocol v1 只要求它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact APIs 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 +外部 harness runner(Claude 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 class ListAgentRunnersResponse(BaseModel): runners: list[AgentRunnerManifest] ``` -### 3.2 AgentRunnerManifest +### 4.2 AgentRunnerManifest ```python class AgentRunnerManifest(BaseModel): @@ -69,6 +64,7 @@ class AgentRunnerManifest(BaseModel): name: str label: I18nObject description: I18nObject | None = None + protocol_version: str = "1" capabilities: AgentRunnerCapabilities permissions: AgentRunnerPermissions context: AgentRunnerContextPolicy @@ -76,14 +72,12 @@ class AgentRunnerManifest(BaseModel): metadata: dict[str, Any] = {} ``` -字段要求: - -- `id` 必须稳定,推荐 `plugin:author/name/runner`。 +- `id` 必须稳定,格式 `plugin:author/name/runner`。 - `name` 是插件内 runner 名称,例如 `default`。 - `config_schema` 只描述绑定配置表单,不代表插件实例状态。 -- `metadata` 只能放展示、诊断、非稳定扩展信息。 +- `metadata` 只放展示、诊断、非稳定扩展信息。 -### 3.3 Capabilities +### 4.3 Capabilities ```python class AgentRunnerCapabilities(BaseModel): @@ -101,8 +95,8 @@ class AgentRunnerCapabilities(BaseModel): 语义: - `streaming`: runner 可以返回 `message.delta`。 -- `tool_calling`: runner 可能调用 Host tool APIs。 -- `knowledge_retrieval`: runner 可能调用 Host knowledge APIs。 +- `tool_calling`: runner 可能调用 Host tool API。 +- `knowledge_retrieval`: runner 可能调用 Host knowledge API。 - `multimodal_input`: runner 可以处理非纯文本 input / artifact。 - `event_context`: runner 理解 event-first 输入。 - `platform_api`: runner 可能请求平台动作。 @@ -110,7 +104,9 @@ class AgentRunnerCapabilities(BaseModel): - `stateful_session`: runner 可能维护跨 run 会话状态。 - `self_managed_context`: runner 自己管理 working context,Host 不应默认 inline 历史。 -### 3.4 Permissions +> Capabilities 字段全部是 `bool`。runner 是否寄宿 host-owned state **不在 capabilities 表达**,而通过 `permissions.storage` 声明(见 §4.4),避免出现非 bool 取值。 + +### 4.4 Permissions ```python class AgentRunnerPermissions(BaseModel): @@ -125,16 +121,12 @@ class AgentRunnerPermissions(BaseModel): 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 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_search: bool = False supports_artifact_pull: bool = True @@ -147,12 +139,14 @@ Host 不使用该声明给 runner inline 历史窗口。默认原则: - Host 不得默认 inline 全量历史。 - Host 只 inline 当前 event / input 和 context handles。 - Runner 拥有 working context assembly。 -- Runner 可在授权后通过 Host history / event / artifact / state APIs 拉取更多上下文。 +- Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。 - 历史窗口策略不属于 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: @@ -163,11 +157,11 @@ class AgentRunRequest(BaseModel): 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 class AgentRunContext(BaseModel): @@ -184,7 +178,6 @@ class AgentRunContext(BaseModel): state: AgentRunState runtime: AgentRuntimeContext config: dict[str, Any] = {} - bootstrap: BootstrapContext | None = None adapter: AdapterContext | None = None metadata: dict[str, Any] = {} ``` @@ -193,29 +186,26 @@ class AgentRunContext(BaseModel): - `event` 是必选字段,Protocol v1 是 event-first。 - `input` 表示当前事件的主输入,不等于历史消息。 -- `bootstrap` 是可选字段;LangBot Host 默认不填历史窗口。 +- `bootstrap` / `messages` **不是协议字段**;Host 不内联历史窗口。 - `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。 - `config` 是 Agent/runner config,不是插件实例状态。 -### 4.3 AgentTrigger +### 5.3 AgentTrigger ```python class AgentTrigger(BaseModel): type: str - source: Literal["platform", "webui", "api", "scheduler", "system", "pipeline_adapter"] + source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"] timestamp: int | None = None ``` -`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如 Pipeline 兼容入口触发消息时: +`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时: ```json -{ - "type": "message.received", - "source": "pipeline_adapter" -} +{ "type": "message.received", "source": "host_adapter" } ``` -### 4.4 AgentEventContext +### 5.4 AgentEventContext ```python class AgentEventContext(BaseModel): @@ -228,13 +218,11 @@ class AgentEventContext(BaseModel): data: dict[str, Any] = {} ``` -要求: - -- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。 +- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 - 平台原始事件名放入 `source_event_type`。 - 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`。 -### 4.5 Actor / Subject / Conversation +### 5.5 Conversation / Actor / Subject ```python class ConversationContext(BaseModel): @@ -263,7 +251,7 @@ class SubjectContext(BaseModel): - 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。 - 定时事件:actor 可以是 system,subject 是 schedule。 -### 4.6 AgentInput +### 5.6 AgentInput ```python class AgentInput(BaseModel): @@ -273,13 +261,11 @@ class AgentInput(BaseModel): message_chain: dict[str, Any] | None = None ``` -要求: - - 文本、多模态、附件都属于当前 event input。 - 大文件、图片、音频、工具大结果应以 artifact ref 传递。 - `message_chain` 是平台兼容字段,不应成为长期稳定依赖。 -### 4.7 DeliveryContext +### 5.7 DeliveryContext ```python class DeliveryContext(BaseModel): @@ -292,9 +278,9 @@ class DeliveryContext(BaseModel): 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 class ContextAccess(BaseModel): @@ -306,12 +292,7 @@ class ContextAccess(BaseModel): has_history_before: bool = False inline_policy: InlineContextPolicy available_apis: ContextAPICapabilities -``` -`ContextAccess` 告诉 runner:Host inline 了什么、没有 inline 什么、如果需要更多上下文应该通过哪些 API 拉取。 -它不是 Host 的业务上下文编排策略,而是 runner 按需读取上下文的入口说明。 - -```python class InlineContextPolicy(BaseModel): mode: Literal["none", "current_event", "recent_tail", "summary_tail"] delivered_count: int = 0 @@ -330,28 +311,14 @@ class ContextAPICapabilities(BaseModel): storage: bool = False ``` -### 4.9 BootstrapContext +`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。 -```python -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 +### 5.9 AgentRuntimeContext ```python class AgentRuntimeContext(BaseModel): host: str = "langbot" + protocol_version: str = "1" langbot_version: str | None = None trace_id: str deadline_at: float | None = None @@ -361,9 +328,9 @@ class AgentRuntimeContext(BaseModel): 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 class AgentRunState(BaseModel): @@ -375,7 +342,7 @@ class AgentRunState(BaseModel): State 是可选 host-owned snapshot。Runner 也可以完全自管状态。 -## 5. Resources +## 6. Resources ```python 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` 访问这些能力。 -## 6. Result Stream +## 7. Result Stream -### 6.1 AgentRunResult +### 7.1 AgentRunResult ```python class AgentRunResult(BaseModel): @@ -402,180 +369,78 @@ class AgentRunResult(BaseModel): timestamp: int | None = None ``` -### 6.2 稳定 result types +### 7.2 稳定 result types -| type | 说明 | -| --- | --- | -| `message.delta` | 流式消息片段。 | -| `message.completed` | 完整消息。 | -| `tool.call.started` | runner 开始工具调用的可观测事件。 | -| `tool.call.completed` | runner 完成工具调用的可观测事件。 | -| `artifact.created` | runner 生成 artifact。 | -| `state.updated` | runner 请求更新 host-owned state。 | -| `action.requested` | runner 请求 Host 执行平台动作。 | -| `run.completed` | run 正常结束。 | -| `run.failed` | run 失败。 | +| type | 说明 | 当前消费 | +| --- | --- | --- | +| `message.delta` | 流式消息片段。 | ✅ | +| `message.completed` | 完整消息。 | ✅ | +| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | +| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | +| `artifact.created` | runner 生成 artifact。 | ✅ | +| `state.updated` | runner 请求更新 host-owned state。 | ✅ | +| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | +| `run.completed` | 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 -{ - "type": "message.delta", - "data": { - "chunk": { - "role": "assistant", - "content": "hel" - } - } -} +{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } } +{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } } +{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } } +{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } } ``` -### 6.4 message.completed +Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。 -```json -{ - "type": "message.completed", - "data": { - "message": { - "role": "assistant", - "content": "hello" - } - } -} -``` +## 8. AgentRunAPIProxy -### 6.5 state.updated - -```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 +所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。 ```python +# Model 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.rerank(model_id, query, documents, top_k=None) -``` -### 7.2 Tool APIs - -```python +# Tool await api.tools.get_detail(tool_name) await api.tools.call(tool_name, parameters) -``` -### 7.3 Knowledge APIs - -```python +# Knowledge 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 -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 +# Event(返回稳定 event envelope 或受限 raw ref,不默认返回大 payload) await api.events.get(event_id) await api.events.page(before_cursor=None, limit=50) -``` -Event API 返回稳定 event envelope 或受限 raw ref,不默认返回大 payload。 - -### 7.6 Artifact APIs - -```python +# Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围) await api.artifacts.metadata(artifact_id) await api.artifacts.read_range(artifact_id, offset=0, length=65536) 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 - -```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 +# Platform(受限能力,默认不开放,需 manifest + binding policy + 用户审批同时允许) await api.platform.request_action(action, target, payload) ``` -平台 API 是受限能力。默认不开放。需要 runner manifest、binding policy、用户审批策略同时允许。 +`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / runner / binding),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。 -## 8. 错误模型 +返回数据结构(如 `HistoryPage`、artifact metadata)见 AGENT_CONTEXT_PROTOCOL §4。 -Host API 错误统一返回: +## 9. 错误模型 ```python class AgentAPIError(BaseModel): @@ -585,8 +450,6 @@ class AgentAPIError(BaseModel): details: dict[str, Any] = {} ``` -建议 code: - | code | 说明 | | --- | --- | | `unauthorized` | 未授权访问资源或 scope。 | @@ -600,96 +463,46 @@ class AgentAPIError(BaseModel): Runner 失败使用 `run.failed`: ```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` 中下发总 deadline。SDK proxy 必须用该 deadline 限制单次 action timeout。 - -取消语义: - -- Host 可以取消 active run。 -- Runtime 应尽力中断 runner。 +- Host 在 `ctx.runtime.deadline_at` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。 +- Host 可以取消 active run;Runtime 应尽力中断 runner。 - Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。 - Host 必须 unregister active run session。 -## 10. Security 与 Guardrail +## 11. Security 与 Guardrail(协议层) Protocol v1 的安全边界在 Host: - Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。 - SDK 本地校验只提升开发体验,不能替代 Host 校验。 - 所有 resource id 对 runner 来说都是 opaque。 -- 默认只能访问当前 conversation / thread 的 history。 -- 跨会话、workspace 级 history 或 storage 必须额外授权。 +- 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。 - 大 payload 必须 artifact 化。 - 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 过滤和审计记录。 -- 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`。 +对外部 harness runner,Host 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计;runner plugin 把授权后的 context/resource projection 适配为目标 harness 的形式;harness 的 native permission mode、allowed/disallowed tools 只是额外执行约束,不能替代 Host 授权。 -完整路径隔离、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 边界 -- 不拼接全量历史。 -- 不替 runner 做业务 prompt assembly。 -- 不内置 agent memory 策略。 -- 不内置 tool loop 业务流程。 -- 不内置上下文压缩策略。 +Pipeline 是当前入口 adapter,不是协议中心。Query entry adapter 负责: -这些能力可以由官方或第三方 AgentRunner 插件实现,并通过公开 Host APIs 消费 LangBot 的状态、历史、存储、artifact、模型、工具和知识库能力。 - -## 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。 +- 从 `Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。 - 从当前 Agent/runner config 构造 `ctx.config`。 -- 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略。 -- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction - package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`。 -- 将 Query-only 字段放入 `adapter`。 +- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`。 -Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。 +约束: -## 12. 最小 v1 完成标准 - -Protocol v1 已在当前分支完成: - -- ✅ 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` +- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。 +- preprocessing / hook 后的有效指令不通过 `ctx.adapter.extra` 主动推送;后续应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。 +- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。 ## 13. 开放问题 diff --git a/docs/agent-runner-pluginization/README.md b/docs/agent-runner-pluginization/README.md index a3bcf76a..1bafa49a 100644 --- a/docs/agent-runner-pluginization/README.md +++ b/docs/agent-runner-pluginization/README.md @@ -2,6 +2,13 @@ 本文档是 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 外化 / 插件化基础设施** @@ -11,7 +18,7 @@ - LangBot 与 SDK 的稳定协议合同(Protocol v1) - Host-side `AgentEventEnvelope` / `AgentBinding` 模型 - `run(event, binding)` event-first 入口 -- `PipelineAdapter`:Pipeline Query → AgentEventEnvelope + AgentBinding +- `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding - EventLog / Transcript / ArtifactStore / PersistentStateStore - History / Event / Artifact / State pull APIs - SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 @@ -32,31 +39,23 @@ EventGateway 在本文档中描述为 **future integration point**,由外部 e **当前 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 capabilities(EventLog / Transcript / ArtifactStore / PersistentStateStore 写入,History / Event / Artifact / State pull API 可用)。 -1. `run_from_query()` 使用 `PipelineAdapter.query_to_event(query)` 转换为 `AgentEventEnvelope` -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 可用 +详细实现进度、已验收能力和未完成收尾见 [PROGRESS.md](./PROGRESS.md)。 ## 设计文档 | 文档 | 关注点 | | --- | --- | -| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:run context、result stream、proxy actions、错误和 adapter 边界。 | -| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力、SDK 协议、runner 发现、绑定、权限、状态、存储、生命周期和调用链。 | +| [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 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 | | [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**。 | | [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 基础能力设计的前置约束。 | | [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 投影和审计。 | -| [PROGRESS.md](./PROGRESS.md) | 当前实现进度、已验收能力、未完成收尾和非本分支范围。 | +| [PROGRESS.md](./PROGRESS.md) | **🔒 唯一状态事实源**。当前实现进度、已验收能力、未完成收尾和非本分支范围。 | ## 工作拆分 @@ -92,7 +91,7 @@ Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API - event-first envelope (`AgentEventEnvelope`) - AgentBinding model - `run(event, binding)` 入口 -- PipelineAdapter(当前 AgentEventEnvelope / AgentBinding 的 Pipeline adapter source) +- QueryEntryAdapter(当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source) 详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 diff --git a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md index 1887c1f2..7a03aa61 100644 --- a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md +++ b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -2,6 +2,8 @@ 本文档记录后续 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. 结论 当前主线应继续收口 AgentRunner v1: diff --git a/docs/agent-runner-pluginization/SECURITY_HARDENING.md b/docs/agent-runner-pluginization/SECURITY_HARDENING.md index 437bad4a..a5a16389 100644 --- a/docs/agent-runner-pluginization/SECURITY_HARDENING.md +++ b/docs/agent-runner-pluginization/SECURITY_HARDENING.md @@ -10,6 +10,8 @@ 安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。 +> **硬规则**:能执行代码 / 访问工作目录的外部 harness runner(Claude Code、Codex、Kimi Code 等)在本文 Release Gate Checklist 完成前,**不得在生产环境默认启用**。本地 smoke 通过不等于可生产默认开启。 + ## 责任边界 ### LangBot Host 负责 diff --git a/src/langbot/pkg/agent/runner/config_migration.py b/src/langbot/pkg/agent/runner/config_migration.py index ea64bebc..c92b1912 100644 --- a/src/langbot/pkg/agent/runner/config_migration.py +++ b/src/langbot/pkg/agent/runner/config_migration.py @@ -1,44 +1,25 @@ -"""Configuration migration for agent runner IDs.""" +"""Helpers for the current AgentRunner config shape.""" from __future__ import annotations 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: - """Configuration migration helper for agent runner IDs. + """Configuration helper for agent runner IDs. Responsibilities: - - Resolve runner ID from new ai.runner.id or old ai.runner.runner - - Map old built-in runner names to official plugin runner IDs + - Resolve runner ID from ai.runner.id - Extract current Agent/runner config from ai.runner_config - - Migrate old ai. blocks into ai.runner_config + - Keep the current config container shape stable on save """ @staticmethod def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: - """Resolve runner ID from pipeline configuration. - - Priority: - 1. New format: ai.runner.id (must be plugin:* format) - 2. Old format: ai.runner.runner (mapped to plugin:* if built-in) + """Resolve runner ID from current configuration. Args: - pipeline_config: Pipeline configuration dict + pipeline_config: Current configuration container Returns: Runner ID string, or None if not configured @@ -46,26 +27,9 @@ class ConfigMigration: ai_config = pipeline_config.get('ai', {}) runner_config = ai_config.get('runner', {}) - # Check new format first runner_id = runner_config.get('id') if runner_id: - if is_plugin_runner_id(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 runner_id return None @@ -74,14 +38,10 @@ class ConfigMigration: pipeline_config: dict[str, typing.Any], runner_id: str, ) -> dict[str, typing.Any]: - """Resolve Agent/runner configuration from pipeline configuration. - - Runtime code should only read the migrated format. Legacy - ai. blocks are handled by migration helpers, not by the - hot path. + """Resolve Agent/runner configuration from the current container. Args: - pipeline_config: Pipeline configuration dict + pipeline_config: Current configuration container runner_id: Resolved runner ID Returns: @@ -89,79 +49,18 @@ class ConfigMigration: """ ai_config = pipeline_config.get('ai', {}) - # Check new format runner_configs = ai_config.get('runner_config', {}) if runner_id in runner_configs: return runner_configs[runner_id] return {} - @staticmethod - def resolve_legacy_runner_config( - pipeline_config: dict[str, typing.Any], - runner_id: str, - ) -> dict[str, typing.Any]: - """Resolve old ai. 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 def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: """Get conversation expire time from configuration. Args: - pipeline_config: Pipeline configuration dict + pipeline_config: Current configuration container Returns: Expire time in seconds (0 means no expiry) @@ -172,54 +71,23 @@ class ConfigMigration: @staticmethod def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]: - """Migrate pipeline config to new format. - - This converts old ai.runner.runner and ai. to - new ai.runner.id and ai.runner_config format. + """Normalize the current config container before saving. Args: - pipeline_config: Original pipeline configuration + pipeline_config: Original configuration Returns: - Migrated pipeline configuration + Configuration with explicit ai.runner and ai.runner_config containers """ - # Create copy new_config = dict(pipeline_config) - ai_config = new_config.get('ai', {}) - if not ai_config: + if 'ai' not in new_config: return new_config - runner_config = ai_config.get('runner', {}) - runner_configs = ai_config.get('runner_config', {}) + ai_config = dict(new_config.get('ai', {})) - # Resolve runner ID - runner_id = ConfigMigration.resolve_runner_id(pipeline_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'] + runner_config = dict(ai_config.get('runner', {})) + runner_configs = dict(ai_config.get('runner_config', {})) - # 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_config'] = runner_configs new_config['ai'] = ai_config diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 1c35ad2b..6f8d5822 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -116,7 +116,6 @@ class AgentRuntimeContext(typing.TypedDict): langbot_version: str | None sdk_protocol_version: str - query_id: int | None trace_id: str | None deadline_at: float | None metadata: dict[str, typing.Any] @@ -128,8 +127,8 @@ class AgentRunContextPayload(typing.TypedDict): Protocol v1 structure - matches SDK AgentRunContext. Note: The 'config' field contains the current Agent/runner config - from ai.runner_config[runner_id] while Pipeline remains the temporary - configuration container. It is not plugin instance config. + from ai.runner_config[runner_id] while the current Query entry remains + a temporary configuration container. It is not plugin instance config. """ run_id: str @@ -145,7 +144,6 @@ class AgentRunContextPayload(typing.TypedDict): state: AgentRunState runtime: AgentRuntimeContext 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 metadata: dict[str, typing.Any] # Additional metadata @@ -162,7 +160,7 @@ class AgentRunContextBuilder: - Build runtime context with host info, trace_id, deadline - 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 @@ -266,7 +264,6 @@ class AgentRunContextBuilder: runtime: AgentRuntimeContext = { 'langbot_version': self.ap.ver_mgr.get_current_version(), 'sdk_protocol_version': descriptor.protocol_version, - 'query_id': None, # No query_id in event-first mode 'trace_id': run_id, 'deadline_at': self._build_deadline_from_binding(binding), 'metadata': { @@ -293,7 +290,6 @@ class AgentRunContextBuilder: # Build adapter context (empty for event-first) adapter_context = { - 'query_id': None, 'extra': {}, } @@ -312,7 +308,6 @@ class AgentRunContextBuilder: 'state': state, 'runtime': runtime, 'config': binding.runner_config, - 'bootstrap': None, 'adapter': adapter_context, 'metadata': {}, # Additional metadata } diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index f7c43129..b38e5054 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -20,7 +20,7 @@ from .persistent_state_store import get_persistent_state_store, PersistentStateS from .session_registry import get_session_registry, AgentRunSessionRegistry from .config_migration import ConfigMigration 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 .errors import ( RunnerNotFoundError, @@ -37,7 +37,7 @@ class AgentRunOrchestrator: """Orchestrator for agent runner execution. Responsibilities: - - Resolve runner ID from pipeline config (new or old format) + - Resolve runner ID from current Agent/runner config - Get runner descriptor from registry - Provision AgentRunContext envelope from Query - Build AgentResources with permission filtering @@ -48,7 +48,7 @@ class AgentRunOrchestrator: Entry points: - 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 @@ -125,28 +125,24 @@ class AgentRunOrchestrator: resources=resources, ) + session_query_id = None + # Merge adapter context if provided if adapter_context: + session_query_id = adapter_context.get('query_id') # Merge params into adapter.extra if 'params' in adapter_context: 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 state_context = build_state_context(event, binding, descriptor) # Register session for proxy action permission validation 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( run_id=run_id, runner_id=descriptor.id, - query_id=query_id, + query_id=session_query_id, plugin_identity=descriptor.get_plugin_id(), resources=resources, permissions=descriptor.permissions or {}, @@ -238,7 +234,7 @@ class AgentRunOrchestrator: ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: """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. For the new event-first Protocol v1, use run(event, binding) instead. @@ -260,16 +256,16 @@ class AgentRunOrchestrator: raise RunnerNotFoundError('no runner configured') # Convert Query to event-first envelope - event = PipelineAdapter.query_to_event(query) + event = QueryEntryAdapter.query_to_event(query) - # Convert Pipeline config to binding - binding = PipelineAdapter.pipeline_config_to_binding(query, runner_id) + # Convert current config to binding + binding = QueryEntryAdapter.config_to_binding(query, runner_id) # Extract bound plugins for authorization bound_plugins = query.variables.get('_pipeline_bound_plugins') - # Build adapter context for Pipeline-specific fields - adapter_context = PipelineAdapter.build_adapter_context(query, binding) + # Build adapter context for Query-specific fields + adapter_context = QueryEntryAdapter.build_adapter_context(query, binding) # Delegate to event-first run() async for result in self.run( diff --git a/src/langbot/pkg/agent/runner/pipeline_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py similarity index 92% rename from src/langbot/pkg/agent/runner/pipeline_adapter.py rename to src/langbot/pkg/agent/runner/query_entry_adapter.py index 7480499f..dd559ddc 100644 --- a/src/langbot/pkg/agent/runner/pipeline_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -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 -Protocol v1 architecture. +This adapter bridges the current Query entry point with the event-first +Protocol v1 architecture without exposing Query internals to runners. """ from __future__ import annotations @@ -31,12 +31,12 @@ from .host_models import ( from . import events as runner_events -class PipelineAdapter: - """Adapter for converting Pipeline Query to event-first envelope. +class QueryEntryAdapter: + """Adapter for converting Query to event-first envelope. This adapter is responsible for: - 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 """ @@ -49,10 +49,10 @@ class PipelineAdapter: cls, query: pipeline_query.Query, ) -> AgentEventEnvelope: - """Convert Pipeline Query to AgentEventEnvelope. + """Convert Query to AgentEventEnvelope. Args: - query: Pipeline query + query: Current entry query Returns: AgentEventEnvelope for event-first processing @@ -82,7 +82,7 @@ class PipelineAdapter: event_id=event.event_id or str(query.query_id), event_type=event.event_type or runner_events.MESSAGE_RECEIVED, event_time=event.event_time, - source="pipeline_adapter", + source="host_adapter", source_event_type=event.source_event_type, bot_id=query.bot_uuid, workspace_id=None, # Not available in Query @@ -97,15 +97,15 @@ class PipelineAdapter: ) @classmethod - def pipeline_config_to_binding( + def config_to_binding( cls, query: pipeline_query.Query, runner_id: str, ) -> AgentBinding: - """Convert Pipeline config to temporary AgentBinding. + """Convert current config container to temporary AgentBinding. Args: - query: Pipeline query + query: Current entry query runner_id: Resolved runner ID Returns: @@ -121,7 +121,7 @@ class PipelineAdapter: scope_id=agent_id, ) - # Build resource policy from pipeline config + # Build resource policy from current config resource_policy = ResourcePolicy( allowed_model_uuids=cls._extract_allowed_models(query), allowed_tool_names=cls._extract_allowed_tools(query), @@ -159,10 +159,9 @@ class PipelineAdapter: query: pipeline_query.Query, binding: AgentBinding, ) -> dict[str, typing.Any]: - """Build Query-derived fields for the Pipeline adapter entry.""" + """Build Query-derived fields for the current entry adapter.""" return { 'params': cls.build_params(query), - 'prompt': cls.build_prompt(query), 'query_id': getattr(query, 'query_id', None), } @@ -187,15 +186,6 @@ class PipelineAdapter: 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 def is_json_serializable(cls, value: typing.Any) -> bool: """Return whether a value can safely cross the adapter boundary as JSON.""" @@ -210,18 +200,6 @@ class PipelineAdapter: ) 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 @classmethod @@ -262,7 +240,7 @@ class PipelineAdapter: event_id=cls._build_scoped_event_id(query, source_event_id, event_time), event_type=runner_events.MESSAGE_RECEIVED, event_time=event_time, - source="pipeline_adapter", + source="host_adapter", source_event_type=source_event_type, data=event_data, ) @@ -278,7 +256,7 @@ class PipelineAdapter: launcher_type = getattr(query, 'launcher_type', None) launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None scope_parts = [ - 'pipeline_adapter', + 'host_adapter', getattr(query, 'pipeline_uuid', None), getattr(query, 'bot_uuid', None), launcher_type_value, @@ -289,7 +267,7 @@ class PipelineAdapter: ] scoped = '|'.join('' if part is None else str(part) for part in scope_parts) digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32] - return f'pipeline:{digest}' + return f'host:{digest}' @classmethod def _build_conversation_context( diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index 1f6ae876..65d3ebc0 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -23,7 +23,7 @@ class AgentRunSession(typing.TypedDict): Fields: run_id: Unique run identifier (UUID from AgentRunContext) 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 conversation_id: Conversation ID for history/event access resources: Authorized resources for this run (from AgentResources) @@ -82,7 +82,7 @@ class AgentRunSessionRegistry: Args: run_id: Unique run identifier 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) resources: Authorized resources for this run conversation_id: Conversation ID for history/event access @@ -247,4 +247,4 @@ def get_session_registry() -> AgentRunSessionRegistry: with _global_registry_lock: if _global_registry is None: _global_registry = AgentRunSessionRegistry() - return _global_registry \ No newline at end of file + return _global_registry diff --git a/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py b/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py index 5f49a77c..5e97e3d3 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py +++ b/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py @@ -1,4 +1,4 @@ -"""Migrate pipeline config to new runner format +"""Normalize AgentRunner config containers Revision ID: 0004_migrate_runner_config Revises: 0003_add_rerank_models @@ -14,101 +14,23 @@ down_revision = '0003_add_rerank_models' branch_labels = 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: - """Migrate pipeline config to new format.""" + """Keep current AgentRunner config containers explicit.""" new_config = dict(config) - ai_config = new_config.get('ai', {}) - if not ai_config: + if 'ai' not in new_config: return new_config - runner_config = ai_config.get('runner', {}) - runner_configs = ai_config.get('runner_config', {}) + ai_config = dict(new_config.get('ai', {})) - # Check for new format first - runner_id = runner_config.get('id') - 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 + ai_config['runner'] = dict(ai_config.get('runner', {})) + ai_config['runner_config'] = dict(ai_config.get('runner_config', {})) new_config['ai'] = ai_config return new_config def upgrade() -> None: - """Migrate existing pipeline configs to new runner format.""" + """Normalize existing pipeline config containers.""" conn = op.get_bind() inspector = sa.inspect(conn) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index f7d21255..34eea9bf 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -11,12 +11,19 @@ from .. import handler from ... import entities 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 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.provider.message as provider_message +DEFAULT_PROMPT_CONFIG = [ + {'role': 'system', 'content': 'You are a helpful assistant.'}, +] + + class ChatMessageHandler(handler.MessageHandler): """Chat message handler using AgentRunOrchestrator. @@ -140,8 +147,9 @@ class ChatMessageHandler(handler.MessageHandler): ) # Update conversation history - query.session.using_conversation.messages.append(query.user_message) - query.session.using_conversation.messages.extend(query.resp_messages) + conversation = await self._ensure_conversation_for_history(query) + conversation.messages.append(query.user_message) + conversation.messages.extend(query.resp_messages) except Exception as e: # Import orchestrator errors for specific handling @@ -234,3 +242,69 @@ class ChatMessageHandler(handler.MessageHandler): await self.ap.survey.trigger_event('first_bot_response_success') except Exception as 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 diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index c8a7c3b6..1bf8f16c 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -239,7 +239,7 @@ async def _get_pipeline_knowledge_base_uuids(ap: app.Application, query: Any) -> try: descriptor = await registry.get(runner_id, bound_plugins) 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 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: - """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: 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: - """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 if session: query_id = session.get('query_id') @@ -762,8 +762,6 @@ class RuntimeConnectionHandler(handler.Handler): parameters = data.get('tool_parameters') or data.get('parameters', {}) run_id = data.get('run_id') # Optional: present for AgentRunner calls caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation - # session_data = data['session'] - # query_id = data['query_id'] session = None # Permission validation for AgentRunner calls @@ -1322,7 +1320,7 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) 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 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 - Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check """ - query_id = data['query_id'] kb_id = data['kb_id'] query_text = data['query_text'] top_k = data.get('top_k', 5) filters = data.get('filters') or {} run_id = data.get('run_id') # Optional: present for AgentRunner calls caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation - - 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] + session = None + query = None # Permission validation for AgentRunner calls if run_id: @@ -1353,7 +1345,16 @@ class RuntimeConnectionHandler(handler.Handler): ) if error: return error + query = _resolve_action_query(data, session, self.ap) 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 # schema-defined KB selectors or the preprocessed query scope. allowed_kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) @@ -1370,16 +1371,22 @@ class RuntimeConnectionHandler(handler.Handler): ) 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( query_text, - settings={ - 'top_k': top_k, - 'filters': filters, - 'session_name': session_name, - 'bot_uuid': query.bot_uuid or '', - 'sender_id': str(query.sender_id), - }, + settings=settings, ) results = [entry.model_dump(mode='json') for entry in entries] return handler.ActionResponse.success(data={'results': results}) diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 6018aabf..42bcb06f 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -540,7 +540,7 @@ class MCPLoader(loader.ToolLoader): return function 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 function in session.get_tools(): diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py index 5b741848..7e6aab82 100644 --- a/src/langbot/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -45,7 +45,12 @@ class PluginToolLoader(loader.ToolLoader): return tool 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: return await self.ap.plugin_connector.call_tool( name, parameters, session=query.session, query_id=query.query_id diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 7e45b8f2..51bc8217 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -117,7 +117,9 @@ class ToolManager: 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): return await self.native_tool_loader.invoke_tool(name, parameters, query) if await self.plugin_tool_loader.has_tool(name): diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py index dcdc5413..e4c9e19e 100644 --- a/tests/unit_tests/agent/conftest.py +++ b/tests/unit_tests/agent/conftest.py @@ -45,7 +45,7 @@ def make_session( Args: run_id: Unique run identifier runner_id: Runner descriptor ID - query_id: Pipeline query ID + query_id: Host entry query ID plugin_identity: Plugin identifier (author/name) resources: AgentResources dict (uses make_resources() default if None) diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py index 40d1a5eb..77b4f6aa 100644 --- a/tests/unit_tests/agent/test_chat_handler.py +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -29,8 +29,9 @@ class MockLauncherType: class MockConversation: - uuid = 'conv-uuid' - messages = [] + def __init__(self): + self.uuid = 'conv-uuid' + self.messages = [] class MockMessage: @@ -51,7 +52,9 @@ class MockAdapter: class MockSession: launcher_type = MockLauncherType() launcher_id = 'user123' - using_conversation = MockConversation() + + def __init__(self): + self.using_conversation = MockConversation() class MockQuery: @@ -155,6 +158,10 @@ class MockApplication: self.model_mgr = MagicMock() 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: """Tests for streaming mode behavior.""" @@ -232,7 +239,7 @@ class TestConfigMigrationInChatHandler: assert runner_id == 'plugin:langbot/local-agent/default' def test_resolve_runner_id_from_old_format(self): - """ConfigMigration should handle old runner format.""" + """ConfigMigration should not resolve removed runner aliases.""" pipeline_config = { 'ai': { 'runner': { @@ -242,7 +249,7 @@ class TestConfigMigrationInChatHandler: } runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - assert runner_id == 'plugin:langbot/local-agent/default' + assert runner_id is None class TestErrorHandling: @@ -399,6 +406,50 @@ class TestChatHandlerAsyncBehavior: assert query.resp_messages[0].content == 'Response 1' 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 async def test_runner_not_found_error(self): """Handler should catch RunnerNotFoundError and return INTERRUPT.""" @@ -550,4 +601,4 @@ class TestChatHandlerAsyncBehavior: # Should return CONTINUE with reply message assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - assert len(query.resp_messages) == 1 \ No newline at end of file + assert len(query.resp_messages) == 1 diff --git a/tests/unit_tests/agent/test_config_migration.py b/tests/unit_tests/agent/test_config_migration.py index 4dd7805f..24393a84 100644 --- a/tests/unit_tests/agent/test_config_migration.py +++ b/tests/unit_tests/agent/test_config_migration.py @@ -1,56 +1,14 @@ -"""Tests for agent runner config migration.""" +"""Tests for current AgentRunner config helpers.""" from __future__ import annotations - -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') +from langbot.pkg.agent.runner.config_migration import ConfigMigration class TestResolveRunnerId: """Tests for ConfigMigration.resolve_runner_id.""" - def test_resolve_new_format_runner_id(self): - """Resolve runner ID from new format.""" + def test_resolve_current_runner_id(self): pipeline_config = { 'ai': { 'runner': { @@ -62,8 +20,7 @@ class TestResolveRunnerId: runner_id = ConfigMigration.resolve_runner_id(pipeline_config) assert runner_id == 'plugin:langbot/local-agent/default' - def test_resolve_old_format_runner_name(self): - """Resolve runner ID from old format.""" + def test_does_not_resolve_old_runner_field(self): pipeline_config = { 'ai': { '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) assert runner_id is None - def test_resolve_priority_new_over_old(self): - """New format takes priority over old format.""" - pipeline_config = { - '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' + def test_resolve_no_runner_config(self): + runner_id = ConfigMigration.resolve_runner_id({}) + assert runner_id is None class TestResolveRunnerConfig: """Tests for ConfigMigration.resolve_runner_config.""" - def test_resolve_new_format_config(self): - """Resolve runner config from new format.""" + def test_resolve_current_config(self): pipeline_config = { 'ai': { 'runner_config': { @@ -132,13 +58,11 @@ class TestResolveRunnerConfig: ) assert config == {'model': 'uuid-123', 'custom_option': 10} - def test_resolve_old_format_config(self): - """Runtime config resolver should not read old format.""" + def test_does_not_read_old_runner_block(self): pipeline_config = { 'ai': { 'local-agent': { 'model': 'uuid-123', - 'custom_option': 10, }, }, } @@ -149,62 +73,18 @@ class TestResolveRunnerConfig: ) 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): - """Resolve runner config when not found.""" - pipeline_config = {} - config = ConfigMigration.resolve_runner_config( - pipeline_config, + {}, 'plugin:langbot/local-agent/default', ) 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: """Tests for ConfigMigration.get_expire_time.""" def test_get_expire_time_zero(self): - """Get expire time when zero.""" pipeline_config = { 'ai': { 'runner': { @@ -217,7 +97,6 @@ class TestGetExpireTime: assert expire_time == 0 def test_get_expire_time_positive(self): - """Get expire time when positive.""" pipeline_config = { 'ai': { 'runner': { @@ -230,22 +109,44 @@ class TestGetExpireTime: assert expire_time == 3600 def test_get_expire_time_default(self): - """Get expire time when not configured.""" - pipeline_config = {} - - expire_time = ConfigMigration.get_expire_time(pipeline_config) + expire_time = ConfigMigration.get_expire_time({}) assert expire_time == 0 -class TestGetOldRunnerName: - """Tests for ConfigMigration.get_old_runner_name.""" +class TestNormalizePipelineConfig: + """Tests for ConfigMigration.migrate_pipeline_config.""" - def test_get_old_runner_name_mapped(self): - """Get old runner name for mapped runner ID.""" - old_name = ConfigMigration.get_old_runner_name('plugin:langbot/local-agent/default') - assert old_name == 'local-agent' + def test_normalizes_current_containers(self): + config = {'ai': {}} - def test_get_old_runner_name_not_mapped(self): - """Get old runner name for unmapped runner ID.""" - old_name = ConfigMigration.get_old_runner_name('plugin:alice/my-agent/custom') - assert old_name is None + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated == {'ai': {'runner': {}, 'runner_config': {}}} + + def test_preserves_current_config(self): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/my-runner/default'}, + 'runner_config': { + 'plugin:test/my-runner/default': {'custom-option': 20}, + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default' + assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20 + + def test_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'} diff --git a/tests/unit_tests/agent/test_config_migration_full.py b/tests/unit_tests/agent/test_config_migration_full.py index 87586af9..cb3519bb 100644 --- a/tests/unit_tests/agent/test_config_migration_full.py +++ b/tests/unit_tests/agent/test_config_migration_full.py @@ -1,4 +1,4 @@ -"""Tests for pipeline config migration to new runner format.""" +"""Tests for persisted AgentRunner config shape.""" from __future__ import annotations @@ -10,62 +10,8 @@ from langbot.pkg.agent.runner.config_migration import ConfigMigration class TestMigratePipelineConfig: """Tests for ConfigMigration.migrate_pipeline_config.""" - def test_migrate_old_local_agent_config(self): - """Old local-agent config should migrate to plugin format.""" - 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 = { + def test_current_format_config_stays_unchanged(self): + config = { 'ai': { 'runner': { '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_config']['plugin:langbot/local-agent/default']['custom-option'] == 10 - def test_new_format_local_agent_config_normalizes_legacy_kb_key(self): - """Migration should normalize legacy KB aliases before runtime.""" + def test_old_runner_field_is_not_mapped(self): 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': { 'runner': { 'runner': 'local-agent', 'expire-time': 3600, }, - 'local-agent': {}, + 'local-agent': { + 'model': 'old-model', + }, }, } - migrated = ConfigMigration.migrate_pipeline_config(old_config) - assert migrated['ai']['runner']['expire-time'] == 3600 + migrated = ConfigMigration.migrate_pipeline_config(config) + + 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: """Tests for default-pipeline-config.json format.""" - def test_default_config_is_new_format(self): - """Default pipeline template should use the new runner config shape.""" + def test_default_config_is_current_format(self): from langbot.pkg.utils import paths as path_utils template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') with open(template_path, 'r', encoding='utf-8') as f: config = json.load(f) - # Should have new format assert 'ai' in config assert 'runner' in config['ai'] assert 'id' in config['ai']['runner'] 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 config['ai']['runner_config'] == {} - - # Should NOT have old local-agent key 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') - with open(template_path, 'r', encoding='utf-8') as f: - config = json.load(f) +class TestResolveRunnerId: + """Tests for current runner id resolution.""" - assert config['ai']['runner_config'] == {} - - -class TestResolveRunnerIdAliases: - """Tests for runner id alias resolution.""" - - def test_resolve_new_format_id(self): - """resolve_runner_id should work with new format.""" + def test_resolve_current_id(self): config = { 'ai': { 'runner': {'id': 'plugin:test/my-runner/default'}, @@ -216,45 +95,20 @@ class TestResolveRunnerIdAliases: runner_id = ConfigMigration.resolve_runner_id(config) assert runner_id == 'plugin:test/my-runner/default' - def test_resolve_old_format_runner(self): - """resolve_runner_id should map old format to plugin ID.""" + def test_old_runner_field_is_ignored(self): config = { 'ai': { 'runner': {'runner': 'local-agent'}, }, } runner_id = ConfigMigration.resolve_runner_id(config) - assert runner_id == 'plugin:langbot/local-agent/default' - - 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' + assert runner_id is None class TestResolveRunnerConfig: """Tests for runtime runner config resolution.""" - def test_resolve_new_format_config(self): - """resolve_runner_config should read from runner_config.""" + def test_resolve_current_config(self): config = { 'ai': { 'runner_config': { @@ -265,8 +119,7 @@ class TestResolveRunnerConfig: runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') assert runner_config['custom-option'] == 20 - def test_resolve_old_format_config(self): - """resolve_runner_config should not read old ai.local-agent at runtime.""" + def test_old_runner_block_is_ignored(self): config = { 'ai': { 'local-agent': {'custom-option': 20}, @@ -274,26 +127,3 @@ class TestResolveRunnerConfig: } runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') 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 diff --git a/tests/unit_tests/agent/test_context_builder_params_state.py b/tests/unit_tests/agent/test_context_builder_params_state.py index ae2152a7..05e868eb 100644 --- a/tests/unit_tests/agent/test_context_builder_params_state.py +++ b/tests/unit_tests/agent/test_context_builder_params_state.py @@ -1,31 +1,15 @@ -"""Tests for Pipeline adapter params and prompt packaging.""" +"""Tests for Query entry adapter params packaging.""" from __future__ import annotations -from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter - - -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 [] +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter class TestBuildParams: - """Tests for PipelineAdapter.build_params filtering.""" + """Tests for QueryEntryAdapter.build_params filtering.""" def test_params_empty_when_no_variables(self): query = type('Query', (), {'variables': None})() - assert PipelineAdapter.build_params(query) == {} + assert QueryEntryAdapter.build_params(query) == {} def test_params_filters_underscore_prefix(self): 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 '_pipeline_bound_plugins' 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 '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_id'] == 'group_123' 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 'int_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_dict_with_bad' not in params assert 'good_nested_list' in params assert 'good_nested_dict' in params def test_is_json_serializable_primitives_and_collections(self): - assert PipelineAdapter.is_json_serializable(None) is True - assert PipelineAdapter.is_json_serializable('string') is True - assert PipelineAdapter.is_json_serializable(42) is True - assert PipelineAdapter.is_json_serializable(['a', 'b']) is True - assert PipelineAdapter.is_json_serializable({'key': 'value'}) is True - assert PipelineAdapter.is_json_serializable((1, 2, 3)) is True + assert QueryEntryAdapter.is_json_serializable(None) is True + assert QueryEntryAdapter.is_json_serializable('string') is True + assert QueryEntryAdapter.is_json_serializable(42) is True + assert QueryEntryAdapter.is_json_serializable(['a', 'b']) is True + assert QueryEntryAdapter.is_json_serializable({'key': 'value'}) is True + assert QueryEntryAdapter.is_json_serializable((1, 2, 3)) is True def test_is_json_serializable_rejects_sets_and_objects(self): class CustomObject: pass - assert PipelineAdapter.is_json_serializable(CustomObject()) is False - assert PipelineAdapter.is_json_serializable({1, 2, 3}) is False - assert PipelineAdapter.is_json_serializable([1, {2, 3}]) is False - assert PipelineAdapter.is_json_serializable({'key': {1, 2}}) is False + assert QueryEntryAdapter.is_json_serializable(CustomObject()) is False + assert QueryEntryAdapter.is_json_serializable({1, 2, 3}) is False + assert QueryEntryAdapter.is_json_serializable([1, {2, 3}]) is False + assert QueryEntryAdapter.is_json_serializable({'key': {1, 2}}) is False -class TestBuildPrompt: - """Tests for PipelineAdapter.build_prompt.""" +class TestBuildAdapterContext: + """Tests for QueryEntryAdapter.build_adapter_context.""" - def test_prompt_empty_when_missing(self): - query = type('Query', (), {})() - assert PipelineAdapter.build_prompt(query) == [] - - def test_prompt_serializes_messages(self): + def test_adapter_context_does_not_push_prompt(self): query = type('Query', (), { - 'prompt': FakePrompt([FakeMessage('Effective prompt')]), + 'variables': {}, + 'query_id': 123, + 'prompt': object(), })() - prompt = PipelineAdapter.build_prompt(query) - assert prompt == [{'role': 'user', 'content': 'Effective prompt'}] + context = QueryEntryAdapter.build_adapter_context(query, binding=None) + + assert context == {'params': {}, 'query_id': 123} diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py index cb090525..b9fe3df1 100644 --- a/tests/unit_tests/agent/test_context_validation.py +++ b/tests/unit_tests/agent/test_context_validation.py @@ -259,7 +259,7 @@ class TestContextValidation: # Protocol v1 DOES have these assert 'delivery' in context_dict, "delivery 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 'metadata' in context_dict, "metadata should exist" diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index c5b12688..b93b158b 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -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: -1. Pipeline Query -> AgentEventEnvelope conversion -2. Pipeline config -> AgentBinding conversion +1. Query -> AgentEventEnvelope conversion +2. Current config -> AgentBinding conversion 3. AgentRunContext not inlining full history by default 4. LangBot Host not defining context-window controls 5. Event-first run() entry point @@ -31,32 +31,32 @@ from langbot_plugin.api.entities.builtin.agent_runner.permissions import ( ) # 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: - """Test Pipeline Query -> AgentEventEnvelope conversion.""" +class TestQueryToEventEnvelope: + """Test Query -> AgentEventEnvelope conversion.""" def test_query_to_event_basic_fields(self, mock_query): """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.source == "pipeline_adapter" + assert event.source == "host_adapter" assert event.bot_id == mock_query.bot_uuid assert event.actor is not None assert event.actor.actor_type == "user" def test_query_to_event_input(self, mock_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.text == "Hello world" def test_query_to_event_conversation(self, mock_query): """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" @@ -65,7 +65,7 @@ class TestPipelineQueryToEventEnvelope: mock_query.session.using_conversation.uuid = None 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" @@ -73,13 +73,13 @@ class TestPipelineQueryToEventEnvelope: """Debug Chat and legacy pipeline runs may not have a conversation UUID.""" 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" def test_query_to_event_delivery_context(self, mock_query): """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.surface == "platform" @@ -98,7 +98,7 @@ class TestPipelineQueryToEventEnvelope: }) 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.event_time == 1700000000 @@ -111,28 +111,28 @@ class TestPipelineQueryToEventEnvelope: """Test delivery context building when Query has no 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} def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query): """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" - 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 second.event_id != first.event_id -class TestPipelineConfigToBinding: - """Test Pipeline config -> AgentBinding conversion.""" +class TestQueryConfigToBinding: + """Test current config -> AgentBinding conversion.""" def test_config_to_binding_runner_id(self, mock_query): """Test binding runner_id extraction.""" - binding = PipelineAdapter.pipeline_config_to_binding( + binding = QueryEntryAdapter.config_to_binding( mock_query, "plugin:author/plugin/runner" ) @@ -140,7 +140,7 @@ class TestPipelineConfigToBinding: def test_config_to_binding_scope(self, mock_query): """Test binding scope extraction.""" - binding = PipelineAdapter.pipeline_config_to_binding( + binding = QueryEntryAdapter.config_to_binding( mock_query, "plugin:test/plugin/runner" ) @@ -177,8 +177,8 @@ class TestAgentRunContextProtocolV1: assert ctx.event is not None assert ctx.event.event_type == "message.received" - def test_sdk_context_messages_default_empty(self): - """Test that messages default to empty (not full history).""" + def test_sdk_context_has_no_history_message_fields(self): + """AgentRunContext should not expose inline history message fields.""" trigger = AgentTrigger(type="message.received") event = AgentEventContext( event_id="evt_1", @@ -200,34 +200,9 @@ class TestAgentRunContextProtocolV1: runtime=AgentRuntimeContext(), ) - # messages is now in bootstrap, not top-level - assert ctx.bootstrap is None or ctx.bootstrap.messages == [] - - 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) + assert "messages" not in AgentRunContext.model_fields + assert "bootstrap" not in AgentRunContext.model_fields + assert not hasattr(ctx, "bootstrap") class TestHostManagedHistoryNotInProtocol: @@ -306,7 +281,7 @@ class TestSDKResultProtocolV1: # Fixtures @pytest.fixture def mock_query(): - """Create a mock Pipeline Query for testing.""" + """Create a mock query for testing.""" query = Mock() query.query_id = 123 query.bot_uuid = "bot-uuid-123" diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 2deacce5..6a81b4b9 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -576,11 +576,10 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: assert 'kb_custom' in allowed_kbs - def test_retrieve_kb_fix_old_format(self): - """Fix should work for old format pipeline config.""" + def test_retrieve_kb_ignores_old_runner_format(self): + """Old runner format is not resolved by current AgentRunner helpers.""" from langbot.pkg.agent.runner.config_migration import ConfigMigration - # Old format: ai.runner.runner = 'local-agent' pipeline_config = { 'ai': { 'runner': { @@ -590,31 +589,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: } runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - # Should resolve to plugin:langbot/local-agent/default - 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']} + assert runner_id is None class TestHandlerActionAuthorization: @@ -850,7 +825,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency: """CALL_TOOL: SDK includes 'run_id' field.""" # SDK agent_run_api.py line 144: "run_id": self.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'] for field in host_expected_fields: diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index a984c401..aae1515e 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -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.errors import RunnerExecutionError 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.persistent_state_store import reset_persistent_state_store 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=")] ) - input_data = PipelineAdapter._build_input(query) + input_data = QueryEntryAdapter._build_input(query) assert input_data.contents[0].text == "see attached" 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 context = plugin_connector.contexts[0] assert context["config"]["custom-option"] == 2 - assert context["bootstrap"] is None - assert set(context["adapter"]) == {"query_id", "extra"} + assert "bootstrap" not in context + assert set(context["adapter"]) == {"extra"} assert "context_packaging" not in context["runtime"]["metadata"] assert [message.content for message in query.messages] == [ "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() == [] -class TestPipelineCompatibilityQueryIdInSession: - """Tests for query_id entering session registry.""" +class TestQueryEntrySessionQueryId: + """Tests for internal query_id entering session registry.""" @pytest.mark.asyncio - async def test_query_id_registered_in_session_for_pipeline_flow(self, clean_agent_state): - """query_id from Pipeline flow is registered in session.""" + async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state): + """query_id from Query entry flow is registered internally in session.""" db_engine = clean_agent_state descriptor = make_descriptor() plugin_connector = FakePluginConnector( @@ -557,12 +557,12 @@ class TestPipelineCompatibilityQueryIdInSession: assert session_during_run["query_id"] is None -class TestPipelineAdapterPromptAndParams: - """Tests for prompt and params handling in Pipeline adapter.""" +class TestQueryEntryAdapterParams: + """Tests for params handling in Query entry adapter.""" @pytest.mark.asyncio - async def test_prompt_in_adapter_extra(self, clean_agent_state): - """Pipeline prompt is placed in adapter.extra.prompt.""" + async def test_prompt_not_pushed_into_adapter_extra(self, clean_agent_state): + """Pipeline prompt is not pushed into adapter.extra.""" from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt db_engine = clean_agent_state @@ -590,12 +590,8 @@ class TestPipelineAdapterPromptAndParams: _messages = [message async for message in orchestrator.run_from_query(query)] 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["adapter"]["extra"] @pytest.mark.asyncio async def test_params_filtering_keeps_public_param(self, clean_agent_state): @@ -721,8 +717,8 @@ class TestPipelineAdapterPromptAndParams: assert "a_lambda" not in params -class TestPipelineAdapterHostCapabilities: - """Tests for event-first host capabilities via Pipeline adapter path.""" +class TestQueryEntryAdapterHostCapabilities: + """Tests for event-first host capabilities via Query entry adapter path.""" @pytest.mark.asyncio 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) # Build snapshot to check if state was written # Note: We need to rebuild the event and binding to query the store - from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter - event = PipelineAdapter.query_to_event(query) - binding = PipelineAdapter.pipeline_config_to_binding(query, RUNNER_ID) + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + event = QueryEntryAdapter.query_to_event(query) + binding = QueryEntryAdapter.config_to_binding(query, RUNNER_ID) snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) assert snapshot["conversation"]["external.test_key"] == "test_value" diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index 48d94032..2486af06 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock import pytest 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 @@ -62,7 +62,7 @@ def make_query( 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( event=Mock(), binding=binding,