Compare commits

...

49 Commits

Author SHA1 Message Date
huanghuoguoguo
f86f12c3f2 docs: reconcile agent runner protocol status 2026-06-05 12:34:14 +08:00
huanghuoguoguo
121a736e6a fix(agent-runner): align plugin runner runtime boundaries 2026-06-05 09:35:17 +08:00
huanghuoguoguo
36292102f9 feat(agent-runner): add bounded native tool artifacts 2026-06-04 11:11:40 +08:00
huanghuoguoguo
7053acfb1b feat(agent-runner): expose effective prompt and transcript history 2026-06-04 11:11:40 +08:00
huanghuoguoguo
08c51118c5 refactor(agent-runner): make agent binding and auth snapshot explicit 2026-06-04 11:11:40 +08:00
huanghuoguoguo
a850127893 refactor(agent-runner): simplify event-first entry path 2026-06-04 11:11:40 +08:00
huanghuoguoguo
4d0a2b117a refactor(agent-runner): align config with agent semantics 2026-06-04 11:11:40 +08:00
huanghuoguoguo
8116acf462 refactor(agent-runner): remove host context windowing 2026-06-04 11:11:40 +08:00
huanghuoguoguo
4d4ccfabd5 feat(agent-runner): normalize binding config boundaries 2026-06-04 11:11:39 +08:00
huanghuoguoguo
93cd852061 fix: enforce agent run API permissions 2026-06-04 11:11:39 +08:00
huanghuoguoguo
bbe7666642 fix(agent-runner): authorize external runner tools 2026-06-04 11:11:39 +08:00
huanghuoguoguo
99afe8e999 docs(agent-runner): document external MCP bridge 2026-06-04 11:11:39 +08:00
huanghuoguoguo
93febbb342 docs(agent-runner): align runner protocol boundaries 2026-06-04 11:11:39 +08:00
huanghuoguoguo
6afe8b67f8 docs(agent-runner): record codex runner smoke 2026-06-04 11:11:39 +08:00
huanghuoguoguo
2d83c9928a fix(agent-runner): stabilize event context and streams 2026-06-04 11:11:39 +08:00
huanghuoguoguo
13ff4fd9c1 docs(agent-runner): update pluginization design status 2026-06-04 11:11:39 +08:00
huanghuoguoguo
ab96070036 refactor(agent-runner): tighten protocol v1 runtime boundaries 2026-06-04 11:11:39 +08:00
huanghuoguoguo
cc911cc413 feat(agent-runner): align protocol adapter terminology 2026-06-04 11:11:39 +08:00
huanghuoguoguo
32e42f04ea feat(agent-runner): route pipeline runs through event-first flow
- run_from_query() now delegates to run(event, binding) instead of maintaining
  a separate legacy execution path
- Pipeline Query is converted to AgentEventEnvelope via PipelineCompatAdapter
- Pipeline config is converted to AgentBinding with StatePolicy
- bound_plugins authorization preserved from Pipeline
- Legacy compatibility fields preserved:
  - query_id → context.runtime.query_id → session registry
  - prompt → context.compatibility.extra.prompt (not top-level)
  - params → context.compatibility.extra.params (with proper filtering)
  - max-round → bootstrap.messages and compatibility.legacy_messages
- Pipeline path gains event-first host capabilities:
  - EventLog and Transcript writing
  - ArtifactStore registration
  - PersistentStateStore for state.updated
- Removed legacy handlers:
  - _handle_artifact_created_query() (replaced by _handle_artifact_created)
  - _handle_state_updated() (replaced by _handle_state_updated_event)

This change unifies the execution path while preserving backward compatibility
for Pipeline-based runners. EventGateway is not implemented in this branch;
only the event-first entry point is reserved.
2026-06-04 11:11:39 +08:00
huanghuoguoguo
24993ac700 feat(agent-runner): add persistent state APIs 2026-06-04 11:11:39 +08:00
huanghuoguoguo
cefd99e416 feat(agent-runner): scope event-first state by binding 2026-06-04 11:11:39 +08:00
huanghuoguoguo
8aba467a42 feat(agent-runner): persist created artifacts 2026-06-04 11:11:39 +08:00
huanghuoguoguo
201c805802 feat(agent-runner): add artifact store pull APIs 2026-06-04 11:11:39 +08:00
huanghuoguoguo
0a3bafae4b feat(agent-runner): add event-first context facts and pull APIs
Add EventLog and Transcript persistence entities for storing auditable
event facts and conversation history projection. Implement event-first
AgentRunContext builder that produces Protocol v1 compliant context
payloads with required fields: event, delivery, context (ContextAccess).

Key changes:
- EventLog ORM: auditable event records with indexes
- Transcript ORM: conversation history projection with composite indexes
- AgentRunContextBuilder: Protocol v1 payload with delivery, context, bootstrap
- EventLogStore/TranscriptStore: async stores for fact sources
- Host action handlers: HISTORY_PAGE, HISTORY_SEARCH, EVENT_GET, EVENT_PAGE
- Context validation: build_context output validates via SDK AgentRunContext
- Alembic migration for event_log and transcript tables
- Alembic env.py imports all ORM models for autogenerate discovery

Legacy compatibility: max-round messages go into bootstrap.messages and
compatibility.legacy_messages, not top-level messages field.
2026-06-04 11:11:39 +08:00
huanghuoguoguo
9086f77cc5 docs(agent-runner): split protocol and context design 2026-06-04 11:11:39 +08:00
huanghuoguoguo
275a6d44d6 fix(agent-runner): package context for plugin execution 2026-06-04 11:11:39 +08:00
huanghuoguoguo
0ae6204fd8 feat: make agent runner config schema driven 2026-06-04 11:11:39 +08:00
huanghuoguoguo
0cb6d2187d chore(pipeline): clarify preferred default runner 2026-06-04 11:11:39 +08:00
huanghuoguoguo
472da29a38 chore(agent): remove v1 wording from runner internals 2026-06-04 11:11:39 +08:00
huanghuoguoguo
363e3312f6 Revert "chore: update uv lock registry urls"
This reverts commit 0cf29930a8.
2026-06-04 11:11:39 +08:00
huanghuoguoguo
9d1f4c7598 chore: update uv lock registry urls 2026-06-04 11:11:39 +08:00
huanghuoguoguo
7c7e517ac6 feat(agent): reserve stable runner event names 2026-06-04 11:11:39 +08:00
huanghuoguoguo
f2ff796800 docs: add phase1 qa report 2026-06-04 11:11:39 +08:00
huanghuoguoguo
b5f08eb1d4 feat(agent-runner): enrich plugin runner host context 2026-06-04 11:11:39 +08:00
huanghuoguoguo
88ed3851e2 fix: log agent runner best-effort failures 2026-06-04 11:11:39 +08:00
huanghuoguoguo
548ea56f72 test: address agent runner review comments 2026-06-04 11:11:39 +08:00
huanghuoguoguo
03cbe8043d fix: stabilize dynamic forms and mcp testing 2026-06-04 11:11:39 +08:00
huanghuoguoguo
cc3e9090b2 refactor(modelmgr): simplify model sync logic and remove timeout configuration 2026-06-04 11:11:39 +08:00
huanghuoguoguo
7661075294 fix(rag): align knowledge engine plugin actions 2026-06-04 11:11:39 +08:00
huanghuoguoguo
c2037f9833 feat: support dynamic agent runner defaults 2026-06-04 11:11:39 +08:00
huanghuoguoguo
b1cde952e4 feat(toolmgr): add get_tool_by_name for unified tool lookup
Add unified tool lookup method that searches both plugin and MCP loaders.
Also add _get_tool method to MCPLoader for consistency with PluginToolLoader.
2026-06-04 11:11:39 +08:00
huanghuoguoguo
ef113bb7a7 docs: update PROGRESS.md - rerank support completed 2026-06-04 11:11:39 +08:00
huanghuoguoguo
e81a1af36c feat(plugin): implement INVOKE_RERANK handler with run-scoped authorization
- Add invoke_rerank action handler in plugin handler
- Validate rerank model access via run session
- Cap documents at 64 for API limit
- Return sorted results by relevance score
2026-06-04 11:11:39 +08:00
huanghuoguoguo
2fd126b0d7 docs(runner): mark legacy runners and add PROGRESS.md
- Add DEPRECATED docstring to all legacy runners in pkg/provider/runners/
- Mark migration target for each runner (local-agent, dify, n8n, coze, dashscope, langflow, tbox)
- Add PROGRESS.md to track agent-runner-pluginization implementation status
- Remove completed PHASE0_INTEGRATION_RECORD.md
2026-06-04 11:11:39 +08:00
huanghuoguoguo
47b1890a4b perf(agent-runner): improve session registry and orchestrator efficiency
- Add pre-computed _authorized_ids (frozenset) at session registration for O(1) lookup
- Refactor is_resource_allowed() from linear search to set membership check
- Add thread-safe locking to get_session_registry() singleton
- Cache _session_registry and _state_store references in orchestrator __init__
- Add asyncio.gather() for parallel resource building in AgentResourceBuilder
- Create shared test fixtures in tests/unit_tests/agent/conftest.py
- Update test files to import from shared conftest.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:11:39 +08:00
huanghuoguoguo
3812bd97eb feat(agent-runner): integrate AgentRunner Protocol v1 with plugin system
Phase 0 integration complete - verified minimal loop with local-agent stub runner.

Changes:
- Add AgentRunOrchestrator for plugin-based agent execution
- Add AgentResultNormalizer for Protocol v1 result conversion
- Add AgentRunnerDescriptor for runner ID parsing (plugin:author/name/runner)
- Update chat handler to use new orchestrator instead of direct runner lookup
- Add plugin handler methods for list_agent_runners and run_agent
- Add connector methods for AgentRunner protocol forwarding
- Update pipeline API to include runner options in metadata
- Add integration docs and implementation plan

Integration verified:
- Runner: plugin:langbot/local-agent/default
- Input: "你好"
- Output: [stub] Echo: 你好
- Date: 2026-05-10 10:09

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:11:39 +08:00
Junyan Qin
98bbdf268f docs: record agent runner design decisions 2026-06-04 11:11:38 +08:00
Junyan Qin
7c1ff5b841 docs: design agent runner pluginization 2026-06-04 11:11:38 +08:00
Junyan Qin
5d3dfccae1 chore: stash code 2026-06-04 11:11:38 +08:00
129 changed files with 21844 additions and 1576 deletions

View File

@@ -0,0 +1,153 @@
# Agent-owned Context 协议设计
本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行LangBot 不应成为最终 agentic context manager它提供 context substrateAgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。
> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。实现进度见 [PROGRESS.md](./PROGRESS.md)。
## 1. 设计原则
### 1.1 Agent 拥有上下文策略
不同 runner 背后的 runtime 差异很大:
- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。
- 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。
### 1.2 Host 不定义通用历史窗口
历史窗口策略不是 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界runner 自己决定是否读取、读取多少、如何截断和压缩。
正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是:
- 这类 runner 是否自管 context
- 事件到来时 host 应 inline 哪些最小信息?
- agent 需要更多上下文时通过什么 API 拉取?
- host 如何保证安全、可审计和可分页?
### 1.3 Host 保存事实源Agent 管理 working context
三类数据要分开:
- `EventLog`: Host 保存原始事件、工具调用、投递结果、错误和系统事件。
- `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
- `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。
LangBot 不提供 host-side inline history window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。
## 2. Event 到来时传什么
默认 `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 API when authorized.
- Official runners MUST consume Host infrastructure through the same public API as third-party runners.
### 2.1 必须 inline 的内容
当前 event 的类型/id/时间/source当前输入文本和结构化内容附件/文件/图片的 metadata 和 artifact refactor / subject / conversation / thread / bot / workspacedelivery 能力已授权资源列表context cursors 和可用 API 能力Agent/runner config。这些是 agent 决定下一步所需的最低信息。
### 2.2 默认不 inline 的内容
完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。
### 2.3 不提供 Host Inline History Window
`AgentRunContext` 不包含 `bootstrap` 字段。Host 不下发历史窗口,也不通过 Pipeline 配置决定窗口大小。runner 若需要类似 `recent_tail` 的策略,应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 history API 读取、裁剪和压缩。Host 只负责权限、分页、hard cap 和事实源。
## 3. ContextAccess 的作用
`ContextAccess`PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限。
## 4. Agent 如何获取更多上下文
所有 API 都走 `AgentRunAPIProxy`PROTOCOL_V1 §8由 host 用 `run_id` 校验。
### 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)
```
返回:
```python
class HistoryPage(BaseModel):
items: list[TranscriptItem]
next_cursor: str | None
prev_cursor: str | None
has_more: bool
```
约束:`limit` 有 host hard cap默认只能读当前 conversation / thread跨会话读取需 manifest permission + binding policy返回 artifact ref不默认返回大文件内容。
### 4.2 Search
```python
await api.history.search(query="用户之前提到的数据库连接信息",
filters={"conversation_id": ..., "event_types": ["message.received"]},
top_k=10)
```
Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。
### 4.3 Event / Artifact / State
- 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`
### 4.4 大文件与工具协作
大文件、多模态输入和工具产物不要内联进 prompt 或 tool resultmessage/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref`artifact_id``mime_type``size``digest``summary``expires_at``permissions`)。工具之间传递大结果时传 artifact ref不传完整 blob。Host 校验 artifact 是否属于当前 run / scope默认不允许插件直接读任意本地路径临时文件应有 TTL 和清理机制。
### 4.5 External harness context 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`:人类可读摘要。
- `resources`:只包含本次 run 授权后的句柄,不暴露 Host 内部私有对象。
- `skills`:已授权 skill 投影为目标 harness 可读目录(如 Claude Code 的 `.claude/skills/<name>/SKILL.md`)。
- `MCP config`scoped MCP 配置runner adapter 转成目标 harness 的配置文件或 CLI 参数。
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。
当前 Claude Code runner 使用 schema `langbot.agent_runner.external_harness_context.v1`(现状见 OFFICIAL_RUNNER_PLUGINS §7。这类 projection 是"把 LangBot 事实源和授权资源交给 harness",不是"由 LangBot 决定最终模型上下文"。
## 5. Runner manifest 中的上下文声明
`AgentRunnerContextPolicy`PROTOCOL_V1 §4.5)声明 runner 的上下文能力:`supports_history_pull` / `supports_history_search` / `supports_artifact_pull` / `owns_compaction` / `wants_static_context_refs`。它表示 Host 只给当前事件和 context handlesrunner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。
## 6. KV cache 友好的上下文管理
支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt
- 稳定 session key`workspace/bot/binding/runner/conversation/thread`
- 静态内容使用 `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。
- 大文件和工具结果 artifact 化。
- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。
- 对自管 runtime优先让它复用自身 session/cache而不是强制 LangBot 每轮重放 transcript。
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner由 runner 决定预算和压缩策略。
稳定 session key 的用途是隔离外部 runtime 的 resume/cache/state不是改变 PROTOCOL_V1 §13 定义的 Agent 复用和 dispatch 边界。只有当某个外部 harness 的同一 native session 不支持并发 turn 时runner 或 future runtime control plane 才应按 external session key 做 turn-level 串行化。
对长期运行的 external harness / daemon推荐运行形态是 reader 与 writer 分离:一个 session reader 独占读取 stdout/SSE/native event stream并把 native event 转成 `AgentRunResult` 或 task progress用户输入只作为 turn write 进入该 session。当前一次性 CLI subprocess runner 可以继续在单次 `run(ctx)` 内同步收集 stdout但后续改成长连接时不应让多个 request 同时读取同一 native stream。
## 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 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。
## 8. 官方 runner 与业务编排边界
官方 runner 插件可以把状态寄宿在 LangBot但必须和第三方 runner 一样通过公开 Host API 消费。LangBot core 不内置官方 agent 的业务流程prompt 组装、tool loop、RAG 编排、summary/compaction、"local-agent 专用"状态字段)。
官方 local-agent 应作为"依附 LangBot 基础设施的复杂 runner 参考实现"transcript/history 通过 `api.history` 读取summary/checkpoint/外部 session id/用户偏好通过 `api.state``api.storage` 保存,图片/文件/工具大结果通过 `api.artifacts` 读取,模型/工具/知识库通过 `api.models` / `api.tools` / `api.knowledge` 调用。这样 LangBot 保持为通用 agent host不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。

View File

@@ -0,0 +1,252 @@
# Agent Runner QA 指南
本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。
目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。
## 1. 测试边界
当前主线验证的是 AgentRunner Protocol v1
```text
event -> binding -> runner.run(ctx) -> result stream
```
本指南验证:
- Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。
- Runner 来自插件 registry而不是旧内置 runner 分支。
- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。
- 外部 harness runnerClaude Code / Codex能消费 event-first context并把 session / working directory 等指针写回 host-owned state。
- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。
本指南不验证:
- Runtime Control Plane v2。
- EventGateway / EventRouter 完整落地。
- 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。
- 所有外部服务 runner 的真实凭据联调。
这些属于后续能力或发布门槛,分别见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 与 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
## 2. 状态定义
测试报告只使用以下状态:
| 状态 | 含义 |
| --- | --- |
| PASS | 按步骤执行,用户可见行为和日志证据都满足通过条件。 |
| FAIL | 环境可用,但行为不满足通过条件。 |
| BLOCKED | 凭据、CLI、外部服务、测试数据或本地配置缺失导致无法执行。必须写清阻塞原因。 |
| N/A | 当前 runner 或平台明确不支持该能力。必须引用 manifest、文档或配置说明。 |
不能使用“看起来正常”“大概通过”“基本没问题”等模糊状态。
## 3. 执行顺序
推荐按以下顺序执行,前一层失败时不要继续扩大测试面:
1. Host / SDK / runner 单测。
2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。
3. `local-agent` 高价值场景。
4. Claude Code / Codex 外部 harness smoke。
5. 权限和错误路径补充检查。
6. 汇总 PASS / FAIL / BLOCKED并给出下一步建议。
用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。
## 4. 必跑基线
### 4.1 单测基线
在 LangBot 仓库运行:
```bash
uv run --frozen pytest tests/unit_tests/agent
```
如果本次改动只触及默认配置或 API service也至少补跑相关目标测试例如
```bash
uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py
```
通过条件:
- agent 单测全 PASS或失败项已确认与本次 agent-runner 路径无关。
- 若失败来自 `context_builder``orchestrator``session_registry``resource_builder``plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。
### 4.2 环境基线
`langbot-skills` 做环境检查:
```bash
cd "$LANGBOT_SKILLS_REPO"
bin/lbs env doctor
bin/lbs case list
```
`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case而不是临时发明测试路径。
推荐首批 case
- `webui-login-state`
- `pipeline-debug-chat`
- `local-agent-basic-debug-chat`
- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge
- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy
## 5. WebUI 主链路 Smoke
### 5.1 Runner registry
步骤:
1. 打开 WebUI Pipeline 配置页。
2. 查看 AI runner 下拉列表。
3. 选择 `plugin:langbot/local-agent/default`
4. 保存并刷新页面。
通过条件:
- runner 选项来自插件 registry。
- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`
- `runner_config` 表示 Agent/runner config不表示插件实例状态。
- 不读取或回写旧 `ai.runner.runner` 字段。
- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。
- 插件没有循环重启或 metadata 加载失败。
### 5.2 主聊天路径
步骤:
1. 使用绑定 `plugin:langbot/local-agent/default` 的 Pipeline。
2. 在 Debug Chat 发送确定性普通文本。
3. 查看 WebUI 回复和后端日志。
通过条件:
- 用户可见回复正常。
- 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`
- 不走旧内置 local-agent 主执行分支。
- conversation transcript 写入用户消息和助手消息。
## 6. `local-agent` 高价值测试
只保留最能覆盖架构边界的场景。
| ID | 场景 | 操作 | 通过条件 |
| --- | --- | --- | --- |
| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 |
| 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 检索;回复使用检索内容。 |
| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 |
| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 |
| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 |
Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测不作为每轮必跑项。
## 7. 外部 Harness Runner Smoke
这些测试用于验证 Claude Code / Codex 这类自管 runtime 能走同一条 Host 协议路径。若本机没有 CLI、登录态或代理配置标记 BLOCKED不要伪造 PASS。
Smoke 前应优先保留一层轻量单测或 fixture 测试provider-native outputClaude stream-json、Codex JSONL、外部 API SSE / JSON必须能稳定转换成 `AgentRunResult`,未知 native event 只记录诊断不导致解析器崩溃。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。
### 7.1 Claude Code runner
步骤:
1. 确认 `claude` CLI 在 LangBot runtime host 上可执行。
2. 绑定 `plugin:langbot/claude-code-agent/default`
3. 使用保守权限模式和确定性 prompt。
4. 在 Debug Chat 执行一次真实 smoke。
5. 检查 context / skill / MCP projection 和 host-owned state。
通过条件:
- WebUI 可见回复包含预期 sentinel。
- context JSON schema 为 `langbot.agent_runner.external_harness_context.v1` 或当前文档声明的等价 schema。
- context 包含 event、input、delivery、resources、context、state。
- 如启用 skills / MCP投影路径和配置可被 Claude Code 读取。
- `external.session_id` / `external.working_directory` 写入 host-owned state。
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`
- resume 到同一 `external.session_id` 时,不并发写入同一 native session全局锁边界符合 PROTOCOL_V1 §13。
### 7.2 Codex runner
步骤:
1. 确认 `codex` CLI 在 LangBot runtime host 上可执行。
2. 绑定 `plugin:langbot/codex-agent/default`
3. 如需要代理,使用 Agent/runner config 的 `environment-json` 显式传入。
4. 在 Debug Chat 执行一次真实 smoke。
5. 检查 JSONL 事件、last message、host-owned state。
通过条件:
- WebUI 可见回复包含预期 sentinel。
- Codex JSONL 至少包含 thread/session 起始事件、agent message、turn completed。
- `external.session_id` / `external.working_directory` 写入 host-owned state。
- timeout/cancel 不遗留 orphan CLI 子进程。
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`
- resume 到同一 `thread_id` / `external.session_id` 时,不并发写入同一 native session全局锁边界符合 PROTOCOL_V1 §13。
### 7.3 API 型外部 runner
Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。
通过条件:
- runner 可选,配置可保存。
- 请求成功,或外部服务错误被清晰返回。
- 外部服务凭据缺失时标记 BLOCKED并记录缺失项。
## 8. 权限与隔离补充
以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。
| 场景 | 推荐证据 |
| --- | --- |
| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 |
| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 |
| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 |
| run_id 结束后复用被拒绝 | session registry 注销测试。 |
| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 |
| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 |
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
如果这些单测失败,不能用 WebUI 正常回复替代。
## 9. 证据要求
每轮测试报告至少记录:
- LangBot commit、SDK commit、相关 runner 插件 commit。
- Pipeline UUID/name、runner id、关键 runner config 摘要。
- WebUI 截图或 Playwright 操作记录。
- 后端日志中对应 query id / run id 的关键行。
- `langbot-skills` case/report 路径。
- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。
- FAIL/BLOCKED 的复现步骤和归属仓库建议。
报告结论必须回答:
- 是否建议继续进入下一阶段测试。
- 是否存在主聊天路径阻塞。
- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。
- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。
## 10. 历史高价值记录
历史报告已合并为本指南,不再保留单独文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。
截至 2026-05-29已有本地 smoke 证明:
- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。
- Claude Code runner 可以通过同一条 `run(event, binding)` 路径执行。
- Claude Code runner 可以读取 LangBot event-first context / skill / MCP 投影,并写回 `external.session_id` / `external.working_directory`
- Codex runner 可以通过同一条路径执行,并把 Codex `thread_id` 写回 host-owned state。
这些记录只证明本地协议闭环可用,不代表发布级 security hardening 已完成。

View File

@@ -0,0 +1,92 @@
# Event Based Agent 预留设计
> **future design note**不是当前分支实现范围。EventGateway、EventRouter、Event subscription/notification 由其他分支实现;本分支只预留 event-first 入口和 envelope/binding models。实现进度见 [PROGRESS.md](./PROGRESS.md)。
>
> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)Host 内部模型);本文只讲 EBA 语义,不重抄 schema。
> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
本文描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner以及如何复用插件化 agent 基础设施。本阶段不实现完整 EventBus / EventRouter / Platform API目标是把协议边界设计对避免当前消息入口继续绑死 Pipeline 和用户文本消息。
## 1. 设计目标
- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`
- AgentRunner 通过同一套 orchestrator 被调用。
- 非消息事件不伪造成用户文本消息。
- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。
## 2. 事件不是消息
`message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。
| event_type | actor | subject | input |
| --- | --- | --- | --- |
| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 |
| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 |
| `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 |
| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 |
| `schedule.triggered` | 系统 | 定时任务 | 任务 payload |
| `api.invoked` | API caller | API request | request payload |
## 3. 稳定事件名
先保留的稳定事件名(作为插件协议的一部分保持稳定):
- `message.received`
- `message.recalled`
- `group.member_joined`
- `friend.request_received`
平台原始事件名只能进入 `ctx.event.source_event_type` / `raw_ref`,不能成为 `ctx.event.event_type` 的公共契约。
## 4. Event Envelope 与 Binding
- 入口事件用 `AgentEventEnvelope`HOST_SDK §4.1)承载;顶层字段使用 LangBot 稳定协议名,平台原始事件名和原始 payload 放 `metadata` / `raw_ref`
- 触发关系用 `AgentBinding`HOST_SDK §4.2表达。EBA 阶段 binding 通过 `event_types``scope``filters` 决定哪些事件触发当前 bot / channel 绑定的 Agent。
EBA dispatch 基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准;本节只说明 future EventRouter 如何产出当前 v1 主线需要的 binding。
Binding scope 示例workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。旧 Pipeline 可迁移为 `message.received` 的临时 binding source但目标持久配置应是 Agent不是 Pipeline。
Event Source 可包括:`platform_adapter`飞书、QQ、微信、Telegram 等)、`webui``http_api``scheduler``system`。EventRouter 不应写死平台 adapter 的类名。
## 5. EventRouter 调用链
```text
Platform Adapter / WebUI / API
-> Event Gateway normalize payload
-> EventLog append raw event
-> EventRouter resolve one effective AgentBinding
-> AgentRunOrchestrator.run(event, binding)
-> AgentRunContextBuilder.build(event, binding)
-> PluginRuntimeConnector.run_agent()
-> AgentRunResult stream
-> DeliveryController render / platform action
```
约束:必须复用现有 orchestrator不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorizationdelivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。
## 6. 平台动作执行
EBA 后 `action.requested`PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作:
```json
{ "type": "action.requested",
"data": { "action": "friend.request.accept",
"target": {"platform": "wechat", "request_id": "..."},
"reason": "policy matched" } }
```
Host 必须校验runner manifest 是否声明 `platform_api` capability、binding 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批。EBA 还可能预留 `delivery.requested`(请求投递到某 surface
Delivery 方面event 不一定回复到当前聊天窗口:消息事件通常带 reply target系统事件可能没有默认 reply target需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。
## 7. 与 Context 协议的关系
EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)inline 当前事件、大 payload 用 raw/artifact ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript但不能强制伪装为 user messageAgentRunner 根据 event type 自己决定是否纳入模型上下文。
## 8. 未来 EBA 完整落地需要
EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。
落地顺序:① 把当前 Pipeline 消息入口适配成 `message.received` event已完成→ ② 增加 `AgentBinding` 抽象,先由 current config 生成(已完成)→ ③ context builder 改为从 event + binding 构造(已完成)→ ④ 引入 EventLog / Transcript已完成→ ⑤ 增加非消息事件的协议测试,不接真实平台 → ⑥ 接入真实 EventRouter 和 platform action。

View File

@@ -0,0 +1,51 @@
# AgentRunner 外化扩展边界矩阵
本文用于回答一个问题:本分支只做 AgentRunner 外化时,哪些能力已经作为扩展底座完成,哪些只是为后续 EBA / Agent Platform / Runtime Control Plane 预留,后续分支接入时应该走哪个扩展点。
结论:本分支不实现完整 Agent Platform也不实现完整 EBA。它必须把 runner 外化的 Host / SDK 边界做干净,让后续分支只需要接入持久模型、事件路由或 runtime task而不需要重写 `AgentRunner Protocol v1`
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的单一事实源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13本矩阵只说明后续能力应该接入哪个扩展点。
## 1. 分支边界
| 范围 | 本分支职责 | 不在本分支做 |
| --- | --- | --- |
| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 |
| Host runner 外化底座 | 提供 `AgentEventEnvelope``AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / Artifact / State。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 |
| 当前 Pipeline 入口 | 通过 `QueryEntryAdapter` 把旧 Query / Pipeline config 投影成 event + binding作为迁移期入口。 | 不继续把 Pipeline 当作长期 agent 配置中心。 |
| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 |
## 2. 扩展矩阵
| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 |
| --- | --- | --- | --- | --- |
| Product `Agent` | 已有运行期 `AgentConfig` / `AgentBinding` 投影;还没有正式持久化产品对象。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 |
| Bot / channel 绑定 Agent | 已有单次运行前的 `AgentBinding` 解析投影;目标调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。 | 不在本矩阵重定义 fan-out / observer 语义;需要时按 §3 新增设计。 |
| Agent session / run | 当前只有 `run_id` 和 active `AgentRunSessionRegistry`,用于权限校验和生命周期。 | Agent Platform / Runtime Control Plane。 | 如需要可新增持久 `AgentRun` / `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 |
| EventLog / Transcript / Artifact | 已完成 Host-owned store 和 pull APIrunner 不直接写 DB。 | 本分支持续维护底座Agent Platform 可复用。 | 后续 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript / Artifact。 | 不让 runner / sandbox 直接访问 Host DB不把大 payload 内联进 prompt。 |
| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State APIstorage 作为授权能力保留。 | 本分支持续维护底座Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state大对象用 storage / artifact。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 |
| EventGateway / EventRouter | 只预留 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支。 | EventGateway 规范化平台/WebUI/API/scheduler 事件EventRouter 解析一个 binding调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 |
| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 |
| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 |
| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API不把平台动作伪装成文本回复副作用。 |
| Runtime registry / worker / task queue | 不实现。当前 Claude Code / Codex 是本机 subprocess MVP path。 | Runtime Control Plane v2。 | Host 新增 runtime registry、heartbeat、task queue、daemon claim、progress/auditrunner 可选择 runtime-managed 执行模式。 | 不把 heartbeat/task/warm pool 放进 Protocol v1不让管理插件拥有 runtime/task 事实源。 |
| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 |
| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage/artifact 基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 |
| External harness native session | 已支持 external session id / working directory state handoff 和 resource projection。 | 官方 runner 后续增强Runtime Control Plane v2 可接管执行。 | 一次性 CLI runner 可继续走 `runner.run(ctx)`;长连接/daemon 模式按 external session key 串行 turnreader 独占 native stream。 | 不把 Claude/Codex native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 |
## 3. 后续分支接入规则
后续 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则:
- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。
- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式即便如此runner 可见合同仍应保持 Protocol v1。
- Host-owned facts 继续写入 EventLog / Transcript / Artifact / State产品层可以新增更高阶视图但不能替代这些事实源。
- 新能力如果需要持久化,优先加 Host-owned 表或 service不要把事实源藏在插件 storage 或 runner subprocess 内。
- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。
- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。
## 4. 与 LiteLLM Agent Platform 的关系
这里的 LiteLLM Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。
LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event把 Agent / binding 配置统一投影到 runner binding把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。

View File

@@ -0,0 +1,264 @@
# LangBot Host 与 SDK 基础设施设计
本文档描述 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. 目标
LangBot 要转为 agent host而不是内置 runner 容器:
- 接收 IM、WebUI、API 和未来 EventRouter 产生的事件。
- 根据事件、bot、workspace、scope 解析应该调用的 Agent / agent binding。
- 发现、校验和调用插件提供的 AgentRunner。
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。
## 2. 非目标
- 不把 Pipeline 当作长期架构中心。
- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。
- 不在 host 中实现通用 agentic prompt assembler。
- 不强制 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)
|
v
Event Gateway (future - external event branch)
|
v
AgentBindingResolver
|
v
AgentRunOrchestrator
|-- AgentRunnerRegistry
|-- AgentResourceBuilder
|-- AgentContextBuilder
|-- AgentRunSessionRegistry
|-- PersistentStateStore / EventLogStore / TranscriptStore / ArtifactStore
v
Plugin Runtime / AgentRunner
|
v
AgentRunResult stream
|
v
Delivery / Renderer / Platform API
```
目标产品模型、单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。本文只说明 Host 如何把当前入口投影为内部模型。当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received` 并投影出临时 `AgentConfig` / `AgentBinding`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway 由外部 event branch 实现。
## 4. LangBot 侧能力
### 4.1 Event GatewayFuture Integration Point
> EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。
Event Gateway 将把入口统一成 host eventIM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`Host 内部模型):
```python
class AgentEventEnvelope(BaseModel):
event_id: str
event_type: str
event_time: int | None
source: str
bot_id: str | None
workspace_id: str | None
conversation_id: str | None
thread_id: str | None
actor: ActorRef | None
subject: SubjectRef | None
input: AgentInput # 见 PROTOCOL_V1 §5.6
delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7
raw_ref: RawEventRef | None
metadata: dict[str, Any] = {}
```
`AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 artifact ref不扩散到 runner 协议顶层。
**当前 adapter source**`QueryEntryAdapter.query_to_event(query)` 从 Query 生成 `AgentEventEnvelope`
### 4.2 AgentConfig 与 AgentBinding
`AgentConfig` 是迁移期的 Host 内部 Agent 配置投影(不暴露给 SDK。当前 Query entry adapter 从 Pipeline config 投影出它;未来持久 Agent 也应先投影成这个运行期配置,再由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`
```python
class AgentConfig(BaseModel):
agent_id: str | None = None
runner_id: str
runner_config: dict[str, Any] = {}
resource_policy: ResourcePolicy = ResourcePolicy()
state_policy: StatePolicy = StatePolicy()
delivery_policy: DeliveryPolicy = DeliveryPolicy()
event_types: list[str] = ["message.received"]
enabled: bool = True
metadata: dict[str, Any] = {}
```
`AgentBinding` 是"什么事件调用哪个 AgentRunner、带什么 Agent 配置"的 Host 内部运行投影(不暴露给 SDK。它是 EventRouter / 当前 QueryEntryAdapter 在一次运行前解析出的有效绑定。
```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
```
BindingResolver 的基数、fan-out 和冲突处理约束见 PROTOCOL_V1 §13本节只定义 Host 内部投影形态。
**当前 adapter source**`QueryEntryAdapter.config_to_agent_config(query, runner_id)`
先把 current config 投影为迁移期 `AgentConfig`,再由
`AgentBindingResolver.resolve_one(event, [agent_config])` 解析出唯一
`AgentBinding`。Pipeline 当前只是迁移期 Agent config sourceAI runner config
→ runner_config、extension preference → resource_policy、output settings →
delivery_policy但新设计不再把这些字段命名为 Pipeline 专属概念。
### 4.3 AgentRunnerRegistry
Registry 收集 runner descriptor来自插件 runtime、开发期本地插件
```python
class AgentRunnerDescriptor(BaseModel):
id: str
source: Literal["plugin"]
label: I18nObject
description: I18nObject | None = None
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_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 格式;插件实例边界见 PROTOCOL_V1 §13。
Host 内置 runner / adapter 不能作为 `AgentRunnerDescriptor.source` 绕过插件
runtime、`run_id``ctx.resources``AgentRunAPIProxy` 权限链。若需要
开发期调试 adapter应放在 Host 内部测试入口,不进入可选 runner 列表。
刷新触发点:插件安装/卸载/升级/重启后Pipeline metadata 请求时发现缓存为空;可选 TTL优先保证正确性
### 4.4 AgentRunOrchestrator
Orchestrator 是唯一运行入口:
```text
run(event, binding)
-> resolve runner descriptor
-> build resources
-> build context
-> register run session
-> call plugin runtime
-> normalize result stream
-> update state
-> unregister run session
```
它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。
典型 run 时序:
```text
QueryEntryAdapter / EventRouter
-> AgentRunOrchestrator.run(event, binding)
-> AgentRunnerRegistry.resolve(runner_id)
-> AgentResourceBuilder.freeze_snapshot(binding, event)
-> AgentRunSessionRegistry.register(run_id, runner_id, snapshot)
-> AgentContextBuilder.build(event, binding, snapshot)
-> PluginRuntimeConnector.run_agent(ctx)
-> AgentRunAPIProxy action
-> validate active run session + caller identity + snapshot
-> Host API / Store
<- AgentRunResult stream
-> apply state.updated to PersistentStateStore
-> write message.completed / artifact.created to Transcript / ArtifactStore
-> render delivery or raise RunnerExecutionError
-> AgentRunSessionRegistry.unregister(run_id)
```
`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata不直接访问插件 runtime跨请求持久化状态必须走授权 storage / 外部服务。
### 4.5 Resource Authorization三层裁剪
LangBot 在每次 run 前生成 `ctx.resources`PROTOCOL_V1 §6来自三层约束
1. runner manifest 声明的 `permissions`(最大能力)。
2. binding / resource policy 允许的资源范围。
3. 当前 event / actor / bot / workspace 的实际权限。
这次裁剪结果必须冻结为 run-scoped authorization snapshot并由
`AgentRunSessionRegistry``run_id` 保存。`ctx.resources` 是投影给 runner
看的同一份授权结果;运行期每个 proxy action 只依据该 snapshot 校验 active
run session、caller plugin identity、resource id、scope、payload size、rate
limit 和 deadline。Handler 不应重新执行三层裁剪,否则 build-time 与 runtime
授权逻辑会漂移。
SDK 侧本地校验只用于开发体验host 侧 run authorization snapshot 才是安全边界。
资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
执行/文件/skill/MCP 等能力的接入方向:先由 Host 封装成普通 tool再通过 `ctx.resources.tools` 进入 runnerrunner 不应识别或硬编码执行环境 provider。
### 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 只能作为过渡实现,不能作为正式生产语义。
### 4.7 EventLog / Transcript / Artifact事实源
- `EventLog`: durable append-only保存原始事件、系统事件、工具调用、投递结果、错误。
- `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
- `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。
三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。
### 4.8 Prompt / Instruction Package占位
当前 Query 入口不把 preprocessing 后的有效 prompt 放进 adapter metadata。目标形态是 Host 保存或生成一个 run-scoped instruction packagerunner 通过 Host API 拉取:
- 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 侧仍保持统一边界Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计Host 或 binding policy 决定哪些 MCP server、skill、artifact、history/state 句柄可投影给 runnerrunner plugin 把 scoped projection 转成目标 harness 可消费形式;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume。
投影的具体形态context 文件、skill 目录、MCP config、state pointers见 AGENT_CONTEXT_PROTOCOL §4.5Claude Code / Codex 当前实现见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。
## 5. SDK 侧协议
SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。
```python
class AgentRunner(BaseComponent):
__kind__ = "AgentRunner"
@classmethod
def get_capabilities(cls) -> AgentRunnerCapabilities: ... # PROTOCOL_V1 §4.3
@classmethod
def get_config_schema(cls) -> list[dict]: ...
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ...
# ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7
```
- Manifest / capabilities / permissions / context policyPROTOCOL_V1 §4。
- `AgentRunContext`PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
- `AgentRunResult`PROTOCOL_V1 §7。
- `AgentRunAPIProxy`PROTOCOL_V1 §8是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`

View File

@@ -0,0 +1,151 @@
# 官方 AgentRunner 插件迁移计划
本文档描述内置 `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 入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner不能被官方插件的实现细节绑死。
## 1. 仓库组织
官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代LangBot 主仓库只维护宿主协议和调度SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。
当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局:
```text
langbot-app/
langbot-local-agent/ # plugin:langbot/local-agent/default
manifest.yaml
components/agent_runner/default.{yaml,py}
langbot-agent-runner/ # 外部服务 runner 仓库
claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ...
```
后续可聚合进 monorepo也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。
## 2. 插件命名和 runner id
| 旧 runner | 官方插件 | runner id |
| --- | --- | --- |
| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` |
| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` |
| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` |
| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` |
| - | `langbot/claude-code-agent` | `plugin:langbot/claude-code-agent/default` |
| - | `langbot/codex-agent` | `plugin:langbot/codex-agent/default` |
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
每个插件可后续提供多个 runner但迁移目标的默认 runner 统一叫 `default`
## 3. 迁移批次
- **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`(平台特有响应格式、引用资料、文件/图片输入)。
## 4. 每个官方插件的组件要求
每个插件至少包含一个 `AgentRunner` 组件manifest 示例:
```yaml
apiVersion: langbot/v1
kind: AgentRunner
metadata:
name: default
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: # 字段语义见 PROTOCOL_V1 §4.3
streaming: true
event_context: true
stateful_session: true
permissions: # 字段语义见 PROTOCOL_V1 §4.4
storage: ["plugin"]
context: # 字段语义见 PROTOCOL_V1 §4.5
supports_history_pull: true
owns_compaction: true
execution:
python: { path: ./main.py, attr: DefaultAgentRunner }
```
## 5. local-agent 插件方向
`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
迁移或重写需覆盖旧内置 runner 的用户可见能力model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。
责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束
-`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` 等)。
### 5.1 Native Execution / Skills 后续接入
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后命令执行、文件操作、skill、MCP managed process 应先由 Host 封装成 scoped tools再通过 `ctx.resources.tools` 暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。
## 6. 外部 runner 插件要求
外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`
### 6.1 Code-agent harness runner
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插件实例边界见 PROTOCOL_V1 §13CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射harness 的 permission mode / allow-deny / MCP 配置只是一层执行约束Host 仍负责调用前的资源授权、路径策略、secret 过滤和审计(发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
实现结构应把 provider-native output 解析与 LangBot result stream 组装分开Claude stream-json、Codex JSONL、Kimi / OpenCode 事件等只在 runner adapter 内解析,输出统一归一为 `AgentRunResult``message.completed` / `message.delta``state.updated``artifact.created``run.completed` / `run.failed`)。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。
并发约束应按外部 session 粒度表达,而不是按 Agent / runner id / 插件实例表达Agent 复用和全局锁边界见 PROTOCOL_V1 §13。若 runner 使用 `external.session_id` / `thread_id` resume 到同一 native session且该 harness 不支持并发 turnrunner 应按稳定 external session key 串行写入;一次性 subprocess runner 可以只在单次 `run(ctx)` 内处理,长连接/daemon runner 则应采用 reader 独占 native stream、turn writer 串行写入的结构。
### 6.2 SDK-owned LangBot 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 actionMCP tool schema 由注解和 Pydantic args model 生成。
- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridgerun 结束后 bridge 关闭。
第一批工具保持很小当前事件快照、history page、knowledge retrieve、authorized tool call。新增工具必须先进入 SDK-owned annotated surface再由 MCP adapter 自动投影。
## 7. Claude Code / Codex runner 当前形态
`claude-code-agent``codex-agent` 是最小可运行 MVP / dev path用来证明外部 harness runner 可以接入同一套 AgentRunner 协议。本地 smoke 验收记录见 [PROGRESS.md](./PROGRESS.md) 与 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
MVP 含义:已验证 event-first context、resource projection、result stream 和
基础 resume state 可以跑通;不表示 Docker 生产部署、发布级执行隔离、
workspace lifecycle、secret projection、团队级 audit 或 runtime sidecar 已完成。
### 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`
- 投影:写入 `agent-context.json`schema `langbot.agent_runner.external_harness_context.v1`)和 `LANGBOT_CONTEXT.md`;可把 `skills-json` 投影到 `.claude/skills/<name>/SKILL.md`;可把 `mcp-config-json` 写成每次 run 的 MCP config 经 `--mcp-config` / `--strict-mcp-config` 传入;可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge。
- 状态Claude Code 返回 `session_id` 时通过 `state.updated` 写回 `external.session_id`;工作目录优先用 config 的 `working-directory`,其次用 Host state 的 `external.working_directory`
### 7.2 Codex runner
- 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 环境变量。
### 7.3 当前限制
不是发布级安全边界实现;默认只做本地 CLI 调用,不实现完整执行隔离或 workspace 生命周期;不实现 issue-centric 队列、复杂 workflow engine 或长期任务调度Docker 环境只能访问容器内 CLI 和凭据Codex 仅验证协议形态,不代表 Codex 发布级能力或 Kimi runner 已完成。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
## 8. 发布和安装策略
最终 LangBot 安装/升级时需保证官方 runner 插件可用可选方案首次启动检测缺失并提示安装打包发行版预装migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用。
## 9. 验收标准
- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
- `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 的用户可见核心能力;代码结构和运行路径不需要相同。

View File

@@ -0,0 +1,163 @@
# Agent Runner 插件化实现进度
本文档跟踪 Agent Runner 插件化的实现状态,便于快速了解当前进度。
> 本文是 agent-runner 插件化**实现状态的唯一事实源**。协议规范见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)Host 架构见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。规范类文档不再各自维护"当前状态/✅"段落,状态一律以本文为准。
> 本文记录最近一次已知实现 / 验收状态,但不替代对当前 checkout 的代码和 WebUI smoke 复核;复核步骤见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
## 总体进度
**当前阶段**: Phase 3.6 已完成Event-first 基础设施与外部 harness runner smoke 已完成2026-06-04 已完成协议 / 文档漂移复核,当前未发布分支不保留 PoC 兼容 shim。
| Phase | 描述 | 状态 |
|-------|------|------|
| Phase 0 | PoC 验证 | ✅ 完成 |
| Phase 1 | 核心架构Registry、Orchestrator、上下文模型 | ✅ 完成 |
| Phase 2 | 权限、能力声明、资源注入 | ✅ 完成 |
| Phase 3 | 内置 runner 迁移到插件 | ✅ 完成7/7 |
| Phase 3.5 | Event-first 基础设施 | ✅ 完成 |
| Phase 3.6 | 外部 harness runner 协议 smoke | ✅ 完成Claude Code MVP |
| Phase 4 | EBA 事件支持 | 🔲 未开始(已预留 event-first 入口EventGateway 由其他分支实现) |
---
## 详细状态
### SDK 侧 (`langbot-plugin-sdk`)
| 组件 | 状态 | 备注 |
|------|------|------|
| `AgentRunner` 组件 | ✅ | `api/definition/components/agent_runner/runner.py` |
| `AgentRunContext` | ✅ | `api/entities/builtin/agent_runner/context.py` |
| `AgentRunResult` | ✅ | `api/entities/builtin/agent_runner/result.py` |
| `AgentRunnerCapabilities` | ✅ | `api/entities/builtin/agent_runner/capabilities.py` |
| `AgentRunnerPermissions` | ✅ | `api/entities/builtin/agent_runner/permissions.py` |
| EBA 事件模型 (Event/Actor/Subject) | ✅ | `api/entities/builtin/agent_runner/event.py` |
| `LIST_AGENT_RUNNERS` action | ✅ | `runtime/io/handlers/control.py` |
| `RUN_AGENT` action | ✅ | `runtime/io/handlers/control.py` |
| `AgentRunAPIProxy` | ✅ | `api/proxies/agent_run_api.py` |
| Pull API handlers (State/History/Event/Artifact) | ✅ | `runtime/io/handlers/plugin.py` |
| `caller_plugin_identity` injection | ✅ | Pull API handlers inject caller identity |
### LangBot 侧
| 组件 | 状态 | 备注 |
|------|------|------|
| `AgentRunnerRegistry` | ✅ | `pkg/agent/runner/registry.py` |
| `AgentRunOrchestrator` | ✅ | `pkg/agent/runner/orchestrator.py` - event-first `run(event, binding)` |
| `AgentRunnerDescriptor` | ✅ | `pkg/agent/runner/descriptor.py` |
| `AgentResourceBuilder` | ✅ | `pkg/agent/runner/resource_builder.py` |
| `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context |
| `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.py` |
| `ConfigMigration` | ✅ | `pkg/agent/runner/config_migration.py` |
| `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 |
| Plugin connector | ✅ | `list_agent_runners()` / `run_agent()` |
| `EventLogStore` | ✅ | `pkg/agent/runner/event_log_store.py` |
| `TranscriptStore` | ✅ | `pkg/agent/runner/transcript_store.py` |
| `ArtifactStore` | ✅ | `pkg/agent/runner/artifact_store.py` |
| `PersistentStateStore` | ✅ | `pkg/agent/runner/persistent_state_store.py` |
| History / Event pull APIs | ✅ | Orchestrator + APIProxy |
| Artifact pull APIs | ✅ | Orchestrator + APIProxy |
| State pull APIs | ✅ | Orchestrator + APIProxy |
| `artifact.created` / `state.updated` handling | ✅ | Event-first handlers in orchestrator |
| Pipeline path host capability coverage | ✅ | EventLog/Transcript/ArtifactStore/PersistentStateStore |
| External harness state handoff | ✅ | `external.session_id` / `external.working_directory` 写入 PersistentStateStore |
### 官方插件
> 外部服务插件仓库:`langbot-agent-runner/`
> 本地 Local Agent 插件仓库:`langbot-local-agent/`
| 插件 | 状态 | 备注 |
|------|------|------|
| `local-agent` | ✅ 已完成 | 核心功能:模型、工具、知识库、流式、会话 |
| `dify-agent` | ✅ 已完成 | 支持 chat/agent/workflow 三种应用类型 |
| `n8n-agent` | ✅ 已完成 | Webhook 调用,支持 basic/jwt/header 认证 |
| `coze-agent` | ✅ 已完成 | 多模态输入,思维链处理 |
| `claude-code-agent` | ✅ MVP smoke 通过 | 本地 Claude Code CLIcontext / skill / MCP 投影host-owned resume state |
| `dashscope-agent` | ✅ 已完成 | 阿里云百炼,支持 agent/workflow 两种模式 |
| `langflow-agent` | ✅ 已完成 | SSE 流式tweaks 配置支持 |
| `tbox-agent` | ✅ 已完成 | 蚂蚁百宝箱,多模态输入 |
**注意**: LangBot 内置 runner`pkg/provider/runners/`)已停用,文件顶部添加了 DEPRECATED 注释。
### 本地验收
| 日期 | 范围 | 状态 | 证据 |
|------|------|------|------|
| 2026-05-29 | `local-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-17-59-00-462-08-00-pipeline-debug-chat.md` |
| 2026-05-29 | `claude-code-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-18-03-31-169-08-00-pipeline-debug-chat.md` |
| 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` |
| 2026-05-29 | Claude Code 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 | 见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) §10 / `langbot-skills/reports/` |
| 2026-06-04 | 协议 / 文档漂移复核 | ✅ PASS | SDK scaffold 与 Protocol v1 对齐LangBot UI 旧 runner fallback 已移除run-scoped API 身份校验已收紧。 |
---
## 未完成但仍属本分支收尾
以下项目属于本分支收尾工作:
- [x] Smoke / manual validation — `local-agent`、Claude Code MVP、Codex MVP 已通过本地 WebUI smoke
- [x] Docs final QA — 2026-06-04 已完成当前 Protocol v1 / scaffold / QA 指南漂移复核
- [ ] Claude Code runner 文档、安装和 marketplace 发布准备
---
## 非本分支范围
以下能力由其他分支负责:
| 能力 | 负责分支 | 备注 |
|------|----------|------|
| EventGateway implementation | event branch | 完整事件网关、事件路由、持久化管理 |
| Event subscription / notification | event branch | 事件订阅、推送通知 |
| BindingResolver persistence UI | 其他模块 | 绑定配置的持久化 UI |
| Event router integration | event branch | 与 BindingResolver 集成 |
| Scheduler / background event source | 其他模块 | 定时任务、后台事件源 |
| Security release hardening | 后续 release gate | 路径隔离、权限边界、secret、MCP/skill 投影策略、资源配额、审计 |
| Codex / Kimi runner 全量接入 | 后续 runner 插件工作 | Codex MVP 已打通Codex 发布级能力、Kimi runner 和全量 hardening 仍不扩大到当前协议闭环 |
| Issue-centric 产品模型 / 异步队列 / workflow engine | 后续产品架构 | 不属于当前 agent-runner plugin 协议闭环 |
---
## 待办事项
### 高优先级
- [x] 工具详情 API — SDK `GET_TOOL_DETAIL` action、`AgentRunAPIProxy.get_tool_detail()` 与 Host 侧授权校验已接通
- [x] Pipeline `run_from_query()``run(event, binding)` — 已完成
- [x] EventLog / Transcript / ArtifactStore / PersistentStateStore — 已完成
- [x] History / Event / Artifact / State pull APIs — 已完成
- [x] `caller_plugin_identity` 验证路径 — 已完成run-scoped session 绑定插件身份时,省略或不匹配 caller identity 都会被拒绝
### 低优先级 / 未来
- [ ] EBA 完整集成 — EventGateway、event subscription、event notification 由其他分支实现
- [ ] 平台 API 动作执行 — `action.requested` 结果类型存在但未执行
- [ ] 安全发布级 hardening — 作为生产默认启用前的 release gate不阻塞当前协议闭环
---
## 关键决策记录
| 日期 | 决策 |
|------|------|
| 2026-05-10 | Phase 0 集成测试通过SDK v1 协议验证成功 |
| 2026-05-13 | Phase 3 完成:所有 7 个官方 runner 插件迁移完成 |
| 2026-05-23 | Phase 3.5 完成:`run_from_query()` 委托到 event-first `run(event, binding)`Pipeline path 获得 host capabilities |
| 2026-05-29 | 本地 `local-agent``claude-code-agent` 通过 WebUI smokeClaude Code runner 验证 external harness context 投影和 host-owned resume state |
| 2026-06-04 | 未发布协议面收敛:移除旧 runner 字段 / 旧本地 runner 名 / PoC schema 兼容分支SDK 文档和模板对齐当前 `AgentRunContext` |
---
## 相关文档
- [README.md](./README.md) — 总体设计与路由
- [PROTOCOL_V1.md](./PROTOCOL_V1.md) — 协议规范(唯一 schema 事实源)
- [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) — Agent Runner QA 指南和下一轮测试入口
- [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划
- [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛

View File

@@ -0,0 +1,669 @@
# LangBot AgentRunner Protocol v1
本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源single source of truth**。
- 本文件描述"稳定接口应是什么",是 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 只解决四件事:
- LangBot 如何发现插件提供的 AgentRunner。
- LangBot 如何把一次事件调用封装成 `AgentRunContext`
- AgentRunner 如何以事件流形式返回运行结果。
- AgentRunner 如何通过受限 API 访问 LangBot host 能力。
Protocol v1 **不定义**
- 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 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
## 2. 参与方
| 名称 | 职责 |
| --- | --- |
| LangBot Host | 事件入口、绑定解析、权限、资源、存储、生命周期、结果投递。 |
| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 |
| AgentRunner | 插件提供的 agent 执行组件。 |
| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 |
| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK见 HOST_SDK §4.2)。 |
产品层的 `Agent` 替代旧 Pipeline 承载 agent 配置bot / IM channel
绑定一个 Agent一个 Agent 可以被多个 bot / channel 复用。Host 内部的
`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的
`ctx.config``ctx.resources``ctx.context``ctx.delivery`。SDK 不需要知道
Agent / binding 的持久化形态。
外部 harness runnerClaude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact API 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。
## 3. 版本协商
- `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 不提升大版本。
## 4. Discovery 协议
### 4.1 LIST_AGENT_RUNNERS
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回
```python
class ListAgentRunnersResponse(BaseModel):
runners: list[AgentRunnerManifest]
```
### 4.2 AgentRunnerManifest
```python
class AgentRunnerManifest(BaseModel):
id: str
name: str
label: I18nObject
description: I18nObject | None = None
protocol_version: str = "1"
capabilities: AgentRunnerCapabilities
permissions: AgentRunnerPermissions
context: AgentRunnerContextPolicy
config_schema: list[DynamicFormItemSchema] = []
metadata: dict[str, Any] = {}
```
- `id` 必须稳定,格式 `plugin:author/name/runner`
- `name` 是插件内 runner 名称,例如 `default`
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。
- `metadata` 只放展示、诊断、非稳定扩展信息。
### 4.3 Capabilities
```python
class AgentRunnerCapabilities(BaseModel):
streaming: bool = False
tool_calling: bool = False
knowledge_retrieval: bool = False
multimodal_input: bool = False
skill_authoring: bool = False
skill_injection: bool = False
event_context: bool = True
platform_api: bool = False
interrupt: bool = False
stateful_session: bool = False
self_managed_context: bool = True
```
语义:
- `streaming`: runner 可以返回 `message.delta`
- `tool_calling`: runner 可能调用 Host tool API。
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
- `skill_authoring`: runner 需要 Host 提供的 skill authoring tools。
- `skill_injection`: runner 需要 Host 在 effective prompt 中注入 skill index。
- `event_context`: runner 理解 event-first 输入。
- `platform_api`: runner 可能请求平台动作。
- `interrupt`: runner 支持取消或中断。
- `stateful_session`: runner 可能维护跨 run 会话状态。
- `self_managed_context`: runner 自己管理 working contextHost 不应默认 inline 历史。
> Capabilities 字段全部是 `bool`。runner 是否寄宿 host-owned state **不在 capabilities 表达**,而通过 `permissions.storage` 声明(见 §4.4),避免出现非 bool 取值。
### 4.4 Permissions
```python
class AgentRunnerPermissions(BaseModel):
models: list[Literal["invoke", "stream", "rerank"]] = []
tools: list[Literal["detail", "call"]] = []
knowledge_bases: list[Literal["list", "retrieve"]] = []
history: list[Literal["page", "search"]] = []
events: list[Literal["get", "page"]] = []
artifacts: list[Literal["metadata", "read"]] = []
storage: list[Literal["plugin", "workspace", "binding"]] = []
files: list[Literal["config", "knowledge"]] = []
platform_api: list[str] = []
```
Manifest permissions 是 runner 需要的**最大能力**。实际可用资源还要经过 Host binding policy 和当前 run scope 裁剪(三层裁剪见 HOST_SDK §4.5)。
### 4.5 Context Policy
```python
class AgentRunnerContextPolicy(BaseModel):
supports_history_pull: bool = True
supports_history_search: bool = False
supports_artifact_pull: bool = True
owns_compaction: bool = True
wants_static_context_refs: bool = True
```
Host 不使用该声明给 runner inline 历史窗口。默认原则:
- Host 不得默认 inline 全量历史。
- Host 只 inline 当前 event / input 和 context handles。
- Runner 拥有 working context assembly。
- Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。
- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。
context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
## 5. Run 协议
### 5.1 RUN_AGENT
Host 调用 Runtime
```python
class AgentRunRequest(BaseModel):
runner_id: str
runner_name: str
context: AgentRunContext
```
Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id``context` 为准。
### 5.2 AgentRunContext
这是 SDK 看到的**唯一权威 context 定义**。
```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
delivery: DeliveryContext
resources: AgentResources
context: ContextAccess
state: AgentRunState
runtime: AgentRuntimeContext
config: dict[str, Any] = {}
adapter: AdapterContext | None = None
metadata: dict[str, Any] = {}
```
核心约束:
- `event` 是必选字段Protocol v1 是 event-first。
- `input` 表示当前事件的主输入,不等于历史消息。
- `bootstrap` / `messages` **不是协议字段**Host 不内联历史窗口。
- `adapter` 只放入口 adapter 的非核心元数据runner 不应依赖它做长期能力。
- `config` 是 Agent/runner config不是插件实例状态。
### 5.3 AgentTrigger
```python
class AgentTrigger(BaseModel):
type: str
source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"]
timestamp: int | None = None
```
`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时:
```json
{ "type": "message.received", "source": "host_adapter" }
```
### 5.4 AgentEventContext
```python
class AgentEventContext(BaseModel):
event_id: str
event_type: str
event_time: int | None = None
source: str
source_event_type: str | None = None
raw_ref: RawEventRef | None = None
data: dict[str, Any] = {}
```
- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
- 平台原始事件名放入 `source_event_type`
- 大型原始 payload 必须放入 `raw_ref` 或 artifact不应直接塞入 `data`
### 5.5 Conversation / Actor / Subject
```python
class ConversationContext(BaseModel):
conversation_id: str | None = None
thread_id: str | None = None
launcher_type: str | None = None
launcher_id: str | None = None
bot_id: str | None = None
workspace_id: str | None = None
class ActorContext(BaseModel):
actor_type: str
actor_id: str | None = None
actor_name: str | None = None
metadata: dict[str, Any] = {}
class SubjectContext(BaseModel):
subject_type: str
subject_id: str | None = None
data: dict[str, Any] = {}
```
示例:
- 消息事件actor 是发消息的人subject 是当前消息。
- 入群事件actor 是新成员或邀请人subject 是群/成员关系。
- 定时事件actor 可以是 systemsubject 是 schedule。
### 5.6 AgentInput
```python
class AgentInput(BaseModel):
text: str | None = None
contents: list[ContentElement] = []
attachments: list[ArtifactRef] = []
message_chain: dict[str, Any] | None = None
```
- 文本、多模态、附件都属于当前 event input。
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。
- `message_chain` 是平台兼容字段,不应成为长期稳定依赖。
### 5.7 DeliveryContext
```python
class DeliveryContext(BaseModel):
surface: str
reply_target: dict[str, Any] | None = None
supports_streaming: bool = False
supports_edit: bool = False
supports_reaction: bool = False
max_message_size: int | None = None
platform_capabilities: dict[str, Any] = {}
```
Runner 可参考 delivery 能力决定返回 `message.delta``message.completed``action.requested`
### 5.8 ContextAccess
```python
class ContextAccess(BaseModel):
conversation_id: str | None = None
thread_id: str | None = None
latest_cursor: str | None = None
event_seq: int | None = None
transcript_seq: int | None = None
has_history_before: bool = False
inline_policy: InlineContextPolicy
available_apis: ContextAPICapabilities
class InlineContextPolicy(BaseModel):
mode: Literal["none", "current_event", "recent_tail", "summary_tail"]
delivered_count: int = 0
source_total_count: int | None = None
messages_complete: bool = False
reason: str | None = None
class ContextAPICapabilities(BaseModel):
history_page: bool = False
history_search: bool = False
event_get: bool = False
event_page: bool = False
artifact_metadata: bool = False
artifact_read: bool = False
state: bool = False
storage: bool = False
```
`ContextAccess` 告诉 runnerHost inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。
### 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
locale: str | None = None
timezone: str | None = None
static_refs: dict[str, StaticContextRef] = {}
metadata: dict[str, Any] = {}
```
`static_refs` 用于 KV cache 友好的静态上下文引用system policy、tool schema、resource manifest 的 hash/version。理由见 AGENT_CONTEXT_PROTOCOL §6。
### 5.10 AgentRunState
```python
class AgentRunState(BaseModel):
conversation: dict[str, Any] = {}
actor: dict[str, Any] = {}
subject: dict[str, Any] = {}
runner: dict[str, Any] = {}
```
State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
## 6. Resources
```python
class AgentResources(BaseModel):
models: list[ModelResource] = []
tools: list[ToolResource] = []
knowledge_bases: list[KnowledgeBaseResource] = []
files: list[FileResource] = []
storage: StorageResource = StorageResource()
platform_capabilities: dict[str, Any] = {}
```
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
## 7. Result Stream
### 7.1 AgentRunResult envelope
```python
JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
ResultType = Literal[
"message.delta",
"message.completed",
"tool.call.started",
"tool.call.completed",
"artifact.created",
"state.updated",
"action.requested",
"run.completed",
"run.failed",
]
class AgentRunResultBase(BaseModel):
run_id: str
sequence: int | None = None
timestamp: int | None = None
metadata: dict[str, Any] = {}
```
`AgentRunResult` 是以下 typed result 的 discriminated union。Host 必须按 `type` 校验对应 `data` 结构;未知 `type` 按 §3 版本演进规则忽略并记录 warning。
### 7.2 稳定 result payloads
```python
class AssistantMessageChunk(BaseModel):
role: Literal["assistant"] = "assistant"
content: str | None = None
contents: list[ContentElement] = []
metadata: dict[str, Any] = {}
class AssistantMessage(BaseModel):
role: Literal["assistant"] = "assistant"
content: str | None = None
contents: list[ContentElement] = []
artifacts: list[ArtifactRef] = []
metadata: dict[str, Any] = {}
class MessageDeltaData(BaseModel):
chunk: AssistantMessageChunk
class MessageCompletedData(BaseModel):
message: AssistantMessage
class ToolCallStartedData(BaseModel):
tool_call_id: str
tool_name: str
parameters: dict[str, Any] = {}
class ToolCallCompletedData(BaseModel):
tool_call_id: str
tool_name: str
result_preview: dict[str, Any] | None = None
error_code: str | None = None
error_message: str | None = None
class ArtifactCreatedData(BaseModel):
artifact: ArtifactRef
class StateUpdatedData(BaseModel):
scope: Literal["conversation", "actor", "subject", "runner", "binding", "workspace"]
key: str
value: JSONValue
class ActionRequestedData(BaseModel):
action: str
target: dict[str, Any]
payload: dict[str, Any] = {}
idempotency_key: str | None = None
approval_hint: str | None = None
class RunCompletedData(BaseModel):
finish_reason: str = "stop"
message: AssistantMessage | None = None
usage: dict[str, Any] = {}
class RunFailedData(BaseModel):
code: str
message: str
retryable: bool = False
details: dict[str, Any] = {}
class MessageDeltaResult(AgentRunResultBase):
type: Literal["message.delta"]
data: MessageDeltaData
class MessageCompletedResult(AgentRunResultBase):
type: Literal["message.completed"]
data: MessageCompletedData
class ToolCallStartedResult(AgentRunResultBase):
type: Literal["tool.call.started"]
data: ToolCallStartedData
class ToolCallCompletedResult(AgentRunResultBase):
type: Literal["tool.call.completed"]
data: ToolCallCompletedData
class ArtifactCreatedResult(AgentRunResultBase):
type: Literal["artifact.created"]
data: ArtifactCreatedData
class StateUpdatedResult(AgentRunResultBase):
type: Literal["state.updated"]
data: StateUpdatedData
class ActionRequestedResult(AgentRunResultBase):
type: Literal["action.requested"]
data: ActionRequestedData
class RunCompletedResult(AgentRunResultBase):
type: Literal["run.completed"]
data: RunCompletedData
class RunFailedResult(AgentRunResultBase):
type: Literal["run.failed"]
data: RunFailedData
AgentRunResult = (
MessageDeltaResult
| MessageCompletedResult
| ToolCallStartedResult
| ToolCallCompletedResult
| ArtifactCreatedResult
| StateUpdatedResult
| ActionRequestedResult
| RunCompletedResult
| RunFailedResult
)
```
### 7.3 稳定 result types
| 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 失败。 | ✅ |
`action.requested` 是为 EBA 和 platform API 预留的协议表面:当前阶段 Host 收到后只记 telemetry**不执行**runner 作者不应依赖其副作用。执行模型见 EVENT_BASED_AGENT §6。
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。`action.requested` 如果请求未来会产生外部副作用runner 必须提供稳定 `idempotency_key`;当前阶段 Host 仍只记录 telemetry。
### 7.4 Stream delivery semantics
- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay也不承诺 at-least-once从 Host 视角,收到的 result 最多应用一次。
- `sequence` 是单个 `run_id` 内的结果序号。in-process / stdio 这类天然有序的在线 stream 可以省略;任何会缓冲、重放、跨进程队列或 runtime-managed task 的 transport 必须提供从 1 开始严格递增的 `sequence`
- Host 看到已提供 `sequence` 的 result 时,应按 `(run_id, sequence)` 做重复检测,并在缺号或乱序时记录 warning除非 transport 明确声明 replay 语义Host 不应自行等待缺失序号重排用户可见输出。
- `run.failed.data.retryable` 只表示整次 run 理论上可由上层重试Protocol v1 不自动重试 run也不自动重试 proxy action。任何未来自动重试的 side-effecting action 必须依赖 `idempotency_key` 或等价 Host-owned 去重键。
- History / Event / Transcript cursor 是 opaque token。runner 不得解析 cursor也不得假设 cursor 在不同 API、conversation、thread 或 retention window 之间可比较;当前实现即使返回数字字符串,也只是实现细节。
### 7.5 示例
```json
{ "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": "..."}, "idempotency_key": "run_1:edit:msg_1" } }
```
## 8. AgentRunAPIProxy
所有 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)
# Tool
await api.tools.get_detail(tool_name)
await api.tools.call(tool_name, parameters)
# Knowledge
await api.knowledge.retrieve(kb_id, query_text, top_k=5, filters=None)
# 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)
# Event返回稳定 event envelope 或受限 raw ref不默认返回大 payload
await api.events.get(event_id)
await api.events.page(before_cursor=None, limit=50)
# 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)
# 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)
# Platform受限能力默认不开放需 manifest + binding policy + 用户审批同时允许)
await api.platform.request_action(action, target, payload)
```
`state``storage` 的建议边界:`state` 放小型 JSONconversation / actor / runner / binding`storage` 放 blob 或较大数据插件私有数据、workspace 数据、checkpoint
返回数据结构(如 `HistoryPage`、artifact metadata见 AGENT_CONTEXT_PROTOCOL §4。
## 9. 错误模型
```python
class AgentAPIError(BaseModel):
code: str
message: str
retryable: bool = False
details: dict[str, Any] = {}
```
| code | 说明 |
| --- | --- |
| `unauthorized` | 未授权访问资源或 scope。 |
| `not_found` | 资源不存在或对当前 runner 不可见。 |
| `deadline_exceeded` | 超过 run deadline。 |
| `payload_too_large` | 请求或响应过大。 |
| `rate_limited` | Host 限流。 |
| `invalid_argument` | 参数错误。 |
| `runtime_error` | Host 或下游能力错误。 |
Runner 失败使用 `run.failed`
```json
{ "type": "run.failed", "data": { "code": "runner.error", "message": "failed to call external agent", "retryable": false } }
```
## 10. Timeout 与 Cancellation
- Host 在 `ctx.runtime.deadline_at` 下发总 deadlineSDK proxy 必须用该 deadline 限制单次 action timeout。
- Host 可以取消 active runRuntime 应尽力中断 runner。
- Runner 支持中断时应返回或触发 `run.failed`code 为 `cancelled`
- Host 必须 unregister active run session。
## 11. Security 与 Guardrail协议层
Protocol v1 的安全边界在 Host
- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。
- SDK 本地校验只提升开发体验,不能替代 Host 校验。
- 所有 resource id 对 runner 来说都是 opaque。
- 默认只能访问当前 conversation / thread 的 history跨会话、workspace 级访问必须额外授权。
- 大 payload 必须 artifact 化。
- Host 必须记录 run_id、runner_id、action、resource、scope、result。
Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。
对外部 harness runnerHost 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计runner plugin 把授权后的 context/resource projection 适配为目标 harness 的形式harness 的 native permission mode、allowed/disallowed tools 只是额外执行约束,不能替代 Host 授权。
> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
## 12. Pipeline Adapter 边界
Pipeline 是当前入口 adapter不是协议中心。目标产品模型中 Agent 会替代
Pipeline 承载 runner config、resource policy 和 delivery policy当前 Query
entry adapter 只是迁移桥。它负责:
-`Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。
- 从当前 Agent/runner config 构造 `ctx.config`
- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`
约束:
- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。
- `ctx.adapter.extra` 只允许承载一次性、JSON-safe、入口相关的非核心元数据例如 `params`;不得承载 `prompt`、history window、RAG 结果、tool schema 或授权资源。
- 静态绑定 prompt 属于 `ctx.config.prompt`。preprocessing / hook 后的动态有效指令不通过 `ctx.adapter.extra` 主动推送;后续如需要保留这类能力,应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。
- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。
## 13. 已确认约束
- v1 / EBA 主线是 `one event -> one AgentBinding -> one run_id -> one runner`
- 一个 bot / IM channel 在同一时间只绑定一个负责 agentic 处理的 Agent一个 Agent 可以被多个 bot / channel 复用。
- 如果配置层出现多个匹配 AgentBindingBindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。
- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。
- `AgentRunnerDescriptor.source` 只允许 `plugin`Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。
- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshotruntime handler 不应重新执行资源裁剪。
- v1 不要求 Agent、AgentRunner 插件实例或 runner id 全局串行。多个 bot / channel 可复用同一个 Agent并发隔离依赖 `run_id`、binding、conversation / thread scope 和 Host authorization snapshot。
-`stateful_session` runner若外部 runtime 不支持同一 session 并发 turn串行化粒度应是稳定的 external session key例如 workspace / bot / binding / runner / conversation / thread / external session id不是 Agent 或插件实例全局锁。
- 外部 harness runner 当前是 MVP / dev path证明协议可接入不代表发布级安全边界或 Docker 生产可用性完成。
## 14. 开放问题
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
- `TranscriptItem` 的最小字段集如何定义。
- ArtifactStore 是否复用现有 BinaryStorage backend还是引入独立实体。
- State 与 Storage 的边界是否需要更强类型。
- `platform_api` action 的审批模型如何表达。
- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。

View File

@@ -0,0 +1,153 @@
# Agent Runner 插件化文档入口
本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 预留和官方 runner 迁移混在同一份 README 里。
## 背景与问题
旧 runner 路径主要围绕 Pipeline / Query 和 `pkg/provider/runners` 内置实现展开,扩展外部 agent runtime 时容易把 runner 选择、上下文裁剪、资源授权和消息投递绑在同一条聊天链路里。这个分支要把 LangBot 收敛成 Agent HostHost 负责事件、绑定、授权、事实源和结果投递AgentRunner 作为插件或外部 harness 消费统一协议并自主管理 prompt / history / memory。
## 文档维护原则(单一事实源)
- **协议数据结构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 外化 / 插件化基础设施**
本分支只做 LangBot 作为 Agent Host 的基础能力建设,为后续用 `Agent`
替代 Pipeline 承载 agent 配置打底:
- LangBot 与 SDK 的稳定协议合同Protocol v1
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型
- `run(event, binding)` event-first 入口
- `QueryEntryAdapter`Query → AgentEventEnvelope + AgentBinding
- EventLog / Transcript / ArtifactStore / PersistentStateStore
- History / Event / Artifact / State pull APIs
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
## 本分支不实现
以下能力由其他分支负责,本分支只预留 integration point
- **EventGateway**:完整事件网关实现、事件路由、事件持久化管理
- **Event subscription / Event notification**:事件订阅、推送通知
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
- **Scheduler / Background event source**:定时任务、后台事件源
- **Runtime control plane v2**runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit
EventGateway 在本文档中描述为 **future integration point**,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。
本分支与后续 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
## 目标产品模型
未来产品层应把 `Agent` 理解为 Pipeline 的替代物:原先 bot 绑定 PipelinePipeline 携带 agent/provider/RAG/tool 等配置;后续应改为 bot 或 IM channel 绑定一个 AgentAgent 携带 runner id、runner config、resource/state/delivery policy 等 agent 配置。
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13README 不复写这些约束。
## 当前入口关系
**当前 Pipeline 是入口 adapter不再是 agent runner 设计核心。**
主入口仍可由 Pipeline 触发,但内部已转换成 event-first path`run_from_query()``QueryEntryAdapter``Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Pipeline path 因此获得了 event-first host capabilitiesEventLog / Transcript / ArtifactStore / PersistentStateStore 写入History / Event / Artifact / State pull API 可用)。
详细实现进度、已验收能力和未完成收尾见 [PROGRESS.md](./PROGRESS.md)。
## 术语表
| 术语 | 含义 |
| --- | --- |
| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 |
| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 |
| AgentConfig | Host 内部迁移期配置投影,由当前 Pipeline config 或未来持久 Agent 生成。 |
| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 |
| envelope | Host 内部事件封装,即 `AgentEventEnvelope`runner 看到的是由它投影出的 `ctx.event`。 |
| descriptor / manifest | runner discovery 的能力和配置描述manifest 来自插件descriptor 是 Host 校验后的注册表视图。 |
| EBA | Event Based Agent未来把消息、撤回、入群、定时任务等都统一成 host event 的接入方向。 |
| harness runner | Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 |
| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 |
| `static_refs` | KV cache 友好的静态上下文引用,例如 system policy、tool schema、resource manifest 的 hash/version。 |
| Runtime Control Plane | v2 Host 能力层,负责 runtime registry、heartbeat、task queue、progress/cancel 和 audit不是 Protocol v1 主线。 |
## 设计文档
| 文档 | 关注点 |
| --- | --- |
| [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 友好的上下文管理。 |
| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与后续 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由后续分支接入。 |
| [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 基础能力设计的前置约束。 |
| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
| [PROGRESS.md](./PROGRESS.md) | **🔒 唯一状态事实源**。当前实现进度、已验收能力、未完成收尾和非本分支范围。 |
## 工作拆分
### 1. LangBot + SDK 基础设施
目标是把 LangBot 从内置 runner 执行器变成 agent host
- LangBot 与 SDK 的稳定协议合同
- runner manifest / descriptor / registry
- Agent / binding 配置解析
- run orchestration 和生命周期管理
- resource authorization 与 `run_id` 级权限校验
- host-owned state / storage / event log / transcript / artifact 能力
- SDK `AgentRunner``AgentRunContext``AgentRunResult``AgentRunAPIProxy`
协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
### 2. Agent-owned context
LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 APIagent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。
Host 不定义通用历史窗口字段或策略runner 通过 Host pull API 按需拉取历史并自行管理 working context。
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
### 3. Event Based AgentFuture
消息只是事件的一种。后续 `message.received``message.recalled``group.member_joined``friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。
EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支为 EBA 预留的入口点。
**本分支不实现 EBA 完整能力,只预留:**
- event-first envelope (`AgentEventEnvelope`)
- AgentBinding model
- `run(event, binding)` 入口
- QueryEntryAdapter当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source
详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
### 4. 官方 runner 插件
官方 `local-agent` 和外部 runner 迁移是下游工作。它们需要依附 LangBot 提供的宿主能力,但不应反过来决定宿主协议。
`local-agent` 可以外移,也可以重写。验收重点是它能完整消费 LangBot 的模型、工具、知识库、存储、事件、history API 和 result stream而不是保留旧内置 runner 的内部结构。
详见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
### 5. Runtime Control Plane v2Future
当前 AgentRunner v1 主线只负责 `event -> binding -> runner.run(ctx) -> result stream`
后续 Agent Platform v2 可以在 Host 侧新增 runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit。
在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验runtime/task 的事实源仍由 Host 持有。
详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
## 约束事实源
本分支已确认约束不在 README 重写:
- Runner 可见协议、result stream 和调度边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
- Host 内部 `AgentConfig` / `AgentBinding` 投影见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
- 后续 EBA / Agent Platform / Runtime Control Plane 接入边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。

View File

@@ -0,0 +1,228 @@
# Agent Runtime Control Plane V2
本文档记录后续 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。
> 与当前 runner 外化分支、EBA 和 Agent Platform 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
## 1. 结论
当前主线应继续收口 AgentRunner v1
```text
message/event -> binding -> runner.run(ctx) -> result stream
```
Runtime Control Plane v2 在 Host 侧新增 runtime control plane
```text
event -> task -> runtime selection -> daemon claim -> execute -> progress/audit/result
```
在 Runtime Control Plane v2 之上,可以构建独立的 agent 管控面插件。插件负责 UI、策略和编排体验runtime、task、heartbeat、audit 的事实源必须属于 LangBot Host而不是插件私有 storage。
## 2. 不影响 v1 主线
v2 不应改变 AgentRunner v1 的基本契约:
- 现有 `local-agent`、Dify、n8n、Coze 等 runner 仍可按 v1 直接执行。
- 当前 Claude Code / Codex MVP runner 可以继续作为本机 subprocess 开发路径。
- Host v1 已有的 event-first context、resource authorization、history / event / artifact / state / storage pull APIs 继续保留。
- Pipeline 仍只是当前入口 adapter不参与 v2 runtime 管控面的设计中心。
v2 只是在 Host 上新增一层可选能力。需要管控面的 runner 或管理插件可以声明使用它;不需要的 runner 不受影响。
## 3. 当前 Host 能力与缺口
当前 Host 已经具备 v2 的基础设施底座:
- `AgentEventEnvelope` / `AgentBinding`
- run-scoped resource authorization
- EventLog / Transcript / ArtifactStore / PersistentStateStore
- History / Event / Artifact / State / Storage pull APIs
- AgentRunner result stream 和受控错误回流
- Agent/runner config 与 host-owned state
这些能力足够支持一次 `runner.run(ctx)` 内的安全执行,但不足以承担完整 runtime 管控面。
v2 还需要 Host 新增:
- runtime registryruntime id、所属 workspace、所在机器、provider 能力、状态。
- capability discovery`claude` / `codex` / 其它 CLI 是否存在、版本、登录状态、执行隔离能力。
- heartbeat / livenessruntime 在线、忙闲、最后心跳、可用 slot。
- task queueenqueue、claim、start、progress、complete、fail、cancel。
- workspace mappingLangBot workspace / project 如何映射到 runtime 上的真实目录、仓库或挂载。
- secret / env projection按授权向 runtime 投影 token、代理、MCP 配置、技能和环境变量。
- runtime auditstdout、stderr、事件流、产物、失败原因、执行耗时、使用量。
- control API / UI选择 runtime、测试 runtime、查看状态、下线、取消任务、重试任务。
## 4. 角色边界
### 4.1 LangBot Host
Host 是事实源和控制面内核:
- 保存 runtime / task / heartbeat / audit 状态。
- 做权限校验、资源裁剪、workspace 绑定和审计。
- 决定任务是否可被某 runtime claim。
- 将执行结果统一回写到 event / transcript / artifact / state。
Host 不应内置具体 agent CLI 的复杂业务逻辑,也不应把某个官方 runner 的特殊行为提升为通用协议。
### 4.2 Agent 管控面插件
管理插件是 v2 control plane 的产品化管理层:
- 展示 runtime、agent、task、进度、失败、审计。
- 提供策略配置,例如默认 runtime、provider 偏好、并发限制、重试策略。
- 触发 runtime 测试、任务取消、任务重试、手动分配。
管理插件不应把 runtime/task 的事实源放进自己的 plugin storage。它应该调用 Host v2 API。
### 4.3 Runtime daemon / worker
Runtime daemon 负责真实执行:
- 在所在机器上检测 CLI 和版本。
- 管理工作目录、仓库、挂载、临时文件和进程。
- 从 Host claim 任务,执行后上报 progress / complete / fail。
- 将 stdout / stderr / artifacts / session id 回流 Host。
Claude Code、Codex、OpenCode、Gemini CLI 等 provider 适配逻辑应主要落在 daemon / worker 或 provider adapter 中。
## 5. 部署形态
### 5.1 uv / local embedded
用户用 `uv` 或源码直接启动 LangBot 时LangBot 进程所在机器就是 runtime host。
这种模式下可以直接检测用户主机上的 `claude``codex` 等 CLI也可以直接 subprocess 执行。它适合个人开发和本地 smoke但不应作为团队级管控面的唯一形态。
### 5.2 Docker embedded
用户用 Docker 启动 LangBot 时runtime host 是容器,不是宿主机。
因此:
- 只能检测容器内的 `claude``codex`
- 只能使用容器内的 HOME、PATH、凭据和挂载目录。
- 如果镜像未安装 CLI或未挂载认证文件 / workspaceCLI runner 会不可用。
Docker embedded 可以作为高级部署选项,但需要用户显式安装 CLI、挂载工作区和凭据。Host 不应假设 Docker 容器能自动访问宿主机 CLI。
### 5.3 Sidecar daemon
推荐的 v2 形态是 sidecar daemon
```text
LangBot Host (Docker or server)
<-> Runtime daemon on user host / worker host
-> claude / codex / other CLI
```
这种模式下LangBot 可以跑在 Docker 内runtime daemon 跑在宿主机或独立 worker 机器上。daemon 负责检测本机 CLI、持有本机凭据和工作区访问能力。
### 5.4 Remote runtime
团队场景可以使用远端 runtime
- 开发机、构建机、云主机或专用 worker。
- 多个 workspace 可绑定不同 runtime。
- Host 只通过 registry / task queue / heartbeat / audit 进行管理。
### 5.5 API-only agent
Dify、n8n、Coze、DashScope 等 API 型 runner 不依赖本地 CLI。它们可以继续按 v1 直接执行,也可以在未来按需要接入 v2 task/audit。
## 6. 与 Claude Code / Codex MVP runner 的关系
当前 Claude Code / Codex runner 是 v1 runner
```text
runner.run(ctx) -> subprocess("claude" / "codex")
```
它们适合验证 Host context 投影、state resume、result stream 和基础 CLI 调用,但有明确限制:
- 命令只在 LangBot runtime host 上执行。
- Docker 环境只能看到容器内 CLI。
- 没有 runtime registry、heartbeat、task queue、cancel、workspace lifecycle。
- 不提供发布级执行隔离、secret projection、团队级 audit。
v2 不需要删除这些 runner。它们可以继续作为 dev / MVP 路径存在。未来若接入管控面,可以增加 runtime-managed 执行模式:
```text
runner binding -> Host task -> runtime daemon -> provider CLI -> Host result
```
## 7. 最小 v2 API 草案
以下仅记录能力边界,不代表最终 API 命名。
Runtime
- `runtime.register`
- `runtime.heartbeat`
- `runtime.list`
- `runtime.get`
- `runtime.disable`
- `runtime.capabilities.report`
- `runtime.capabilities.probe`
Task
- `task.enqueue`
- `task.claim`
- `task.start`
- `task.progress`
- `task.complete`
- `task.fail`
- `task.cancel`
- `task.retry`
Workspace
- `runtime.workspace.bind`
- `runtime.workspace.unbind`
- `runtime.workspace.resolve`
Audit / artifacts
- `task.log.append`
- `task.artifact.create`
- `task.events.page`
这些 API 应由 Host 提供,并受 workspace、runtime、binding、actor 和 plugin identity 约束。
## 8. 管控面插件可以构建的能力
基于 v2 Host 能力,可以实现一个类似 Multica 的 agent 管控面插件。这里的“类似 Multica”只指产品形态一个集中页面管理 agent profile、runtime 连接、任务队列、执行进度、失败诊断和审计视图;不是引入新的 runner 协议或把 runtime/task 事实源交给插件。
- runtime 列表、在线状态、CLI 能力、版本、认证状态。
- agent profile 与 runtime/provider 绑定。
- 任务看板、任务详情、进度流、失败原因、重试和取消。
- workspace 到 runtime 目录 / 仓库的映射管理。
- provider capability 测试,例如 Claude Code / Codex 是否可执行。
- 审计视图输入、输出、工具、artifact、stdout/stderr、session id。
- 策略配置:并发、队列、默认 runtime、fallback runtime、权限模式。
该插件应该是 Host v2 的消费者,而不是 Host v2 的替代品。
## 9. 设计原则
- v1 先稳定v2 可选叠加。
- Host 保存事实源,插件提供管理体验。
- Runtime daemon 执行具体 CLI 和本机资源访问。
- Docker 不假设拥有宿主机 CLI需要 sidecar 或显式挂载。
- Pipeline 不进入 v2 控制面中心。
- 直接 subprocess runner 可保留,但只作为 local/dev/MVP 路径。
- 发布级能力必须经过 Host 权限、审计和资源边界。
## 10. 待定问题
- runtime daemon 与 Host 的认证模型workspace token、device token、还是 scoped PAT。
- task 与 AgentRunner binding 的映射关系:由 binding 直接 enqueue还是由独立 task policy 决定。
- runtime capability schema 的稳定字段provider、version、login status、execution isolation、workspace access、slot。
- secret projection 的边界Host 存储、用户本机存储、或外部 secret manager。
- Docker compose 是否提供官方 sidecar daemon 示例。
- v2 UI 是核心前端的一部分,还是完全由管理插件提供。

View File

@@ -0,0 +1,74 @@
# Agent Runner Security Hardening
本文档记录 agent-runner 插件化进入生产发布前需要补齐的安全与稳定加固项。
## 状态
**当前结论:暂不塞进本阶段 agent-runner plugin 协议闭环。**
本阶段目标是验证 LangBot 可以通过统一的 `run(event, binding)` 协议接入 `local-agent` 与外部 harness runner如 Claude Code runner并能传递事件、上下文、资源句柄、状态和结果流。
安全发布级 hardening 是后续 release gate不应阻塞当前协议闭环但必须作为进入生产默认启用前的验收条件。
> **硬规则**:能执行代码 / 访问工作目录的外部 harness runnerClaude Code、Codex、Kimi Code 等)在本文 Release Gate Checklist 完成前,**不得在生产环境默认启用**。本地 smoke 通过不等于可生产默认开启。
## 责任边界
### LangBot Host 负责
- 资源授权:决定某个 `run_id` / binding 可以访问哪些模型、RAG、MCP、skill、artifact、history、state。
- 资源投影:只把授权后的资源句柄、配置片段或上下文文件传给 runner。
- 路径策略:限制 workspace / context file / artifact 的允许路径和清理策略。
- Secret 策略:过滤环境变量、配置、日志和 transcript 中的 secret。
- 运行约束:配置超时、轮次、并发、配额、输出大小和取消路径。
- 审计记录记录事件、绑定、资源授权、runner 调用、外部 harness session id、关键错误和结果摘要。
### Runner Plugin 负责
- 遵守 LangBot 下发的 Agent/runner config、授权资源和运行约束。
- 将 LangBot 资源投影成目标 runner 可消费的形式,例如 context 文件、MCP 配置、环境变量或 CLI 参数。
- 遵守 PROTOCOL_V1 §13 的插件实例边界;需要跨轮次保存的外部 session id / working directory 等状态应写入 host-owned state。
- 对外部进程做最小必要封装,包括命令参数构造、超时、取消、输出解析和错误映射。
### 外部 Harness 负责
Claude Code、Codex、Kimi Code 等外部 harness 可以继续使用自身的权限模型、工具 allow / deny 规则、MCP 加载策略、session/resume 机制和沙箱能力。
但外部 harness 不是 LangBot 的唯一安全边界。LangBot 仍必须在调用前完成资源授权、路径限制、secret 过滤和审计记录。
## 当前 MVP 可接受边界
当前阶段可以接受以下前提:
- 由可信管理员配置 runner binding。
- 工作目录和 context 输出目录为显式配置或 host 生成路径。
- 外部 runner 默认使用保守权限,例如 plan / no-write 模式或禁用高风险工具。
- 通过 timeout、max turns、输出长度和进程取消降低失控风险。
- 通过 host-owned state 保存 `external.session_id``external.working_directory` 等 resume 所需指针。
这些前提足够做本地 E2E 与协议验收,不等同于生产发布完成。
## Release Gate Checklist
进入生产默认启用前,需要补齐:
- Path isolationworkspace allowlist、路径规范化、防止 `..` 逃逸、context / artifact 清理。
- Permission boundaryrunner 能力声明、binding 级资源授权、run 级权限校验。
- Secret handling环境变量白名单、配置脱敏、日志和 transcript redaction。
- MCP policyMCP server allowlist、scoped token、tool allow / deny、危险工具审计。
- Skill projection policyskill 来源验证、只读投影、版本和摘要记录。
- Process isolation进程组管理、取消、超时、CPU / 内存 / 输出配额。
- State lifecyclesession id、workspace、artifact 的过期、清理、迁移和审计。
- Audit first-class事件、资源授权、外部命令、session id、结果摘要可追踪。
- UI / Admin control管理员能看到 runner 权限、风险提示、资源绑定和禁用入口。
- Test matrix路径逃逸、secret 泄漏、权限拒绝、timeout、取消、MCP deny、resume、cleanup、audit 完整性。
## 非当前范围
以下内容不属于本阶段协议闭环:
- 完整异步队列与 issue-centric 产品模型。
- 复杂 workflow engine。
- Codex / Kimi runner 全量接入。
- EBA 分支完整迁移和联调。
- 发布级安全 hardening 的完整实现。

View File

@@ -105,6 +105,9 @@ classifiers = [
"Topic :: Communications :: Chat",
]
[tool.uv.sources]
langbot-plugin = { path = "../langbot-plugin-sdk", editable = true }
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"

View File

@@ -0,0 +1,37 @@
"""Agent runner subsystem for LangBot."""
from __future__ import annotations
from .runner.descriptor import AgentRunnerDescriptor
from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id
from .runner.errors import (
AgentRunnerError,
RunnerNotFoundError,
RunnerNotAuthorizedError,
RunnerProtocolError,
RunnerExecutionError,
)
from .runner.registry import AgentRunnerRegistry
from .runner.context_builder import AgentRunContextBuilder
from .runner.resource_builder import AgentResourceBuilder
from .runner.result_normalizer import AgentResultNormalizer
from .runner.orchestrator import AgentRunOrchestrator
from .runner.config_migration import ConfigMigration
__all__ = [
'AgentRunnerDescriptor',
'parse_runner_id',
'format_runner_id',
'is_plugin_runner_id',
'RunnerIdParts',
'AgentRunnerError',
'RunnerNotFoundError',
'RunnerNotAuthorizedError',
'RunnerProtocolError',
'RunnerExecutionError',
'AgentRunnerRegistry',
'AgentRunContextBuilder',
'AgentResourceBuilder',
'AgentResultNormalizer',
'AgentRunOrchestrator',
'ConfigMigration',
]

View File

@@ -0,0 +1,61 @@
"""Agent runner modules."""
from __future__ import annotations
from .descriptor import AgentRunnerDescriptor
from .id import parse_runner_id, format_runner_id, RunnerIdParts
from .errors import (
AgentRunnerError,
RunnerNotFoundError,
RunnerNotAuthorizedError,
RunnerProtocolError,
RunnerExecutionError,
)
from .registry import AgentRunnerRegistry
from .context_builder import AgentRunContextBuilder
from .resource_builder import AgentResourceBuilder
from .result_normalizer import AgentResultNormalizer
from .orchestrator import AgentRunOrchestrator
from .config_migration import ConfigMigration
from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError
from .session_registry import (
AgentRunSessionRegistry,
AgentRunSession,
RunAuthorizationSnapshot,
get_session_registry,
)
from .events import (
MESSAGE_RECEIVED,
MESSAGE_RECALLED,
GROUP_MEMBER_JOINED,
FRIEND_REQUEST_RECEIVED,
RESERVED_EVENT_TYPES,
)
__all__ = [
'AgentRunnerDescriptor',
'parse_runner_id',
'format_runner_id',
'RunnerIdParts',
'AgentRunnerError',
'RunnerNotFoundError',
'RunnerNotAuthorizedError',
'RunnerProtocolError',
'RunnerExecutionError',
'AgentRunnerRegistry',
'AgentRunContextBuilder',
'AgentResourceBuilder',
'AgentResultNormalizer',
'AgentRunOrchestrator',
'ConfigMigration',
'AgentBindingResolver',
'AgentBindingResolutionError',
'AgentRunSessionRegistry',
'AgentRunSession',
'RunAuthorizationSnapshot',
'get_session_registry',
'MESSAGE_RECEIVED',
'MESSAGE_RECALLED',
'GROUP_MEMBER_JOINED',
'FRIEND_REQUEST_RECEIVED',
'RESERVED_EVENT_TYPES',
]

View File

@@ -0,0 +1,430 @@
"""Artifact store for managing Host-owned artifacts."""
from __future__ import annotations
import json
import datetime
import typing
import uuid
import base64
import os
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.artifact import AgentArtifact
from ...entity.persistence.bstorage import BinaryStorage
_FILE_ARTIFACT_METADATA_KEY = '_langbot_file_artifact'
class ArtifactStore:
"""Store for AgentArtifact records.
Handles artifact metadata registration and content retrieval.
Actual blob storage is delegated to BinaryStorage or external storage.
All methods are async and use the provided database engine.
"""
engine: AsyncEngine
# Hard limits
MAX_INLINE_READ_BYTES = 1024 * 1024 # 1MB max for inline base64
MAX_RANGE_READ_BYTES = 10 * 1024 * 1024 # 10MB max for range reads
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def register_file_artifact(
self,
*,
artifact_id: str | None,
host_path: str,
host_root: str,
artifact_type: str = 'file',
source: str = 'tool',
mime_type: str | None = None,
name: str | None = None,
size_bytes: int | None = None,
sha256: str | None = None,
conversation_id: str | None = None,
run_id: str | None = None,
runner_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
expires_at: datetime.datetime | None = None,
metadata: dict[str, typing.Any] | None = None,
) -> str:
"""Register a Host-owned artifact backed by a bounded local file path.
The public metadata intentionally excludes the real host path. Reads go
through read_artifact(), which revalidates the path against host_root.
"""
real_path, real_root = self._validate_file_artifact_path(host_path, host_root)
if not os.path.isfile(real_path):
raise ValueError('file artifact path must point to a file')
public_metadata = dict(metadata or {})
public_metadata[_FILE_ARTIFACT_METADATA_KEY] = {
'path': real_path,
'root': real_root,
}
if size_bytes is None:
size_bytes = os.path.getsize(real_path)
return await self.register_artifact(
artifact_id=artifact_id,
artifact_type=artifact_type,
source=source,
storage_key=f'file:{uuid.uuid4().hex}',
storage_type='file',
mime_type=mime_type,
name=name or os.path.basename(real_path),
size_bytes=size_bytes,
sha256=sha256,
conversation_id=conversation_id,
run_id=run_id,
runner_id=runner_id,
bot_id=bot_id,
workspace_id=workspace_id,
expires_at=expires_at,
metadata=public_metadata,
content=None,
)
async def register_artifact(
self,
artifact_id: str | None,
artifact_type: str,
source: str,
storage_key: str | None = None,
storage_type: str = 'binary_storage',
mime_type: str | None = None,
name: str | None = None,
size_bytes: int | None = None,
sha256: str | None = None,
conversation_id: str | None = None,
run_id: str | None = None,
runner_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
expires_at: datetime.datetime | None = None,
metadata: dict[str, typing.Any] | None = None,
content: bytes | None = None,
) -> str:
"""Register a new artifact.
If content is provided and storage_key is None, stores content
in BinaryStorage automatically.
Args:
artifact_id: Unique artifact ID (generated if None)
artifact_type: Type of artifact (image, file, voice, tool_result, etc.)
source: Source of artifact (platform, runner, tool, system)
storage_key: Key in BinaryStorage or external reference
storage_type: Storage type (binary_storage, file, url)
mime_type: MIME type
name: Original file name
size_bytes: Size in bytes
sha256: SHA256 hash
conversation_id: Conversation ID
run_id: Run ID that created this
runner_id: Runner ID that created this
bot_id: Bot UUID
workspace_id: Workspace ID
expires_at: Expiration time
metadata: Additional metadata
content: Optional content to store in BinaryStorage
Returns:
The artifact_id
"""
if artifact_id is None:
artifact_id = str(uuid.uuid4())
# If content provided, store in BinaryStorage
if content is not None and storage_key is None:
storage_key = f"artifact:{artifact_id}"
storage_type = 'binary_storage'
if size_bytes is None:
size_bytes = len(content)
async with self._session_factory() as session:
# Store content in BinaryStorage if provided
if content is not None:
binary_storage = BinaryStorage(
unique_key=f'artifact:{artifact_id}',
key=storage_key,
owner_type='artifact',
owner='host',
value=content,
)
session.add(binary_storage)
# Store artifact metadata
artifact = AgentArtifact(
artifact_id=artifact_id,
artifact_type=artifact_type,
mime_type=mime_type,
name=name,
size_bytes=size_bytes,
sha256=sha256,
source=source,
storage_key=storage_key,
storage_type=storage_type,
conversation_id=conversation_id,
run_id=run_id,
runner_id=runner_id,
bot_id=bot_id,
workspace_id=workspace_id,
created_at=datetime.datetime.utcnow(),
expires_at=expires_at,
metadata_json=json.dumps(metadata) if metadata else None,
)
session.add(artifact)
await session.commit()
return artifact_id
async def get_metadata(
self,
artifact_id: str,
) -> dict[str, typing.Any] | None:
"""Get artifact metadata (public fields only, no internal storage info).
Args:
artifact_id: Artifact ID
Returns:
Artifact metadata dict compatible with SDK ArtifactMetadata, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(AgentArtifact).where(
AgentArtifact.artifact_id == artifact_id
)
)
row = result.scalars().first()
if row is None:
return None
return self._row_to_public_dict(row)
async def _get_internal_record(
self,
artifact_id: str,
) -> AgentArtifact | None:
"""Get full artifact record including internal fields.
Used internally by read_artifact to access storage_key/storage_type.
Args:
artifact_id: Artifact ID
Returns:
AgentArtifact ORM instance, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(AgentArtifact).where(
AgentArtifact.artifact_id == artifact_id
)
)
return result.scalars().first()
async def read_artifact(
self,
artifact_id: str,
offset: int = 0,
limit: int | None = None,
) -> dict[str, typing.Any] | None:
"""Read artifact content.
For small artifacts, returns content_base64 directly.
For large artifacts, returns file_key for chunked transfer.
Args:
artifact_id: Artifact ID
offset: Byte offset to start reading from (must be >= 0)
limit: Maximum bytes to read (must be > 0 if provided)
Returns:
ArtifactReadResult dict, or None if not found
Raises:
ValueError: If offset < 0 or limit <= 0
"""
# Validate offset and limit
if offset < 0:
raise ValueError("offset must be >= 0")
if limit is not None and limit <= 0:
raise ValueError("limit must be > 0")
# Get internal record (includes storage_key/storage_type)
record = await self._get_internal_record(artifact_id)
if record is None:
return None
storage_type = record.storage_type or 'binary_storage'
storage_key = record.storage_key
size_bytes = record.size_bytes or 0
# Cap limit at hard limit
if limit is None:
limit = self.MAX_INLINE_READ_BYTES
limit = min(limit, self.MAX_RANGE_READ_BYTES)
# For binary_storage, read content
if storage_type == 'binary_storage' and storage_key:
content = await self._read_binary_storage(storage_key)
if content is None:
return None
# Apply offset and limit
if offset > 0:
content = content[offset:]
if limit and len(content) > limit:
content = content[:limit]
has_more = True
else:
has_more = False
return {
'artifact_id': artifact_id,
'mime_type': record.mime_type,
'size_bytes': size_bytes,
'offset': offset,
'length': len(content),
'content_base64': base64.b64encode(content).decode('utf-8'),
'file_key': None,
'has_more': has_more,
}
if storage_type == 'file':
return self._read_file_storage(record, artifact_id, offset, limit)
# For other storage types, return storage reference
# (caller can use file_key for chunked transfer)
return {
'artifact_id': artifact_id,
'mime_type': record.mime_type,
'size_bytes': size_bytes,
'offset': offset,
'length': None,
'content_base64': None,
'file_key': storage_key,
'has_more': False,
}
async def _read_binary_storage(self, key: str) -> bytes | None:
"""Read content from BinaryStorage.
Uses unique_key for isolation to prevent cross-artifact access.
Args:
key: The unique_key used when storing the artifact
Returns:
Content bytes, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(BinaryStorage).where(BinaryStorage.unique_key == key)
)
row = result.scalars().first()
if row is None:
return None
return row.value
def _read_file_storage(
self,
record: AgentArtifact,
artifact_id: str,
offset: int,
limit: int,
) -> dict[str, typing.Any] | None:
metadata = self._load_metadata(record.metadata_json)
file_info = metadata.get(_FILE_ARTIFACT_METADATA_KEY)
if not isinstance(file_info, dict):
return None
host_path = file_info.get('path')
host_root = file_info.get('root')
if not isinstance(host_path, str) or not isinstance(host_root, str):
return None
real_path, _ = self._validate_file_artifact_path(host_path, host_root)
if not os.path.isfile(real_path):
return None
file_size = os.path.getsize(real_path)
if offset >= file_size:
content = b''
else:
with open(real_path, 'rb') as f:
f.seek(offset)
content = f.read(limit)
return {
'artifact_id': artifact_id,
'mime_type': record.mime_type,
'size_bytes': file_size,
'offset': offset,
'length': len(content),
'content_base64': base64.b64encode(content).decode('utf-8'),
'file_key': None,
'has_more': offset + len(content) < file_size,
}
@staticmethod
def _validate_file_artifact_path(host_path: str, host_root: str) -> tuple[str, str]:
real_path = os.path.realpath(host_path)
real_root = os.path.realpath(host_root)
if not real_root:
raise ValueError('file artifact root is required')
if not (real_path == real_root or real_path.startswith(real_root + os.sep)):
raise ValueError('file artifact path escapes allowed root')
return real_path, real_root
@staticmethod
def _load_metadata(metadata_json: str | None) -> dict[str, typing.Any]:
if not metadata_json:
return {}
try:
metadata = json.loads(metadata_json)
except Exception:
return {}
return metadata if isinstance(metadata, dict) else {}
@staticmethod
def _public_metadata(metadata_json: str | None) -> dict[str, typing.Any]:
metadata = ArtifactStore._load_metadata(metadata_json)
metadata.pop(_FILE_ARTIFACT_METADATA_KEY, None)
return metadata
def _row_to_public_dict(self, row: AgentArtifact) -> dict[str, typing.Any]:
"""Convert an AgentArtifact row to public dict.
Returns only fields that match SDK ArtifactMetadata entity.
Host-only fields (bot_id, workspace_id, storage_key, storage_type) are excluded.
"""
return {
'artifact_id': row.artifact_id,
'artifact_type': row.artifact_type,
'mime_type': row.mime_type,
'name': row.name,
'size_bytes': row.size_bytes,
'sha256': row.sha256,
'source': row.source,
'conversation_id': row.conversation_id,
'run_id': row.run_id,
'runner_id': row.runner_id,
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
'expires_at': int(row.expires_at.timestamp()) if row.expires_at else None,
'metadata': self._public_metadata(row.metadata_json),
}

View File

@@ -0,0 +1,63 @@
"""Resolve host events to one effective Agent binding."""
from __future__ import annotations
from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope
class AgentBindingResolutionError(Exception):
"""Raised when an event cannot resolve to exactly one Agent binding."""
class AgentBindingResolver:
"""Resolve an event to a single AgentBinding.
The target product model is one bot / IM channel -> one Agent. Fan-out,
observer agents, or multi-runner arbitration require separate delivery and
state semantics and are intentionally not hidden in this resolver.
"""
def resolve_one(
self,
event: AgentEventEnvelope,
agents: list[AgentConfig],
) -> AgentBinding:
"""Resolve exactly one enabled Agent for the event."""
matches = [
agent
for agent in agents
if agent.enabled and event.event_type in agent.event_types
]
if not matches:
raise AgentBindingResolutionError(
f'No Agent binding matches event_type={event.event_type}'
)
if len(matches) > 1:
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
raise AgentBindingResolutionError(
f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}'
)
return self._to_binding(matches[0])
def _to_binding(self, agent: AgentConfig) -> AgentBinding:
"""Project product-level Agent config into the run-time binding model."""
scope = BindingScope(
scope_type='agent',
scope_id=agent.agent_id,
)
return AgentBinding(
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
scope=scope,
event_types=list(agent.event_types),
runner_id=agent.runner_id,
runner_config=agent.runner_config,
resource_policy=agent.resource_policy,
state_policy=agent.state_policy,
delivery_policy=agent.delivery_policy,
enabled=agent.enabled,
agent_id=agent.agent_id,
)

View File

@@ -0,0 +1,95 @@
"""Helpers for the current AgentRunner config shape."""
from __future__ import annotations
import typing
class ConfigMigration:
"""Configuration helper for agent runner IDs.
Responsibilities:
- Resolve runner ID from ai.runner.id
- Extract current Agent/runner config from 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 current configuration.
Args:
pipeline_config: Current configuration container
Returns:
Runner ID string, or None if not configured
"""
ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner', {})
runner_id = runner_config.get('id')
if runner_id:
return runner_id
return None
@staticmethod
def resolve_runner_config(
pipeline_config: dict[str, typing.Any],
runner_id: str,
) -> dict[str, typing.Any]:
"""Resolve Agent/runner configuration from the current container.
Args:
pipeline_config: Current configuration container
runner_id: Resolved runner ID
Returns:
Runner configuration dict (empty if not found)
"""
ai_config = pipeline_config.get('ai', {})
runner_configs = ai_config.get('runner_config', {})
if runner_id in runner_configs:
return runner_configs[runner_id]
return {}
@staticmethod
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
"""Get conversation expire time from configuration.
Args:
pipeline_config: Current configuration container
Returns:
Expire time in seconds (0 means no expiry)
"""
ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner', {})
return runner_config.get('expire-time', 0)
@staticmethod
def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""Normalize the current config container before saving.
Args:
pipeline_config: Original configuration
Returns:
Configuration with explicit ai.runner and ai.runner_config containers
"""
new_config = dict(pipeline_config)
if 'ai' not in new_config:
return new_config
ai_config = dict(new_config.get('ai', {}))
runner_config = dict(ai_config.get('runner', {}))
runner_configs = dict(ai_config.get('runner_config', {}))
ai_config['runner'] = runner_config
ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config
return new_config

View File

@@ -0,0 +1,222 @@
"""Helpers for interpreting AgentRunner DynamicForm configuration."""
from __future__ import annotations
import typing
from .descriptor import AgentRunnerDescriptor
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
PROMPT_EDITOR_TYPES = {'prompt-editor'}
NONE_SENTINELS = {'', '__none__', '__none'}
def iter_schema_items(
descriptor: AgentRunnerDescriptor | None,
field_types: set[str],
) -> typing.Iterator[dict[str, typing.Any]]:
"""Yield descriptor config schema items whose type is in field_types."""
if descriptor is None:
return
for item in descriptor.config_schema or []:
if not isinstance(item, dict):
continue
if item.get('type') in field_types:
yield item
def has_permission(
descriptor: AgentRunnerDescriptor | None,
name: str,
actions: set[str],
) -> bool:
"""Return whether a runner descriptor requests one of the given actions."""
if descriptor is None:
return False
configured_actions = descriptor.permissions.get(name, [])
return any(action in configured_actions for action in actions)
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should resolve model resources for this runner."""
return (
has_permission(descriptor, 'models', {'invoke', 'stream', 'list'})
and any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
)
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should expose tool resources to this runner."""
return (
descriptor is not None
and descriptor.supports_tool_calling()
and has_permission(descriptor, 'tools', {'list', 'detail', 'call'})
)
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should expose knowledge-base resources to this runner."""
return (
descriptor is not None
and descriptor.supports_knowledge_retrieval()
and has_permission(descriptor, 'knowledge_bases', {'list', 'retrieve'})
)
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether the runner wants Host skill-authoring tools."""
if descriptor is None:
return False
return bool(descriptor.capabilities.get('skill_authoring', False))
def supports_skill_injection(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether the runner wants the Host skill index in the effective prompt."""
if descriptor is None:
return False
return bool(descriptor.capabilities.get('skill_injection', False))
def extract_prompt_config(
descriptor: AgentRunnerDescriptor | None,
runner_config: dict[str, typing.Any],
default_prompt: list[dict[str, typing.Any]],
) -> list[dict[str, typing.Any]]:
"""Extract the prompt-editor value selected by the runner schema."""
for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES):
field_name = item.get('name')
if field_name and field_name in runner_config:
configured_prompt = runner_config[field_name]
if isinstance(configured_prompt, list):
return configured_prompt
default_value = item.get('default')
if isinstance(default_value, list):
return default_value
return default_prompt
def extract_model_selection(
descriptor: AgentRunnerDescriptor | None,
runner_config: dict[str, typing.Any],
) -> tuple[str, list[str]]:
"""Extract primary/fallback LLM selections from schema-defined fields."""
primary_uuid = ''
fallback_uuids: list[str] = []
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
field_name = item.get('name')
if not field_name:
continue
value = runner_config.get(field_name, item.get('default'))
if item.get('type') == 'model-fallback-selector':
if isinstance(value, str):
primary_uuid = value
elif isinstance(value, dict):
primary_uuid = value.get('primary') or ''
fallbacks = value.get('fallbacks', [])
if isinstance(fallbacks, list):
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
break
if item.get('type') == 'llm-model-selector' and isinstance(value, str):
primary_uuid = value
break
return primary_uuid, fallback_uuids
def extract_knowledge_base_uuids(
descriptor: AgentRunnerDescriptor | None,
runner_config: dict[str, typing.Any],
) -> list[str]:
"""Extract configured knowledge-base UUIDs from schema-defined fields."""
if not uses_host_knowledge_bases(descriptor):
return []
kb_uuids: list[str] = []
for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES):
field_name = item.get('name')
if not field_name:
continue
value = runner_config.get(field_name, item.get('default', []))
if isinstance(value, list):
kb_uuids.extend(
kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS
)
return list(dict.fromkeys(kb_uuids))
def iter_config_model_refs(
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
) -> typing.Iterator[tuple[str, str]]:
"""Yield model references declared by schema-defined model selector fields."""
for item in descriptor.config_schema or []:
if not isinstance(item, dict):
continue
field_name = item.get('name')
field_type = item.get('type')
if not field_name or field_name not in runner_config:
continue
value = runner_config.get(field_name)
if field_type == 'model-fallback-selector':
if isinstance(value, str) and value not in NONE_SENTINELS:
yield 'llm', value
elif isinstance(value, dict):
primary = value.get('primary')
if isinstance(primary, str) and primary not in NONE_SENTINELS:
yield 'llm', primary
fallbacks = value.get('fallbacks', [])
if isinstance(fallbacks, list):
for fallback_uuid in fallbacks:
if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS:
yield 'llm', fallback_uuid
elif field_type == 'llm-model-selector':
if isinstance(value, str) and value not in NONE_SENTINELS:
yield 'llm', value
elif field_type == 'rerank-model-selector':
if isinstance(value, str) and value not in NONE_SENTINELS:
yield 'rerank', value
def set_empty_llm_model_selection(
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
model_uuid: str,
) -> bool:
"""Set the first empty schema-defined LLM selector to model_uuid."""
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
field_name = item.get('name')
field_type = item.get('type')
if not field_name:
continue
value = runner_config.get(field_name, item.get('default'))
if field_type == 'model-fallback-selector':
if isinstance(value, dict):
primary = value.get('primary') or ''
if primary not in NONE_SENTINELS:
return False
fallbacks = value.get('fallbacks', [])
runner_config[field_name] = {
'primary': model_uuid,
'fallbacks': fallbacks if isinstance(fallbacks, list) else [],
}
return True
if isinstance(value, str) and value not in NONE_SENTINELS:
return False
runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []}
return True
if field_type == 'llm-model-selector':
if isinstance(value, str) and value not in NONE_SENTINELS:
return False
runner_config[field_name] = model_uuid
return True
return False

View File

@@ -0,0 +1,420 @@
"""Agent run context builder for provisioning AgentRunContext envelopes."""
from __future__ import annotations
import uuid
import time
import typing
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .persistent_state_store import get_persistent_state_store
from .host_models import AgentEventEnvelope, AgentBinding
DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
# Internal models for the agent runner context protocol.
class AgentTrigger(typing.TypedDict):
"""Agent trigger information."""
type: str
source: str
timestamp: int | None
class ConversationContext(typing.TypedDict):
"""Conversation context."""
conversation_id: str | None
thread_id: str | None
launcher_type: str | None
launcher_id: str | None
sender_id: str | None
bot_id: str | None
workspace_id: str | None
session_id: str | None
class AgentInput(typing.TypedDict):
"""Agent input."""
text: str | None
contents: list[dict[str, typing.Any]]
message_chain: dict[str, typing.Any] | None
attachments: list[dict[str, typing.Any]]
class AgentRunState(typing.TypedDict):
"""Agent run state with 4 scopes."""
conversation: dict[str, typing.Any]
actor: dict[str, typing.Any]
subject: dict[str, typing.Any]
runner: dict[str, typing.Any]
# Resource payload models matching langbot-plugin-sdk/resources.py.
class ModelResource(typing.TypedDict):
"""Model resource payload."""
model_id: str
model_type: str | None
provider: str | None
class ToolResource(typing.TypedDict):
"""Tool resource payload."""
tool_name: str
tool_type: str | None
description: str | None
class KnowledgeBaseResource(typing.TypedDict):
"""Knowledge base resource payload."""
kb_id: str
kb_name: str | None
kb_type: str | None
class FileResource(typing.TypedDict):
"""File resource payload."""
file_id: str
file_name: str | None
mime_type: str | None
source: str | None
class StorageResource(typing.TypedDict):
"""Storage resource payload."""
plugin_storage: bool
workspace_storage: bool
class AgentResources(typing.TypedDict):
"""Agent resources payload."""
models: list[ModelResource]
tools: list[ToolResource]
knowledge_bases: list[KnowledgeBaseResource]
files: list[FileResource]
storage: StorageResource
platform_capabilities: dict[str, typing.Any]
class AgentRuntimeContext(typing.TypedDict):
"""Agent runtime context."""
langbot_version: str | None
sdk_protocol_version: str
trace_id: str | None
deadline_at: float | None
metadata: dict[str, typing.Any]
class AgentRunContextPayload(typing.TypedDict):
"""AgentRunContext payload passed to an agent runner.
Protocol v1 structure - matches SDK AgentRunContext.
Note: The 'config' field contains the current Agent/runner 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
trigger: AgentTrigger
conversation: ConversationContext | None
event: dict[str, typing.Any] # REQUIRED for Protocol v1
actor: dict[str, typing.Any] | None
subject: dict[str, typing.Any] | None
input: AgentInput
delivery: dict[str, typing.Any] # REQUIRED for Protocol v1
resources: AgentResources
context: dict[str, typing.Any] # ContextAccess - REQUIRED for Protocol v1
state: AgentRunState
runtime: AgentRuntimeContext
config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id]
adapter: dict[str, typing.Any] | None # Entry adapter context
metadata: dict[str, typing.Any] # Additional metadata
class AgentRunContextBuilder:
"""Builder for provisioning AgentRunContext.
Responsibilities:
- Generate new run_id (UUID, not query id)
- Set trigger type based on event source
- Build conversation context from event
- Build input from event
- Build state snapshot from PersistentStateStore
- Build runtime context with host info, trace_id, deadline
- Set config from current Agent/runner configuration.
Query adaptation belongs to QueryEntryAdapter, not this builder.
"""
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
async def build_context_from_event(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
resources: AgentResources,
) -> AgentRunContextPayload:
"""Build AgentRunContext from event-first envelope.
This is the main entry point for Protocol v1.
Does NOT inline full history by default.
Args:
event: Event envelope
binding: Agent binding
descriptor: Runner descriptor
resources: Built resources
Returns:
AgentRunContextPayload for the runner
"""
# Generate new run_id
run_id = str(uuid.uuid4())
# Build trigger from event
trigger: AgentTrigger = {
'type': event.event_type,
'source': event.source,
'timestamp': event.event_time or int(time.time()),
}
# Build conversation context from event
conversation: ConversationContext | None = None
if event.conversation_id:
conversation = {
'session_id': None,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'launcher_type': None, # Will be filled from actor/subject if needed
'launcher_id': None,
'sender_id': event.actor.actor_id if event.actor else None,
'bot_id': event.bot_id,
'workspace_id': event.workspace_id,
}
# Build event context (Protocol v1 event-first)
event_context = {
'event_id': event.event_id,
'event_type': event.event_type,
'event_time': event.event_time,
'source': event.source,
'source_event_type': event.source_event_type,
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
'data': event.data,
}
# Build actor context
actor_context = None
if event.actor:
actor_context = {
'actor_type': event.actor.actor_type,
'actor_id': event.actor.actor_id,
'actor_name': event.actor.actor_name,
}
# Build subject context
subject_context = None
if event.subject:
subject_context = {
'subject_type': event.subject.subject_type,
'subject_id': event.subject.subject_id,
'data': event.subject.data,
}
# Build input from event
input: AgentInput = {
'text': event.input.text,
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
'message_chain': event.input.message_chain,
'attachments': [
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
],
}
# Build context access (no history inlined by default for Protocol v1)
# Populate with actual values from stores
context_access = await self._build_context_access(event, descriptor, binding)
# Build state snapshot from persistent state store (event-first Protocol v1)
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
# Build runtime context
runtime: AgentRuntimeContext = {
'langbot_version': self.ap.ver_mgr.get_current_version(),
'sdk_protocol_version': descriptor.protocol_version,
'trace_id': run_id,
'deadline_at': self._build_deadline_from_binding(binding),
'metadata': {
'bot_id': event.bot_id,
'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': None,
# TODO(model-info): populate model_context_window_tokens after
# LiteLLM/model metadata lands. Runners fall back to their
# ctx.config until Host can provide the real window.
},
}
# Build delivery context
delivery_context = {
'surface': event.delivery.surface,
'reply_target': event.delivery.reply_target,
'supports_streaming': event.delivery.supports_streaming,
'supports_edit': event.delivery.supports_edit,
'supports_reaction': event.delivery.supports_reaction,
'max_message_size': event.delivery.max_message_size,
'platform_capabilities': event.delivery.platform_capabilities,
}
# Build adapter context (empty for event-first)
adapter_context = {
'extra': {},
}
# Build full context - Protocol v1 structure
context: AgentRunContextPayload = {
'run_id': run_id,
'trigger': trigger,
'conversation': conversation,
'event': event_context, # REQUIRED
'actor': actor_context,
'subject': subject_context,
'input': input,
'delivery': delivery_context, # REQUIRED
'resources': resources,
'context': context_access, # ContextAccess - REQUIRED
'state': state,
'runtime': runtime,
'config': binding.runner_config,
'adapter': adapter_context,
'metadata': {}, # Additional metadata
}
return context
def _build_deadline_from_binding(self, binding: AgentBinding) -> float | None:
"""Build deadline timestamp from binding timeout config.
Args:
binding: Agent binding with runner_config
Returns:
Deadline timestamp or None
"""
timeout = binding.runner_config.get('timeout', DEFAULT_RUNNER_TIMEOUT_SECONDS)
if timeout is None:
return None
try:
timeout_seconds = float(timeout)
except (TypeError, ValueError):
return None
if timeout_seconds <= 0:
return None
return time.time() + timeout_seconds
async def _build_context_access(
self,
event: AgentEventEnvelope,
descriptor: AgentRunnerDescriptor,
binding: AgentBinding | None = None,
) -> dict[str, typing.Any]:
"""Build ContextAccess with actual values from stores.
Args:
event: Event envelope
descriptor: Runner descriptor
binding: Agent binding (required for state_policy in event-first mode)
Returns:
ContextAccess dict
"""
conversation_id = event.conversation_id
# Check if history APIs are available for this runner
# Based on runner permissions
permissions = descriptor.permissions or {}
history_permissions = permissions.get('history', [])
event_permissions = permissions.get('events', [])
artifact_permissions = permissions.get('artifacts', [])
history_page_enabled = 'page' in history_permissions and conversation_id is not None
history_search_enabled = 'search' in history_permissions and conversation_id is not None
event_get_enabled = 'get' in event_permissions
event_page_enabled = 'page' in event_permissions and conversation_id is not None
artifact_metadata_enabled = 'metadata' in artifact_permissions
artifact_read_enabled = 'read' in artifact_permissions
# Determine state API availability based on binding state_policy.
state_enabled = False
if binding is not None:
state_policy = binding.state_policy
if state_policy.enable_state and state_policy.state_scopes:
state_enabled = True
# Get latest cursor and has_history_before if conversation exists
latest_cursor = None
has_history_before = False
if conversation_id:
try:
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
latest_cursor = await store.get_latest_cursor(conversation_id)
if latest_cursor:
has_history_before = True
except Exception as e:
self.ap.logger.warning(f'Failed to get transcript cursor: {e}')
return {
'conversation_id': conversation_id,
'thread_id': event.thread_id,
'latest_cursor': latest_cursor,
'event_seq': None, # Will be populated when EventLog is written
'transcript_seq': int(latest_cursor) if latest_cursor else None,
'has_history_before': has_history_before,
'inline_policy': {
'mode': 'current_event',
'delivered_count': 0,
'source_total_count': None,
'messages_complete': False,
'reason': 'self_managed_context',
},
'available_apis': {
'history_page': history_page_enabled,
'history_search': history_search_enabled,
'event_get': event_get_enabled,
'event_page': event_page_enabled,
'artifact_metadata': artifact_metadata_enabled,
'artifact_read': artifact_read_enabled,
'state': state_enabled,
'storage': True,
'prompt_get': False,
},
}

View File

@@ -0,0 +1,72 @@
"""Agent runner descriptor."""
from __future__ import annotations
import typing
import pydantic
class AgentRunnerDescriptor(pydantic.BaseModel):
"""Descriptor for an agent runner.
Represents the discovered metadata for a runner, including
its identity, capabilities, permissions, and configuration schema.
"""
id: str
"""Unique runner ID: plugin:author/plugin_name/runner_name"""
source: typing.Literal['plugin']
"""Runner source type"""
label: dict[str, str]
"""Display labels keyed by locale (e.g., en_US, zh_Hans)"""
description: dict[str, str] | None = None
"""Optional description keyed by locale"""
plugin_author: str
"""Plugin author from manifest"""
plugin_name: str
"""Plugin name from manifest"""
runner_name: str
"""AgentRunner component name from manifest"""
plugin_version: str | None = None
"""Optional plugin version"""
protocol_version: str = '1'
"""SDK protocol version, default '1'"""
config_schema: list[dict[str, typing.Any]] = []
"""Configuration schema using DynamicForm format"""
capabilities: dict[str, bool] = {}
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
permissions: dict[str, list[str]] = {}
"""Requested permissions: models, tools, knowledge_bases, storage, files, platform_api"""
raw_manifest: dict[str, typing.Any] = {}
"""Original manifest for reference"""
model_config = pydantic.ConfigDict(
extra='allow',
)
def get_plugin_id(self) -> str:
"""Return plugin identifier as author/name."""
return f'{self.plugin_author}/{self.plugin_name}'
def supports_streaming(self) -> bool:
"""Check if runner supports streaming output."""
return self.capabilities.get('streaming', False)
def supports_tool_calling(self) -> bool:
"""Check if runner supports tool calling."""
return self.capabilities.get('tool_calling', False)
def supports_knowledge_retrieval(self) -> bool:
"""Check if runner supports knowledge retrieval."""
return self.capabilities.get('knowledge_retrieval', False)

View File

@@ -0,0 +1,37 @@
"""Agent runner errors."""
from __future__ import annotations
class AgentRunnerError(Exception):
"""Base error for agent runner operations."""
pass
class RunnerNotFoundError(AgentRunnerError):
"""Runner not found in registry."""
def __init__(self, runner_id: str):
self.runner_id = runner_id
super().__init__(f'Agent runner not found: {runner_id}')
class RunnerNotAuthorizedError(AgentRunnerError):
"""Runner not authorized for this binding."""
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
self.runner_id = runner_id
self.bound_plugins = bound_plugins
super().__init__(f'Agent runner {runner_id} not authorized for bound_plugins={bound_plugins}')
class RunnerProtocolError(AgentRunnerError):
"""Runner protocol version mismatch or invalid manifest."""
def __init__(self, runner_id: str, message: str):
self.runner_id = runner_id
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
class RunnerExecutionError(AgentRunnerError):
"""Runner execution failed."""
def __init__(self, runner_id: str, message: str, retryable: bool = False):
self.runner_id = runner_id
self.retryable = retryable
super().__init__(f'Agent runner {runner_id} execution failed: {message}')

View File

@@ -0,0 +1,255 @@
"""EventLog store for writing and querying event records."""
from __future__ import annotations
import json
import datetime
import typing
import uuid
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.event_log import EventLog
class EventLogStore:
"""Store for EventLog records.
Handles writing events to the event log and querying them.
All methods are async and use the provided database engine.
"""
engine: AsyncEngine
# Hard limits
MAX_INPUT_SUMMARY_LENGTH = 1000
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def append_event(
self,
event_id: str | None,
event_type: str,
source: str,
bot_id: str | None = None,
workspace_id: str | None = None,
conversation_id: str | None = None,
thread_id: str | None = None,
actor_type: str | None = None,
actor_id: str | None = None,
actor_name: str | None = None,
subject_type: str | None = None,
subject_id: str | None = None,
input_summary: str | None = None,
input_json: dict[str, typing.Any] | None = None,
raw_ref: str | None = None,
run_id: str | None = None,
runner_id: str | None = None,
event_time: datetime.datetime | None = None,
metadata: dict[str, typing.Any] | None = None,
) -> str:
"""Append an event to the event log.
Args:
event_id: Unique event ID (generated if None)
event_type: Event type
source: Event source
bot_id: Bot UUID
workspace_id: Workspace ID
conversation_id: Conversation ID
thread_id: Thread ID
actor_type: Actor type
actor_id: Actor ID
actor_name: Actor display name
subject_type: Subject type
subject_id: Subject ID
input_summary: Brief input summary
input_json: Full input JSON
raw_ref: Reference to raw event payload
run_id: Run ID processing this event
runner_id: Runner ID processing this event
event_time: When the event occurred
metadata: Additional metadata
Returns:
The event_id
"""
if event_id is None:
event_id = str(uuid.uuid4())
# Truncate input summary if too long
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..."
async with self._session_factory() as session:
event = EventLog(
event_id=event_id,
event_type=event_type,
event_time=event_time,
source=source,
bot_id=bot_id,
workspace_id=workspace_id,
conversation_id=conversation_id,
thread_id=thread_id,
actor_type=actor_type,
actor_id=actor_id,
actor_name=actor_name,
subject_type=subject_type,
subject_id=subject_id,
input_summary=input_summary,
input_json=json.dumps(input_json) if input_json else None,
raw_ref=raw_ref,
run_id=run_id,
runner_id=runner_id,
metadata_json=json.dumps(metadata) if metadata else None,
created_at=datetime.datetime.utcnow(),
)
session.add(event)
await session.commit()
return event_id
async def get_event(
self,
event_id: str,
) -> dict[str, typing.Any] | None:
"""Get a single event by ID.
Args:
event_id: Event ID
Returns:
Event record as dict, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
)
row = result.scalars().first()
if row is None:
return None
return self._row_to_dict(row)
async def page_events(
self,
conversation_id: str | None = None,
event_types: list[str] | None = None,
before_seq: int | None = None,
limit: int = 50,
) -> tuple[list[dict[str, typing.Any]], int | None, bool]:
"""Page through event records.
Args:
conversation_id: Filter by conversation ID
event_types: Filter by event types
before_seq: Get events before this sequence number
limit: Maximum items to return (capped at 100)
Returns:
Tuple of (items, next_seq, has_more)
"""
limit = min(limit, 100) # Hard cap
async with self._session_factory() as session:
query = sqlalchemy.select(EventLog)
if conversation_id is not None:
query = query.where(EventLog.conversation_id == conversation_id)
if event_types:
query = query.where(EventLog.event_type.in_(event_types))
if before_seq is not None:
query = query.where(EventLog.id < before_seq)
query = query.order_by(EventLog.id.desc()).limit(limit + 1)
result = await session.execute(query)
rows = result.scalars().all()
items = [self._row_to_dict(row) for row in rows[:limit]]
has_more = len(rows) > limit
next_seq = items[-1]['id'] if items and has_more else None
return items, next_seq, has_more
async def get_latest_cursor(
self,
conversation_id: str,
) -> str | None:
"""Get the latest cursor for a conversation.
Args:
conversation_id: Conversation ID
Returns:
Cursor string (seq number), or None if no events
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(EventLog.id)
.where(EventLog.conversation_id == conversation_id)
.order_by(EventLog.id.desc())
.limit(1)
)
row = result.scalars().first()
if row is None:
return None
return str(row)
async def has_events_before(
self,
conversation_id: str,
seq: int,
) -> bool:
"""Check if there are events before a sequence number.
Args:
conversation_id: Conversation ID
seq: Sequence number
Returns:
True if there are events before
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(EventLog)
.where(
EventLog.conversation_id == conversation_id,
EventLog.id < seq,
)
)
count = result.scalar()
return count > 0
def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]:
"""Convert an EventLog row to dict."""
return {
'id': row.id,
'event_id': row.event_id,
'event_type': row.event_type,
'event_time': int(row.event_time.timestamp()) if row.event_time else None,
'source': row.source,
'bot_id': row.bot_id,
'workspace_id': row.workspace_id,
'conversation_id': row.conversation_id,
'thread_id': row.thread_id,
'actor_type': row.actor_type,
'actor_id': row.actor_id,
'actor_name': row.actor_name,
'subject_type': row.subject_type,
'subject_id': row.subject_id,
'input_summary': row.input_summary,
'input_json': json.loads(row.input_json) if row.input_json else None,
'raw_ref': row.raw_ref,
'run_id': row.run_id,
'runner_id': row.runner_id,
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
}

View File

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

View File

@@ -0,0 +1,207 @@
"""Agent event envelope and binding models for LangBot Host.
These are Host-internal models, not exposed to SDK.
"""
from __future__ import annotations
import typing
import pydantic
from langbot_plugin.api.entities.builtin.agent_runner.event import (
ActorContext,
SubjectContext,
RawEventRef,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
class AgentEventEnvelope(pydantic.BaseModel):
"""Event envelope for LangBot Host event gateway.
This is the unified input model that replaces Query-first approach.
IM / WebUI / API / EventRouter all produce this envelope.
"""
event_id: str
"""Unique event identifier."""
event_type: str
"""Event type (message.received, message.recalled, etc.)."""
event_time: int | None = None
"""Event timestamp (epoch seconds)."""
source: str
"""Event source (platform, webui, api, scheduler, system)."""
source_event_type: str | None = None
"""Original source event type, when available."""
bot_id: str | None = None
"""Bot UUID handling this event."""
workspace_id: str | None = None
"""Workspace ID (for multi-tenant)."""
conversation_id: str | None = None
"""Conversation ID."""
thread_id: str | None = None
"""Thread ID (for platforms supporting threads)."""
actor: ActorContext | None = None
"""Actor (who triggered the event)."""
subject: SubjectContext | None = None
"""Subject (what the event is about)."""
input: AgentInput
"""Event input."""
delivery: DeliveryContext
"""Delivery context."""
raw_ref: RawEventRef | None = None
"""Reference to raw event payload."""
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Small structured event payload. Large payloads should be referenced via raw_ref/artifacts."""
# Binding scope types
class BindingScope(pydantic.BaseModel):
"""Scope for agent binding."""
scope_type: typing.Literal["agent", "bot", "workspace", "global"] = "agent"
"""Scope type."""
scope_id: str | None = None
"""Scope identifier (agent_id, bot_uuid, etc.)."""
class ResourcePolicy(pydantic.BaseModel):
"""Resource policy for agent binding.
Controls what resources the runner can access.
"""
allowed_model_uuids: list[str] | None = None
"""Additional model UUID grants. None means no additional model grants."""
allowed_tool_names: list[str] | None = None
"""Additional tool name grants. None means no additional tool grants."""
allowed_kb_uuids: list[str] | None = None
"""Additional knowledge base UUID grants. None means no additional KB grants."""
allow_plugin_storage: bool = True
"""Whether plugin storage is allowed."""
allow_workspace_storage: bool = False
"""Whether workspace storage is allowed."""
class StatePolicy(pydantic.BaseModel):
"""State policy for agent binding.
Controls state management behavior.
"""
enable_state: bool = True
"""Whether host-owned state is enabled."""
state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = (
pydantic.Field(default_factory=lambda: ["conversation", "actor"])
)
"""Enabled state scopes."""
class DeliveryPolicy(pydantic.BaseModel):
"""Delivery policy for agent binding.
Controls how results are delivered.
"""
enable_streaming: bool = True
"""Whether streaming output is enabled."""
enable_reply: bool = True
"""Whether reply is enabled."""
max_message_size: int | None = None
"""Maximum message size."""
class AgentConfig(pydantic.BaseModel):
"""Host-side Agent configuration.
Product-level Agent is the target replacement for Pipeline-owned agent
config. Current Pipeline entry paths can project their config into this
model during migration.
"""
agent_id: str | None = None
"""Host-side Agent/config identifier."""
runner_id: str
"""Runner ID to invoke."""
runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Agent/runner binding configuration."""
resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy)
"""Resource policy for this Agent."""
state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy)
"""State policy for this Agent."""
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
"""Delivery policy for this Agent."""
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
"""Event types this Agent handles."""
enabled: bool = True
"""Whether this Agent can be selected by a binding resolver."""
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Non-protocol diagnostic metadata, such as legacy config source."""
class AgentBinding(pydantic.BaseModel):
"""Binding configuration for mapping events to runners.
This is Host-internal model for event-to-runner binding.
It replaces the old Pipeline runner config role.
"""
binding_id: str
"""Unique binding identifier."""
scope: BindingScope = pydantic.Field(default_factory=BindingScope)
"""Binding scope."""
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
"""Event types this binding handles."""
runner_id: str
"""Runner ID to invoke."""
runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Current Agent/runner configuration."""
resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy)
"""Resource policy."""
state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy)
"""State policy."""
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
"""Delivery policy."""
enabled: bool = True
"""Whether binding is enabled."""
agent_id: str | None = None
"""Host-side Agent/config identifier for this binding."""

View File

@@ -0,0 +1,91 @@
"""Agent runner ID parsing and formatting."""
from __future__ import annotations
import dataclasses
@dataclasses.dataclass(frozen=True)
class RunnerIdParts:
"""Parsed runner ID components."""
source: str # 'plugin' (future: 'builtin')
plugin_author: str
plugin_name: str
runner_name: str
def to_plugin_id(self) -> str:
"""Return plugin identifier as author/name."""
return f'{self.plugin_author}/{self.plugin_name}'
def parse_runner_id(runner_id: str) -> RunnerIdParts:
"""Parse runner ID string into components.
Args:
runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name'
Returns:
RunnerIdParts with parsed components
Raises:
ValueError: If runner_id format is invalid
"""
if runner_id.startswith('plugin:'):
parts = runner_id[7:].split('/')
if len(parts) != 3:
raise ValueError(
f'Invalid plugin runner ID format: {runner_id}. '
f'Expected: plugin:author/plugin_name/runner_name'
)
plugin_author, plugin_name, runner_name = parts
if not plugin_author or not plugin_name or not runner_name:
raise ValueError(
f'Invalid plugin runner ID: {runner_id}. '
f'author, plugin_name, and runner_name must be non-empty'
)
return RunnerIdParts(
source='plugin',
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
else:
# Only plugin runner IDs are valid at the protocol boundary.
raise ValueError(
f'Invalid runner ID format: {runner_id}. '
f'Expected: plugin:author/plugin_name/runner_name'
)
def format_runner_id(
source: str,
plugin_author: str,
plugin_name: str,
runner_name: str,
) -> str:
"""Format runner ID from components.
Args:
source: Runner source ('plugin')
plugin_author: Plugin author
plugin_name: Plugin name
runner_name: Runner component name
Returns:
Runner ID string
"""
if source == 'plugin':
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
else:
raise ValueError(f'Invalid runner source: {source}')
def is_plugin_runner_id(runner_id: str) -> bool:
"""Check if runner ID is a plugin runner.
Args:
runner_id: Runner ID string
Returns:
True if runner ID starts with 'plugin:'
"""
return runner_id.startswith('plugin:')

View File

@@ -0,0 +1,888 @@
"""Agent run orchestrator for coordinating runner execution."""
from __future__ import annotations
import typing
import traceback
import asyncio
import time
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .registry import AgentRunnerRegistry
from .context_builder import AgentRunContextBuilder, AgentRunContextPayload
from .resource_builder import AgentResourceBuilder
from .result_normalizer import AgentResultNormalizer
from .persistent_state_store import get_persistent_state_store, PersistentStateStore
from .session_registry import get_session_registry, AgentRunSessionRegistry
from .config_migration import ConfigMigration
from .host_models import AgentEventEnvelope, AgentBinding
from .query_entry_adapter import QueryEntryAdapter
from .binding_resolver import AgentBindingResolver
from .state_scope import build_state_context
from .errors import (
RunnerNotFoundError,
RunnerExecutionError,
RunnerProtocolError,
)
# Maximum inline artifact content size (1MB)
MAX_ARTIFACT_INLINE_BYTES = 1 * 1024 * 1024
class AgentRunOrchestrator:
"""Orchestrator for agent runner execution.
Responsibilities:
- Resolve runner ID from current Agent/runner config
- Get runner descriptor from registry
- Provision AgentRunContext envelope from Query
- Build AgentResources with permission filtering
- Invoke plugin runtime RUN_AGENT action
- Normalize AgentRunResult to Pipeline messages
- Handle errors, timeouts, protocol errors
- Maintain streaming card behavior
Entry points:
- run(event, binding): Main entry for event-first Protocol v1
- run_from_query(query): current Query entry adapter wrapper
"""
ap: app.Application
registry: AgentRunnerRegistry
context_builder: AgentRunContextBuilder
resource_builder: AgentResourceBuilder
result_normalizer: AgentResultNormalizer
binding_resolver: AgentBindingResolver
# Cached singleton references (set in __init__)
_session_registry: AgentRunSessionRegistry
_persistent_state_store: PersistentStateStore | None
def __init__(
self,
ap: app.Application,
registry: AgentRunnerRegistry,
):
self.ap = ap
self.registry = registry
self.context_builder = AgentRunContextBuilder(ap)
self.resource_builder = AgentResourceBuilder(ap)
self.result_normalizer = AgentResultNormalizer(ap)
self.binding_resolver = AgentBindingResolver()
# Cache singleton references to avoid per-request getter calls
self._session_registry = get_session_registry()
self._persistent_state_store = None # Lazy init on first use
async def run(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
bound_plugins: list[str] | None = None,
adapter_context: dict[str, typing.Any] | None = None,
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""Run agent runner from event-first envelope.
This is the main entry point for Protocol v1.
Event Gateway -> AgentBindingResolver -> run(event, binding).
Args:
event: Event envelope from event gateway
binding: Agent binding
bound_plugins: Optional list of bound plugin identities for authorization
adapter_context: Optional context from an entry adapter
Yields:
Message or MessageChunk for pipeline response
Raises:
RunnerNotFoundError: If runner not found
RunnerNotAuthorizedError: If runner not authorized
RunnerExecutionError: If runner execution failed
"""
runner_id = binding.runner_id
# Get runner descriptor
descriptor = await self.registry.get(runner_id, bound_plugins)
# Build resources from binding
resources = await self.resource_builder.build_resources_from_binding(
event=event,
binding=binding,
descriptor=descriptor,
)
# Build context from event + binding
context = await self.context_builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
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']
if adapter_context.get('prompt_get'):
context['context']['available_apis']['prompt_get'] = True
# 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']
await self._session_registry.register(
run_id=run_id,
runner_id=descriptor.id,
query_id=session_query_id,
plugin_identity=descriptor.get_plugin_id(),
resources=resources,
permissions=descriptor.permissions or {},
conversation_id=event.conversation_id,
state_policy={
'enable_state': binding.state_policy.enable_state,
'state_scopes': list(binding.state_policy.state_scopes),
},
state_context=state_context,
)
# Write incoming event to EventLog
event_log_id = await self._write_event_log(
event=event,
binding=binding,
run_id=run_id,
runner_id=descriptor.id,
)
# Register incoming attachments so input/transcript artifact_refs are resolvable.
await self._register_input_artifacts(
event=event,
run_id=run_id,
runner_id=descriptor.id,
)
# Write user message to Transcript if message.received
if event.event_type == 'message.received' and event.conversation_id:
await self._write_user_transcript(
event=event,
event_log_id=event_log_id,
)
# Track artifact refs for assistant transcript (cleared after each message.completed)
pending_artifact_refs: list[dict[str, typing.Any]] = []
try:
# Run via plugin connector
async for result_dict in self._invoke_runner(descriptor, context):
# Handle artifact.created first - consume before normalizer
if result_dict.get('type') == 'artifact.created':
artifact_ref = await self._handle_artifact_created(
result_dict=result_dict,
event=event,
run_id=run_id,
runner_id=descriptor.id,
)
pending_artifact_refs.append(artifact_ref)
# Pass to normalizer for logging, but don't yield to pipeline
await self.result_normalizer.normalize(result_dict, descriptor)
continue
# Handle state.updated first - consume before normalizer
if result_dict.get('type') == 'state.updated':
await self._handle_state_updated_event(result_dict, event, binding, descriptor)
# Pass to normalizer for logging, but don't yield to pipeline
await self.result_normalizer.normalize(result_dict, descriptor)
continue
# Handle message.completed - write to Transcript
if result_dict.get('type') == 'message.completed' and event.conversation_id:
# Merge pending artifact refs with message's own refs
merged_refs = self._merge_artifact_refs(
pending_artifact_refs,
result_dict,
)
# Clear pending refs after attaching to this message
pending_artifact_refs.clear()
await self._write_assistant_transcript(
result_dict=result_dict,
event=event,
run_id=run_id,
runner_id=descriptor.id,
artifact_refs=merged_refs if merged_refs else None,
)
# Normalize result for other types
result = await self.result_normalizer.normalize(result_dict, descriptor)
if result is not None:
yield result
finally:
# Unregister session after run completes (success or error)
await self._session_registry.unregister(run_id)
async def run_from_query(
self,
query: pipeline_query.Query,
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""Run agent runner from pipeline query.
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.
Args:
query: Pipeline query with pipeline_config, session, messages, etc.
Yields:
Message or MessageChunk for pipeline response
Raises:
RunnerNotFoundError: If runner not found
RunnerNotAuthorizedError: If runner not authorized
RunnerExecutionError: If runner execution failed
"""
# Resolve runner ID using ConfigMigration
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
if not runner_id:
raise RunnerNotFoundError('no runner configured')
# Convert Query to event-first envelope
event = QueryEntryAdapter.query_to_event(query)
# Project the current Pipeline adapter config into target Agent config.
# exactly one effective binding for this event.
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
binding = self.binding_resolver.resolve_one(event, [agent_config])
# Extract bound plugins for authorization
bound_plugins = query.variables.get('_pipeline_bound_plugins')
# 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(
event,
binding,
bound_plugins=bound_plugins,
adapter_context=adapter_context,
):
yield result
async def _invoke_runner(
self,
descriptor: AgentRunnerDescriptor,
context: AgentRunContextPayload,
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
"""Invoke runner via plugin connector.
Args:
descriptor: Runner descriptor
context: AgentRunContext dict
Yields:
Raw result dicts from plugin runtime
Raises:
RunnerExecutionError: If plugin system disabled or runtime error
"""
if not self.ap.plugin_connector.is_enable_plugin:
raise RunnerExecutionError(
descriptor.id,
'Plugin system is disabled',
retryable=False,
)
try:
gen = self.ap.plugin_connector.run_agent(
plugin_author=descriptor.plugin_author,
plugin_name=descriptor.plugin_name,
runner_name=descriptor.runner_name,
context=context,
)
while True:
try:
result_dict = await self._next_with_deadline(gen, descriptor, context)
except StopAsyncIteration:
break
yield result_dict
except asyncio.TimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
'Runner timed out (code: runner.timeout)',
retryable=True,
) from e
except ActionCallTimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
f'{e} (code: runner.timeout)',
retryable=True,
) from e
except RunnerExecutionError:
raise
except Exception as e:
# Wrap unexpected errors
self.ap.logger.error(
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
)
raise RunnerExecutionError(
descriptor.id,
str(e),
retryable=False,
)
async def _next_with_deadline(
self,
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
descriptor: AgentRunnerDescriptor,
context: AgentRunContextPayload,
) -> dict[str, typing.Any]:
"""Read the next runner result while enforcing the run deadline."""
remaining = self._remaining_deadline_seconds(context)
if remaining is not None and remaining <= 0:
await self._close_generator(gen, descriptor)
raise asyncio.TimeoutError
try:
if remaining is None:
return await anext(gen)
return await asyncio.wait_for(anext(gen), timeout=remaining)
except StopAsyncIteration:
if self._is_deadline_exhausted(context):
raise asyncio.TimeoutError
raise
except asyncio.TimeoutError:
await self._close_generator(gen, descriptor)
raise
def _remaining_deadline_seconds(
self,
context: AgentRunContextPayload,
) -> float | None:
runtime = context.get('runtime') or {}
deadline_at = runtime.get('deadline_at')
if deadline_at is None:
return None
try:
return float(deadline_at) - time.time()
except (TypeError, ValueError):
return None
def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool:
remaining = self._remaining_deadline_seconds(context)
return remaining is not None and remaining <= 0
async def _close_generator(
self,
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
descriptor: AgentRunnerDescriptor,
) -> None:
try:
await gen.aclose()
except Exception as e:
self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}')
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
"""Resolve runner ID for telemetry/logging without full execution.
Args:
query: Pipeline query
Returns:
Runner ID string, or None
"""
return ConfigMigration.resolve_runner_id(query.pipeline_config)
async def _handle_state_updated_event(
self,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> None:
"""Handle state.updated result in event-first mode.
Persists state to database via PersistentStateStore.
Args:
result_dict: Raw result dict with type='state.updated'
event: Event envelope
binding: Agent binding
descriptor: Runner descriptor
"""
data = result_dict.get('data', {})
scope = data.get('scope')
if not scope:
raise RunnerProtocolError(
descriptor.id,
'state.updated missing required field: scope',
)
# Extract key and value
key = data.get('key')
value = data.get('value')
if not key:
raise RunnerProtocolError(
descriptor.id,
'state.updated missing required field: key',
)
# Lazy init persistent state store
if self._persistent_state_store is None:
self._persistent_state_store = get_persistent_state_store(
self.ap.persistence_mgr.get_db_engine()
)
# Apply update to persistent state store
success, error = await self._persistent_state_store.apply_update_from_event(
event=event,
binding=binding,
descriptor=descriptor,
scope=scope,
key=key,
value=value,
logger=self.ap.logger,
)
if success:
self.ap.logger.debug(
f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}'
)
elif error:
self.ap.logger.warning(
f'Runner {descriptor.id} state.updated rejected: {error}'
)
async def _write_event_log(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
run_id: str,
runner_id: str,
) -> str:
"""Write incoming event to EventLog.
Args:
event: Event envelope
binding: Agent binding
run_id: Run ID
runner_id: Runner ID
Returns:
Event log ID
"""
import datetime
from .event_log_store import EventLogStore
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
# Build input summary
input_summary = None
input_json = None
if event.input:
if event.input.text:
input_summary = event.input.text[:1000]
input_json = {
'text': event.input.text,
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
'attachments': [a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments],
}
return await store.append_event(
event_id=event.event_id,
event_type=event.event_type,
source=event.source,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
actor_type=event.actor.actor_type if event.actor else None,
actor_id=event.actor.actor_id if event.actor else None,
actor_name=event.actor.actor_name if event.actor else None,
subject_type=event.subject.subject_type if event.subject else None,
subject_id=event.subject.subject_id if event.subject else None,
input_summary=input_summary,
input_json=input_json,
run_id=run_id,
runner_id=runner_id,
event_time=datetime.datetime.fromtimestamp(event.event_time) if event.event_time else None,
)
async def _register_input_artifacts(
self,
event: AgentEventEnvelope,
run_id: str,
runner_id: str,
) -> None:
"""Register current-event attachments referenced by AgentInput."""
if not event.input or not event.input.attachments:
return
from .artifact_store import ArtifactStore
store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
for attachment in event.input.attachments:
data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment
if not isinstance(data, dict):
continue
artifact_id = data.get('artifact_id')
artifact_type = data.get('artifact_type') or 'file'
if not artifact_id:
continue
content, parsed_mime_type = self._decode_attachment_content(data.get('content'))
url = data.get('url')
platform_ref_id = data.get('id')
storage_key = None
storage_type = 'metadata_only'
if content is None:
if url:
storage_key = url
storage_type = 'url'
elif platform_ref_id:
storage_key = platform_ref_id
storage_type = 'platform_ref'
metadata = {
'input_attachment': True,
'input_source': data.get('source') or 'platform',
}
if url:
metadata['url'] = url
if platform_ref_id:
metadata['platform_ref_id'] = platform_ref_id
try:
await store.register_artifact(
artifact_id=artifact_id,
artifact_type=artifact_type,
source='platform',
storage_key=storage_key,
storage_type=storage_type,
mime_type=data.get('mime_type') or parsed_mime_type,
name=data.get('name'),
size_bytes=data.get('size') or (len(content) if content is not None else None),
conversation_id=event.conversation_id,
run_id=run_id,
runner_id=runner_id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
metadata=metadata,
content=content,
)
except Exception as e:
self.ap.logger.warning(
f'Failed to register input artifact {artifact_id}: {e}'
)
def _decode_attachment_content(
self,
content: typing.Any,
) -> tuple[bytes | None, str | None]:
"""Decode base64 attachment content, including data URLs."""
if not isinstance(content, str) or not content:
return None, None
import base64
import binascii
mime_type = None
payload = content
if content.startswith('data:') and ',' in content:
header, payload = content.split(',', 1)
if ';base64' in header:
mime_type = header[5:].split(';', 1)[0] or None
try:
return base64.b64decode(payload, validate=False), mime_type
except (binascii.Error, ValueError):
return None, mime_type
async def _write_user_transcript(
self,
event: AgentEventEnvelope,
event_log_id: str,
) -> None:
"""Write user message to Transcript.
Args:
event: Event envelope
event_log_id: Event log ID
"""
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
# Build content
content = event.input.text if event.input else None
content_json = None
if event.input:
content_json = {
'role': 'user',
'content': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents] if event.input.contents else [],
}
# Build artifact refs
artifact_refs = []
if event.input and event.input.attachments:
for a in event.input.attachments:
artifact_refs.append(a.model_dump(mode='json') if hasattr(a, 'model_dump') else a)
await store.append_transcript(
transcript_id=None, # Auto-generate
event_id=event_log_id,
conversation_id=event.conversation_id,
role='user',
content=content,
content_json=content_json,
artifact_refs=artifact_refs if artifact_refs else None,
thread_id=event.thread_id,
item_type='message',
metadata={
'actor_type': event.actor.actor_type if event.actor else None,
'actor_id': event.actor.actor_id if event.actor else None,
},
)
async def _handle_artifact_created(
self,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
run_id: str,
runner_id: str,
) -> dict[str, typing.Any]:
"""Handle artifact.created result - register artifact and write EventLog.
Args:
result_dict: Raw result dict with type='artifact.created'
event: Event envelope
run_id: Current run ID
runner_id: Runner ID
Returns:
Artifact reference dict for Transcript
Raises:
RunnerProtocolError: On validation failures or registration errors
"""
import base64
import uuid
from .artifact_store import ArtifactStore
from .event_log_store import EventLogStore
data = result_dict.get('data', {})
# Validate run_id matches current context
result_run_id = result_dict.get('run_id')
if result_run_id and result_run_id != run_id:
raise RunnerProtocolError(
runner_id,
f'artifact.created run_id mismatch: expected {run_id}, got {result_run_id}',
)
# Extract artifact fields
artifact_id = data.get('artifact_id') or str(uuid.uuid4())
artifact_type = data.get('artifact_type')
if not artifact_type:
raise RunnerProtocolError(
runner_id,
'artifact.created missing required field: artifact_type',
)
mime_type = data.get('mime_type')
name = data.get('name')
size_bytes = data.get('size_bytes')
sha256 = data.get('sha256')
metadata = data.get('metadata')
content_base64 = data.get('content_base64')
# Decode and validate content if provided
content: bytes | None = None
if content_base64:
try:
content = base64.b64decode(content_base64, validate=True)
except Exception as e:
raise RunnerProtocolError(
runner_id,
f'artifact.created invalid base64 content: {e}',
)
# Validate content size
if len(content) > MAX_ARTIFACT_INLINE_BYTES:
raise RunnerProtocolError(
runner_id,
f'artifact.created content size {len(content)} bytes exceeds limit {MAX_ARTIFACT_INLINE_BYTES} bytes',
)
# Register artifact via ArtifactStore
artifact_store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
try:
registered_id = await artifact_store.register_artifact(
artifact_id=artifact_id,
artifact_type=artifact_type,
source='runner',
mime_type=mime_type,
name=name,
size_bytes=size_bytes,
sha256=sha256,
conversation_id=event.conversation_id,
run_id=run_id,
runner_id=runner_id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
metadata=metadata,
content=content,
)
except Exception as e:
raise RunnerProtocolError(
runner_id,
f'artifact.created failed to register artifact: {e}',
)
# Write to EventLog
event_log_store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
await event_log_store.append_event(
event_id=str(uuid.uuid4()),
event_type='artifact.created',
source='runner',
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
actor_type=event.actor.actor_type if event.actor else None,
actor_id=event.actor.actor_id if event.actor else None,
actor_name=event.actor.actor_name if event.actor else None,
input_summary=f'Artifact created: {artifact_type}',
input_json={
'artifact_id': registered_id,
'artifact_type': artifact_type,
'mime_type': mime_type,
'name': name,
'size_bytes': size_bytes,
},
run_id=run_id,
runner_id=runner_id,
)
# Return artifact ref for Transcript
return {
'artifact_id': registered_id,
'artifact_type': artifact_type,
'mime_type': mime_type,
'name': name,
}
def _merge_artifact_refs(
self,
pending_refs: list[dict[str, typing.Any]],
result_dict: dict[str, typing.Any],
) -> list[dict[str, typing.Any]]:
"""Merge pending artifact refs with message's own refs, deduplicating by artifact_id.
Args:
pending_refs: Artifact refs accumulated from artifact.created events
result_dict: Result dict that may contain message with artifact_refs
Returns:
Merged and deduplicated list of artifact refs
"""
# Start with pending refs
merged = list(pending_refs)
seen_ids = {ref.get('artifact_id') for ref in pending_refs if ref.get('artifact_id')}
# Extract refs from message data if present
data = result_dict.get('data', {})
message = data.get('message', {})
message_refs = message.get('artifact_refs', [])
if isinstance(message_refs, list):
for ref in message_refs:
if isinstance(ref, dict):
artifact_id = ref.get('artifact_id')
if artifact_id and artifact_id not in seen_ids:
merged.append(ref)
seen_ids.add(artifact_id)
return merged
async def _write_assistant_transcript(
self,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
run_id: str,
runner_id: str,
artifact_refs: list[dict[str, typing.Any]] | None = None,
) -> None:
"""Write assistant message to Transcript.
Args:
result_dict: Result dict from runner
event: Original event envelope
run_id: Run ID
runner_id: Runner ID
artifact_refs: Optional artifact references to include
"""
import uuid
from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
data = result_dict.get('data', {})
message = data.get('message', {})
# Build content
content = None
content_json = None
if isinstance(message.get('content'), str):
content = message['content']
content_json = message
elif isinstance(message.get('content'), list):
# Extract text from content list
text_parts = []
for c in message['content']:
if isinstance(c, dict) and c.get('type') == 'text':
text_parts.append(c.get('text', ''))
content = ' '.join(text_parts) if text_parts else None
content_json = message
# Generate a unique event ID for assistant message
assistant_event_id = str(uuid.uuid4())
await store.append_transcript(
transcript_id=str(uuid.uuid4()),
event_id=assistant_event_id,
conversation_id=event.conversation_id,
role='assistant',
content=content,
content_json=content_json,
artifact_refs=artifact_refs,
thread_id=event.thread_id,
item_type='message',
run_id=run_id,
runner_id=runner_id,
metadata={
'run_id': run_id,
'runner_id': runner_id,
},
)

View File

@@ -0,0 +1,431 @@
"""Persistent state store for AgentRunner protocol state.
This module provides a database-backed state store for event-first Protocol v1.
"""
from __future__ import annotations
import typing
import json
import threading
from datetime import datetime
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy import select, delete, update
from .descriptor import AgentRunnerDescriptor
from .host_models import AgentEventEnvelope, AgentBinding
from .state_scope import (
VALID_STATE_SCOPES,
build_state_scope_key,
get_binding_identity,
normalize_state_key,
)
from ...entity.persistence.agent_runner_state import AgentRunnerState
# Maximum value_json size (256KB)
MAX_VALUE_JSON_BYTES = 256 * 1024
class PersistentStateStore:
"""Database-backed state store for AgentRunner protocol state.
IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state.
This store provides:
1. Persistent storage across runs via database
2. Scope isolation by runner_id + binding_identity + scope
3. Policy enforcement (enable_state, state_scopes)
4. JSON value validation and size limits
Used by:
- Event-first Protocol v1 (async methods)
- State API handlers (get/set/delete/list)
"""
def __init__(self, db_engine: AsyncEngine):
self._db_engine = db_engine
def _get_scope_key(
self,
scope: str,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> str | None:
"""Get scope key for given scope."""
return build_state_scope_key(scope, event, binding, descriptor)
def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool:
"""Check if scope is enabled by binding's state_policy."""
state_policy = binding.state_policy
if not state_policy.enable_state:
return False
return scope in state_policy.state_scopes
def _validate_json_value(
self,
value: typing.Any,
logger: typing.Any = None,
) -> tuple[str | None, str | None]:
"""Validate and serialize value to JSON.
Returns:
Tuple of (json_string, error_message). If error_message is not None,
json_string will be None.
"""
try:
json_str = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError) as e:
return None, f'Value is not JSON-serializable: {e}'
# Check size limit
json_bytes = len(json_str.encode('utf-8'))
if json_bytes > MAX_VALUE_JSON_BYTES:
return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes'
return json_str, None
# ========== Async DB Operations ==========
async def build_snapshot_from_event(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> dict[str, dict[str, typing.Any]]:
"""Build state snapshot for all scopes from event and binding.
Reads from database, respects state_policy.
"""
state_policy = binding.state_policy
# If state is disabled, return all empty scopes
if not state_policy.enable_state:
return {
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
}
snapshot: dict[str, dict[str, typing.Any]] = {
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
}
async with self._db_engine.connect() as conn:
for scope in VALID_STATE_SCOPES:
if not self._check_scope_enabled(scope, binding):
continue
scope_key = self._get_scope_key(scope, event, binding, descriptor)
if not scope_key:
continue
# Query all state entries for this scope_key
result = await conn.execute(
select(AgentRunnerState.state_key, AgentRunnerState.value_json)
.where(AgentRunnerState.scope_key == scope_key)
)
rows = result.fetchall()
for row in rows:
key = row.state_key
value_json = row.value_json
if value_json:
try:
snapshot[scope][key] = json.loads(value_json)
except json.JSONDecodeError:
pass # Skip invalid JSON
# Seed external.conversation_id from event.conversation_id if not set
if self._check_scope_enabled('conversation', binding) and event.conversation_id:
if 'external.conversation_id' not in snapshot['conversation']:
snapshot['conversation']['external.conversation_id'] = event.conversation_id
return snapshot
async def apply_update_from_event(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
scope: str,
key: str,
value: typing.Any,
logger: typing.Any = None,
) -> tuple[bool, str | None]:
"""Apply a state update from event context.
Returns:
Tuple of (success, error_message). If success is False, error_message
contains the reason.
"""
state_policy = binding.state_policy
# Check if state is disabled
if not state_policy.enable_state:
return False, 'State is disabled by binding policy'
# Validate scope
if scope not in VALID_STATE_SCOPES:
return False, f'Invalid scope: {scope}'
# Check if scope is enabled
if not self._check_scope_enabled(scope, binding):
return False, f'Scope "{scope}" not enabled by binding policy'
# Map accepted key aliases
key = normalize_state_key(key)
# Get scope key
scope_key = self._get_scope_key(scope, event, binding, descriptor)
if not scope_key:
return False, f'Missing identity for scope "{scope}"'
# Validate and serialize value
value_json, error = self._validate_json_value(value, logger)
if error:
return False, error
# Build context fields
binding_identity = get_binding_identity(binding)
async with self._db_engine.begin() as conn:
# Check if entry exists
result = await conn.execute(
select(AgentRunnerState.id)
.where(AgentRunnerState.scope_key == scope_key)
.where(AgentRunnerState.state_key == key)
)
existing = result.first()
now = datetime.utcnow()
if existing:
# Update existing entry
await conn.execute(
update(AgentRunnerState)
.where(AgentRunnerState.id == existing.id)
.values(
value_json=value_json,
updated_at=now,
)
)
else:
# Insert new entry
await conn.execute(
sqlalchemy.insert(AgentRunnerState).values(
runner_id=descriptor.id,
binding_identity=binding_identity,
scope=scope,
scope_key=scope_key,
state_key=key,
value_json=value_json,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
actor_type=event.actor.actor_type if event.actor else None,
actor_id=event.actor.actor_id if event.actor else None,
subject_type=event.subject.subject_type if event.subject else None,
subject_id=event.subject.subject_id if event.subject else None,
created_at=now,
updated_at=now,
)
)
return True, None
async def state_get(
self,
scope_key: str,
state_key: str,
) -> typing.Any:
"""Get a single state value by scope_key and state_key.
Used by State API handlers.
"""
state_key = normalize_state_key(state_key)
async with self._db_engine.connect() as conn:
result = await conn.execute(
select(AgentRunnerState.value_json)
.where(AgentRunnerState.scope_key == scope_key)
.where(AgentRunnerState.state_key == state_key)
)
row = result.first()
if not row or not row.value_json:
return None
try:
return json.loads(row.value_json)
except json.JSONDecodeError:
return None
async def state_set(
self,
scope_key: str,
state_key: str,
value: typing.Any,
runner_id: str,
binding_identity: str,
scope: str,
context: dict[str, typing.Any] | None = None,
logger: typing.Any = None,
) -> tuple[bool, str | None]:
"""Set a state value.
Used by State API handlers.
Context contains optional fields like bot_id, conversation_id, etc.
"""
state_key = normalize_state_key(state_key)
# Validate and serialize value
value_json, error = self._validate_json_value(value, logger)
if error:
return False, error
context = context or {}
async with self._db_engine.begin() as conn:
# Check if entry exists
result = await conn.execute(
select(AgentRunnerState.id)
.where(AgentRunnerState.scope_key == scope_key)
.where(AgentRunnerState.state_key == state_key)
)
existing = result.first()
now = datetime.utcnow()
if existing:
# Update existing entry
await conn.execute(
update(AgentRunnerState)
.where(AgentRunnerState.id == existing.id)
.values(
value_json=value_json,
updated_at=now,
)
)
else:
# Insert new entry
await conn.execute(
sqlalchemy.insert(AgentRunnerState).values(
runner_id=runner_id,
binding_identity=binding_identity,
scope=scope,
scope_key=scope_key,
state_key=state_key,
value_json=value_json,
bot_id=context.get('bot_id'),
workspace_id=context.get('workspace_id'),
conversation_id=context.get('conversation_id'),
thread_id=context.get('thread_id'),
actor_type=context.get('actor_type'),
actor_id=context.get('actor_id'),
subject_type=context.get('subject_type'),
subject_id=context.get('subject_id'),
created_at=now,
updated_at=now,
)
)
return True, None
async def state_delete(
self,
scope_key: str,
state_key: str,
) -> bool:
"""Delete a state value.
Returns True if deleted, False if not found.
"""
state_key = normalize_state_key(state_key)
async with self._db_engine.begin() as conn:
result = await conn.execute(
delete(AgentRunnerState)
.where(AgentRunnerState.scope_key == scope_key)
.where(AgentRunnerState.state_key == state_key)
.returning(AgentRunnerState.id)
)
deleted = result.first()
return deleted is not None
async def state_list(
self,
scope_key: str,
prefix: str | None = None,
limit: int = 100,
) -> tuple[list[str], bool]:
"""List state keys in a scope.
Returns tuple of (keys, has_more).
"""
# Enforce limit cap
limit = min(limit, 100)
async with self._db_engine.connect() as conn:
query = (
select(AgentRunnerState.state_key)
.where(AgentRunnerState.scope_key == scope_key)
.order_by(AgentRunnerState.state_key)
.limit(limit + 1) # Fetch one extra to check has_more
)
if prefix:
prefix = normalize_state_key(prefix)
query = query.where(
AgentRunnerState.state_key.like(f'{prefix}%')
)
result = await conn.execute(query)
rows = result.fetchall()
keys = [row.state_key for row in rows[:limit]]
has_more = len(rows) > limit
return keys, has_more
async def clear_all(self) -> None:
"""Clear all state entries (for testing)."""
async with self._db_engine.begin() as conn:
await conn.execute(delete(AgentRunnerState))
# Global singleton persistent state store
_persistent_state_store: PersistentStateStore | None = None
_persistent_state_store_lock = threading.Lock()
def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore:
"""Get the global persistent state store singleton.
Args:
db_engine: Database engine (required on first call)
Returns:
PersistentStateStore singleton
"""
global _persistent_state_store
with _persistent_state_store_lock:
if _persistent_state_store is None:
if db_engine is None:
raise RuntimeError("db_engine required for first call to get_persistent_state_store")
_persistent_state_store = PersistentStateStore(db_engine)
return _persistent_state_store
def reset_persistent_state_store() -> None:
"""Reset the global persistent state store (for testing)."""
global _persistent_state_store
with _persistent_state_store_lock:
_persistent_state_store = None

View File

@@ -0,0 +1,585 @@
"""Query entry adapter for converting Query to event-first envelope.
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
import hashlib
import typing
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.agent_runner.event import (
AgentEventContext,
ConversationContext,
ActorContext,
SubjectContext,
RawEventRef,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from .host_models import (
AgentConfig,
AgentEventEnvelope,
ResourcePolicy,
StatePolicy,
DeliveryPolicy,
)
from . import events as runner_events
class QueryEntryAdapter:
"""Adapter for converting Query to event-first envelope.
This adapter is responsible for:
- Converting Query to AgentEventEnvelope
- Projecting current Pipeline config to temporary AgentConfig
- Putting Query-only fields into adapter context
"""
INTERNAL_PREFIX = '_'
SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey')
PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission')
@classmethod
def query_to_event(
cls,
query: pipeline_query.Query,
) -> AgentEventEnvelope:
"""Convert Query to AgentEventEnvelope.
Args:
query: Current entry query
Returns:
AgentEventEnvelope for event-first processing
"""
# Build event context
event = cls._build_event_context(query)
# Build conversation context
conversation = cls._build_conversation_context(query)
# Build actor context
actor = cls._build_actor_context(query)
# Build subject context
subject = cls._build_subject_context(query)
# Build input
input = cls._build_input(query)
# Build delivery context
delivery = cls._build_delivery_context(query)
# Build raw ref
raw_ref = cls._build_raw_ref(query)
return AgentEventEnvelope(
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="host_adapter",
source_event_type=event.source_event_type,
bot_id=query.bot_uuid,
workspace_id=None, # Not available in Query
conversation_id=conversation.conversation_id,
thread_id=conversation.thread_id,
actor=actor,
subject=subject,
input=input,
delivery=delivery,
raw_ref=raw_ref,
data=event.data,
)
@classmethod
def config_to_agent_config(
cls,
query: pipeline_query.Query,
runner_id: str,
) -> AgentConfig:
"""Project the current Pipeline config container into target Agent config."""
pipeline_config = query.pipeline_config or {}
ai_config = pipeline_config.get('ai', {})
runner_config = ai_config.get('runner_config', {}).get(runner_id, {})
agent_id = getattr(query, 'pipeline_uuid', None)
# 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),
allowed_kb_uuids=cls._extract_allowed_kbs(query),
)
# Build state policy
state_policy = StatePolicy(
enable_state=True,
state_scopes=["conversation", "actor", "subject", "runner"],
)
# Build delivery policy
delivery_policy = DeliveryPolicy(
enable_streaming=True,
enable_reply=True,
)
return AgentConfig(
agent_id=agent_id,
runner_id=runner_id,
runner_config=runner_config,
resource_policy=resource_policy,
state_policy=state_policy,
delivery_policy=delivery_policy,
event_types=[runner_events.MESSAGE_RECEIVED],
enabled=True,
metadata={'source': 'pipeline_adapter'},
)
@classmethod
def build_adapter_context(
cls,
query: pipeline_query.Query,
binding: AgentBinding,
) -> dict[str, typing.Any]:
"""Build Query-derived fields for the current entry adapter."""
return {
'params': cls.build_params(query),
'query_id': getattr(query, 'query_id', None),
'prompt_get': cls._has_effective_prompt(query),
}
@classmethod
def build_params(cls, query: pipeline_query.Query) -> dict[str, typing.Any]:
"""Build adapter params from Pipeline variables with host filtering."""
params: dict[str, typing.Any] = {}
variables = getattr(query, 'variables', None)
if not variables:
return params
for key, value in variables.items():
if key.startswith(cls.INTERNAL_PREFIX):
continue
key_lower = key.lower()
if any(pattern in key_lower for pattern in cls.SENSITIVE_PATTERNS):
continue
if any(key == perm_var or key.startswith(perm_var) for perm_var in cls.PERMISSION_VARS):
continue
if cls.is_json_serializable(value):
params[key] = value
return params
@classmethod
def is_json_serializable(cls, value: typing.Any) -> bool:
"""Return whether a value can safely cross the adapter boundary as JSON."""
if value is None or isinstance(value, (str, int, float, bool)):
return True
if isinstance(value, (list, tuple)):
return all(cls.is_json_serializable(item) for item in value)
if isinstance(value, dict):
return all(
isinstance(k, str) and cls.is_json_serializable(v)
for k, v in value.items()
)
return False
@classmethod
def _has_effective_prompt(cls, query: pipeline_query.Query) -> bool:
prompt = getattr(query, 'prompt', None)
messages = getattr(prompt, 'messages', None) if prompt is not None else None
return isinstance(messages, list)
# Private helper methods
@classmethod
def _build_event_context(
cls,
query: pipeline_query.Query,
) -> AgentEventContext:
"""Build AgentEventContext from Query."""
message_event = getattr(query, 'message_event', None)
event_data: dict[str, typing.Any] = {}
if message_event and hasattr(message_event, 'model_dump'):
try:
event_data = message_event.model_dump(mode='json')
except TypeError:
event_data = message_event.model_dump()
except Exception:
event_data = {}
event_data.pop('source_platform_object', None)
source_event_type = None
if message_event:
source_event_type = getattr(message_event, 'type', None)
message_chain = getattr(query, 'message_chain', None)
message_id = getattr(message_chain, 'message_id', None)
if message_id == -1:
message_id = None
event_time = None
if message_event:
event_time = getattr(message_event, 'time', None)
if isinstance(event_time, (int, float)):
event_time = int(event_time)
source_event_id = str(message_id or query.query_id)
return AgentEventContext(
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
event_type=runner_events.MESSAGE_RECEIVED,
event_time=event_time,
source="host_adapter",
source_event_type=source_event_type,
data=event_data,
)
@classmethod
def _build_scoped_event_id(
cls,
query: pipeline_query.Query,
source_event_id: str,
event_time: int | None,
) -> str:
"""Build a globally unique host event id from pipeline-local ids."""
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 = [
'host_adapter',
getattr(query, 'pipeline_uuid', None),
getattr(query, 'bot_uuid', None),
launcher_type_value,
getattr(query, 'launcher_id', None),
getattr(query, 'sender_id', None),
source_event_id,
event_time,
]
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'host:{digest}'
@classmethod
def _build_conversation_context(
cls,
query: pipeline_query.Query,
) -> ConversationContext:
"""Build ConversationContext from Query."""
# Handle launcher_type safely
launcher_type = getattr(query, 'launcher_type', None)
launcher_type_value = None
if launcher_type is not None:
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
# Handle launcher_id
launcher_id = getattr(query, 'launcher_id', None)
# Build session_id from launcher info if available
session_id = None
if launcher_type_value and launcher_id:
session_id = f'{launcher_type_value}_{launcher_id}'
# Handle session and conversation_id
conversation_id = None
session = getattr(query, 'session', None)
if session:
conversation = getattr(session, 'using_conversation', None)
if conversation:
conversation_id = getattr(conversation, 'uuid', None)
if not conversation_id:
variables = getattr(query, 'variables', None) or {}
conversation_id = variables.get('conversation_id') or None
if not conversation_id:
conversation_id = session_id
# Handle sender_id
sender_id = getattr(query, 'sender_id', None)
if sender_id is not None:
sender_id = str(sender_id)
# Handle bot_uuid
bot_uuid = getattr(query, 'bot_uuid', None)
return ConversationContext(
conversation_id=str(conversation_id) if conversation_id is not None else None,
thread_id=None,
launcher_type=launcher_type_value,
launcher_id=launcher_id,
sender_id=sender_id,
bot_id=bot_uuid,
workspace_id=None,
session_id=session_id,
)
@classmethod
def _build_actor_context(
cls,
query: pipeline_query.Query,
) -> ActorContext:
"""Build ActorContext from Query."""
message_event = getattr(query, 'message_event', None)
sender = getattr(message_event, 'sender', None) if message_event else None
sender_id = getattr(query, 'sender_id', None)
actor_id = getattr(sender, 'id', None) if sender else None
if actor_id is None:
actor_id = sender_id
actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None
return ActorContext(
actor_type="user",
actor_id=str(actor_id) if actor_id is not None else None,
actor_name=actor_name,
metadata={},
)
@classmethod
def _build_subject_context(
cls,
query: pipeline_query.Query,
) -> SubjectContext:
"""Build SubjectContext from Query."""
message_chain = getattr(query, 'message_chain', None)
message_id = getattr(message_chain, 'message_id', None) if message_chain else None
if message_id == -1:
message_id = None
query_id = getattr(query, 'query_id', None)
# Safely get launcher_type
launcher_type = getattr(query, 'launcher_type', None)
launcher_type_value = None
if launcher_type is not None:
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
return SubjectContext(
subject_type="message",
subject_id=str(message_id or query_id or ''),
data={
"launcher_type": launcher_type_value,
"launcher_id": getattr(query, 'launcher_id', None),
"sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None,
"bot_uuid": getattr(query, 'bot_uuid', None),
},
)
@classmethod
def _build_input(
cls,
query: pipeline_query.Query,
) -> AgentInput:
"""Build AgentInput from Query."""
text = None
text_parts: list[str] = []
contents: list[dict[str, typing.Any]] = []
user_message = getattr(query, 'user_message', None)
if user_message:
content = getattr(user_message, 'content', None)
if isinstance(content, list):
for elem in content:
elem_dict = None
if hasattr(elem, 'model_dump'):
elem_dict = elem.model_dump(mode='json')
elif isinstance(elem, dict):
elem_dict = elem
if not isinstance(elem_dict, dict):
continue
contents.append(elem_dict)
if elem_dict.get('type') == 'text':
elem_text = elem_dict.get('text')
if elem_text:
text_parts.append(elem_text)
elif content is not None:
text = str(content)
contents.append({'type': 'text', 'text': text})
if text_parts:
text = ''.join(text_parts)
message_chain_dict = None
message_chain = getattr(query, 'message_chain', None)
if message_chain:
if hasattr(message_chain, 'model_dump'):
message_chain_dict = message_chain.model_dump(mode='json')
attachments = cls._build_attachments(query, contents)
return AgentInput(
text=text,
contents=contents,
message_chain=message_chain_dict,
attachments=attachments,
)
@classmethod
def _build_attachments(
cls,
query: pipeline_query.Query,
contents: list[dict[str, typing.Any]],
) -> list[dict[str, typing.Any]]:
"""Extract attachments from query."""
import uuid
attachments: list[dict[str, typing.Any]] = []
for elem in contents:
elem_type = elem.get('type')
artifact_id = str(uuid.uuid4()) # Generate unique ID
if elem_type == 'image_url':
image_url = elem.get('image_url') or {}
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'image',
'source': 'url',
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
})
elif elem_type == 'image_base64':
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'image',
'source': 'base64',
'content': elem.get('image_base64'),
})
elif elem_type == 'file_url':
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'file',
'source': 'url',
'url': elem.get('file_url'),
'name': elem.get('file_name'),
})
elif elem_type == 'file_base64':
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'file',
'source': 'base64',
'content': elem.get('file_base64'),
'name': elem.get('file_name'),
})
message_chain = getattr(query, 'message_chain', None)
if message_chain:
try:
message_components = iter(message_chain)
except TypeError:
message_components = iter(())
for component in message_components:
artifact_id = str(uuid.uuid4()) # Generate unique ID
if isinstance(component, platform_message.Image):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'image',
'source': 'message_chain',
'id': component.image_id or None,
'url': component.url or None,
})
elif isinstance(component, platform_message.File):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'file',
'source': 'message_chain',
'id': component.id or None,
'name': component.name or None,
})
elif isinstance(component, platform_message.Voice):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'voice',
'source': 'message_chain',
'id': component.voice_id or None,
'url': component.url or None,
})
return attachments
@classmethod
def _build_delivery_context(
cls,
query: pipeline_query.Query,
) -> DeliveryContext:
"""Build DeliveryContext from Query."""
message_chain = getattr(query, 'message_chain', None)
return DeliveryContext(
surface="platform",
reply_target={
"message_id": getattr(message_chain, 'message_id', None),
},
supports_streaming=True,
supports_edit=False,
supports_reaction=False,
platform_capabilities={},
)
@classmethod
def _build_raw_ref(
cls,
query: pipeline_query.Query,
) -> RawEventRef | None:
"""Build RawEventRef from Query."""
# For now, we don't store raw event payload
return None
@classmethod
def _extract_allowed_models(
cls,
query: pipeline_query.Query,
) -> list[str] | None:
"""Extract allowed model UUIDs from query."""
model_uuids: list[str] = []
model_uuid = getattr(query, 'use_llm_model_uuid', None)
if model_uuid:
model_uuids.append(model_uuid)
variables = getattr(query, 'variables', None) or {}
for fallback_uuid in variables.get('_fallback_model_uuids', []) or []:
if fallback_uuid and fallback_uuid not in model_uuids:
model_uuids.append(fallback_uuid)
return model_uuids or None
@classmethod
def _extract_allowed_tools(
cls,
query: pipeline_query.Query,
) -> list[str] | None:
"""Extract allowed tool names from query."""
use_funcs = getattr(query, 'use_funcs', None)
if not use_funcs:
return None
try:
tool_names = []
for func in use_funcs:
if isinstance(func, dict):
name = func.get('name')
elif hasattr(func, 'name'):
name = func.name
else:
continue
if name:
tool_names.append(name)
return tool_names if tool_names else None
except (TypeError, AttributeError):
return None
@classmethod
def _extract_allowed_kbs(
cls,
query: pipeline_query.Query,
) -> list[str] | None:
"""Extract allowed knowledge base UUIDs from query."""
variables = getattr(query, 'variables', None)
if not variables:
return None
kb_uuids = variables.get('_knowledge_base_uuids')
if kb_uuids:
return kb_uuids
return None

View File

@@ -0,0 +1,293 @@
"""Agent runner registry for discovering and caching runner descriptors."""
from __future__ import annotations
import typing
import asyncio
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .id import parse_runner_id, format_runner_id
from .errors import RunnerNotFoundError, RunnerNotAuthorizedError
class AgentRunnerRegistry:
"""Registry for discovering and managing agent runners.
Responsibilities:
- Discover runners from plugin runtime via LIST_AGENT_RUNNERS
- Validate runner manifests (kind, metadata, spec)
- Cache discovered runners for performance
- Filter runners by bound plugins
- Handle manifest errors gracefully (log warning, skip runner)
"""
ap: app.Application
_cache: dict[str, AgentRunnerDescriptor] | None
"""Cached runner descriptors keyed by runner ID"""
_cache_lock: asyncio.Lock
"""Lock for cache refresh operations"""
def __init__(self, ap: app.Application):
self.ap = ap
self._cache = None
self._cache_lock = asyncio.Lock()
async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]:
"""Discover runners from plugin runtime.
Always discovers ALL runners (no bound_plugins filter).
The cache should contain unfiltered discovery results.
Returns:
Dict of runner descriptors keyed by runner ID
"""
if not self.ap.plugin_connector.is_enable_plugin:
return {}
runners: dict[str, AgentRunnerDescriptor] = {}
try:
# Always list all runners (bound_plugins=None)
plugin_runners = await self.ap.plugin_connector.list_agent_runners(None)
for runner_data in plugin_runners:
try:
descriptor = self._validate_and_build_descriptor(runner_data)
if descriptor is not None:
runners[descriptor.id] = descriptor
except Exception as e:
plugin_author = runner_data.get('plugin_author', 'unknown')
plugin_name = runner_data.get('plugin_name', 'unknown')
runner_name = runner_data.get('runner_name', 'unknown')
self.ap.logger.warning(
f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}'
)
continue
except Exception as e:
self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}')
return {}
return runners
def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None:
"""Validate runner manifest and build descriptor.
Args:
runner_data: Raw runner data from plugin runtime with fields:
- plugin_author, plugin_name, runner_name
- manifest (full component manifest dict)
- protocol_version, capabilities, permissions, config (extracted from spec)
Returns:
AgentRunnerDescriptor if valid, None if invalid
"""
plugin_author = runner_data.get('plugin_author', '')
plugin_name = runner_data.get('plugin_name', '')
runner_name = runner_data.get('runner_name', '')
if not plugin_author or not plugin_name or not runner_name:
return None
manifest = runner_data.get('manifest', {})
# Validate kind
kind = manifest.get('kind', '')
if kind != 'AgentRunner':
return None
# Validate metadata
metadata = manifest.get('metadata', {})
name = metadata.get('name', '')
if not name:
return None
# metadata.label must exist
label = metadata.get('label', {})
if not label:
label = {name: name} # fallback
spec = manifest.get('spec', {})
# SDK now provides these directly extracted from spec. Fall back to
# manifest.spec for older runtimes/tests that return the raw manifest.
protocol_version = runner_data.get('protocol_version') or spec.get('protocol_version', '1')
config_schema = runner_data.get('config') or spec.get('config', [])
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
permissions = runner_data.get('permissions') or spec.get('permissions', {})
# Build descriptor
runner_id = format_runner_id(
source='plugin',
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label=label,
description=metadata.get('description') or runner_data.get('runner_description'),
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
plugin_version=runner_data.get('plugin_version'),
protocol_version=protocol_version,
config_schema=config_schema,
capabilities=capabilities,
permissions=permissions,
raw_manifest=manifest,
)
async def refresh(self) -> None:
"""Refresh runner cache.
Always discovers ALL runners (no bound_plugins filter).
The cache contains unfiltered discovery results.
"""
async with self._cache_lock:
self._cache = await self._discover_runners()
async def list_runners(
self,
bound_plugins: list[str] | None = None,
use_cache: bool = True,
) -> list[AgentRunnerDescriptor]:
"""List available runners.
Args:
bound_plugins: Optional filter for bound plugins (applied locally)
use_cache: Use cached data if available
Returns:
List of runner descriptors
"""
if use_cache and self._cache is not None:
# Filter from cache
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
# Discover fresh (always full list)
runners = await self._discover_runners()
# Update cache (full list, unfiltered)
async with self._cache_lock:
self._cache = runners
# Filter locally
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
def _filter_runners_by_bound_plugins(
self,
runners: dict[str, AgentRunnerDescriptor],
bound_plugins: list[str] | None,
) -> list[AgentRunnerDescriptor]:
"""Filter runners by bound plugins.
Args:
runners: Dict of runner descriptors
bound_plugins: Optional filter (None means all plugins allowed)
Returns:
Filtered list of runner descriptors
"""
if bound_plugins is None:
# All plugins allowed
return list(runners.values())
allowed_plugin_ids = set(bound_plugins)
filtered = []
for descriptor in runners.values():
plugin_id = descriptor.get_plugin_id()
if plugin_id in allowed_plugin_ids:
filtered.append(descriptor)
return filtered
async def get(
self,
runner_id: str,
bound_plugins: list[str] | None = None,
) -> AgentRunnerDescriptor:
"""Get a specific runner descriptor.
Args:
runner_id: Runner ID to lookup
bound_plugins: Optional bound plugins filter
Returns:
AgentRunnerDescriptor
Raises:
RunnerNotFoundError: If runner not found
RunnerNotAuthorizedError: If runner not in bound plugins
"""
# Parse and validate runner ID format
try:
parse_runner_id(runner_id)
except ValueError as e:
raise RunnerNotFoundError(runner_id) from e
# Get from cache or discover (always full list)
if self._cache is None:
await self.refresh()
if self._cache is None:
raise RunnerNotFoundError(runner_id)
descriptor = self._cache.get(runner_id)
if descriptor is None:
raise RunnerNotFoundError(runner_id)
# Check authorization
if bound_plugins is not None:
plugin_id = descriptor.get_plugin_id()
if plugin_id not in bound_plugins:
raise RunnerNotAuthorizedError(runner_id, bound_plugins)
return descriptor
async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]:
"""Get runner metadata for pipeline configuration UI.
Returns runner options and their config schemas for the DynamicForm.
"""
# Get all runners (no bound plugin filter for metadata listing)
runners = await self.list_runners(bound_plugins=None)
options = []
stages = []
for descriptor in runners:
config_schema = []
for index, config_item in enumerate(descriptor.config_schema):
item = dict(config_item)
if not item.get('id'):
item_name = item.get('name') or str(index)
item['id'] = f'{descriptor.id}.{item_name}'
config_schema.append(item)
# Add runner option
options.append(
{
'name': descriptor.id,
'label': descriptor.label,
'description': descriptor.description,
}
)
# Add config schema as stage if not empty
if descriptor.config_schema:
stages.append(
{
'name': descriptor.id,
'label': descriptor.label,
'description': descriptor.description,
'config': config_schema,
}
)
return options, stages

View File

@@ -0,0 +1,268 @@
"""Agent resource builder for constructing authorized resources."""
from __future__ import annotations
import typing
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .context_builder import (
AgentResources,
ModelResource,
ToolResource,
KnowledgeBaseResource,
StorageResource,
)
from . import config_schema
from .host_models import AgentEventEnvelope, AgentBinding
class AgentResourceBuilder:
"""Builder for constructing AgentResources with permission filtering.
Responsibilities:
- Apply 3-layer permission filtering:
1. Runner manifest declared permissions
2. Pipeline extensions_preference (bound plugins/MCP servers)
3. Agent/runner config selected resources
- Build models list from authorized models
- Build tools list from bound plugins/MCP servers
- Build knowledge_bases list from config
- Build storage and files permissions summary
Note: This only builds the resource declaration. The actual proxy actions
in handler.py must still validate against ctx.resources at runtime.
Resource field names match the plugin SDK payload:
- ModelResource: model_id, model_type, provider
- ToolResource: tool_name, tool_type, description
- KnowledgeBaseResource: kb_id, kb_name, kb_type
- StorageResource: plugin_storage, workspace_storage
"""
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
async def build_resources_from_binding(
self,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> AgentResources:
"""Build AgentResources from event and binding.
This is the main entry point for Protocol v1.
Args:
event: Event envelope
binding: Agent binding with resource policy
descriptor: Runner descriptor with permissions and capabilities
Returns:
AgentResources dict with filtered resource lists
"""
# Layer 1: Runner manifest permissions
manifest_perms = descriptor.permissions
# Layer 2: Binding resource policy
resource_policy = binding.resource_policy
# Layer 3: Agent/runner config
runner_config = binding.runner_config
# Build each resource category
models = await self._build_models_from_binding(
manifest_perms, resource_policy, descriptor, runner_config
)
tools = await self._build_tools_from_binding(
manifest_perms, resource_policy, binding
)
knowledge_bases = await self._build_knowledge_bases_from_binding(
manifest_perms, resource_policy, descriptor, runner_config
)
storage = self._build_storage_from_binding(manifest_perms, binding)
return {
'models': models,
'tools': tools,
'knowledge_bases': knowledge_bases,
'files': [], # Files are populated at runtime
'storage': storage,
'platform_capabilities': {}, # Reserved for EBA
}
async def _build_models_from_binding(
self,
manifest_perms: dict[str, list[str]],
resource_policy: typing.Any,
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
) -> list[ModelResource]:
"""Build models list from binding."""
models: list[ModelResource] = []
seen_model_ids: set[str] = set()
model_perms = manifest_perms.get('models', [])
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
allow_rerank = 'rerank' in model_perms
if not allow_llm and not allow_rerank:
return models
# Get additional model UUID grants from resource policy.
allowed_uuids = resource_policy.allowed_model_uuids
# Add model resources from Agent/runner config schema
await self._append_config_declared_model_resources(
models=models,
seen_model_ids=seen_model_ids,
descriptor=descriptor,
runner_config=runner_config,
include_llm=allow_llm,
include_rerank=allow_rerank,
)
# Add explicitly allowed models
if allowed_uuids and allow_llm:
for model_uuid in allowed_uuids:
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
return models
async def _build_tools_from_binding(
self,
manifest_perms: dict[str, list[str]],
resource_policy: typing.Any,
binding: AgentBinding,
) -> list[ToolResource]:
"""Build tools list from binding."""
tools: list[ToolResource] = []
# Check manifest permission
tool_perms = manifest_perms.get('tools', [])
if 'detail' not in tool_perms and 'call' not in tool_perms:
return tools
# Get tool names from resource policy
allowed_names = resource_policy.allowed_tool_names
if allowed_names:
for tool_name in allowed_names:
tools.append({
'tool_name': tool_name,
'tool_type': None,
'description': None,
})
return tools
async def _build_knowledge_bases_from_binding(
self,
manifest_perms: dict[str, list[str]],
resource_policy: typing.Any,
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
) -> list[KnowledgeBaseResource]:
"""Build knowledge bases list from binding."""
kb_resources: list[KnowledgeBaseResource] = []
# Check manifest permission
kb_perms = manifest_perms.get('knowledge_bases', [])
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
return kb_resources
# Get KB UUID grants from schema-defined config fields.
kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
# Also include resource policy grants.
allowed_uuids = resource_policy.allowed_kb_uuids
if allowed_uuids:
kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids]))
for kb_uuid in kb_uuids:
try:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if kb:
kb_resources.append({
'kb_id': kb_uuid,
'kb_name': kb.get_name(),
'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None,
})
except Exception as e:
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
return kb_resources
def _build_storage_from_binding(
self,
manifest_perms: dict[str, list[str]],
binding: AgentBinding,
) -> StorageResource:
"""Build storage permissions from binding."""
storage_perms = manifest_perms.get('storage', [])
resource_policy = binding.resource_policy
return {
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,
'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage,
}
async def _append_config_declared_model_resources(
self,
models: list[ModelResource],
seen_model_ids: set[str],
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
include_llm: bool,
include_rerank: bool,
) -> None:
"""Authorize model-like values selected through DynamicForm fields."""
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
if model_type == 'llm' and include_llm:
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
elif model_type == 'rerank' and include_rerank:
await self._append_rerank_model_resource(models, seen_model_ids, model_uuid)
async def _append_llm_model_resource(
self,
models: list[ModelResource],
seen_model_ids: set[str],
model_uuid: str | None,
) -> None:
"""Append an LLM model resource if it exists and has not been added."""
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
return
try:
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
if model and model.model_entity:
models.append({
'model_id': model_uuid,
'model_type': getattr(model.model_entity, 'model_type', None),
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
})
seen_model_ids.add(model_uuid)
except Exception as e:
self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}')
async def _append_rerank_model_resource(
self,
models: list[ModelResource],
seen_model_ids: set[str],
model_uuid: str | None,
) -> None:
"""Append a rerank model resource if it exists and has not been added."""
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
return
try:
model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid)
if model and model.model_entity:
models.append({
'model_id': model_uuid,
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
})
seen_model_ids.add(model_uuid)
except Exception as e:
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')

View File

@@ -0,0 +1,193 @@
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
from __future__ import annotations
import typing
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .errors import RunnerExecutionError, RunnerProtocolError
# Maximum size for a single result payload (prevent memory exhaustion)
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
class AgentResultNormalizer:
"""Normalizer for converting AgentRunResult to Pipeline messages.
Responsibilities:
- Accept only supported result types (message.delta, message.completed, etc.)
- Map message.delta -> MessageChunk
- Map message.completed -> Message
- Map run.completed (with message) -> Message
- Handle run.failed as controlled error
- Ignore unknown types with warning
- Validate result size
- Validate message schema
Accepted result types:
- message.delta
- message.completed
- tool.call.started
- tool.call.completed
- state.updated
- run.completed
- run.failed
- action.requested (log only, don't execute)
"""
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
async def normalize(
self,
result_dict: dict[str, typing.Any],
descriptor: AgentRunnerDescriptor,
) -> provider_message.Message | provider_message.MessageChunk | None:
"""Normalize AgentRunResult to Message or MessageChunk.
Args:
result_dict: Raw result dict from plugin runtime
descriptor: Runner descriptor for error context
Returns:
Message, MessageChunk, or None (for non-message events)
Raises:
RunnerExecutionError: On run.failed
RunnerProtocolError: On invalid result format
"""
# Validate result type
result_type = result_dict.get('type')
if not result_type:
raise RunnerProtocolError(descriptor.id, 'Missing result type')
# Validate result size
try:
import json
result_json = json.dumps(result_dict)
if len(result_json) > MAX_RESULT_SIZE_BYTES:
self.ap.logger.warning(
f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating'
)
# Truncate content if possible
data = result_dict.get('data', {})
if 'chunk' in data or 'message' in data:
content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '')
if isinstance(content, str) and len(content) > 10000:
# Keep reasonable length
data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'}
except Exception as e:
self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}')
# Handle each result type
data = result_dict.get('data', {})
if result_type == 'message.delta':
return self._normalize_message_delta(data, descriptor)
elif result_type == 'message.completed':
return self._normalize_message_completed(data, descriptor)
elif result_type == 'tool.call.started':
# Log only, don't yield to pipeline
self.ap.logger.debug(
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
)
return None
elif result_type == 'tool.call.completed':
# Log only, don't yield to pipeline
self.ap.logger.debug(
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
)
return None
elif result_type == 'state.updated':
# Log for telemetry, don't yield to pipeline
# Orchestrator already handles the actual PersistentStateStore update.
scope = data.get('scope', 'unknown')
key = data.get('key', 'unknown')
value_repr = repr(data.get('value', '...'))[:100] # Truncate for log
self.ap.logger.debug(
f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}'
)
return None
elif result_type == 'run.completed':
# May include final message
if 'message' in data:
return self._normalize_message_completed(data, descriptor)
# If no message, it's just completion signal
return None
elif result_type == 'run.failed':
error_msg = data.get('error', 'Unknown error')
error_code = data.get('code', 'unknown')
retryable = data.get('retryable', False)
raise RunnerExecutionError(
descriptor.id,
f'{error_msg} (code: {error_code})',
retryable=retryable,
)
elif result_type == 'action.requested':
# Reserved for EBA - log only, don't execute
self.ap.logger.info(
f'Runner {descriptor.id} requested action (not executed in current phase): '
f'{data.get("action", "unknown")}'
)
return None
elif result_type == 'artifact.created':
# Log for telemetry, consumed by orchestrator
artifact_id = data.get('artifact_id', 'unknown')
artifact_type = data.get('artifact_type', 'unknown')
self.ap.logger.debug(
f'Runner {descriptor.id} artifact.created logged: artifact_id={artifact_id}, type={artifact_type}'
)
return None
else:
# Unknown type - warn and ignore.
self.ap.logger.warning(
f'Runner {descriptor.id} returned unknown result type: {result_type}. '
f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)'
)
return None
def _normalize_message_delta(
self,
data: dict[str, typing.Any],
descriptor: AgentRunnerDescriptor,
) -> provider_message.MessageChunk:
"""Normalize message.delta to MessageChunk."""
chunk_data = data.get('chunk', {})
if not chunk_data:
raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data')
try:
chunk = provider_message.MessageChunk.model_validate(chunk_data)
return chunk
except Exception as e:
raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}')
def _normalize_message_completed(
self,
data: dict[str, typing.Any],
descriptor: AgentRunnerDescriptor,
) -> provider_message.Message:
"""Normalize message.completed to Message."""
message_data = data.get('message', {})
if not message_data:
raise RunnerProtocolError(descriptor.id, 'message.completed missing message data')
try:
msg = provider_message.Message.model_validate(message_data)
return msg
except Exception as e:
raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}')

View File

@@ -0,0 +1,263 @@
"""Agent run session registry for proxy action permission validation."""
from __future__ import annotations
import asyncio
import copy
import typing
import time
import threading
from .context_builder import AgentResources
class AgentRunSessionStatus(typing.TypedDict):
"""Status tracking for agent run session."""
started_at: int
last_activity_at: int
class RunAuthorizationSnapshot(typing.TypedDict):
"""Frozen authorization data for one active run.
ResourceBuilder creates the authorized resource list once before runner
execution. Runtime proxy handlers must validate against this run-scoped
snapshot instead of recomputing resource policy.
"""
resources: AgentResources
permissions: dict[str, list[str]]
conversation_id: str | None
state_policy: dict[str, typing.Any]
state_context: dict[str, typing.Any]
authorized_ids: dict[str, set[str]]
class AgentRunSession(typing.TypedDict):
"""Session for an active agent runner execution.
Stored in AgentRunSessionRegistry for proxy action permission validation.
Fields:
run_id: Unique run identifier (UUID from AgentRunContext)
runner_id: Runner descriptor ID (plugin:author/name/runner)
query_id: Host entry query ID, only present for query-based adapters
plugin_identity: Plugin identifier (author/name) of the runner
authorization: Run-scoped authorization snapshot; runtime auth truth
status: Session status tracking
"""
run_id: str
runner_id: str
query_id: int | None
plugin_identity: str # author/name
authorization: RunAuthorizationSnapshot
status: AgentRunSessionStatus
class AgentRunSessionRegistry:
"""Registry for active agent run sessions.
Host-owned registry for tracking active AgentRunner executions.
Used by proxy actions in handler.py to validate resource access.
Key: run_id (UUID from AgentRunContext)
Value: AgentRunSession with authorized resources
Thread-safe via asyncio.Lock.
"""
_sessions: dict[str, AgentRunSession]
_lock: asyncio.Lock
def __init__(self):
self._sessions = {}
self._lock = asyncio.Lock()
async def register(
self,
run_id: str,
runner_id: str,
query_id: int | None,
plugin_identity: str,
resources: AgentResources,
conversation_id: str | None = None,
permissions: dict[str, list[str]] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
) -> None:
"""Register a new agent run session.
Args:
run_id: Unique run identifier
runner_id: Runner descriptor 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
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
state_policy: State policy from binding (enable_state, state_scopes)
state_context: Context for state API (scope_keys, binding_identity, etc.)
"""
now = int(time.time())
# Normalize permissions to empty dict if None
permissions = permissions or {}
# Normalize state_policy to defaults if None
if state_policy is None:
state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
# Normalize state_context to empty dict if None
state_context = state_context or {}
resources_snapshot = copy.deepcopy(resources)
authorization: RunAuthorizationSnapshot = {
'resources': resources_snapshot,
'permissions': copy.deepcopy(permissions),
'conversation_id': conversation_id,
'state_policy': copy.deepcopy(state_policy),
'state_context': copy.deepcopy(state_context),
'authorized_ids': self._build_authorized_ids(resources_snapshot),
}
session: AgentRunSession = {
'run_id': run_id,
'runner_id': runner_id,
'query_id': query_id,
'plugin_identity': plugin_identity,
'authorization': authorization,
'status': {
'started_at': now,
'last_activity_at': now,
},
}
async with self._lock:
self._sessions[run_id] = session
def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]:
"""Pre-compute authorized resource IDs for O(1) lookup."""
return {
'model': {m.get('model_id') for m in resources.get('models', [])},
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
'file': {f.get('file_id') for f in resources.get('files', [])},
}
async def unregister(self, run_id: str) -> None:
"""Unregister an agent run session.
Args:
run_id: Unique run identifier
"""
async with self._lock:
if run_id in self._sessions:
del self._sessions[run_id]
async def get(self, run_id: str) -> AgentRunSession | None:
"""Get session by run_id.
Args:
run_id: Unique run identifier
Returns:
AgentRunSession if found, None otherwise
"""
async with self._lock:
return self._sessions.get(run_id)
async def update_activity(self, run_id: str) -> None:
"""Update last activity timestamp for session.
Args:
run_id: Unique run identifier
"""
async with self._lock:
if run_id in self._sessions:
self._sessions[run_id]['status']['last_activity_at'] = int(time.time())
def is_resource_allowed(
self,
session: AgentRunSession,
resource_type: str,
resource_id: str,
) -> bool:
"""Check if resource access is allowed for this session.
Uses pre-computed authorized IDs for O(1) lookup.
Args:
session: AgentRunSession to check
resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file')
resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace', file_key)
Returns:
True if resource is authorized, False otherwise
"""
authorization = session['authorization']
authorized_ids = authorization['authorized_ids']
resources = authorization['resources']
if resource_type in ('model', 'tool', 'knowledge_base', 'file'):
return resource_id in authorized_ids.get(resource_type, set())
if resource_type == 'storage':
storage = resources.get('storage', {})
if resource_id == 'plugin':
return storage.get('plugin_storage', False)
elif resource_id == 'workspace':
return storage.get('workspace_storage', False)
return False
return False
async def list_active_runs(self) -> list[AgentRunSession]:
"""List all active run sessions.
Returns:
List of active AgentRunSession dicts
"""
async with self._lock:
return list(self._sessions.values())
async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int:
"""Cleanup sessions that have been inactive for too long.
Args:
max_age_seconds: Maximum inactivity time in seconds (default 1 hour)
Returns:
Number of sessions cleaned up
"""
now = int(time.time())
cleaned = 0
async with self._lock:
stale_run_ids = []
for run_id, session in self._sessions.items():
last_activity = session['status'].get('last_activity_at', 0)
if now - last_activity > max_age_seconds:
stale_run_ids.append(run_id)
for run_id in stale_run_ids:
del self._sessions[run_id]
cleaned += 1
return cleaned
# Global registry instance (singleton)
_global_registry: AgentRunSessionRegistry | None = None
_global_registry_lock = threading.Lock()
def get_session_registry() -> AgentRunSessionRegistry:
"""Get global session registry instance (thread-safe singleton).
Returns:
AgentRunSessionRegistry singleton
"""
global _global_registry
with _global_registry_lock:
if _global_registry is None:
_global_registry = AgentRunSessionRegistry()
return _global_registry

View File

@@ -0,0 +1,113 @@
"""State scope key helpers for AgentRunner host-owned state."""
from __future__ import annotations
import typing
from .descriptor import AgentRunnerDescriptor
from .host_models import AgentBinding, AgentEventEnvelope
VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner')
STATE_KEY_ALIASES = {
'conversation_id': 'external.conversation_id',
}
def normalize_state_key(key: str) -> str:
"""Map accepted public aliases to protocol state keys."""
return STATE_KEY_ALIASES.get(key, key)
def get_binding_identity(binding: AgentBinding) -> str:
"""Return the stable binding identity used for state isolation."""
if binding.binding_id:
return binding.binding_id
scope = binding.scope
if scope.scope_type and scope.scope_id:
return f'{scope.scope_type}:{scope.scope_id}'
return 'unknown_binding'
def build_state_scope_key(
scope: str,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> str | None:
"""Build the storage key for one state scope.
Returns None when the event lacks the identity required by that scope.
"""
binding_identity = get_binding_identity(binding)
if scope == 'conversation':
if not event.conversation_id:
return None
parts = [descriptor.id, binding_identity, event.conversation_id]
if event.thread_id:
parts.append(event.thread_id)
return f'conversation:{":".join(parts)}'
if scope == 'actor':
if not event.actor or not event.actor.actor_id:
return None
parts = [
descriptor.id,
binding_identity,
event.actor.actor_type or 'user',
event.actor.actor_id,
]
return f'actor:{":".join(parts)}'
if scope == 'subject':
if not event.subject or not event.subject.subject_id:
return None
parts = [
descriptor.id,
binding_identity,
event.subject.subject_type or 'unknown',
event.subject.subject_id,
]
return f'subject:{":".join(parts)}'
if scope == 'runner':
return f'runner:{descriptor.id}:{binding_identity}'
return None
def build_state_scope_keys(
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> dict[str, str]:
"""Build all available scope keys for an event/binding pair."""
scope_keys: dict[str, str] = {}
for scope in VALID_STATE_SCOPES:
scope_key = build_state_scope_key(scope, event, binding, descriptor)
if scope_key:
scope_keys[scope] = scope_key
return scope_keys
def build_state_context(
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> dict[str, typing.Any]:
"""Build the State API context stored in the run session."""
return {
'scope_keys': build_state_scope_keys(event, binding, descriptor),
'binding_identity': get_binding_identity(binding),
'bot_id': event.bot_id,
'workspace_id': event.workspace_id,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'actor_type': event.actor.actor_type if event.actor else None,
'actor_id': event.actor.actor_id if event.actor else None,
'subject_type': event.subject.subject_type if event.subject else None,
'subject_id': event.subject.subject_id if event.subject else None,
}

View File

@@ -0,0 +1,341 @@
"""Transcript store for writing and querying conversation history."""
from __future__ import annotations
import json
import datetime
import typing
import uuid
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.transcript import Transcript
from langbot_plugin.api.entities.builtin.provider import message as provider_message
class TranscriptStore:
"""Store for Transcript records.
Handles writing transcript items and querying them for history API.
All methods are async and use the provided database engine.
"""
engine: AsyncEngine
# Hard limits
MAX_CONTENT_LENGTH = 4000
HARD_LIMIT = 100
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def append_transcript(
self,
transcript_id: str | None,
event_id: str,
conversation_id: str,
role: str,
content: str | None = None,
content_json: dict[str, typing.Any] | None = None,
artifact_refs: list[dict[str, typing.Any]] | None = None,
thread_id: str | None = None,
item_type: str = "message",
run_id: str | None = None,
runner_id: str | None = None,
metadata: dict[str, typing.Any] | None = None,
) -> str:
"""Append a transcript item.
Args:
transcript_id: Unique transcript ID (generated if None)
event_id: Source event ID
conversation_id: Conversation ID
role: Message role (user, assistant, system, tool)
content: Text content
content_json: Full structured content
artifact_refs: Artifact references
thread_id: Thread ID
item_type: Item type
run_id: Run ID that generated this
runner_id: Runner ID that generated this
metadata: Additional metadata
Returns:
The transcript_id
"""
if transcript_id is None:
transcript_id = str(uuid.uuid4())
# Truncate content if too long
if content and len(content) > self.MAX_CONTENT_LENGTH:
content = content[:self.MAX_CONTENT_LENGTH - 3] + "..."
async with self._session_factory() as session:
item = Transcript(
transcript_id=transcript_id,
event_id=event_id,
conversation_id=conversation_id,
thread_id=thread_id,
role=role,
item_type=item_type,
content=content,
content_json=json.dumps(content_json) if content_json else None,
artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None,
seq=0,
run_id=run_id,
runner_id=runner_id,
created_at=datetime.datetime.utcnow(),
metadata_json=json.dumps(metadata) if metadata else None,
)
session.add(item)
await session.flush()
item.seq = item.id or await self._get_next_seq(conversation_id)
await session.commit()
return transcript_id
async def page_transcript(
self,
conversation_id: str,
before_seq: int | None = None,
after_seq: int | None = None,
limit: int = 50,
direction: str = "backward",
include_artifacts: bool = False,
) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]:
"""Page through transcript items.
Args:
conversation_id: Conversation ID
before_seq: Get items before this sequence (backward)
after_seq: Get items after this sequence (forward)
limit: Maximum items to return (capped at 100)
direction: 'backward' (older) or 'forward' (newer)
include_artifacts: Include artifact refs
Returns:
Tuple of (items, next_seq, prev_seq, has_more)
"""
limit = min(limit, self.HARD_LIMIT)
async with self._session_factory() as session:
query = sqlalchemy.select(Transcript).where(
Transcript.conversation_id == conversation_id
)
if direction == "backward" and before_seq is not None:
query = query.where(Transcript.seq < before_seq)
query = query.order_by(Transcript.seq.desc())
elif direction == "forward" and after_seq is not None:
query = query.where(Transcript.seq > after_seq)
query = query.order_by(Transcript.seq.asc())
else:
# Default: most recent items first (backward from latest)
query = query.order_by(Transcript.seq.desc())
query = query.limit(limit + 1)
result = await session.execute(query)
rows = result.scalars().all()
items = [self._row_to_dict(row, include_artifacts) for row in rows[:limit]]
has_more = len(rows) > limit
# Calculate cursors
next_seq = None
prev_seq = None
if direction == "backward":
# Items are in descending order
if items:
next_seq = items[-1].get('seq') if has_more else None
prev_seq = items[0].get('seq')
else:
# Items are in ascending order
if items:
next_seq = items[-1].get('seq') if has_more else None
prev_seq = items[0].get('seq')
return items, next_seq, prev_seq, has_more
async def search_transcript(
self,
conversation_id: str,
query_text: str,
filters: dict[str, typing.Any] | None = None,
top_k: int = 10,
) -> list[dict[str, typing.Any]]:
"""Search transcript items.
Basic implementation using LIKE filtering.
Args:
conversation_id: Conversation ID
query_text: Search query
filters: Optional filters
top_k: Maximum results
Returns:
List of matching items
"""
async with self._session_factory() as session:
query = sqlalchemy.select(Transcript).where(
Transcript.conversation_id == conversation_id,
Transcript.content.ilike(f"%{query_text}%"),
)
# Apply additional filters
if filters:
if 'roles' in filters:
query = query.where(Transcript.role.in_(filters['roles']))
if 'item_types' in filters:
query = query.where(Transcript.item_type.in_(filters['item_types']))
query = query.order_by(Transcript.seq.desc()).limit(top_k)
result = await session.execute(query)
rows = result.scalars().all()
return [self._row_to_dict(row, include_artifacts=True) for row in rows]
async def get_latest_cursor(
self,
conversation_id: str,
) -> str | None:
"""Get the latest cursor for a conversation.
Args:
conversation_id: Conversation ID
Returns:
Cursor string (seq number), or None if no items
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(Transcript.seq)
.where(Transcript.conversation_id == conversation_id)
.order_by(Transcript.seq.desc())
.limit(1)
)
row = result.scalars().first()
if row is None:
return None
return str(row)
async def get_legacy_provider_messages(
self,
conversation_id: str,
limit: int = HARD_LIMIT,
) -> list[provider_message.Message]:
"""Project Transcript rows into the legacy provider Message view.
AgentRunner history is canonical in Transcript. This view exists for
legacy Pipeline readers such as PromptPreProcessing that still expect
query.messages.
"""
items, _, _, _ = await self.page_transcript(
conversation_id=conversation_id,
limit=limit,
direction="backward",
)
messages: list[provider_message.Message] = []
for item in reversed(items):
message = self._transcript_item_to_provider_message(item)
if message is not None:
messages.append(message)
return messages
async def has_history_before(
self,
conversation_id: str,
seq: int,
) -> bool:
"""Check if there is history before a sequence number.
Args:
conversation_id: Conversation ID
seq: Sequence number
Returns:
True if there are items before
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(Transcript)
.where(
Transcript.conversation_id == conversation_id,
Transcript.seq < seq,
)
)
count = result.scalar()
return count > 0
async def _get_next_seq(self, conversation_id: str) -> int:
"""Fallback next sequence number for stores that cannot expose autoincrement IDs."""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(sqlalchemy.func.max(Transcript.seq))
.where(Transcript.conversation_id == conversation_id)
)
max_seq = result.scalar()
return (max_seq or 0) + 1
def _row_to_dict(
self,
row: Transcript,
include_artifacts: bool = False,
) -> dict[str, typing.Any]:
"""Convert a Transcript row to dict."""
result = {
'transcript_id': row.transcript_id,
'event_id': row.event_id,
'conversation_id': row.conversation_id,
'thread_id': row.thread_id,
'role': row.role,
'item_type': row.item_type,
'content': row.content,
'content_json': json.loads(row.content_json) if row.content_json else None,
'seq': row.seq,
'cursor': str(row.seq),
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
}
if include_artifacts and row.artifact_refs_json:
result['artifact_refs'] = json.loads(row.artifact_refs_json)
else:
result['artifact_refs'] = []
return result
def _transcript_item_to_provider_message(
self,
item: dict[str, typing.Any],
) -> provider_message.Message | None:
"""Convert one Transcript API item into a provider Message."""
if item.get('item_type') != 'message':
return None
role = item.get('role')
if role not in {'user', 'assistant'}:
return None
content_json = item.get('content_json')
if isinstance(content_json, dict):
message_data = dict(content_json)
message_data['role'] = role
try:
return provider_message.Message.model_validate(message_data)
except Exception:
pass
content = item.get('content')
if content is None:
return None
return provider_message.Message(role=role, content=content)

View File

@@ -9,6 +9,8 @@ from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
from ....agent.runner.config_migration import ConfigMigration
from ....agent.runner import config_schema
def _parse_provider_api_keys(provider_dict: dict) -> dict:
@@ -40,6 +42,40 @@ class LLMModelsService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def _get_runner_descriptor(self, runner_id: str):
registry = getattr(self.ap, 'agent_runner_registry', None)
if registry is None:
return None
try:
return await registry.get(runner_id, bound_plugins=None)
except Exception as e:
logger = getattr(self.ap, 'logger', None)
if logger:
logger.warning(f'Failed to load AgentRunner descriptor while setting default model: {e}')
return None
async def _auto_set_default_pipeline_llm_model(self, pipeline: persistence_pipeline.LegacyPipeline, model_uuid: str):
pipeline_config = pipeline.config
if not isinstance(pipeline_config, dict):
return
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
if not runner_id:
return
descriptor = await self._get_runner_descriptor(runner_id)
if descriptor is None:
return
ai_config = pipeline_config.setdefault('ai', {})
runner_configs = ai_config.setdefault('runner_config', {})
runner_config = runner_configs.setdefault(runner_id, {})
if not config_schema.set_empty_llm_model_selection(descriptor, runner_config, model_uuid):
return
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config})
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
"""Get all LLM models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
@@ -109,7 +145,6 @@ class LLMModelsService:
self.ap.model_mgr.llm_models.append(runtime_llm_model)
if auto_set_to_default_pipeline:
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
@@ -117,15 +152,7 @@ class LLMModelsService:
)
pipeline = result.first()
if pipeline is not None:
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
if not model_config.get('primary', ''):
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = {
'primary': model_data['uuid'],
'fallbacks': [],
}
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
await self._auto_set_default_pipeline_llm_model(pipeline, model_data['uuid'])
return model_data['uuid']

View File

@@ -3,17 +3,22 @@ from __future__ import annotations
import uuid
import json
import sqlalchemy
import typing
from ....core import app
from ....entity.persistence import pipeline as persistence_pipeline
# Prefer the official local-agent plugin when it is installed. This is not a
# built-in fallback: when no AgentRunner plugin is available, the default
# pipeline stays unbound so the UI can guide users to install a runner.
PREFERRED_DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
default_stage_order = [
'GroupRespondRuleCheckStage', # 群响应规则检查
'BanSessionCheckStage', # 封禁会话检查
'PreContentFilterStage', # 内容过滤前置阶段
'PreProcessor', # 预处理器
'ConversationMessageTruncator', # 会话消息截断器
'RequireRateLimitOccupancy', # 请求速率限制占用
'MessageProcessor', # 处理器
'ReleaseRateLimitOccupancy', # 释放速率限制占用
@@ -30,11 +35,108 @@ class PipelineService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
def _get_default_values_from_schema(self, config_schema: list[dict[str, typing.Any]]) -> dict[str, typing.Any]:
"""Build runner config defaults from a DynamicForm schema."""
defaults: dict[str, typing.Any] = {}
for item in config_schema:
name = item.get('name')
if not name:
continue
if 'default' in item:
defaults[name] = item['default']
return defaults
async def get_default_pipeline_config(self) -> dict[str, typing.Any]:
"""Get the default pipeline config, rendering runner defaults from installed plugins."""
from ....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)
agent_runner_registry = getattr(self.ap, 'agent_runner_registry', None)
if agent_runner_registry is None:
return config
try:
runners = await agent_runner_registry.list_runners(bound_plugins=None)
except Exception as e:
logger = getattr(self.ap, 'logger', None)
if logger:
logger.warning(f'Failed to load plugin agent runners for default pipeline config: {e}')
return config
if not runners:
return config
selected_runner = next(
(runner for runner in runners if runner.id == PREFERRED_DEFAULT_RUNNER_ID),
runners[0],
)
ai_config = config.setdefault('ai', {})
runner_config = ai_config.setdefault('runner', {})
runner_config['id'] = selected_runner.id
runner_config.setdefault('expire-time', 0)
ai_config['runner_config'] = {
selected_runner.id: self._get_default_values_from_schema(selected_runner.config_schema),
}
return config
async def get_pipeline_metadata(self) -> list[dict]:
"""Get pipeline metadata with dynamically loaded plugin runners from registry"""
import copy
# Deep copy AI metadata to avoid modifying the original
ai_metadata = copy.deepcopy(self.ap.pipeline_config_meta_ai)
# Find the runner stage
runner_stage = None
for stage in ai_metadata.get('stages', []):
if stage.get('name') == 'runner':
runner_stage = stage
break
if runner_stage:
# Find the runner select config (now uses 'id' field)
for config_item in runner_stage.get('config', []):
if config_item.get('name') == 'id':
# Get plugin agent runners from registry
try:
(
runner_options,
runner_stages,
) = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline()
# Replace options entirely with registry options
# Only installed/available runners should be shown
config_item['options'] = runner_options
# Prefer the official local-agent plugin when installed; otherwise use the first
# discoverable runner. If no runner is available, leave the default unset so the
# UI can recommend installing an AgentRunner plugin, similar to the RAG flow.
if runner_options and 'default' not in config_item:
default_option = next(
(option for option in runner_options if option['name'] == PREFERRED_DEFAULT_RUNNER_ID),
runner_options[0],
)
config_item['default'] = default_option['name']
# Add corresponding stage configuration for each runner
for stage_config in runner_stages:
# Avoid duplicate stages
existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])}
if stage_config['name'] not in existing_stage_names:
ai_metadata['stages'].append(stage_config)
except Exception as e:
self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}')
return [
self.ap.pipeline_config_meta_trigger,
self.ap.pipeline_config_meta_safety,
self.ap.pipeline_config_meta_ai,
ai_metadata,
self.ap.pipeline_config_meta_output,
]
@@ -74,8 +176,6 @@ class PipelineService:
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_pipelines = limitation.get('max_pipelines', -1)
@@ -89,9 +189,7 @@ class PipelineService:
pipeline_data['stages'] = default_stage_order.copy()
pipeline_data['is_default'] = default
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
with open(template_path, 'r', encoding='utf-8') as f:
pipeline_data['config'] = json.load(f)
pipeline_data['config'] = await self.get_default_pipeline_config()
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
if 'extensions_preferences' not in pipeline_data:
@@ -113,10 +211,16 @@ class PipelineService:
return pipeline_data['uuid']
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
from ....agent.runner.config_migration import ConfigMigration
pipeline_data = pipeline_data.copy()
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
pipeline_data.pop(protected_field, None)
# Migrate config to new format before saving
if 'config' in pipeline_data:
pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)

View File

@@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
python - <<'PY'
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
@@ -225,7 +231,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
python -m venv "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then

View File

@@ -4,6 +4,7 @@ import logging
import asyncio
import traceback
import os
from typing import TYPE_CHECKING
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
@@ -46,6 +47,9 @@ from ..telemetry import telemetry as telemetry_module
from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
if TYPE_CHECKING:
from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator
class Application:
"""Runtime application object and context"""
@@ -165,6 +169,11 @@ class Application:
maintenance_service: maintenance_service.MaintenanceService = None
# Agent runner subsystem
agent_runner_registry: AgentRunnerRegistry = None
agent_run_orchestrator: AgentRunOrchestrator = None
def __init__(self):
pass

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('msg-truncator-cfg-migration', 9)
class MsgTruncatorConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'msg-truncate' not in self.ap.pipeline_cfg.data
async def run(self):
"""执行迁移"""
self.ap.pipeline_cfg.data['msg-truncate'] = {
'method': 'round',
'round': {'max-round': 10},
}
await self.ap.pipeline_cfg.dump_config()

View File

@@ -39,6 +39,7 @@ from ...vector import mgr as vectordb_mgr
from .. import taskmgr
from ...telemetry import telemetry as telemetry_module
from ...survey import manager as survey_module
from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator
@stage.stage_class('BuildAppStage')
@@ -194,5 +195,12 @@ class BuildAppStage(stage.BootingStage):
await plugin_connector_inst.initialize()
ap.plugin_connector = plugin_connector_inst
# Initialize agent runner subsystem
agent_runner_registry_inst = AgentRunnerRegistry(ap)
ap.agent_runner_registry = agent_runner_registry_inst
agent_run_orchestrator_inst = AgentRunOrchestrator(ap, agent_runner_registry_inst)
ap.agent_run_orchestrator = agent_run_orchestrator_inst
ctrl = controller.Controller(ap)
ap.ctrl = ctrl

View File

@@ -0,0 +1,88 @@
"""Agent runner state persistence entity for host-owned state."""
from __future__ import annotations
import sqlalchemy
import datetime
from .base import Base
class AgentRunnerState(Base):
"""AgentRunnerState stores host-owned state for AgentRunner protocol.
State is:
- Host-owned: Managed by LangBot, not by plugin instances
- Scope-isolated: Separated by runner_id + binding_identity + scope
- Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes)
Scope key design:
- conversation: runner_id + binding_id + conversation_id [+ thread_id]
- actor: runner_id + binding_id + actor_type + actor_id
- subject: runner_id + binding_id + subject_type + subject_id
- runner: runner_id + binding_id
This table is the production store for AgentRunner state.
"""
__tablename__ = 'agent_runner_state'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
"""Auto-increment ID for sequencing."""
# Identity
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""Runner descriptor ID (plugin:author/name/runner)."""
binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""Binding identity for isolation (binding_id or scope_type:scope_id)."""
scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
"""State scope: 'conversation', 'actor', 'subject', or 'runner'."""
scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False, index=True)
"""Full scope key for unique lookup (includes all identity parts)."""
state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
"""State key within scope (should use namespace prefix like external.*)."""
value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""State value as JSON string (size-limited by host)."""
# Context fields for querying/filtering
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Bot UUID if applicable."""
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Workspace ID for multi-tenant."""
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Conversation ID for conversation scope."""
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Thread ID for thread-scoped conversation state."""
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Actor type for actor scope."""
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Actor ID for actor scope."""
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Subject type for subject scope."""
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Subject ID for subject scope."""
# Lifecycle
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this state entry was created."""
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
"""When this state entry was last updated."""
# Unique constraint: scope_key + state_key
__table_args__ = (
sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'),
sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'),
sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'),
)

View File

@@ -0,0 +1,77 @@
"""Artifact persistence entity for Host-owned artifact store."""
from __future__ import annotations
import sqlalchemy
import datetime
from .base import Base
class AgentArtifact(Base):
"""AgentArtifact stores metadata for large files, images, tool results, etc.
This table only stores metadata. The actual blob content is stored in
BinaryStorage or external storage, referenced by storage_key.
Artifacts are accessed via artifact_metadata and artifact_read APIs
with run_id authorization.
"""
__tablename__ = 'agent_artifact'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
"""Auto-increment ID for sequencing."""
artifact_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
"""Unique artifact identifier."""
artifact_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
"""Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc."""
mime_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""MIME type of the content."""
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Original file name (if applicable)."""
size_bytes = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
"""Size in bytes."""
sha256 = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
"""SHA256 hash of content (for integrity verification)."""
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
"""Source of artifact: 'platform', 'runner', 'tool', 'system'."""
# Storage reference (points to BinaryStorage or external storage)
storage_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Key in BinaryStorage or external storage reference."""
storage_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='binary_storage')
"""Storage type: 'binary_storage', 'file', 'url', etc."""
# Context
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Conversation this artifact belongs to."""
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Run ID that created this artifact."""
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Runner ID that created this artifact."""
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Bot UUID that handled this artifact."""
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Workspace ID for multi-tenant deployments."""
# Lifecycle
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this artifact was created."""
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
"""When this artifact expires (optional)."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Additional metadata as JSON string."""

View File

@@ -0,0 +1,85 @@
"""EventLog persistence entity for storing auditable event facts."""
from __future__ import annotations
import sqlalchemy
import datetime
from .base import Base
class EventLog(Base):
"""EventLog stores auditable event records for AgentRunner.
This is the fact source for events - messages, tool calls, system events, etc.
Large payloads are stored separately as artifacts; this table stores
references and summaries.
"""
__tablename__ = 'event_log'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
"""Auto-increment ID for sequencing."""
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
"""Unique event identifier."""
event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
"""Event type (message.received, tool.call.started, etc.)."""
event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
"""When the event occurred."""
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
"""Event source (platform, webui, api, scheduler, system, pipeline_adapter)."""
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Bot UUID that handled this event."""
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Workspace ID for multi-tenant deployments."""
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Conversation ID this event belongs to."""
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Thread ID if platform supports threads."""
# Actor information
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Actor type (user, system, runner)."""
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Actor identifier."""
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Actor display name."""
# Subject information
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Subject type (message, tool_call, artifact)."""
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Subject identifier."""
# Input information
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Brief summary of input (truncated text, max 1000 chars)."""
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
# Raw event reference
raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Reference to raw event payload in ArtifactStore."""
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Run ID that processed this event."""
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Runner ID that processed this event."""
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this record was created."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Additional metadata as JSON string."""

View File

@@ -0,0 +1,72 @@
"""Transcript persistence entity for conversation history projection."""
from __future__ import annotations
import sqlalchemy
import datetime
from .base import Base
class Transcript(Base):
"""Transcript stores conversation-oriented message projection for history API.
This is a projection of EventLog, optimized for agent history retrieval.
It includes message content and artifact refs, but not raw platform payloads.
"""
__tablename__ = 'transcript'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
"""Auto-increment ID for sequencing."""
transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
"""Unique transcript item identifier."""
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""Reference to the source event in EventLog."""
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""Conversation this item belongs to."""
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Thread ID if platform supports threads."""
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
"""Message role: 'user', 'assistant', 'system', or 'tool'."""
item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message')
"""Item type: 'message', 'tool_call', 'tool_result', 'system'."""
# Content
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Full structured content as JSON string (Message model dump)."""
# Artifact references
artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Artifact references as JSON string (list of ArtifactRef)."""
# Sequence for cursor-based pagination
seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, index=True)
"""Monotonic cursor sequence for pagination."""
# Context
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
"""Run ID that generated this item (for assistant messages)."""
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Runner ID that generated this item."""
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this item was created."""
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
# Indexes
__table_args__ = (
sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'),
sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'),
)

View File

@@ -13,6 +13,28 @@ from sqlalchemy.engine import Connection
from langbot.pkg.entity.persistence.base import Base
# Import all ORM models so they are registered with Base.metadata
# This is required for autogenerate to detect model changes
from langbot.pkg.entity.persistence import (
agent_runner_state,
apikey,
artifact,
bot,
bstorage,
event_log,
mcp,
metadata,
model,
monitoring,
pipeline,
plugin,
rag,
transcript,
user,
vector,
webhook,
)
target_metadata = Base.metadata

View File

@@ -0,0 +1,67 @@
"""Normalize AgentRunner config containers
Revision ID: 0004_migrate_runner_config
Revises: 0003_add_rerank_models
Create Date: 2026-05-10
"""
import json
import sqlalchemy as sa
from alembic import op
revision = '0004_migrate_runner_config'
down_revision = '0003_add_rerank_models'
branch_labels = None
depends_on = None
def migrate_pipeline_config(config: dict) -> dict:
"""Keep current AgentRunner config containers explicit."""
new_config = dict(config)
if 'ai' not in new_config:
return new_config
ai_config = dict(new_config.get('ai', {}))
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:
"""Normalize existing pipeline config containers."""
conn = op.get_bind()
inspector = sa.inspect(conn)
# Check if pipelines table exists (may not exist in fresh install)
if 'pipelines' not in inspector.get_table_names():
return
# Get all pipelines
result = conn.execute(sa.text('SELECT uuid, config FROM pipelines'))
pipelines = result.fetchall()
for pipeline_uuid, config_json in pipelines:
if not config_json:
continue
try:
config = json.loads(config_json)
migrated_config = migrate_pipeline_config(config)
# Only update if config changed
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
conn.execute(
sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
)
except Exception:
# Skip invalid configs
continue
def downgrade() -> None:
"""Downgrade is not supported for data migration."""
# No downgrade - keep configs in new format
pass

View File

@@ -0,0 +1,102 @@
"""add_event_log_and_transcript_tables
Revision ID: 58846a8d7a81
Revises: 0004_migrate_runner_config
Create Date: 2026-05-23 15:41:47.030841
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '58846a8d7a81'
down_revision = '0004_migrate_runner_config'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create event_log table
op.create_table(
'event_log',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('event_id', sa.String(255), nullable=False, unique=True),
sa.Column('event_type', sa.String(100), nullable=False),
sa.Column('event_time', sa.DateTime(), nullable=True),
sa.Column('source', sa.String(50), nullable=False),
sa.Column('bot_id', sa.String(255), nullable=True),
sa.Column('workspace_id', sa.String(255), nullable=True),
sa.Column('conversation_id', sa.String(255), nullable=True),
sa.Column('thread_id', sa.String(255), nullable=True),
sa.Column('actor_type', sa.String(50), nullable=True),
sa.Column('actor_id', sa.String(255), nullable=True),
sa.Column('actor_name', sa.String(255), nullable=True),
sa.Column('subject_type', sa.String(50), nullable=True),
sa.Column('subject_id', sa.String(255), nullable=True),
sa.Column('input_summary', sa.Text(), nullable=True),
sa.Column('input_json', sa.Text(), nullable=True),
sa.Column('raw_ref', sa.String(255), nullable=True),
sa.Column('run_id', sa.String(255), nullable=True),
sa.Column('runner_id', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('metadata_json', sa.Text(), nullable=True),
)
# Create indexes for event_log
with op.batch_alter_table('event_log', schema=None) as batch_op:
batch_op.create_index('ix_event_log_event_id', ['event_id'], unique=True)
batch_op.create_index('ix_event_log_event_type', ['event_type'], unique=False)
batch_op.create_index('ix_event_log_bot_id', ['bot_id'], unique=False)
batch_op.create_index('ix_event_log_conversation_id', ['conversation_id'], unique=False)
batch_op.create_index('ix_event_log_run_id', ['run_id'], unique=False)
# Create transcript table
op.create_table(
'transcript',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('transcript_id', sa.String(255), nullable=False, unique=True),
sa.Column('event_id', sa.String(255), nullable=False),
sa.Column('conversation_id', sa.String(255), nullable=False),
sa.Column('thread_id', sa.String(255), nullable=True),
sa.Column('role', sa.String(50), nullable=False),
sa.Column('item_type', sa.String(50), nullable=False, server_default='message'),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('content_json', sa.Text(), nullable=True),
sa.Column('artifact_refs_json', sa.Text(), nullable=True),
sa.Column('seq', sa.Integer(), nullable=False),
sa.Column('run_id', sa.String(255), nullable=True),
sa.Column('runner_id', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('metadata_json', sa.Text(), nullable=True),
)
# Create indexes for transcript
with op.batch_alter_table('transcript', schema=None) as batch_op:
batch_op.create_index('ix_transcript_transcript_id', ['transcript_id'], unique=True)
batch_op.create_index('ix_transcript_event_id', ['event_id'], unique=False)
batch_op.create_index('ix_transcript_conversation_id', ['conversation_id'], unique=False)
batch_op.create_index('ix_transcript_conversation_seq', ['conversation_id', 'seq'], unique=False)
batch_op.create_index('ix_transcript_conversation_created', ['conversation_id', 'created_at'], unique=False)
batch_op.create_index('ix_transcript_run_id', ['run_id'], unique=False)
def downgrade() -> None:
# Drop transcript table
with op.batch_alter_table('transcript', schema=None) as batch_op:
batch_op.drop_index('ix_transcript_run_id')
batch_op.drop_index('ix_transcript_conversation_created')
batch_op.drop_index('ix_transcript_conversation_seq')
batch_op.drop_index('ix_transcript_conversation_id')
batch_op.drop_index('ix_transcript_event_id')
batch_op.drop_index('ix_transcript_transcript_id')
op.drop_table('transcript')
# Drop event_log table
with op.batch_alter_table('event_log', schema=None) as batch_op:
batch_op.drop_index('ix_event_log_run_id')
batch_op.drop_index('ix_event_log_conversation_id')
batch_op.drop_index('ix_event_log_bot_id')
batch_op.drop_index('ix_event_log_event_type')
batch_op.drop_index('ix_event_log_event_id')
op.drop_table('event_log')

View File

@@ -0,0 +1,68 @@
# Alembic script.py.mako — template for auto-generated revisions
"""add agent_runner_state table for host-owned persistent state
Revision ID: 6dfd3dd7f0c7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-23 19:49:08.529110
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '6dfd3dd7f0c7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_runner_state',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('runner_id', sa.String(length=255), nullable=False),
sa.Column('binding_identity', sa.String(length=255), nullable=False),
sa.Column('scope', sa.String(length=50), nullable=False),
sa.Column('scope_key', sa.String(length=512), nullable=False),
sa.Column('state_key', sa.String(length=255), nullable=False),
sa.Column('value_json', sa.Text(), nullable=True),
sa.Column('bot_id', sa.String(length=255), nullable=True),
sa.Column('workspace_id', sa.String(length=255), nullable=True),
sa.Column('conversation_id', sa.String(length=255), nullable=True),
sa.Column('thread_id', sa.String(length=255), nullable=True),
sa.Column('actor_type', sa.String(length=50), nullable=True),
sa.Column('actor_id', sa.String(length=255), nullable=True),
sa.Column('subject_type', sa.String(length=50), nullable=True),
sa.Column('subject_id', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key')
)
with op.batch_alter_table('agent_runner_state', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_agent_runner_state_actor_id'), ['actor_id'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_binding_identity'), ['binding_identity'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_bot_id'), ['bot_id'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_conversation_id'), ['conversation_id'], unique=False)
batch_op.create_index('ix_agent_runner_state_runner_binding', ['runner_id', 'binding_identity'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_runner_id'), ['runner_id'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_scope'), ['scope'], unique=False)
batch_op.create_index(batch_op.f('ix_agent_runner_state_scope_key'), ['scope_key'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('agent_runner_state', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_agent_runner_state_scope_key'))
batch_op.drop_index(batch_op.f('ix_agent_runner_state_scope'))
batch_op.drop_index(batch_op.f('ix_agent_runner_state_runner_id'))
batch_op.drop_index('ix_agent_runner_state_runner_binding')
batch_op.drop_index(batch_op.f('ix_agent_runner_state_conversation_id'))
batch_op.drop_index(batch_op.f('ix_agent_runner_state_bot_id'))
batch_op.drop_index(batch_op.f('ix_agent_runner_state_binding_identity'))
batch_op.drop_index(batch_op.f('ix_agent_runner_state_actor_id'))
op.drop_table('agent_runner_state')
# ### end Alembic commands ###

View File

@@ -0,0 +1,55 @@
"""add_agent_artifact_table
Revision ID: a1b2c3d4e5f6
Revises: 58846a8d7a81
Create Date: 2026-05-23 20:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'a1b2c3d4e5f6'
down_revision = '58846a8d7a81'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create agent_artifact table
op.create_table(
'agent_artifact',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('artifact_id', sa.String(255), nullable=False, unique=True),
sa.Column('artifact_type', sa.String(50), nullable=False),
sa.Column('mime_type', sa.String(255), nullable=True),
sa.Column('name', sa.String(255), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
sa.Column('sha256', sa.String(64), nullable=True),
sa.Column('source', sa.String(50), nullable=False),
sa.Column('storage_key', sa.String(255), nullable=True),
sa.Column('storage_type', sa.String(50), nullable=False, server_default='binary_storage'),
sa.Column('conversation_id', sa.String(255), nullable=True),
sa.Column('run_id', sa.String(255), nullable=True),
sa.Column('runner_id', sa.String(255), nullable=True),
sa.Column('bot_id', sa.String(255), nullable=True),
sa.Column('workspace_id', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('metadata_json', sa.Text(), nullable=True),
)
# Create indexes for agent_artifact
with op.batch_alter_table('agent_artifact', schema=None) as batch_op:
batch_op.create_index('ix_agent_artifact_artifact_id', ['artifact_id'], unique=True)
batch_op.create_index('ix_agent_artifact_conversation_id', ['conversation_id'], unique=False)
batch_op.create_index('ix_agent_artifact_run_id', ['run_id'], unique=False)
def downgrade() -> None:
# Drop agent_artifact table
with op.batch_alter_table('agent_artifact', schema=None) as batch_op:
batch_op.drop_index('ix_agent_artifact_run_id')
batch_op.drop_index('ix_agent_artifact_conversation_id')
batch_op.drop_index('ix_agent_artifact_artifact_id')
op.drop_table('agent_artifact')

View File

@@ -118,9 +118,6 @@ class DBMigrateV3Config(migration.DBMigration):
'runner': self.ap.provider_cfg.data['runner'],
}
pipeline_config['ai']['local-agent']['model'] = model_uuid
pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][
'max-round'
]
pipeline_config['ai']['local-agent']['prompt'] = [
{

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from .. import stage, entities
from . import truncator
from ...utils import importutil
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from . import truncators
importutil.import_modules_in_pkg(truncators)
@stage.stage_class('ConversationMessageTruncator')
class ConversationMessageTruncator(stage.PipelineStage):
"""Conversation message truncator
Used to truncate the conversation message chain to adapt to the LLM message length limit.
"""
trun: truncator.Truncator
async def initialize(self, pipeline_config: dict):
use_method = 'round'
for trun in truncator.preregistered_truncators:
if trun.name == use_method:
self.trun = trun(self.ap)
break
else:
raise ValueError(f'Unknown truncator: {use_method}')
async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:
"""处理"""
query = await self.trun.truncate(query)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)

View File

@@ -1,56 +0,0 @@
from __future__ import annotations
import typing
import abc
from ...core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
preregistered_truncators: list[typing.Type[Truncator]] = []
def truncator_class(
name: str,
) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]:
"""截断器类装饰器
Args:
name (str): 截断器名称
Returns:
typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器
"""
def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]:
assert issubclass(cls, Truncator)
cls.name = name
preregistered_truncators.append(cls)
return cls
return decorator
class Truncator(abc.ABC):
"""消息截断器基类"""
name: str
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
async def initialize(self):
pass
@abc.abstractmethod
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
"""截断
一般只需要操作query.messages也可以扩展操作query.prompt, query.user_message。
请勿操作其他字段。
"""
pass

View File

@@ -1,30 +0,0 @@
from __future__ import annotations
from .. import truncator
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@truncator.truncator_class('round')
class RoundTruncator(truncator.Truncator):
"""Truncate the conversation message chain to adapt to the LLM message length limit."""
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
"""截断"""
max_round = query.pipeline_config['ai']['local-agent']['max-round']
temp_messages = []
current_round = 0
# Traverse from back to front
for msg in query.messages[::-1]:
if current_round < max_round:
temp_messages.append(msg)
if msg.role == 'user':
current_round += 1
else:
break
query.messages = temp_messages[::-1]
return query

View File

@@ -28,7 +28,6 @@ from . import (
wrapper,
preproc,
ratelimit,
msgtrun,
)
importutil.import_modules_in_pkgs(
@@ -42,7 +41,6 @@ importutil.import_modules_in_pkgs(
wrapper,
preproc,
ratelimit,
msgtrun,
]
)
@@ -438,6 +436,9 @@ class PipelineManager:
# initialize stage containers according to pipeline_entity.stages
stage_containers: list[StageInstContainer] = []
for stage_name in pipeline_entity.stages:
if stage_name not in self.stage_dict:
self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping')
continue
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
for stage_container in stage_containers:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
import typing
from .. import stage, entities
from langbot_plugin.api.entities.builtin.provider import message as provider_message
@@ -9,6 +10,15 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from ...agent.runner.descriptor import AgentRunnerDescriptor
from ...agent.runner.config_migration import ConfigMigration
from ...agent.runner import config_schema
DEFAULT_PROMPT_CONFIG = [
{'role': 'system', 'content': 'You are a helpful assistant.'},
]
@stage.stage_class('PreProcessor')
class PreProcessor(stage.PipelineStage):
@@ -25,55 +35,157 @@ class PreProcessor(stage.PipelineStage):
- use_funcs
"""
async def _get_runner_descriptor(
self,
runner_id: str | None,
bound_plugins: list[str] | None,
) -> AgentRunnerDescriptor | 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
async def _resolve_llm_model(
self,
primary_uuid: str,
) -> typing.Any | None:
if primary_uuid in config_schema.NONE_SENTINELS:
return None
try:
return await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
return None
async def _resolve_fallback_models(self, fallback_uuids: list[str]) -> list[str]:
valid_fallbacks = []
for fallback_uuid in fallback_uuids:
if fallback_uuid in config_schema.NONE_SENTINELS:
continue
try:
await self.ap.model_mgr.get_model_by_uuid(fallback_uuid)
valid_fallbacks.append(fallback_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping')
return valid_fallbacks
def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool:
if descriptor is None:
return True
return descriptor.capabilities.get('multimodal_input', False)
def _model_supports_vision(self, llm_model: typing.Any | None) -> bool:
if not llm_model:
return False
abilities = getattr(getattr(llm_model, 'model_entity', None), 'abilities', [])
return 'vision' in abilities
def _should_keep_image_inputs(
self,
descriptor: AgentRunnerDescriptor | None,
uses_host_models: bool,
llm_model: typing.Any | None,
) -> bool:
if not self._runner_accepts_multimodal_input(descriptor):
return False
if uses_host_models:
return self._model_supports_vision(llm_model)
return True
def _strip_images_from_history(self, query: pipeline_query.Query) -> None:
for msg in query.messages:
if isinstance(msg.content, list):
msg.content = [elem for elem in msg.content if elem.type != 'image_url']
def _has_declared_db_engine(self) -> bool:
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
if persistence_mgr is None:
return False
if 'get_db_engine' in getattr(persistence_mgr, '__dict__', {}):
return True
return hasattr(type(persistence_mgr), 'get_db_engine')
async def _load_agent_runner_history_messages(
self,
runner_id: str | None,
conversation_uuid: str | None,
) -> list[provider_message.Message] | None:
if not runner_id or not conversation_uuid or not self._has_declared_db_engine():
return None
try:
from ...agent.runner.transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
messages = await store.get_legacy_provider_messages(str(conversation_uuid))
except Exception as e:
self.ap.logger.warning(
f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}'
)
return None
return messages or None
async def _resolve_history_messages(
self,
runner_id: str | None,
conversation: typing.Any,
) -> list[provider_message.Message]:
transcript_messages = await self._load_agent_runner_history_messages(
runner_id,
getattr(conversation, 'uuid', None),
)
if transcript_messages is not None:
return transcript_messages
return conversation.messages.copy()
async def process(
self,
query: pipeline_query.Query,
stage_inst_name: str,
) -> entities.StageProcessResult:
"""Process"""
selected_runner = query.pipeline_config['ai']['runner']['runner']
include_skill_authoring = (
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
)
# Resolve runner ID from the current ai.runner.id shape.
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
# Get runner config from ai.runner_config[runner_id].
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
query.variables = query.variables or {}
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
session = await self.ap.sess_mgr.get_session(query)
# When not local-agent, llm_model is None
uses_host_models = config_schema.uses_host_models(descriptor)
uses_host_tools = config_schema.uses_host_tools(descriptor)
include_skill_authoring = (
config_schema.supports_skill_authoring(descriptor)
and getattr(self.ap, 'skill_service', None) is not None
)
inject_skill_context = config_schema.supports_skill_injection(descriptor)
llm_model = None
if selected_runner == 'local-agent':
# Read model config — new format is { primary: str, fallbacks: [str] },
# but handle legacy plain string for backward compatibility
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
if isinstance(model_config, str):
# Legacy format: plain UUID string
primary_uuid = model_config
fallback_uuids = []
else:
primary_uuid = model_config.get('primary', '')
fallback_uuids = model_config.get('fallbacks', [])
if uses_host_models:
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
llm_model = await self._resolve_llm_model(primary_uuid)
valid_fallbacks = await self._resolve_fallback_models(fallback_uuids)
if valid_fallbacks:
query.variables['_fallback_model_uuids'] = valid_fallbacks
if primary_uuid:
try:
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
# Resolve fallback model UUIDs
if fallback_uuids:
valid_fallbacks = []
for fb_uuid in fallback_uuids:
try:
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
valid_fallbacks.append(fb_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
if valid_fallbacks:
query.variables['_fallback_model_uuids'] = valid_fallbacks
prompt_config = config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG)
conversation = await self.ap.sess_mgr.get_conversation(
query,
session,
query.pipeline_config['ai']['local-agent']['prompt'],
prompt_config,
query.pipeline_uuid,
query.bot_uuid,
)
@@ -82,7 +194,7 @@ class PreProcessor(stage.PipelineStage):
# been idle for longer than the configured conversation expire time.
# The idle window is measured from the last preprocess/update time, not
# from the conversation creation time.
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config)
now = datetime.datetime.now()
if conversation_expire_time is not None and conversation_expire_time > 0:
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
@@ -102,17 +214,14 @@ class PreProcessor(stage.PipelineStage):
# 设置query
query.session = session
query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy()
query.messages = await self._resolve_history_messages(runner_id, conversation)
if selected_runner == 'local-agent':
if uses_host_models:
query.use_funcs = []
if llm_model:
query.use_llm_model_uuid = llm_model.model_entity.uuid
if llm_model.model_entity.abilities.__contains__('func_call'):
# Get bound plugins and MCP servers for filtering tools
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
if uses_host_tools and llm_model.model_entity.abilities.__contains__('func_call'):
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
@@ -125,14 +234,22 @@ class PreProcessor(stage.PipelineStage):
# If primary model doesn't support func_call but fallback models exist,
# load tools anyway since fallback models may support them
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'):
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
)
elif uses_host_tools:
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
sender_name = ''
@@ -157,32 +274,21 @@ class PreProcessor(stage.PipelineStage):
}
query.variables.update(variables)
# Check if this model supports vision, if not, remove all images
# TODO this checking should be performed in runner, and in this stage, the image should be reserved
if (
selected_runner == 'local-agent'
and llm_model
and not llm_model.model_entity.abilities.__contains__('vision')
):
for msg in query.messages:
if isinstance(msg.content, list):
for me in msg.content:
if me.type == 'image_url':
msg.content.remove(me)
keep_image_inputs = self._should_keep_image_inputs(descriptor, uses_host_models, llm_model)
if not keep_image_inputs:
self._strip_images_from_history(query)
content_list: list[provider_message.ContentElement] = []
plain_text = ''
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
quote_msg = query.pipeline_config['trigger'].get('misc', {}).get('combine-quote-message', False)
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(me.text))
plain_text += me.text
elif isinstance(me, platform_message.Image):
if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if keep_image_inputs:
if me.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.Voice):
@@ -201,9 +307,7 @@ class PreProcessor(stage.PipelineStage):
if isinstance(msg, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if keep_image_inputs:
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File):
@@ -223,14 +327,12 @@ class PreProcessor(stage.PipelineStage):
query.user_message = provider_message.Message(role='user', content=content_list)
# Extract knowledge base UUIDs into query variables so plugins can modify them
# during PromptPreProcessing before the runner performs retrieval.
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
if not kb_uuids:
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
# Extract configured KB UUIDs into query variables so PromptPreProcessing
# plugins can still adjust the authorized retrieval set before run_agent.
query.variables['_knowledge_base_uuids'] = config_schema.extract_knowledge_base_uuids(
descriptor,
runner_config,
)
# =========== 触发事件 PromptPreProcessing
@@ -248,7 +350,7 @@ class PreProcessor(stage.PipelineStage):
query.prompt.messages = event_ctx.event.default_prompt
query.messages = event_ctx.event.prompt
# =========== Skill awareness for the local-agent runner ===========
# =========== Skill awareness for capable runners ===========
# The actual activation goes through the ``activate`` Tool Call so the
# LLM doesn't see full SKILL.md instructions until it commits to a
# skill (Claude Code's progressive disclosure). But the LLM still has
@@ -260,7 +362,7 @@ class PreProcessor(stage.PipelineStage):
# only) into the system prompt. The contributor's original PR
# relied on this injection; without it the LLM never discovers
# the skills are there and just calls native tools instead.
if selected_runner == 'local-agent' and self.ap.skill_mgr:
if inject_skill_context and self.ap.skill_mgr:
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
enable_all_skills = extensions_prefs.get('enable_all_skills', True)

View File

@@ -9,29 +9,35 @@ from datetime import datetime
from .. import handler
from ... import entities
from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events
from ....utils import importutil, constants, runner as runner_utils
from ....provider import runners
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
importutil.import_modules_in_pkg(runners)
DEFAULT_PROMPT_CONFIG = [
{'role': 'system', 'content': 'You are a helpful assistant.'},
]
class ChatMessageHandler(handler.MessageHandler):
"""Chat message handler using AgentRunOrchestrator.
This handler delegates all runner execution to the agent_run_orchestrator,
which resolves runner ID, builds context, invokes plugin runtime,
and normalizes results.
"""
async def handle(
self,
query: pipeline_query.Query,
) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
"""处理"""
# 调API
# 生成器
# 触发插件事件
"""Handle chat message by delegating to AgentRunOrchestrator."""
# Trigger plugin event
event_class = (
events.PersonNormalMessageReceived
if query.launcher_type == provider_session.LauncherTypes.PERSON
@@ -52,7 +58,7 @@ class ChatMessageHandler(handler.MessageHandler):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
is_create_card = False # 判断下是否需要创建流式卡片
is_create_card = False # Track if streaming card was created
if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None:
@@ -83,35 +89,37 @@ class ChatMessageHandler(handler.MessageHandler):
is_stream = False
try:
for r in runner_module.preregistered_runners:
if r.name == query.pipeline_config['ai']['runner']['runner']:
runner = r(self.ap, query.pipeline_config)
break
else:
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
# Mark start time for telemetry
start_ts = time.time()
if is_stream:
resp_message_id = uuid.uuid4()
chunk_count = 0 # Track streaming chunks to reduce excessive logging
# Create a single resp_message_id for the entire streaming response
resp_message_id = uuid.uuid4()
chunk_count = 0
async for result in runner.run(query):
result.resp_message_id = str(resp_message_id)
# Use AgentRunOrchestrator to run the agent
# This replaces direct runner lookup and PluginAgentRunnerWrapper
async for result in self.ap.agent_run_orchestrator.run_from_query(query):
result.resp_message_id = str(resp_message_id)
# For streaming mode, pop previous response before adding new chunk
# This allows incremental card updates
if is_stream:
if query.resp_messages:
query.resp_messages.pop()
if query.resp_message_chain:
query.resp_message_chain.pop()
# 此时连接外部 AI 服务正常,创建卡片
if not is_create_card: # 只有不是第一次才创建卡片
# Create streaming card on first result (connection established)
if not is_create_card:
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True
query.resp_messages.append(result)
query.resp_messages.append(result)
if is_stream:
chunk_count += 1
# Only log every 10th chunk to reduce excessive logging during streaming
# This prevents memory overflow from thousands of log entries per conversation
# First chunk uses INFO level to confirm connection establishment
# Only log every 10th chunk to reduce excessive logging during streaming.
# First chunk uses INFO level to confirm connection establishment.
if chunk_count == 1:
summary = self.format_result_log(result)
if summary is not None:
@@ -122,46 +130,59 @@ class ChatMessageHandler(handler.MessageHandler):
self.ap.logger.debug(
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
)
if result.content is not None:
text_length += len(result.content)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
# Log final summary after streaming completes
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
)
else:
async for result in runner.run(query):
query.resp_messages.append(result)
else:
summary = self.format_result_log(result)
if summary is not None:
self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}')
if result.content is not None:
text_length += len(result.content)
if result.content is not None:
text_length += len(result.content)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
query.session.using_conversation.messages.append(query.user_message)
# Log final summary after streaming completes
if is_stream:
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
)
# Keep a conversation object available for downstream legacy
# readers, but do not mirror AgentRunner history into
# conversation.messages. TranscriptStore is the canonical
# history source for this path.
await self._ensure_conversation_for_history(query)
query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e:
# Import orchestrator errors for specific handling
from ....agent.runner.errors import (
RunnerNotFoundError,
RunnerNotAuthorizedError,
RunnerExecutionError,
)
error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
traceback.print_exc()
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
# Handle specific runner errors with appropriate messages
if isinstance(e, RunnerNotFoundError):
user_notice = f'Agent runner not found: {e.runner_id}'
elif isinstance(e, RunnerNotAuthorizedError):
user_notice = 'Agent runner not authorized for this pipeline'
elif isinstance(e, RunnerExecutionError):
if e.retryable:
user_notice = 'Agent runner temporarily unavailable. Please try again.'
else:
user_notice = 'Agent runner execution failed.'
else:
# Use existing exception handling
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
if exception_handling == 'show-error':
user_notice = f'{e}'
elif exception_handling == 'show-hint':
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
else: # hide
user_notice = None
if exception_handling == 'show-error':
user_notice = f'{e}'
elif exception_handling == 'show-hint':
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
else: # hide
user_notice = None
yield entities.StageProcessResult(
result_type=entities.ResultType.INTERRUPT,
@@ -171,7 +192,7 @@ class ChatMessageHandler(handler.MessageHandler):
debug_notice=traceback.format_exc(),
)
finally:
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
# Telemetry reporting
try:
end_ts = time.time()
duration_ms = None
@@ -179,16 +200,14 @@ class ChatMessageHandler(handler.MessageHandler):
duration_ms = int((end_ts - start_ts) * 1000)
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
runner_name = (
query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')
if query.pipeline_config
else None
)
# Model name if using localagent
# Use orchestrator to resolve runner ID for telemetry
runner_name = self.ap.agent_run_orchestrator.resolve_runner_id_for_telemetry(query)
# Model name if available
model_name = None
try:
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
if getattr(query, 'use_llm_model_uuid', None):
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
if m and getattr(m, 'model_entity', None):
model_name = getattr(m.model_entity, 'name', None)
@@ -198,7 +217,7 @@ class ChatMessageHandler(handler.MessageHandler):
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
runner_category = runner_utils.get_runner_category_from_runner(
runner_name, runner, query.pipeline_config
runner_name, None, query.pipeline_config
)
payload = {
@@ -216,7 +235,6 @@ class ChatMessageHandler(handler.MessageHandler):
'timestamp': datetime.utcnow().isoformat(),
}
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
await self.ap.telemetry.start_send_task(payload)
# Trigger survey event on first successful non-WebSocket response
@@ -224,5 +242,70 @@ class ChatMessageHandler(handler.MessageHandler):
if self.ap.survey:
await self.ap.survey.trigger_event('first_bot_response_success')
except Exception as ex:
# Ensure telemetry issues do not affect normal flow
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

View File

@@ -84,6 +84,20 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
):
self.listeners.pop(event_type, None)
async def is_stream_output_supported(self) -> bool:
"""Delegate stream output check to ws_adapter."""
if self._ws_adapter is not None:
return await self._ws_adapter.is_stream_output_supported()
return False
async def create_message_card(
self, message_id: str | int, event: platform_events.MessageEvent
) -> bool:
"""Delegate create_message_card to ws_adapter."""
if self._ws_adapter is not None:
return await self._ws_adapter.create_message_card(message_id, event)
return False
async def is_muted(self, group_id: int) -> bool:
return False

View File

@@ -187,6 +187,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def initialize_plugins(self):
pass
async def _refresh_agent_runner_registry(self) -> None:
registry = getattr(self.ap, 'agent_runner_registry', None)
if registry is None:
return
try:
await registry.refresh()
except Exception as e:
self.ap.logger.warning(f'Failed to refresh agent runner registry: {e}')
async def ping_plugin_runtime(self):
if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
@@ -546,6 +555,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
task_context.metadata.update(metadata)
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
await self._refresh_agent_runner_registry()
async def upgrade_plugin(
self,
@@ -564,6 +574,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if task_context is not None:
task_context.trace(trace)
await self._refresh_agent_runner_registry()
async def delete_plugin(
self,
plugin_author: str,
@@ -588,6 +600,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
task_context.trace('Cleaning up plugin configuration and storage...')
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
await self._refresh_agent_runner_registry()
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
@@ -778,6 +792,53 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
yield cmd_ret
# AgentRunner methods
async def list_agent_runners(self, bound_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List all available AgentRunner components.
Returns list of dicts with plugin_author, plugin_name, runner_name, manifest, etc.
"""
if not self.is_enable_plugin:
return []
runners_data = await self.handler.list_agent_runners(include_plugins=bound_plugins)
return runners_data
async def run_agent(
self,
plugin_author: str,
plugin_name: str,
runner_name: str,
context: dict[str, Any],
) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Run an AgentRunner from a plugin.
Args:
plugin_author: Plugin author
plugin_name: Plugin name
runner_name: AgentRunner component name
context: AgentRunContext as dict
Yields:
AgentRunResult dicts
"""
if not self.is_enable_plugin:
# Return a protocol-level failure result.
yield {
'type': 'run.failed',
'data': {
'error': 'Plugin system is disabled',
'code': 'plugin.disabled',
'retryable': False,
},
}
return
gen = self.handler.run_agent(plugin_author, plugin_name, runner_name, context)
async for ret in gen:
yield ret
async def retrieve_knowledge(
self,
plugin_author: str,

File diff suppressed because it is too large Load Diff

View File

@@ -171,7 +171,8 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
# 解析 chunk 数据
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:
delta = {}

View File

@@ -359,7 +359,8 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:

View File

@@ -132,7 +132,8 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:

View File

@@ -144,7 +144,8 @@ class JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions):
# 解析 chunk 数据
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:
delta = {}
@@ -159,7 +160,7 @@ class JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions):
# reasoning_content = delta.get('reasoning_content', '')
if remove_think:
if delta['content'] is not None:
if delta.get('content') is not None:
if '<think>' in delta['content'] and not thinking_started and not thinking_ended:
thinking_started = True
continue

View File

@@ -391,7 +391,8 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
# 解析 chunk 数据
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:
delta = {}

View File

@@ -144,7 +144,8 @@ class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions):
# 解析 chunk 数据
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
delta_obj = getattr(choice, 'delta', None)
delta = delta_obj.model_dump() if delta_obj is not None else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:
delta = {}
@@ -159,7 +160,7 @@ class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions):
# reasoning_content = delta.get('reasoning_content', '')
if remove_think:
if delta['content'] is not None:
if delta.get('content') is not None:
if '<think>' in delta['content'] and not thinking_started and not thinking_ended:
thinking_started = True
continue

View File

@@ -1,3 +1,12 @@
"""
Legacy Coze API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/coze-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/coze-agent/
"""
from __future__ import annotations
import typing

View File

@@ -1,3 +1,12 @@
"""
Legacy DashScope (阿里云百炼) API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/dashscope-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/dashscope-agent/
"""
from __future__ import annotations
import typing

View File

@@ -1,3 +1,12 @@
"""
Legacy Dify Service API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/dify-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/dify-agent/
"""
from __future__ import annotations
import typing

View File

@@ -1,3 +1,12 @@
"""
Legacy Langflow API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/langflow-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/langflow-agent/
"""
from __future__ import annotations
import typing

View File

@@ -1,3 +1,12 @@
"""
Legacy Local Agent Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/local-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-local-agent/
"""
from __future__ import annotations
import json
@@ -12,8 +21,8 @@ import langbot_plugin.api.entities.builtin.rag.context as rag_context
rag_combined_prompt_template = """
The following are relevant context entries retrieved from the knowledge base.
Please use them to answer the user's message.
The following are relevant context entries retrieved from the knowledge base.
Please use them to answer the user's message.
Respond in the same language as the user's input.
<context>

View File

@@ -1,3 +1,12 @@
"""
Legacy n8n Service API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/n8n-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/n8n-agent/
"""
from __future__ import annotations
import typing

View File

@@ -1,3 +1,12 @@
"""
Legacy Tbox (蚂蚁百宝箱) API Runner.
DEPRECATED: This runner has been migrated to the AgentRunner plugin format.
Use the official `langbot/tbox-agent` plugin instead.
Migration target: /home/glwuy/langbot-app/langbot-agent-runner/tbox-agent/
"""
from __future__ import annotations
import typing

View File

@@ -525,7 +525,22 @@ class MCPLoader(loader.ToolLoader):
return True
return False
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
async def _get_tool(self, name: str) -> resource_tool.LLMTool | None:
"""Get tool by name.
Args:
name: Tool name to find
Returns:
LLMTool if found, None otherwise
"""
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
return function
return None
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():

View File

@@ -18,6 +18,7 @@ from ....box.workspace import (
rewrite_mounted_path,
rewrite_venv_command,
unwrap_venv_path,
wrap_python_command_with_env,
)
if TYPE_CHECKING:
@@ -128,6 +129,7 @@ class BoxStdioSessionRuntime:
workspace = self._build_workspace(host_path=None)
host_path = self.resolve_host_path()
process_cwd = '/workspace'
install_cmd: str | None = None
try:
await workspace.create_session()
@@ -168,6 +170,8 @@ class BoxStdioSessionRuntime:
env=self.server_config.get('env', {}),
cwd=process_cwd,
)
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
except Exception:
@@ -328,23 +332,31 @@ class BoxStdioSessionRuntime:
@staticmethod
def detect_install_command(host_path: str, workspace_path: str = '/workspace') -> str | None:
workspace_kind = classify_python_workspace(host_path)
quoted_workspace_path = shlex.quote(workspace_path)
if workspace_kind == 'package':
return (
'mkdir -p /opt/_lb_src'
f' && tar -C {quoted_workspace_path}'
' --exclude=.venv --exclude=.git --exclude=__pycache__'
' --exclude=node_modules --exclude=.tox --exclude=.nox'
' --exclude="*.egg-info" --exclude=.uv-cache'
' -cf - .'
' | tar -C /opt/_lb_src -xf -'
' && pip install --no-cache-dir /opt/_lb_src'
' && rm -rf /opt/_lb_src'
)
if workspace_kind == 'requirements':
return f'pip install --no-cache-dir -r {quoted_workspace_path}/requirements.txt'
if workspace_kind in {'package', 'requirements'}:
return wrap_python_command_with_env('python -c "pass"', mount_path=workspace_path).rstrip()
return None
@staticmethod
def _wrap_process_payload_with_python_env(payload: dict[str, Any], workspace_path: str) -> dict[str, Any]:
"""Start a prepared Python workspace without writing bootstrap output to MCP stdio."""
workspace_root = workspace_path.rstrip('/') or '/workspace'
venv_dir = f'{workspace_root}/.venv'
venv_bin = f'{venv_dir}/bin'
command = ' '.join(
[shlex.quote(payload['command']), *[shlex.quote(arg) for arg in payload.get('args', [])]]
)
wrapped = dict(payload)
wrapped['command'] = 'sh'
wrapped['args'] = [
'-lc',
(
f'export VIRTUAL_ENV={shlex.quote(venv_dir)}; '
f'export PATH={shlex.quote(venv_bin)}:$PATH; '
f'exec {command}'
),
]
return wrapped
def build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict[str, Any]:
workspace = self._build_workspace()
workspace.session_id = session_id

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import mimetypes
import os
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -21,6 +22,15 @@ _ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NA
# Skip these dirs during grep walk to avoid noise
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
_DEFAULT_READ_MAX_LINES = 2000
_MAX_READ_MAX_LINES = 10000
_DEFAULT_TOOL_RESULT_MAX_BYTES = 50 * 1024
_BOX_FILE_SCRIPT_MAX_BYTES = 2048
_GLOB_MAX_MATCHES = 100
_GREP_MAX_MATCHES = 200
_GREP_MAX_FILES = 5000
_GREP_MAX_LINE_CHARS = 500
class NativeToolLoader(loader.ToolLoader):
def __init__(self, ap):
@@ -138,6 +148,7 @@ class NativeToolLoader(loader.ToolLoader):
# via execute_tool. Skills are mounted at /workspace/.skills/{name}/
# via extra_mounts built by BoxService.
result = await self.ap.box_service.execute_tool(parameters, query)
result = self._normalize_exec_result(result)
if selected_skill is not None:
self._refresh_skill_from_disk(selected_skill)
@@ -226,19 +237,65 @@ class NativeToolLoader(loader.ToolLoader):
except Exception:
return {'ok': False, 'error': stdout or 'Box file operation returned no result'}
async def _read_workspace_via_box(self, path: str, query: pipeline_query.Query) -> dict:
async def _read_workspace_via_box(self, path: str, parameters: dict, query: pipeline_query.Query) -> dict:
offset = self._positive_int(parameters.get('offset'), default=1)
max_lines = self._positive_int(
parameters.get('limit'),
default=_DEFAULT_READ_MAX_LINES,
max_value=_MAX_READ_MAX_LINES,
)
# Box file fallback returns through exec stdout, which is already capped
# by BoxService. Keep this payload small enough to remain valid JSON.
max_bytes = min(
self._positive_int(parameters.get('max_bytes'), default=_DEFAULT_TOOL_RESULT_MAX_BYTES),
_BOX_FILE_SCRIPT_MAX_BYTES,
)
script = f"""
import json, os
path = {json.dumps(path)}
offset = {offset}
max_lines = {max_lines}
max_bytes = {max_bytes}
if not path.startswith('/workspace'):
print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
elif not os.path.exists(path):
print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
elif os.path.isdir(path):
print(json.dumps({{'ok': True, 'content': '\\n'.join(sorted(os.listdir(path))), 'is_directory': True}}))
entries = sorted(os.listdir(path))
content = '\\n'.join(entries)
print(json.dumps({{'ok': True, 'content': content, 'is_directory': True, 'total': len(entries), 'truncated': False}}))
else:
lines = []
output_bytes = 0
end_line = offset - 1
truncated = False
next_offset = None
with open(path, 'r', encoding='utf-8', errors='replace') as f:
print(json.dumps({{'ok': True, 'content': f.read()}}))
for line_number, line in enumerate(f, 1):
if line_number < offset:
continue
if len(lines) >= max_lines:
truncated = True
next_offset = line_number
break
line_bytes = len(line.encode('utf-8'))
if output_bytes + line_bytes > max_bytes:
truncated = True
next_offset = line_number
break
lines.append(line.rstrip('\\n'))
output_bytes += line_bytes
end_line = line_number
print(json.dumps({{
'ok': True,
'content': '\\n'.join(lines),
'truncated': truncated,
'start_line': offset,
'end_line': end_line,
'next_offset': next_offset,
'max_lines': max_lines,
'max_bytes': max_bytes,
}}))
""".strip()
return await self._run_workspace_file_script(script, query)
@@ -306,12 +363,27 @@ else:
if not any(part in skip_dirs for part in item.parts)
]
hits.sort(key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
shown = hits[:100]
shown = hits[:{_GLOB_MAX_MATCHES}]
matches = []
output_bytes = 0
truncated_by_bytes = False
for item in shown:
rel = os.path.relpath(str(item), path)
matches.append(os.path.join(path, rel).replace(os.sep, '/'))
print(json.dumps({{'ok': True, 'matches': matches, 'total': len(hits), 'truncated': len(hits) > 100}}))
sandbox_path = os.path.join(path, rel).replace(os.sep, '/')
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if matches else 0)
if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
truncated_by_bytes = True
break
matches.append(sandbox_path)
output_bytes += entry_bytes
print(json.dumps({{
'ok': True,
'matches': matches,
'preview': '\\n'.join(matches),
'total': len(hits),
'truncated': len(hits) > len(matches) or truncated_by_bytes,
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if len(hits) > len(matches) else None),
}}))
""".strip()
return await self._run_workspace_file_script(script, query)
@@ -349,29 +421,54 @@ else:
continue
if item.is_file():
files.append(item)
if len(files) >= 5000:
if len(files) >= {_GREP_MAX_FILES}:
break
matches = []
output_bytes = 0
truncated_by = None
for fp in files:
try:
text = fp.read_text(errors='ignore')
handle = fp.open('r', encoding='utf-8', errors='ignore')
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if regex.search(line):
if base.is_file():
file_path = path
else:
rel = os.path.relpath(str(fp), path)
file_path = os.path.join(path, rel).replace(os.sep, '/')
matches.append({{'file': file_path, 'line': lineno, 'content': line.rstrip()}})
if len(matches) >= 200:
break
if len(matches) >= 200:
with handle:
for lineno, line in enumerate(handle, 1):
if regex.search(line):
if base.is_file():
file_path = path
else:
rel = os.path.relpath(str(fp), path)
file_path = os.path.join(path, rel).replace(os.sep, '/')
content = line.rstrip()
line_truncated = False
if len(content) > {_GREP_MAX_LINE_CHARS}:
content = content[:{_GREP_MAX_LINE_CHARS}] + '... [truncated]'
line_truncated = True
entry = {{'file': file_path, 'line': lineno, 'content': content}}
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
truncated_by = 'bytes'
break
if line_truncated and truncated_by is None:
truncated_by = 'line'
matches.append(entry)
output_bytes += entry_bytes
if len(matches) >= {_GREP_MAX_MATCHES}:
truncated_by = truncated_by or 'matches'
break
if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
break
if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
break
print(json.dumps({{'ok': True, 'matches': matches, 'total': len(matches), 'truncated': len(matches) >= 200}}))
print(json.dumps({{
'ok': True,
'matches': matches,
'total': len(matches),
'truncated': truncated_by is not None,
'truncated_by': truncated_by,
}}))
""".strip()
return await self._run_workspace_file_script(script, query)
@@ -386,14 +483,22 @@ else:
)
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
selected_skill, relative = skill_request
host_path = self._resolve_skill_host_path(selected_skill, relative)
if host_path and os.path.exists(host_path):
if os.path.isdir(host_path):
return self._build_directory_result(os.listdir(host_path))
result = self._read_text_file_preview(host_path, parameters)
host_root = str(selected_skill.get('package_root', '') or '')
return await self._attach_file_artifact_ref(result, host_path, host_root, path, query)
try:
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
return {'ok': True, 'content': result.get('content', '')}
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
except Exception:
try:
result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative)
entries = [entry['name'] for entry in result.get('entries', [])]
return {'ok': True, 'content': '\n'.join(sorted(entries)), 'is_directory': True}
return self._build_directory_result(entries)
except Exception as exc:
return {'ok': False, 'error': str(exc)}
@@ -404,15 +509,15 @@ else:
include_activated=True,
)
if self._should_use_box_workspace_files(selected_skill):
return await self._read_workspace_via_box(path, query)
return await self._read_workspace_via_box(path, parameters, query)
if not os.path.exists(host_path):
return {'ok': False, 'error': f'File not found: {path}'}
if os.path.isdir(host_path):
entries = os.listdir(host_path)
return {'ok': True, 'content': '\n'.join(sorted(entries)), 'is_directory': True}
with open(host_path, 'r', errors='replace') as f:
content = f.read()
return {'ok': True, 'content': content}
return self._build_directory_result(entries)
result = self._read_text_file_preview(host_path, parameters)
host_root = self._get_host_root(selected_skill)
return await self._attach_file_artifact_ref(result, host_path, host_root, path, query)
async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict:
path = parameters['path']
@@ -583,6 +688,29 @@ else:
'type': 'string',
'description': 'Absolute path to the file (must be under /workspace).',
},
'offset': {
'type': 'integer',
'description': '1-indexed line number to start reading from. Defaults to 1.',
'default': 1,
'minimum': 1,
},
'limit': {
'type': 'integer',
'description': f'Maximum number of lines to return. Defaults to {_DEFAULT_READ_MAX_LINES}.',
'default': _DEFAULT_READ_MAX_LINES,
'minimum': 1,
'maximum': _MAX_READ_MAX_LINES,
},
'max_bytes': {
'type': 'integer',
'description': (
'Maximum bytes of file content to return. '
f'Defaults to {_DEFAULT_TOOL_RESULT_MAX_BYTES}.'
),
'default': _DEFAULT_TOOL_RESULT_MAX_BYTES,
'minimum': 1,
'maximum': _DEFAULT_TOOL_RESULT_MAX_BYTES,
},
},
'required': ['path'],
'additionalProperties': False,
@@ -739,22 +867,30 @@ else:
hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
total = len(hits)
shown = hits[:100]
shown = hits[:_GLOB_MAX_MATCHES]
# Convert back to sandbox paths
sandbox_paths = []
output_bytes = 0
truncated_by_bytes = False
for h in shown:
rel = os.path.relpath(str(h), host_path)
sandbox_path = os.path.join(path, rel)
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
truncated_by_bytes = True
break
sandbox_paths.append(sandbox_path)
output_bytes += entry_bytes
result_lines = sandbox_paths
result = '\n'.join(result_lines)
if total > 100:
result += f'\n... ({total} matches, showing first 100)'
return {'ok': True, 'matches': result_lines, 'total': total, 'truncated': total > 100}
return {
'ok': True,
'matches': sandbox_paths,
'preview': '\n'.join(sandbox_paths),
'total': total,
'truncated': total > len(sandbox_paths) or truncated_by_bytes,
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None),
}
async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict:
pattern = parameters['pattern']
@@ -790,32 +926,46 @@ else:
files = self._grep_walk(base, include)
matches = []
output_bytes = 0
truncated_by = None
for fp in files:
try:
text = fp.read_text(errors='ignore')
handle = fp.open('r', encoding='utf-8', errors='ignore')
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if regex.search(line):
rel = os.path.relpath(str(fp), host_path)
sandbox_path = os.path.join(path, rel)
matches.append(
{
with handle:
for lineno, line in enumerate(handle, 1):
if regex.search(line):
rel = os.path.relpath(str(fp), host_path)
sandbox_path = os.path.join(path, rel)
content, line_truncated = self._truncate_grep_line(line.rstrip())
entry = {
'file': sandbox_path,
'line': lineno,
'content': line.rstrip(),
'content': content,
}
)
if len(matches) >= 200:
break
if len(matches) >= 200:
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
truncated_by = 'bytes'
break
if line_truncated and truncated_by is None:
truncated_by = 'line'
matches.append(entry)
output_bytes += entry_bytes
if len(matches) >= _GREP_MAX_MATCHES:
truncated_by = truncated_by or 'matches'
break
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
break
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
break
return {
'ok': True,
'matches': matches,
'total': len(matches),
'truncated': len(matches) >= 200,
'truncated': truncated_by is not None,
'truncated_by': truncated_by,
}
@staticmethod
@@ -827,10 +977,283 @@ else:
continue
if item.is_file():
results.append(item)
if len(results) >= 5000:
if len(results) >= _GREP_MAX_FILES:
break
return results
@staticmethod
def _resolve_skill_host_path(selected_skill: dict, relative: str) -> str | None:
package_root = str(selected_skill.get('package_root', '') or '').strip()
if not package_root:
return None
host_root = os.path.realpath(package_root)
host_path = os.path.realpath(os.path.join(host_root, relative))
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
raise ValueError('Path escapes the skill package boundary.')
return host_path
def _get_host_root(self, selected_skill: dict | None) -> str:
if selected_skill is not None:
return str(selected_skill.get('package_root', '') or '')
return str(getattr(self.ap.box_service, 'default_workspace', '') or '')
async def _attach_file_artifact_ref(
self,
result: dict,
host_path: str,
host_root: str,
sandbox_path: str,
query: pipeline_query.Query,
) -> dict:
if not result.get('ok') or not result.get('truncated') or result.get('artifact_refs'):
return result
if not host_root or not os.path.isfile(host_path):
return result
run_session = self._get_agent_run_session(query)
if not run_session:
return result
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
get_db_engine = getattr(persistence_mgr, 'get_db_engine', None)
if not callable(get_db_engine):
return result
try:
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
authorization = run_session.get('authorization', {}) if isinstance(run_session, dict) else {}
mime_type = mimetypes.guess_type(host_path)[0] or 'text/plain'
size_bytes = os.path.getsize(host_path)
metadata = {
'tool_name': READ_TOOL_NAME,
'sandbox_path': sandbox_path,
'truncated_by': result.get('truncated_by'),
'start_line': result.get('start_line'),
'end_line': result.get('end_line'),
'next_offset': result.get('next_offset'),
}
artifact_id = await ArtifactStore(get_db_engine()).register_file_artifact(
artifact_id=None,
host_path=host_path,
host_root=host_root,
artifact_type='file',
source='tool',
mime_type=mime_type,
name=os.path.basename(host_path),
size_bytes=size_bytes,
conversation_id=authorization.get('conversation_id'),
run_id=run_session.get('run_id') if isinstance(run_session, dict) else None,
runner_id=run_session.get('runner_id') if isinstance(run_session, dict) else None,
bot_id=getattr(query, 'bot_uuid', None),
metadata=metadata,
)
artifact_ref = {
'artifact_id': artifact_id,
'artifact_type': 'file',
'mime_type': mime_type,
'name': os.path.basename(host_path),
'size_bytes': size_bytes,
}
enriched = dict(result)
enriched['preview'] = str(result.get('content') or '')
enriched['artifact_refs'] = [artifact_ref]
return enriched
except Exception as exc:
self.ap.logger.warning(f'Failed to register read artifact for {sandbox_path}: {exc}')
return result
@staticmethod
def _get_agent_run_session(query: pipeline_query.Query) -> dict | None:
session = getattr(query, '_agent_run_session', None)
return session if isinstance(session, dict) else None
def _normalize_exec_result(self, result: dict) -> dict:
normalized = dict(result)
stdout = str(normalized.get('stdout') or '')
stderr = str(normalized.get('stderr') or '')
stdout, stdout_capped = self._truncate_text_to_bytes_with_flag(stdout, _DEFAULT_TOOL_RESULT_MAX_BYTES)
stderr, stderr_capped = self._truncate_text_to_bytes_with_flag(stderr, _DEFAULT_TOOL_RESULT_MAX_BYTES)
normalized['stdout'] = stdout
normalized['stderr'] = stderr
normalized['stdout_truncated'] = bool(normalized.get('stdout_truncated') or stdout_capped)
normalized['stderr_truncated'] = bool(normalized.get('stderr_truncated') or stderr_capped)
if stdout and stderr:
preview_raw = f'stdout:\n{stdout}\n\nstderr:\n{stderr}'
else:
preview_raw = stdout or stderr
preview, preview_capped = self._truncate_text_to_bytes_with_flag(preview_raw, _DEFAULT_TOOL_RESULT_MAX_BYTES)
normalized['preview'] = preview
normalized['truncated'] = bool(
normalized['stdout_truncated'] or normalized['stderr_truncated'] or preview_capped
)
if preview_capped and not normalized.get('truncated_by'):
normalized['truncated_by'] = 'bytes'
return normalized
def _build_directory_result(self, entries: list[str]) -> dict:
sorted_entries = sorted(str(entry) for entry in entries)
content = '\n'.join(sorted_entries)
preview = self._truncate_text_to_bytes(content, _DEFAULT_TOOL_RESULT_MAX_BYTES)
truncated = preview != content
return {
'ok': True,
'content': preview,
'is_directory': True,
'total': len(sorted_entries),
'truncated': truncated,
'truncated_by': 'bytes' if truncated else None,
}
def _read_text_file_preview(self, host_path: str, parameters: dict) -> dict:
offset = self._positive_int(parameters.get('offset'), default=1)
max_lines = self._positive_int(
parameters.get('limit'),
default=_DEFAULT_READ_MAX_LINES,
max_value=_MAX_READ_MAX_LINES,
)
max_bytes = self._positive_int(
parameters.get('max_bytes'),
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
)
lines: list[str] = []
output_bytes = 0
end_line = offset - 1
truncated = False
truncated_by: str | None = None
next_offset: int | None = None
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
for line_number, line in enumerate(f, 1):
if line_number < offset:
continue
if len(lines) >= max_lines:
truncated = True
truncated_by = 'lines'
next_offset = line_number
break
line_bytes = len(line.encode('utf-8'))
if output_bytes + line_bytes > max_bytes:
truncated = True
truncated_by = 'bytes'
next_offset = line_number
break
lines.append(line.rstrip('\n'))
output_bytes += line_bytes
end_line = line_number
if not lines and truncated_by == 'bytes':
content = (
f'[Line {next_offset or offset} exceeds the {self._format_size(max_bytes)} read limit. '
'Use exec with a byte-range command for this line, or read a different offset.]'
)
else:
content = '\n'.join(lines)
return {
'ok': True,
'content': content,
'truncated': truncated,
'truncated_by': truncated_by,
'start_line': offset,
'end_line': end_line,
'next_offset': next_offset,
'max_lines': max_lines,
'max_bytes': max_bytes,
}
def _build_read_result_from_text(self, content: str, parameters: dict) -> dict:
offset = self._positive_int(parameters.get('offset'), default=1)
max_lines = self._positive_int(
parameters.get('limit'),
default=_DEFAULT_READ_MAX_LINES,
max_value=_MAX_READ_MAX_LINES,
)
max_bytes = self._positive_int(
parameters.get('max_bytes'),
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
)
all_lines = content.splitlines()
start_index = offset - 1
if start_index >= len(all_lines) and all_lines:
return {'ok': False, 'error': f'Offset {offset} is beyond end of file ({len(all_lines)} lines total)'}
output_lines: list[str] = []
output_bytes = 0
truncated = False
truncated_by: str | None = None
next_offset: int | None = None
for index, line in enumerate(all_lines[start_index:], start_index + 1):
if len(output_lines) >= max_lines:
truncated = True
truncated_by = 'lines'
next_offset = index
break
line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
if output_bytes + line_bytes > max_bytes:
truncated = True
truncated_by = 'bytes'
next_offset = index
break
output_lines.append(line)
output_bytes += line_bytes
end_line = offset + len(output_lines) - 1
return {
'ok': True,
'content': '\n'.join(output_lines),
'truncated': truncated,
'truncated_by': truncated_by,
'start_line': offset,
'end_line': end_line,
'next_offset': next_offset,
'max_lines': max_lines,
'max_bytes': max_bytes,
}
@staticmethod
def _positive_int(value, *, default: int, max_value: int | None = None) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = default
if parsed <= 0:
parsed = default
if max_value is not None:
parsed = min(parsed, max_value)
return parsed
@staticmethod
def _truncate_grep_line(line: str) -> tuple[str, bool]:
if len(line) <= _GREP_MAX_LINE_CHARS:
return line, False
return f'{line[:_GREP_MAX_LINE_CHARS]}... [truncated]', True
@staticmethod
def _truncate_text_to_bytes(text: str, max_bytes: int) -> str:
return NativeToolLoader._truncate_text_to_bytes_with_flag(text, max_bytes)[0]
@staticmethod
def _truncate_text_to_bytes_with_flag(text: str, max_bytes: int) -> tuple[str, bool]:
data = text.encode('utf-8')
if len(data) <= max_bytes:
return text, False
truncated = data[:max_bytes]
while truncated and (truncated[-1] & 0xC0) == 0x80:
truncated = truncated[:-1]
return truncated.decode('utf-8', errors='ignore'), True
@staticmethod
def _format_size(bytes_count: int) -> str:
if bytes_count < 1024:
return f'{bytes_count}B'
return f'{bytes_count / 1024:.1f}KB'
def _summarize_parameters(self, parameters: dict) -> dict:
summary = dict(parameters)
cmd = str(summary.get('command', '')).strip()

View File

@@ -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

View File

@@ -67,6 +67,37 @@ class ToolManager:
return all_functions
async def get_tool_by_name(self, name: str) -> resource_tool.LLMTool | None:
"""Get tool by name from any active loader.
Args:
name: Tool name.
Returns:
LLMTool if found, None otherwise
"""
for tool_loader in (
self.native_tool_loader,
self.plugin_tool_loader,
self.mcp_tool_loader,
self.skill_tool_loader,
):
tool = await self._get_tool_from_loader(tool_loader, name)
if tool:
return tool
return None
async def _get_tool_from_loader(self, tool_loader: typing.Any, name: str) -> resource_tool.LLMTool | None:
if hasattr(tool_loader, '_get_tool'):
return await tool_loader._get_tool(name)
for tool in await tool_loader.get_tools():
if tool.name == name:
return tool
return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
tools = []
@@ -96,7 +127,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):

View File

@@ -107,7 +107,7 @@ class RAGRuntimeService:
)
async def get_file_stream(self, storage_path: str) -> bytes:
"""Handle GET_KNOWLEDEGE_FILE_STREAM action.
"""Handle GET_KNOWLEDGE_FILE_STREAM action.
Uses the storage manager abstraction to load file content,
regardless of the underlying storage provider.

View File

@@ -38,58 +38,10 @@
},
"ai": {
"runner": {
"runner": "local-agent",
"id": "",
"expire-time": 0
},
"local-agent": {
"model": {
"primary": "",
"fallbacks": []
},
"max-round": 10,
"prompt": [
{
"role": "system",
"content": "You are a helpful assistant. When tools are available, use them for exact calculations, data processing, and code execution instead of guessing. Unless the user explicitly asks for code or a script, return the result directly instead of printing the generated code."
}
],
"knowledge-bases": [],
"box-session-id-template": "{launcher_type}_{launcher_id}",
"rerank-model": "",
"rerank-top-k": 5
},
"dify-service-api": {
"base-url": "https://api.dify.ai/v1",
"app-type": "chat",
"api-key": "your-api-key",
"timeout": 30
},
"dashscope-app-api": {
"app-type": "agent",
"api-key": "your-api-key",
"app-id": "your-app-id",
"references-quote": "参考资料来自:"
},
"n8n-service-api": {
"webhook-url": "http://your-n8n-webhook-url",
"auth-type": "none",
"basic-username": "",
"basic-password": "",
"jwt-secret": "",
"jwt-algorithm": "HS256",
"header-name": "",
"header-value": "",
"timeout": 120,
"output-key": "response"
},
"langflow-api": {
"base-url": "http://localhost:7860",
"api-key": "your-api-key",
"flow-id": "your-flow-id",
"input-type": "chat",
"output-type": "chat",
"tweaks": "{}"
}
"runner_config": {}
},
"output": {
"long-text-processing": {

View File

@@ -34,11 +34,5 @@
"limit": 60
}
}
},
"msg-truncate": {
"method": "round",
"round": {
"max-round": 10
}
}
}
}

View File

@@ -11,42 +11,13 @@ stages:
en_US: Strategy to call AI to process messages
zh_Hans: 调用 AI 处理消息的方式
config:
- name: runner
- name: id
label:
en_US: Runner
zh_Hans: 运行器
type: select
required: true
default: local-agent
options:
- name: local-agent
label:
en_US: Local Agent
zh_Hans: 内置 Agent
- name: dify-service-api
label:
en_US: Dify Service API
zh_Hans: Dify 服务 API
- name: n8n-service-api
label:
en_US: n8n Workflow API
zh_Hans: n8n 工作流 API
- name: coze-api
label:
en_US: Coze API
zh_Hans: 扣子 API
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
- name: langflow-api
label:
en_US: Langflow API
zh_Hans: Langflow API
# Options and default are dynamically populated from AgentRunnerRegistry
- name: expire-time
label:
en_US: Conversation expire time (seconds)
@@ -67,589 +38,6 @@ stages:
type: integer
required: true
default: 0
- name: local-agent
label:
en_US: Local Agent
zh_Hans: 内置 Agent
description:
en_US: Configure the embedded agent of the pipeline
zh_Hans: 配置内置 Agent
config:
- name: model
label:
en_US: Model
zh_Hans: 模型
type: model-fallback-selector
required: true
default:
primary: ''
fallbacks: []
- name: max-round
label:
en_US: Max Round
zh_Hans: 最大回合数
description:
en_US: The maximum number of previous messages that the agent can remember
zh_Hans: 最大前文消息回合数
type: integer
required: true
default: 10
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: prompt
label:
en_US: Prompt
zh_Hans: 提示词
description:
en_US: The prompt of the agent
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor
required: true
default:
- role: system
content: "You are a helpful assistant."
- name: knowledge-bases
label:
en_US: Knowledge Bases
zh_Hans: 知识库
description:
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
type: knowledge-base-multi-selector
required: false
default: []
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: box-session-id-template
label:
en_US: Sandbox Scope
zh_Hans: 沙箱作用域
zh_Hant: 沙箱作用域
ja_JP: サンドボックススコープ
vi_VN: Phạm vi Sandbox
th_TH: ขอบเขต Sandbox
es_ES: Alcance del Sandbox
ru_RU: Область песочницы
description:
en_US: Determines how sandbox environments are shared across messages.
zh_Hans: 决定沙箱环境在不同消息间的共享方式。
zh_Hant: 決定沙箱環境在不同訊息間的共享方式。
ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。
vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.
th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
disable_if:
field: __system.box_available
operator: eq
value: false
disabled_tooltip:
en_US: >-
Box sandbox is disabled or unavailable. Enable it in config.yaml
(box.enabled = true) and ensure the runtime is reachable to change
this setting.
zh_Hans: Box 沙箱已禁用或不可用。请在配置中启用box.enabled = true并确认运行时连接正常才能修改此项。
zh_Hant: Box 沙箱已停用或無法使用。請在設定中啟用box.enabled = true並確認執行時連線正常才能修改此項。
ja_JP: Box サンドボックスが無効または利用できません。設定で有効化box.enabled = trueし、ランタイムが接続できることを確認してから変更してください。
vi_VN: Sandbox Box đã tắt hoặc không khả dụng. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime hoạt động để chỉnh sửa.
th_TH: Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่ารันไทม์เชื่อมต่อปกติก่อนปรับค่า
es_ES: El sandbox de Box está desactivado o no disponible. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime esté conectado para modificar este ajuste.
ru_RU: Песочница Box отключена или недоступна. Включите её в конфигурации (box.enabled = true) и убедитесь, что среда выполнения работает, чтобы изменить эту настройку.
type: select
required: false
default: "{launcher_type}_{launcher_id}"
options:
- name: "{global}"
label:
en_US: Global (shared by all)
zh_Hans: 全局(所有人共享)
zh_Hant: 全域(所有人共用)
ja_JP: グローバル(全員共有)
vi_VN: Toàn cục (chia sẻ cho tất cả)
th_TH: ทั่วไป (แชร์ทั้งหมด)
es_ES: Global (compartido por todos)
ru_RU: Глобальный (общий для всех)
- name: "{launcher_type}_{launcher_id}"
label:
en_US: Per chat (Recommended)
zh_Hans: 每个会话(推荐)
zh_Hant: 每個會話(推薦)
ja_JP: チャットごと(推奨)
vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị)
th_TH: ต่อแชท (แนะนำ)
es_ES: Por chat (Recomendado)
ru_RU: По чату (Рекомендуется)
- name: "{launcher_type}_{launcher_id}_{sender_id}"
label:
en_US: Per user in chat
zh_Hans: 会话中每个用户
zh_Hant: 會話中每個用戶
ja_JP: チャット内のユーザーごと
vi_VN: Mỗi người dùng trong cuộc trò chuyện
th_TH: ต่อผู้ใช้ในแชท
es_ES: Por usuario en chat
ru_RU: По пользователю в чате
- name: "{launcher_type}_{launcher_id}_{conversation_id}"
label:
en_US: Per conversation context
zh_Hans: 每个对话上下文
zh_Hant: 每個對話上下文
ja_JP: 会話コンテキストごと
vi_VN: Mỗi ngữ cảnh hội thoại
th_TH: ต่อบริบทการสนทนา
es_ES: Por contexto de conversación
ru_RU: По контексту разговора
- name: "{query_id}"
label:
en_US: Per message (isolated)
zh_Hans: 每条消息(完全隔离)
zh_Hant: 每條訊息(完全隔離)
ja_JP: メッセージごと(隔離)
vi_VN: Mỗi tin nhắn (cách ly)
th_TH: ต่อข้อความ (แยกส่วน)
es_ES: Por mensaje (aislado)
ru_RU: По сообщению (изолированно)
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: rerank-model
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
type: rerank-model-selector
required: false
default: ''
show_if:
field: knowledge-bases
operator: neq
value: []
- name: rerank-top-k
label:
en_US: Rerank Top K
zh_Hans: 重排序保留数量
description:
en_US: Number of top results to keep after reranking
zh_Hans: 重排序后保留的最相关结果数量
type: integer
required: false
default: 5
show_if:
field: rerank-model
operator: neq
value: ''
- name: dify-service-api
label:
en_US: Dify Service API
zh_Hans: Dify 服务 API
description:
en_US: Configure the Dify service API of the pipeline
zh_Hans: 配置 Dify 服务 API
config:
- name: base-url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
options:
- name: 'https://api.dify.ai/v1'
label:
en_US: Dify Cloud
zh_Hans: Dify 云服务
default: 'https://api.dify.ai/v1'
- name: base-prompt
label:
en_US: Base PROMPT
zh_Hans: 基础提示词
description:
en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it.
zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词
type: string
required: true
default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
- name: app-type
label:
en_US: App Type
zh_Hans: 应用类型
type: select
required: true
default: chat
options:
- name: chat
label:
en_US: Chat
zh_Hans: 聊天包括Chatflow
- name: agent
label:
en_US: Agent
zh_Hans: Agent
- name: workflow
label:
en_US: Workflow
zh_Hans: 工作流
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
default: 'your-api-key'
- name: n8n-service-api
label:
en_US: n8n Workflow API
zh_Hans: n8n 工作流 API
description:
en_US: Configure the n8n workflow API of the pipeline
zh_Hans: 配置 n8n 工作流 API
config:
- name: webhook-url
label:
en_US: Webhook URL
zh_Hans: Webhook URL
description:
en_US: The webhook URL of the n8n workflow
zh_Hans: n8n 工作流的 webhook URL
type: string
required: true
default: 'http://your-n8n-webhook-url'
- name: auth-type
label:
en_US: Authentication Type
zh_Hans: 认证类型
description:
en_US: The authentication type for the webhook call
zh_Hans: webhook 调用的认证类型
type: select
required: true
default: 'none'
options:
- name: 'none'
label:
en_US: None
zh_Hans: 无认证
- name: 'basic'
label:
en_US: Basic Auth
zh_Hans: 基本认证
- name: 'jwt'
label:
en_US: JWT
zh_Hans: JWT认证
- name: 'header'
label:
en_US: Header Auth
zh_Hans: 请求头认证
- name: basic-username
label:
en_US: Username
zh_Hans: 用户名
description:
en_US: The username for Basic Auth
zh_Hans: 基本认证的用户名
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: basic-password
label:
en_US: Password
zh_Hans: 密码
description:
en_US: The password for Basic Auth
zh_Hans: 基本认证的密码
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: jwt-secret
label:
en_US: Secret
zh_Hans: 密钥
description:
en_US: The secret for JWT authentication
zh_Hans: JWT认证的密钥
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: jwt-algorithm
label:
en_US: Algorithm
zh_Hans: 算法
description:
en_US: The algorithm for JWT authentication
zh_Hans: JWT认证的算法
type: string
required: false
default: 'HS256'
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: header-name
label:
en_US: Header Name
zh_Hans: 请求头名称
description:
en_US: The header name for Header Auth
zh_Hans: 请求头认证的名称
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: header-value
label:
en_US: Header Value
zh_Hans: 请求头值
description:
en_US: The header value for Header Auth
zh_Hans: 请求头认证的值
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
description:
en_US: The timeout in seconds for the webhook call
zh_Hans: webhook 调用的超时时间(秒)
type: integer
required: false
default: 120
- name: output-key
label:
en_US: Output Key
zh_Hans: 输出键名
description:
en_US: The key name of the output in the webhook response
zh_Hans: webhook 响应中输出内容的键名
type: string
required: false
default: 'response'
- name: coze-api
label:
en_US: coze API
zh_Hans: 扣子 API
description:
en_US: Configure the Coze API of the pipeline
zh_Hans: 配置Coze API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Coze server
zh_Hans: Coze服务器的 API 密钥
type: string
required: true
default: ''
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: The ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
default: ''
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
zh_Hans: Coze API 的基础 URL请使用 https://api.coze.com 用于全球 Coze 版coze.com
type: string
options:
- name: 'https://api.coze.cn'
label:
en_US: Coze China
zh_Hans: Coze 中国版
- name: 'https://api.coze.com'
label:
en_US: Coze Global
zh_Hans: Coze 全球版
default: "https://api.coze.cn"
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
default: true
- name: timeout
label:
en_US: Request Timeout
zh_Hans: 请求超时
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
default: 120
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
description:
en_US: Configure the Tbox App API of the pipeline
zh_Hans: 配置蚂蚁百宝箱平台 API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
default: ''
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
default: ''
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
description:
en_US: Configure the Aliyun Dashscope App API of the pipeline
zh_Hans: 配置阿里云百炼平台 API
config:
- name: app-type
label:
en_US: App Type
zh_Hans: 应用类型
type: select
required: true
default: agent
options:
- name: agent
label:
en_US: Agent
zh_Hans: Agent
- name: workflow
label:
en_US: Workflow
zh_Hans: 工作流
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
default: 'your-api-key'
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
default: 'your-app-id'
- name: references_quote
label:
en_US: References Quote
zh_Hans: 引用文本
description:
en_US: The text prompt when the references are included
zh_Hans: 包含引用资料时的文本提示
type: string
required: false
default: '参考资料来自:'
- name: langflow-api
label:
en_US: Langflow API
zh_Hans: Langflow API
description:
en_US: Configure the Langflow API of the pipeline, call the Langflow flow through the `Simplified Run Flow` interface
zh_Hans: 配置 Langflow API通过 `Simplified Run Flow` 接口调用 Langflow 的流程
config:
- name: base-url
label:
en_US: Base URL
zh_Hans: 基础 URL
description:
en_US: The base URL of the Langflow server
zh_Hans: Langflow 服务器的基础 URL
type: string
required: true
default: 'http://localhost:7860'
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Langflow server
zh_Hans: Langflow 服务器的 API 密钥
type: string
required: true
default: 'your-api-key'
- name: flow-id
label:
en_US: Flow ID
zh_Hans: 流程 ID
description:
en_US: The ID of the flow to run
zh_Hans: 要运行的流程 ID
type: string
required: true
default: 'your-flow-id'
- name: input-type
label:
en_US: Input Type
zh_Hans: 输入类型
description:
en_US: The input type for the flow
zh_Hans: 流程的输入类型
type: string
required: false
default: 'chat'
- name: output-type
label:
en_US: Output Type
zh_Hans: 输出类型
description:
en_US: The output type for the flow
zh_Hans: 流程的输出类型
type: string
required: false
default: 'chat'
- name: tweaks
label:
en_US: Tweaks
zh_Hans: 调整参数
description:
en_US: Optional tweaks to apply to the flow
zh_Hans: 可选的流程调整参数
type: json
required: false
default: '{}'
# Runner config stages are dynamically added from AgentRunnerRegistry
# Each plugin runner's config schema is added as a separate stage
# The stage name matches the runner id for frontend matching

View File

@@ -18,6 +18,7 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session
# Counter for generating unique IDs
_query_counter = 0
DEFAULT_RUNNER_ID = "plugin:langbot/local-agent/default"
def _next_query_id() -> int:
@@ -163,10 +164,12 @@ def _base_query(
"bot_uuid": "test-bot-uuid",
"pipeline_config": {
"ai": {
"runner": {"runner": "local-agent"},
"local-agent": {
"model": {"primary": "test-model-uuid", "fallbacks": []},
"prompt": "test-prompt",
"runner": {"id": DEFAULT_RUNNER_ID},
"runner_config": {
DEFAULT_RUNNER_ID: {
"model": {"primary": "test-model-uuid", "fallbacks": []},
"prompt": [{"role": "system", "content": "test-prompt"}],
},
},
},
"output": {"misc": {"at-sender": False, "quote-origin": False}},
@@ -469,4 +472,4 @@ def at_all_query(
sender_id=sender_id,
adapter=adapter,
**overrides,
)
)

View File

@@ -0,0 +1,2 @@
"""Tests for agent runner subsystem."""
from __future__ import annotations

View File

@@ -0,0 +1,94 @@
"""Shared test fixtures for agent runner tests."""
from __future__ import annotations
import typing
def make_resources(
models: list[dict] | None = None,
tools: list[dict] | None = None,
knowledge_bases: list[dict] | None = None,
storage: dict | None = None,
files: list[dict] | None = None,
) -> dict[str, typing.Any]:
"""Create a minimal AgentResources dict for testing.
Args:
models: List of model dicts with 'model_id' key
tools: List of tool dicts with 'tool_name' key
knowledge_bases: List of KB dicts with 'kb_id' key
storage: Storage permissions dict
files: List of file dicts with 'file_id' key
Returns:
AgentResources dict with all required fields
"""
return {
'models': models or [],
'tools': tools or [],
'knowledge_bases': knowledge_bases or [],
'files': files or [],
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
'platform_capabilities': {},
}
def make_session(
run_id: str = 'test-run-id',
runner_id: str = 'plugin:test/test-runner/default',
query_id: int | None = 1,
plugin_identity: str = 'test/test-runner',
resources: dict | None = None,
conversation_id: str | None = None,
permissions: dict[str, list[str]] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Create a minimal AgentRunSession dict for testing.
Args:
run_id: Unique run identifier
runner_id: Runner descriptor ID
query_id: Host entry query ID
plugin_identity: Plugin identifier (author/name)
resources: AgentResources dict (uses make_resources() default if None)
Returns:
AgentRunSession dict with run-scoped authorization snapshot
"""
import time
now = int(time.time())
res = resources if resources is not None else make_resources()
perms = permissions if permissions is not None else {}
policy = (
state_policy
if state_policy is not None
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
)
context = state_context if state_context is not None else {}
authorized_ids: dict[str, set[str]] = {
'model': {m.get('model_id') for m in res.get('models', [])},
'tool': {t.get('tool_name') for t in res.get('tools', [])},
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
'file': {f.get('file_id') for f in res.get('files', [])},
}
return {
'run_id': run_id,
'runner_id': runner_id,
'query_id': query_id,
'plugin_identity': plugin_identity,
'authorization': {
'resources': res,
'permissions': perms,
'conversation_id': conversation_id,
'state_policy': policy,
'state_context': context,
'authorized_ids': authorized_ids,
},
'status': {
'started_at': now,
'last_activity_at': now,
},
}

View File

@@ -0,0 +1,654 @@
"""Tests for ArtifactStore and artifact action handlers."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
import base64
import datetime
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
from langbot.pkg.agent.runner.session_registry import (
get_session_registry,
)
from .conftest import make_session
class TestArtifactStore:
"""Test ArtifactStore operations."""
def _make_mock_engine(self):
"""Create a mock database engine for AsyncSession-based store.
Note: The new store uses AsyncSession, so we need to mock
the session factory behavior.
"""
from sqlalchemy.ext.asyncio import AsyncEngine
engine = MagicMock(spec=AsyncEngine)
return engine
@pytest.mark.asyncio
async def test_register_artifact_generates_id(self):
"""Test register_artifact generates ID if not provided."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
# Mock the session factory
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
artifact_id = await store.register_artifact(
artifact_id=None,
artifact_type="image",
source="platform",
)
assert artifact_id is not None
assert len(artifact_id) == 36 # UUID format
@pytest.mark.asyncio
async def test_register_artifact_with_content(self):
"""Test register_artifact stores content in BinaryStorage."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
content = b"test image content"
artifact_id = await store.register_artifact(
artifact_id="art_001",
artifact_type="image",
source="platform",
content=content,
)
assert artifact_id == "art_001"
@pytest.mark.asyncio
async def test_register_artifact_with_storage_key(self):
"""Test register_artifact with pre-existing storage_key."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
artifact_id = await store.register_artifact(
artifact_id="art_002",
artifact_type="file",
source="runner",
storage_key="existing_key",
storage_type="binary_storage",
size_bytes=1024,
)
assert artifact_id == "art_002"
@pytest.mark.asyncio
async def test_get_metadata_not_found(self):
"""Test get_metadata returns None if not found."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
metadata = await store.get_metadata("nonexistent")
assert metadata is None
@pytest.mark.asyncio
async def test_read_artifact_validates_offset(self):
"""Test read_artifact rejects negative offset."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
with pytest.raises(ValueError, match="offset must be >= 0"):
await store.read_artifact("art_001", offset=-1)
@pytest.mark.asyncio
async def test_read_artifact_validates_limit(self):
"""Test read_artifact rejects zero or negative limit."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
with pytest.raises(ValueError, match="limit must be > 0"):
await store.read_artifact("art_001", limit=0)
with pytest.raises(ValueError, match="limit must be > 0"):
await store.read_artifact("art_001", limit=-5)
@pytest.mark.asyncio
async def test_read_artifact_not_found(self):
"""Test read_artifact returns None if not found."""
engine = self._make_mock_engine()
store = ArtifactStore(engine)
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
result = await store.read_artifact("nonexistent")
assert result is None
class TestArtifactAuthorization:
"""Test artifact action handler authorization."""
@pytest.fixture
def mock_session_registry(self):
"""Create a fresh session registry for testing."""
# Reset global registry
import langbot.pkg.agent.runner.session_registry as reg
reg._global_registry = None
return get_session_registry()
@pytest.fixture
def mock_handler(self):
"""Create a mock handler for testing actions."""
from langbot_plugin.runtime.io.handler import Handler
class MockHandler(Handler):
def __init__(self):
self._responses = {}
async def call_action(self, action, data, timeout=30):
# Simulate error response for missing run_id
if not data.get("run_id"):
return {"ok": False, "message": "run_id is required"}
return {"ok": True, "data": {}}
return MockHandler()
@pytest.mark.asyncio
async def test_artifact_metadata_requires_run_id(self, mock_handler):
"""Test artifact_metadata requires run_id."""
result = await mock_handler.call_action(
"artifact_metadata",
{"run_id": None, "artifact_id": "art_001"},
)
assert result.get("ok") is False or "error" in str(result).lower()
@pytest.mark.asyncio
async def test_artifact_read_requires_run_id(self, mock_handler):
"""Test artifact_read requires run_id."""
result = await mock_handler.call_action(
"artifact_read",
{"run_id": None, "artifact_id": "art_001"},
)
assert result.get("ok") is False or "error" in str(result).lower()
class TestArtifactAccessValidation:
"""Test _validate_artifact_access authorization rules."""
def _make_session(self, conversation_id: str | None):
return make_session(
run_id="run_001",
conversation_id=conversation_id,
permissions={"artifacts": ["metadata", "read"]},
)
def _call_validate(self, session, metadata, operation="metadata"):
"""Helper to call the validation function."""
from langbot.pkg.plugin.handler import _validate_artifact_access
return _validate_artifact_access(session, metadata, operation)
def test_global_artifact_denied_by_default(self):
"""Artifacts without conversation_id are denied by default (no global access)."""
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_global",
"conversation_id": None, # No conversation scope
"run_id": None, # Not created by any run
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is False
assert "denied" in error.lower()
def test_own_run_artifact_allowed(self):
"""Artifacts created by same run are allowed (even cross-conversation)."""
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_other", # Different conversation
"run_id": "run_001", # Same run
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is True
assert error is None
def test_same_conversation_allowed(self):
"""Artifacts in same conversation are allowed."""
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Same as session
"run_id": "run_other", # Different run
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is True
assert error is None
def test_different_conversation_and_run_denied(self):
"""Artifacts in different conversation and different run are denied."""
session = self._make_session("conv_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_other", # Different conversation
"run_id": "run_other", # Different run
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is False
assert "denied" in error.lower()
def test_session_without_conversation_denied_for_conversation_artifact(self):
"""Session without conversation_id cannot access conversation-scoped artifacts."""
session = self._make_session(None)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Has conversation
"run_id": "run_other", # Different run
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is False
def test_session_without_conversation_allowed_for_own_artifact(self):
"""Session without conversation can access artifacts it created."""
session = self._make_session(None)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001", # Has conversation
"run_id": "run_001", # Same run (created by this run)
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is True
class TestContextAccessArtifactAPIs:
"""Test ContextAccess reflects artifact API permissions."""
@pytest.mark.asyncio
async def test_context_access_has_artifact_apis_when_permitted(self):
"""Test ContextAccess shows artifact APIs when permissions allow."""
# This tests the context builder logic
# When artifact permissions include 'metadata' and 'read',
# available_apis should reflect that
permissions = {"artifacts": ["metadata", "read"]}
# Check that permissions are properly interpreted
artifact_metadata_enabled = "metadata" in permissions.get("artifacts", [])
artifact_read_enabled = "read" in permissions.get("artifacts", [])
assert artifact_metadata_enabled is True
assert artifact_read_enabled is True
@pytest.mark.asyncio
async def test_context_access_no_artifact_apis_without_permission(self):
"""Test ContextAccess hides artifact APIs when permissions denied."""
permissions = {"artifacts": []}
artifact_metadata_enabled = "metadata" in permissions.get("artifacts", [])
artifact_read_enabled = "read" in permissions.get("artifacts", [])
assert artifact_metadata_enabled is False
assert artifact_read_enabled is False
class TestArtifactMetadataFieldAlignment:
"""Test that Host returns metadata compatible with SDK ArtifactMetadata."""
def test_row_to_public_dict_excludes_host_only_fields(self):
"""_row_to_public_dict should not return Host-only fields."""
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
from langbot.pkg.entity.persistence.artifact import AgentArtifact
from unittest.mock import MagicMock
# Create a mock row
mock_row = MagicMock(spec=AgentArtifact)
mock_row.artifact_id = "art_001"
mock_row.artifact_type = "image"
mock_row.mime_type = "image/png"
mock_row.name = "test.png"
mock_row.size_bytes = 1024
mock_row.sha256 = "abc123"
mock_row.source = "platform"
mock_row.conversation_id = "conv_001"
mock_row.run_id = "run_001"
mock_row.runner_id = "plugin:test/plugin/runner"
mock_row.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0)
mock_row.expires_at = None
mock_row.metadata_json = None
# These are Host-only fields that should NOT be in output
# (they don't exist in SDK ArtifactMetadata)
mock_row.bot_id = "bot_001"
mock_row.workspace_id = "ws_001"
mock_row.storage_key = "artifact:art_001"
mock_row.storage_type = "binary_storage"
store = ArtifactStore(MagicMock())
result = store._row_to_public_dict(mock_row)
# SDK-compatible fields should be present
assert result["artifact_id"] == "art_001"
assert result["artifact_type"] == "image"
assert result["source"] == "platform"
assert result["conversation_id"] == "conv_001"
assert result["run_id"] == "run_001"
# Host-only fields should NOT be present
assert "bot_id" not in result
assert "workspace_id" not in result
assert "storage_key" not in result
assert "storage_type" not in result
class TestSessionRegistryPermissions:
"""Test that session registry stores and retrieves permissions correctly."""
@pytest.fixture
def session_registry(self):
"""Create a fresh session registry for testing."""
import langbot.pkg.agent.runner.session_registry as reg
reg._global_registry = None
return get_session_registry()
@pytest.mark.asyncio
async def test_register_stores_permissions(self, session_registry):
"""Test that register() stores permissions from descriptor."""
await session_registry.register(
run_id="run_001",
runner_id="plugin:author/plugin/runner",
query_id=None,
plugin_identity="author/plugin",
resources={
"models": [],
"tools": [],
"knowledge_bases": [],
"files": [],
"storage": {"plugin_storage": True, "workspace_storage": False},
"platform_capabilities": {},
},
permissions={
"artifacts": ["metadata", "read"],
"history": ["page"],
"events": ["get"],
},
conversation_id="conv_001",
)
session = await session_registry.get("run_001")
assert session is not None
permissions = session["authorization"]["permissions"]
assert permissions["artifacts"] == ["metadata", "read"]
assert permissions["history"] == ["page"]
assert permissions["events"] == ["get"]
@pytest.mark.asyncio
async def test_register_with_empty_permissions(self, session_registry):
"""Test that register() handles empty permissions."""
await session_registry.register(
run_id="run_002",
runner_id="plugin:author/plugin/runner",
query_id=None,
plugin_identity="author/plugin",
resources={
"models": [],
"tools": [],
"knowledge_bases": [],
"files": [],
"storage": {"plugin_storage": True, "workspace_storage": False},
"platform_capabilities": {},
},
permissions={},
conversation_id="conv_001",
)
session = await session_registry.get("run_002")
assert session is not None
assert session["authorization"]["permissions"] == {}
class TestArtifactStoreRealSQLite:
"""Test ArtifactStore with real SQLite database."""
@pytest.fixture
async def db_engine(self):
"""Create an in-memory SQLite database for testing."""
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with engine.begin() as conn:
# Create tables manually for in-memory DB
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.mark.asyncio
async def test_register_get_metadata_round_trip(self, db_engine):
"""Test register_artifact -> get_metadata round trip with real DB."""
store = ArtifactStore(db_engine)
# Register artifact with content
content = b"test image content for round trip"
artifact_id = await store.register_artifact(
artifact_id="art_real_001",
artifact_type="image",
source="platform",
mime_type="image/png",
name="test.png",
content=content,
conversation_id="conv_001",
run_id="run_001",
)
assert artifact_id == "art_real_001"
# Get metadata
metadata = await store.get_metadata(artifact_id)
assert metadata is not None
assert metadata["artifact_id"] == "art_real_001"
assert metadata["artifact_type"] == "image"
assert metadata["mime_type"] == "image/png"
assert metadata["source"] == "platform"
assert metadata["conversation_id"] == "conv_001"
assert metadata["run_id"] == "run_001"
# Verify Host-only fields are NOT in public metadata
assert "storage_key" not in metadata
assert "storage_type" not in metadata
assert "bot_id" not in metadata
assert "workspace_id" not in metadata
@pytest.mark.asyncio
async def test_read_artifact_round_trip(self, db_engine):
"""Test register_artifact -> read_artifact round trip with real DB."""
store = ArtifactStore(db_engine)
# Register artifact with content
content = b"test file content for read test"
artifact_id = await store.register_artifact(
artifact_id="art_real_002",
artifact_type="file",
source="runner",
mime_type="text/plain",
name="test.txt",
content=content,
conversation_id="conv_001",
run_id="run_001",
)
# Read artifact
result = await store.read_artifact(artifact_id)
assert result is not None
assert result["artifact_id"] == "art_real_002"
assert result["mime_type"] == "text/plain"
assert result["offset"] == 0
assert result["length"] == len(content)
assert result["has_more"] is False
# Verify content
decoded_content = base64.b64decode(result["content_base64"])
assert decoded_content == content
@pytest.mark.asyncio
async def test_read_artifact_with_offset_limit(self, db_engine):
"""Test read_artifact with offset and limit."""
store = ArtifactStore(db_engine)
# Register artifact with content
content = b"0123456789" * 100 # 1000 bytes
artifact_id = await store.register_artifact(
artifact_id="art_real_003",
artifact_type="file",
source="runner",
mime_type="application/octet-stream",
content=content,
)
# Read with offset
result = await store.read_artifact(artifact_id, offset=100, limit=100)
assert result is not None
assert result["offset"] == 100
assert result["length"] == 100
# Verify content
decoded_content = base64.b64decode(result["content_base64"])
assert decoded_content == content[100:200]
@pytest.mark.asyncio
async def test_read_artifact_has_more(self, db_engine):
"""Test read_artifact sets has_more correctly."""
store = ArtifactStore(db_engine)
# Register artifact with content
content = b"0123456789" * 100 # 1000 bytes
artifact_id = await store.register_artifact(
artifact_id="art_real_004",
artifact_type="file",
source="runner",
content=content,
)
# Read with limit smaller than content
result = await store.read_artifact(artifact_id, offset=0, limit=100)
assert result is not None
assert result["has_more"] is True
assert result["length"] == 100
@pytest.mark.asyncio
async def test_file_artifact_range_read_and_public_metadata(self, db_engine, tmp_path):
"""File-backed artifacts read ranges without exposing host paths."""
store = ArtifactStore(db_engine)
content = b"0123456789" * 20
file_path = tmp_path / "large.txt"
file_path.write_bytes(content)
artifact_id = await store.register_file_artifact(
artifact_id="art_file_001",
host_path=str(file_path),
host_root=str(tmp_path),
source="tool",
mime_type="text/plain",
name="large.txt",
conversation_id="conv_001",
run_id="run_001",
metadata={"sandbox_path": "/workspace/large.txt"},
)
metadata = await store.get_metadata(artifact_id)
assert metadata is not None
assert metadata["artifact_id"] == "art_file_001"
assert metadata["metadata"] == {"sandbox_path": "/workspace/large.txt"}
assert str(file_path) not in str(metadata)
result = await store.read_artifact(artifact_id, offset=10, limit=15)
assert result is not None
assert result["offset"] == 10
assert result["length"] == 15
assert result["size_bytes"] == len(content)
assert result["has_more"] is True
assert base64.b64decode(result["content_base64"]) == content[10:25]
@pytest.mark.asyncio
async def test_register_file_artifact_rejects_path_escape(self, db_engine, tmp_path):
"""File-backed artifacts must stay inside their declared host root."""
store = ArtifactStore(db_engine)
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("outside")
with pytest.raises(ValueError, match="escapes"):
await store.register_file_artifact(
artifact_id="art_file_escape",
host_path=str(outside),
host_root=str(root),
)
@pytest.mark.asyncio
async def test_metadata_sdk_validation(self, db_engine):
"""Test that metadata can be validated by SDK ArtifactMetadata."""
from langbot_plugin.api.entities.builtin.agent_runner.artifact import ArtifactMetadata
store = ArtifactStore(db_engine)
# Register artifact
artifact_id = await store.register_artifact(
artifact_id="art_real_005",
artifact_type="file",
source="runner",
mime_type="application/pdf",
name="document.pdf",
size_bytes=1024,
conversation_id="conv_001",
run_id="run_001",
runner_id="plugin:test/plugin/runner",
)
# Get metadata
metadata = await store.get_metadata(artifact_id)
assert metadata is not None
# Should not raise ValidationError
validated = ArtifactMetadata.model_validate(metadata)
assert validated.artifact_id == "art_real_005"
assert validated.artifact_type == "file"

View File

@@ -0,0 +1,605 @@
"""Tests for ChatMessageHandler behavior with AgentRunOrchestrator.
Tests focus on:
- Streaming mode behavior (single resp_message_id, pop/append pattern)
- Non-streaming mode behavior (no pop)
- Orchestrator invocation
- Error handling for RunnerNotFoundError, RunnerExecutionError
Avoids circular imports by using proper import structure.
"""
from __future__ import annotations
import uuid
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from langbot.pkg.agent.runner.errors import (
RunnerNotFoundError,
RunnerExecutionError,
RunnerNotAuthorizedError,
)
from langbot.pkg.agent.runner.config_migration import ConfigMigration
# Define mock classes in dependency order (no forward references needed)
class MockLauncherType:
value = 'person'
class MockConversation:
def __init__(self):
self.uuid = 'conv-uuid'
self.messages = []
class MockMessage:
role = 'user'
content = 'Hello'
class MockAdapter:
is_stream = False
async def is_stream_output_supported(self):
return self.is_stream
async def create_message_card(self, resp_message_id, message_event):
pass
class MockSession:
launcher_type = MockLauncherType()
launcher_id = 'user123'
def __init__(self):
self.using_conversation = MockConversation()
class MockQuery:
"""Mock Query for testing."""
def __init__(self):
self.query_id = 1
self.launcher_type = MockLauncherType()
self.launcher_id = 'user123'
self.sender_id = 'user123'
self.bot_uuid = 'bot-uuid'
self.pipeline_uuid = 'pipeline-uuid'
self.pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {},
},
'output': {
'misc': {
'exception-handling': 'show-hint',
'failure-hint': 'Request failed.',
},
},
}
self.variables = {}
self.session = MockSession()
self.user_message = MockMessage()
self.messages = []
self.resp_messages = []
self.resp_message_chain = None
self.adapter = MockAdapter()
self.message_event = MagicMock()
self.message_chain = MagicMock()
class MockMessageChunk:
"""Mock MessageChunk for testing."""
def __init__(self, content, resp_message_id=None):
self.role = 'assistant'
self.content = content
self.resp_message_id = resp_message_id
self.tool_calls = []
self.is_final = False
def readable_str(self):
return self.content
class MockEventContext:
"""Mock event context for testing."""
def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None):
self._prevented = prevented
self.event = MagicMock()
self.event.reply_message_chain = reply_message_chain
self.event.user_message_alter = user_message_alter
def is_prevented_default(self):
return self._prevented
class MockAgentRunOrchestrator:
"""Mock AgentRunOrchestrator for testing."""
def __init__(self, chunks=None, error=None):
self._chunks = chunks or []
self._error = error
async def run_from_query(self, query):
"""Async generator that yields chunks or raises error."""
if self._error:
raise self._error
for chunk in self._chunks:
yield chunk
def resolve_runner_id_for_telemetry(self, query):
return 'plugin:langbot/local-agent/default'
class MockApplication:
"""Mock Application for testing."""
def __init__(self, orchestrator=None):
self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator()
self.logger = MagicMock()
self.logger.info = MagicMock()
self.logger.debug = MagicMock()
self.logger.warning = MagicMock()
self.logger.error = MagicMock()
# Mock plugin_connector
self.plugin_connector = MagicMock()
self.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext())
# Mock telemetry
self.telemetry = MagicMock()
self.telemetry.start_send_task = AsyncMock()
# Mock survey
self.survey = MagicMock()
self.survey.trigger_event = AsyncMock()
# Mock model_mgr
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."""
def test_single_resp_message_id_for_streaming(self):
"""Streaming mode should use single resp_message_id for entire response."""
# Simulate the streaming logic: resp_message_id created outside loop
resp_message_id = uuid.uuid4()
chunks = ['Hello', ' World', '!']
resp_messages = []
for chunk in chunks:
result = MockMessageChunk(chunk)
result.resp_message_id = str(resp_message_id)
# Pop old chunk (streaming behavior)
if resp_messages:
resp_messages.pop()
resp_messages.append(result)
# All chunks should have same resp_message_id
assert len(resp_messages) == 1 # Only last chunk remains after pop/append
assert resp_messages[0].resp_message_id == str(resp_message_id)
def test_pop_before_append_in_streaming(self):
"""Streaming mode should pop old chunk before appending new."""
resp_message_id = uuid.uuid4()
resp_messages = []
# First chunk - no pop
chunk1 = MockMessageChunk('Hello')
chunk1.resp_message_id = str(resp_message_id)
resp_messages.append(chunk1)
assert len(resp_messages) == 1
# Second chunk - pop first, then append
if resp_messages:
resp_messages.pop()
chunk2 = MockMessageChunk('Hello World')
chunk2.resp_message_id = str(resp_message_id)
resp_messages.append(chunk2)
assert len(resp_messages) == 1
assert resp_messages[0].content == 'Hello World'
def test_non_streaming_no_pop(self):
"""Non-streaming mode should NOT pop previous responses."""
resp_messages = []
# First message
msg1 = MockMessageChunk('Response 1')
resp_messages.append(msg1)
assert len(resp_messages) == 1
# Second message - should NOT pop in non-streaming
msg2 = MockMessageChunk('Response 2')
resp_messages.append(msg2)
assert len(resp_messages) == 2
class TestConfigMigrationInChatHandler:
"""Tests for ConfigMigration usage in chat handler context."""
def test_resolve_runner_id_from_pipeline_config(self):
"""Chat handler should use ConfigMigration to resolve runner ID."""
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_resolve_runner_id_from_old_format(self):
"""ConfigMigration should not resolve removed runner aliases."""
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None
class TestErrorHandling:
"""Tests for orchestrator error handling."""
def test_runner_not_found_error_properties(self):
"""RunnerNotFoundError should have runner_id property."""
error = RunnerNotFoundError('plugin:notexist/unknown/default')
assert error.runner_id == 'plugin:notexist/unknown/default'
assert 'not found' in str(error)
def test_runner_execution_error_retryable(self):
"""RunnerExecutionError should have retryable property."""
error = RunnerExecutionError(
'plugin:langbot/local-agent/default',
'Upstream timeout',
retryable=True,
)
assert error.runner_id == 'plugin:langbot/local-agent/default'
assert error.retryable is True
assert 'timeout' in str(error)
def test_runner_execution_error_not_retryable(self):
"""RunnerExecutionError can be non-retryable."""
error = RunnerExecutionError(
'plugin:langbot/local-agent/default',
'Configuration error',
retryable=False,
)
assert error.retryable is False
def test_runner_not_authorized_error_properties(self):
"""RunnerNotAuthorizedError should have bound_plugins property."""
error = RunnerNotAuthorizedError(
'plugin:langbot/local-agent/default',
['langbot/dify-agent'],
)
assert error.runner_id == 'plugin:langbot/local-agent/default'
assert error.bound_plugins == ['langbot/dify-agent']
class TestChatHandlerImports:
"""Test that chat handler can be imported without circular import."""
def test_import_chat_handler_module(self):
"""Import chat handler module should work."""
# This test verifies the import works without circular dependency
from langbot.pkg.pipeline.process.handlers import chat
assert chat.ChatMessageHandler is not None
def test_chat_handler_class_exists(self):
"""ChatMessageHandler class should be defined."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert ChatMessageHandler.__name__ == 'ChatMessageHandler'
def test_chat_handler_has_handle_method(self):
"""ChatMessageHandler should have async generator handle method."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
assert hasattr(ChatMessageHandler, 'handle')
# handle returns AsyncGenerator, so check for async generator function
import inspect
assert inspect.isasyncgenfunction(ChatMessageHandler.handle)
class TestChatHandlerAsyncBehavior:
"""Real async tests for ChatMessageHandler.handle() with mocked orchestrator."""
@pytest.mark.asyncio
async def test_streaming_single_resp_message_id(self):
"""Streaming mode: all chunks should have same resp_message_id."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
# Create chunks for streaming
chunks = [
MockMessageChunk('Hello'),
MockMessageChunk('Hello World'),
MockMessageChunk('Hello World!'),
]
orchestrator = MockAgentRunOrchestrator(chunks=chunks)
mock_ap = MockApplication(orchestrator=orchestrator)
# Mock event context to not prevent default
event_ctx = MockEventContext(prevented=False)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
query = MockQuery()
query.adapter.is_stream = True # Enable streaming mode
handler = ChatMessageHandler(mock_ap)
# Mock event creation and StageProcessResult to bypass pydantic validation
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)
# Verify single resp_message_id
resp_ids = [msg.resp_message_id for msg in query.resp_messages if hasattr(msg, 'resp_message_id')]
assert len(set(resp_ids)) == 1 # All same ID
# Verify pop/append pattern: only last chunk remains
assert len(query.resp_messages) == 1
assert query.resp_messages[0].content == 'Hello World!'
@pytest.mark.asyncio
async def test_non_streaming_no_pop(self):
"""Non-streaming mode: all chunks should remain."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
chunks = [
MockMessageChunk('Response 1'),
MockMessageChunk('Response 2'),
]
orchestrator = MockAgentRunOrchestrator(chunks=chunks)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
query = MockQuery()
query.adapter.is_stream = False # Disable streaming mode
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)
# No pop: all chunks should remain
assert len(query.resp_messages) == 2
assert query.resp_messages[0].content == 'Response 1'
assert query.resp_messages[1].content == 'Response 2'
@pytest.mark.asyncio
async def test_agent_turn_recreates_conversation_if_tool_resets_it(self):
"""Agent turn bookkeeping 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 == []
@pytest.mark.asyncio
async def test_runner_not_found_error(self):
"""Handler should catch RunnerNotFoundError and return INTERRUPT."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerNotFoundError('plugin:notexist/unknown/default')
)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
query = MockQuery()
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'),
user_notice=kwargs.get('user_notice'),
)
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)
# Should return INTERRUPT with user_notice
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert 'not found' in results[0].user_notice
@pytest.mark.asyncio
async def test_runner_not_authorized_error(self):
"""Handler should catch RunnerNotAuthorizedError and return INTERRUPT."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerNotAuthorizedError('plugin:langbot/local-agent/default', ['other/plugin'])
)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
query = MockQuery()
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'),
user_notice=kwargs.get('user_notice'),
)
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.INTERRUPT
assert 'not authorized' in results[0].user_notice
@pytest.mark.asyncio
async def test_runner_execution_error_retryable(self):
"""Handler should catch retryable RunnerExecutionError."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
orchestrator = MockAgentRunOrchestrator(
error=RunnerExecutionError('plugin:langbot/local-agent/default', 'timeout', retryable=True)
)
mock_ap = MockApplication(orchestrator=orchestrator)
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
query = MockQuery()
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'),
user_notice=kwargs.get('user_notice'),
)
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.INTERRUPT
assert 'temporarily unavailable' in results[0].user_notice
@pytest.mark.asyncio
async def test_prevented_default_with_reply(self):
"""When event prevented default with reply, use reply message."""
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
from langbot.pkg.pipeline import entities
# Mock reply message chain
reply_chain = MockMessageChunk('Reply from plugin')
mock_ap = MockApplication()
mock_ap.plugin_connector.emit_event = AsyncMock(
return_value=MockEventContext(prevented=True, reply_message_chain=reply_chain)
)
query = MockQuery()
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)
# Should return CONTINUE with reply message
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1

View File

@@ -0,0 +1,152 @@
"""Tests for current AgentRunner config helpers."""
from __future__ import annotations
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestResolveRunnerId:
"""Tests for ConfigMigration.resolve_runner_id."""
def test_resolve_current_runner_id(self):
pipeline_config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_does_not_resolve_old_runner_field(self):
pipeline_config = {
'ai': {
'runner': {
'runner': 'local-agent',
},
},
}
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
assert runner_id is None
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_current_config(self):
pipeline_config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {
'model': 'uuid-123',
'custom_option': 10,
},
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {'model': 'uuid-123', 'custom_option': 10}
def test_does_not_read_old_runner_block(self):
pipeline_config = {
'ai': {
'local-agent': {
'model': 'uuid-123',
},
},
}
config = ConfigMigration.resolve_runner_config(
pipeline_config,
'plugin:langbot/local-agent/default',
)
assert config == {}
def test_resolve_no_config(self):
config = ConfigMigration.resolve_runner_config(
{},
'plugin:langbot/local-agent/default',
)
assert config == {}
class TestGetExpireTime:
"""Tests for ConfigMigration.get_expire_time."""
def test_get_expire_time_zero(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 0,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 0
def test_get_expire_time_positive(self):
pipeline_config = {
'ai': {
'runner': {
'expire-time': 3600,
},
},
}
expire_time = ConfigMigration.get_expire_time(pipeline_config)
assert expire_time == 3600
def test_get_expire_time_default(self):
expire_time = ConfigMigration.get_expire_time({})
assert expire_time == 0
class TestNormalizePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config."""
def test_normalizes_current_containers(self):
config = {'ai': {}}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated == {'ai': {'runner': {}, 'runner_config': {}}}
def test_preserves_current_config(self):
config = {
'ai': {
'runner': {'id': 'plugin:test/my-runner/default'},
'runner_config': {
'plugin:test/my-runner/default': {'custom-option': 20},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default'
assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20
def test_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'}

View File

@@ -0,0 +1,129 @@
"""Tests for persisted AgentRunner config shape."""
from __future__ import annotations
import json
from langbot.pkg.agent.runner.config_migration import ConfigMigration
class TestMigratePipelineConfig:
"""Tests for ConfigMigration.migrate_pipeline_config."""
def test_current_format_config_stays_unchanged(self):
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
'expire-time': 0,
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'model': {'primary': '', 'fallbacks': []},
'custom-option': 10,
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10
def test_old_runner_field_is_not_mapped(self):
config = {
'ai': {
'runner': {
'runner': 'local-agent',
'expire-time': 3600,
},
'local-agent': {
'model': 'old-model',
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
assert migrated['ai']['runner'] == {
'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_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)
assert 'ai' in config
assert 'runner' in config['ai']
assert 'id' in config['ai']['runner']
assert config['ai']['runner']['id'] == ''
assert 'runner_config' in config['ai']
assert config['ai']['runner_config'] == {}
assert 'local-agent' not in config['ai']
class TestResolveRunnerId:
"""Tests for current runner id resolution."""
def test_resolve_current_id(self):
config = {
'ai': {
'runner': {'id': 'plugin:test/my-runner/default'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id == 'plugin:test/my-runner/default'
def test_old_runner_field_is_ignored(self):
config = {
'ai': {
'runner': {'runner': 'local-agent'},
},
}
runner_id = ConfigMigration.resolve_runner_id(config)
assert runner_id is None
class TestResolveRunnerConfig:
"""Tests for runtime runner config resolution."""
def test_resolve_current_config(self):
config = {
'ai': {
'runner_config': {
'plugin:langbot/local-agent/default': {'custom-option': 20},
},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config['custom-option'] == 20
def test_old_runner_block_is_ignored(self):
config = {
'ai': {
'local-agent': {'custom-option': 20},
},
}
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default')
assert runner_config == {}

View File

@@ -0,0 +1,162 @@
"""Tests for Query entry adapter params packaging."""
from __future__ import annotations
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
class TestBuildParams:
"""Tests for QueryEntryAdapter.build_params filtering."""
def test_params_empty_when_no_variables(self):
query = type('Query', (), {'variables': None})()
assert QueryEntryAdapter.build_params(query) == {}
def test_params_filters_underscore_prefix(self):
query = type('Query', (), {
'variables': {
'_internal_var': 'should_be_excluded',
'_pipeline_bound_plugins': ['a/b'],
'_monitoring_bot_name': 'Bot',
'public_var': 'should_be_included',
},
})()
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
assert params['public_var'] == 'should_be_included'
def test_params_filters_sensitive_naming(self):
query = type('Query', (), {
'variables': {
'api_key': 'secret123',
'API_KEY': 'secret456',
'token': 'tok123',
'secret': 'sec123',
'password': 'pass123',
'credential': 'cred123',
'user_api_key': 'should_be_excluded',
'user_secret_key': 'should_be_excluded',
'my_token_value': 'should_be_excluded',
'user_password_hash': 'should_be_excluded',
'public_name': 'should_be_included',
'safe_value': 'should_be_included',
},
})()
params = QueryEntryAdapter.build_params(query)
assert 'api_key' not in params
assert 'API_KEY' not in params
assert 'token' not in params
assert 'secret' not in params
assert 'password' not in params
assert 'credential' not in params
assert 'user_api_key' not in params
assert 'user_secret_key' not in params
assert 'my_token_value' not in params
assert 'user_password_hash' not in params
assert 'public_name' in params
assert 'safe_value' in params
def test_params_keeps_common_public_vars(self):
query = type('Query', (), {
'variables': {
'launcher_type': 'telegram',
'launcher_id': 'group_123',
'sender_id': 'user_001',
'session_id': 'sess_abc',
'msg_create_time': 1234567890,
'group_name': 'Tech Group',
'sender_name': 'John',
'user_message_text': 'Hello world',
},
})()
params = QueryEntryAdapter.build_params(query)
assert params['launcher_type'] == 'telegram'
assert params['launcher_id'] == 'group_123'
assert params['sender_id'] == 'user_001'
assert params['session_id'] == 'sess_abc'
assert params['msg_create_time'] == 1234567890
assert params['group_name'] == 'Tech Group'
assert params['sender_name'] == 'John'
assert params['user_message_text'] == 'Hello world'
def test_params_filters_non_json_serializable(self):
class CustomObject:
pass
query = type('Query', (), {
'variables': {
'string_value': 'hello',
'int_value': 42,
'float_value': 3.14,
'bool_value': True,
'null_value': None,
'list_value': ['a', 'b', 'c'],
'dict_value': {'nested': 'value'},
'custom_object': CustomObject(),
},
})()
params = QueryEntryAdapter.build_params(query)
assert 'string_value' in params
assert 'int_value' in params
assert 'float_value' in params
assert 'bool_value' in params
assert 'null_value' in params
assert 'list_value' in params
assert 'dict_value' in params
assert 'custom_object' not in params
def test_params_filters_nested_non_serializable(self):
class CustomObject:
pass
query = type('Query', (), {
'variables': {
'nested_list_with_bad': ['a', CustomObject(), 'c'],
'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()},
'good_nested_list': ['a', ['b', 'c']],
'good_nested_dict': {'outer': {'inner': 'value'}},
},
})()
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 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 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 TestBuildAdapterContext:
"""Tests for QueryEntryAdapter.build_adapter_context."""
def test_adapter_context_does_not_push_prompt(self):
query = type('Query', (), {
'variables': {},
'query_id': 123,
'prompt': object(),
})()
context = QueryEntryAdapter.build_adapter_context(query, binding=None)
assert context == {'params': {}, 'query_id': 123, 'prompt_get': False}

View File

@@ -0,0 +1,361 @@
"""Tests for ContextAccess.state determination in AgentRunContextBuilder.
Tests focus on:
- Event-first mode: state=True when enable_state=True and state_scopes non-empty
- Event-first mode: state=False when enable_state=False
- Legacy Query mode: state=False (no persistent state API)
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
class MockApplication:
"""Mock Application for testing."""
def __init__(self):
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
self.persistence_mgr.get_db_engine = MagicMock()
class TestContextAccessStateDetermination:
"""Tests for ContextAccess.state field determination - real calls to _build_context_access."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
return MockApplication()
@pytest.fixture
def mock_event(self):
"""Create mock event envelope."""
return AgentEventEnvelope(
event_id='evt_001',
event_type='message.received',
event_time=1234567890,
source='test',
bot_id='bot_001',
workspace_id='ws_001',
conversation_id='conv_001',
thread_id=None,
actor=ActorContext(actor_type='user', actor_id='user_001'),
subject=None,
input=AgentInput(text='hello', contents=[], attachments=[]),
delivery=DeliveryContext(surface='test', supports_streaming=True),
)
@pytest.fixture
def mock_descriptor(self):
"""Create mock runner descriptor."""
descriptor = MagicMock()
descriptor.id = 'plugin:test/runner/default'
descriptor.protocol_version = '1.0'
descriptor.permissions = {}
return descriptor
@pytest.mark.asyncio
async def test_enable_state_true_with_scopes_sets_state_true(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=True when enable_state=True and state_scopes non-empty."""
# Create binding with state enabled and non-empty scopes
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(
enable_state=True,
state_scopes=['conversation', 'actor'],
),
)
builder = AgentRunContextBuilder(mock_app)
# Real call to _build_context_access
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# Verify state=True based on binding.state_policy
assert context_access['available_apis']['state'] is True
@pytest.mark.asyncio
async def test_enable_state_false_sets_state_false(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=False when enable_state=False."""
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(
enable_state=False,
state_scopes=[],
),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# Verify state=False
assert context_access['available_apis']['state'] is False
@pytest.mark.asyncio
async def test_enable_state_true_empty_scopes_sets_state_false(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=False when enable_state=True but state_scopes empty."""
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(
enable_state=True,
state_scopes=[], # Empty scopes - state not available
),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# Verify state=False (empty scopes means state not available)
assert context_access['available_apis']['state'] is False
@pytest.mark.asyncio
async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=False when no binding is provided."""
builder = AgentRunContextBuilder(mock_app)
# Real call without binding
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None)
# Verify state=False (no binding = no state policy = state disabled)
assert context_access['available_apis']['state'] is False
@pytest.mark.asyncio
async def test_runner_scope_available_without_conversation(self, mock_app, mock_descriptor):
"""State API with runner scope is available even without conversation_id."""
mock_event = AgentEventEnvelope(
event_id='evt_002',
event_type='message.received',
event_time=1234567890,
source='test',
bot_id='bot_001',
workspace_id='ws_001',
conversation_id=None, # No conversation
thread_id=None,
actor=ActorContext(actor_type='user', actor_id='user_001'),
subject=None,
input=AgentInput(text='hello', contents=[], attachments=[]),
delivery=DeliveryContext(surface='test', supports_streaming=True),
)
binding = AgentBinding(
binding_id='binding_002',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='workspace', scope_id='ws_001'),
state_policy=StatePolicy(
enable_state=True,
state_scopes=['runner'], # Runner scope doesn't need conversation_id
),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# State should be True because runner scope is enabled
assert context_access['available_apis']['state'] is True
@pytest.mark.asyncio
async def test_multiple_scopes_all_available(self, mock_app, mock_event, mock_descriptor):
"""State API with multiple scopes enabled."""
binding = AgentBinding(
binding_id='binding_003',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(
enable_state=True,
state_scopes=['conversation', 'actor', 'subject', 'runner'],
),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# State should be True with all scopes enabled
assert context_access['available_apis']['state'] is True
class TestStatePolicyFromBinding:
"""Tests for state_policy extraction from binding."""
def test_state_policy_structure(self):
"""State policy has correct structure."""
policy = StatePolicy(
enable_state=True,
state_scopes=['conversation', 'actor', 'subject', 'runner'],
)
assert policy.enable_state is True
assert len(policy.state_scopes) == 4
assert 'conversation' in policy.state_scopes
def test_state_policy_disabled(self):
"""State policy can be disabled."""
policy = StatePolicy(
enable_state=False,
state_scopes=[],
)
assert policy.enable_state is False
assert len(policy.state_scopes) == 0
class TestBindingWithStatePolicy:
"""Tests for binding with state_policy."""
def test_binding_contains_state_policy(self):
"""Binding contains state_policy field."""
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(
enable_state=True,
state_scopes=['conversation'],
),
)
assert binding.state_policy is not None
assert binding.state_policy.enable_state is True
class TestContextAccessOtherAPIs:
"""Tests for other available_apis fields based on permissions."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
return MockApplication()
@pytest.mark.asyncio
async def test_history_apis_based_on_permissions(self, mock_app):
"""History APIs availability based on runner permissions."""
mock_event = MagicMock()
mock_event.conversation_id = 'conv_001'
mock_event.thread_id = None
mock_descriptor = MagicMock()
mock_descriptor.permissions = {
'history': ['page', 'search'],
}
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# History APIs enabled based on permissions
assert context_access['available_apis']['history_page'] is True
assert context_access['available_apis']['history_search'] is True
@pytest.mark.asyncio
async def test_event_apis_based_on_permissions(self, mock_app):
"""Event APIs availability based on runner permissions."""
mock_event = MagicMock()
mock_event.conversation_id = 'conv_001'
mock_event.thread_id = None
mock_descriptor = MagicMock()
mock_descriptor.permissions = {
'events': ['get', 'page'],
}
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# Event APIs enabled based on permissions
assert context_access['available_apis']['event_get'] is True
assert context_access['available_apis']['event_page'] is True
@pytest.mark.asyncio
async def test_artifact_apis_based_on_permissions(self, mock_app):
"""Artifact APIs availability based on runner permissions."""
mock_event = MagicMock()
mock_event.conversation_id = 'conv_001'
mock_event.thread_id = None
mock_descriptor = MagicMock()
mock_descriptor.permissions = {
'artifacts': ['metadata', 'read'],
}
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# Artifact APIs enabled based on permissions
assert context_access['available_apis']['artifact_metadata'] is True
assert context_access['available_apis']['artifact_read'] is True
@pytest.mark.asyncio
async def test_no_permissions_all_apis_disabled(self, mock_app):
"""All pull APIs disabled when permissions are empty."""
mock_event = MagicMock()
mock_event.conversation_id = 'conv_001'
mock_event.thread_id = None
mock_descriptor = MagicMock()
mock_descriptor.permissions = {} # No permissions
binding = AgentBinding(
binding_id='binding_001',
runner_id='plugin:test/runner/default',
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
# All pull APIs should be disabled
assert context_access['available_apis']['history_page'] is False
assert context_access['available_apis']['history_search'] is False
assert context_access['available_apis']['event_get'] is False
assert context_access['available_apis']['event_page'] is False
assert context_access['available_apis']['artifact_metadata'] is False
assert context_access['available_apis']['artifact_read'] is False
assert context_access['available_apis']['state'] is False

View File

@@ -0,0 +1,336 @@
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
# SDK imports for validation
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
# LangBot imports
from langbot.pkg.agent.runner.context_builder import (
AgentRunContextBuilder,
AgentResources as BuilderResources,
)
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope
from langbot.pkg.core import app
class TestContextValidation:
"""Test that context builder output validates against SDK AgentRunContext."""
def _make_mock_app(self):
"""Create a mock application."""
mock_app = MagicMock(spec=app.Application)
mock_app.ver_mgr = MagicMock()
mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0")
mock_app.persistence_mgr = MagicMock()
mock_app.persistence_mgr.get_db_engine = MagicMock()
mock_app.logger = MagicMock()
return mock_app
def _make_event_envelope(self) -> AgentEventEnvelope:
"""Create a test event envelope."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
return AgentEventEnvelope(
event_id="evt_1",
event_type="message.received",
event_time=1700000000,
source="platform",
source_event_type="platform.message",
bot_id="bot_1",
workspace_id="workspace_1",
conversation_id="conv_1",
thread_id=None,
actor=ActorContext(
actor_type="user",
actor_id="user_1",
actor_name="Test User",
),
subject=None,
input=EventInput(text="Hello world"),
delivery=DeliveryContext(surface="test"),
data={"platform_event_id": "source_evt_1"},
)
def _make_binding(self) -> AgentBinding:
"""Create a test binding."""
return AgentBinding(
binding_id="binding_1",
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
event_types=["message.received"],
runner_id="plugin:test/plugin/runner",
runner_config={"timeout": 300},
agent_id="pipeline_1",
enabled=True,
)
def _make_resources(self) -> BuilderResources:
"""Create test resources."""
return {
'models': [],
'tools': [],
'knowledge_bases': [],
'files': [],
'storage': {'plugin_storage': True, 'workspace_storage': True},
'platform_capabilities': {},
}
def _make_descriptor(self):
"""Create a mock runner descriptor."""
descriptor = MagicMock()
descriptor.id = "plugin:test/plugin/runner"
descriptor.protocol_version = "1"
descriptor.permissions = {
'history': ['page', 'search'],
'events': ['get', 'page'],
}
return descriptor
@pytest.mark.asyncio
async def test_build_context_from_event_validates(self):
"""Test that build_context_from_event output validates against SDK AgentRunContext."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
resources = self._make_resources()
descriptor = self._make_descriptor()
# Mock persistent state store to return empty state snapshot
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
})
mock_get_store.return_value = mock_store
# Build context
context_dict = await builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
resources=resources,
)
# Validate it can be parsed by SDK AgentRunContext
# This will raise ValidationError if invalid
validated = AgentRunContext.model_validate(context_dict)
# Verify required fields
assert validated.run_id is not None
assert validated.event is not None
assert isinstance(validated.event, AgentEventContext)
assert validated.delivery is not None
assert isinstance(validated.delivery, DeliveryContext)
assert validated.context is not None
assert isinstance(validated.context, ContextAccess)
assert validated.input is not None
assert isinstance(validated.input, AgentInput)
assert validated.resources is not None
assert isinstance(validated.resources, AgentResources)
assert validated.runtime is not None
assert isinstance(validated.runtime, AgentRuntimeContext)
# Verify event context
assert validated.event.event_id == "evt_1"
assert validated.event.event_type == "message.received"
assert validated.event.source == "platform"
assert validated.event.source_event_type == "platform.message"
assert validated.event.data == {"platform_event_id": "source_evt_1"}
# Verify conversation context uses SDK field names
assert validated.conversation is not None
assert validated.conversation.bot_id == "bot_1"
assert validated.conversation.workspace_id == "workspace_1"
# Verify delivery context
assert validated.delivery.surface == "test"
# Verify input
assert validated.input.text == "Hello world"
@pytest.mark.asyncio
async def test_build_context_preserves_subject_data_for_non_message_events(self):
"""Non-message EBA events keep subject.data instead of relying on message text."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
event = AgentEventEnvelope(
event_id="evt_recall_1",
event_type="message.recalled",
event_time=1700000001,
source="platform",
source_event_type="platform.message.recall",
bot_id="bot_1",
workspace_id="workspace_1",
conversation_id="conv_1",
actor=ActorContext(actor_type="user", actor_id="user_1"),
subject=SubjectContext(
subject_type="message",
subject_id="message_1",
data={"recalled_message_id": "message_1", "reason": "user_recall"},
),
input=EventInput(text=None),
delivery=DeliveryContext(surface="test"),
data={"source_event_id": "source_recall_1"},
)
binding = self._make_binding()
binding.event_types = ["message.recalled"]
resources = self._make_resources()
descriptor = self._make_descriptor()
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
})
mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
resources=resources,
)
validated = AgentRunContext.model_validate(context_dict)
assert validated.event.event_type == "message.recalled"
assert validated.input.text is None
assert validated.subject is not None
assert validated.subject.subject_type == "message"
assert validated.subject.subject_id == "message_1"
assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"}
@pytest.mark.asyncio
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
"""Test that build_context_from_event does NOT have top-level messages/prompt/params."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
resources = self._make_resources()
descriptor = self._make_descriptor()
# Mock persistent state store to return empty state snapshot
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
})
mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
resources=resources,
)
# Protocol v1 does NOT have these as core fields
assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1"
assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1"
assert 'params' not in context_dict, "params should not be top-level in Protocol v1"
# 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' 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"
@pytest.mark.asyncio
async def test_build_context_from_event_event_is_not_none(self):
"""Test that event field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
resources = self._make_resources()
descriptor = self._make_descriptor()
# Mock persistent state store to return empty state snapshot
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
})
mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
resources=resources,
)
# event is REQUIRED in Protocol v1
assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1"
# Validate
validated = AgentRunContext.model_validate(context_dict)
assert validated.event is not None
@pytest.mark.asyncio
async def test_build_context_from_event_delivery_is_not_none(self):
"""Test that delivery field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
resources = self._make_resources()
descriptor = self._make_descriptor()
# Mock persistent state store to return empty state snapshot
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={
'conversation': {},
'actor': {},
'subject': {},
'runner': {},
})
mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event(
event=event,
binding=binding,
descriptor=descriptor,
resources=resources,
)
# delivery is REQUIRED in Protocol v1
assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1"
# Validate
validated = AgentRunContext.model_validate(context_dict)
assert validated.delivery is not None

View File

@@ -0,0 +1,385 @@
"""Tests for event-first Protocol v1 entities and Query entry adapter.
Tests cover:
1. Query -> AgentEventEnvelope conversion
2. Current config -> AgentConfig projection and single-binding resolution
3. AgentRunContext not inlining full history by default
4. LangBot Host not defining context-window controls
5. Event-first run() entry point
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock
# Import SDK entities
from langbot_plugin.api.entities.builtin.agent_runner.event import (
AgentEventContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import (
AgentRunResult,
AgentRunResultType,
)
from langbot_plugin.api.entities.builtin.agent_runner.capabilities import (
AgentRunnerCapabilities,
)
from langbot_plugin.api.entities.builtin.agent_runner.permissions import (
AgentRunnerPermissions,
)
# Import LangBot host models
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.binding_resolver import (
AgentBindingResolver,
AgentBindingResolutionError,
)
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 = QueryEntryAdapter.query_to_event(mock_query)
assert event.event_type == "message.received"
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 = 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 = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-uuid-123"
def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query):
"""Pipeline variables can provide the conversation identity for state scope."""
mock_query.session.using_conversation.uuid = None
mock_query.variables["conversation_id"] = "conv-from-vars"
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.conversation_id == "conv-from-vars"
def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query):
"""Debug Chat and legacy pipeline runs may not have a conversation UUID."""
mock_query.session.using_conversation.uuid = None
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 = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery is not None
assert event.delivery.surface == "platform"
assert isinstance(event.delivery.supports_streaming, bool)
def test_query_to_event_preserves_source_event_data(self, mock_query):
"""Test source event metadata survives the adapter boundary."""
source_event = Mock()
source_event.type = "platform.message.created"
source_event.time = 1700000000
source_event.sender = None
source_event.model_dump = Mock(return_value={
"type": "platform.message.created",
"message_id": "source-message-1",
"source_platform_object": {"large": "payload"},
})
mock_query.message_event = source_event
event = QueryEntryAdapter.query_to_event(mock_query)
assert event.source_event_type == "platform.message.created"
assert event.event_time == 1700000000
assert event.data == {
"type": "platform.message.created",
"message_id": "source-message-1",
}
def test_query_to_event_handles_missing_message_chain(self, mock_query):
"""Test delivery context building when Query has no message_chain."""
delattr(mock_query, "message_chain")
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 = QueryEntryAdapter.query_to_event(mock_query)
mock_query.launcher_id = "launcher-456"
second = QueryEntryAdapter.query_to_event(mock_query)
assert first.event_id.startswith("host:")
assert first.event_id != "789"
assert second.event_id != first.event_id
class TestQueryConfigToAgentConfig:
"""Test current config projection and single-Agent binding resolution."""
def test_config_to_agent_config_runner_id(self, mock_query):
"""Test AgentConfig runner_id extraction."""
agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:author/plugin/runner"
)
assert agent_config.runner_id == "plugin:author/plugin/runner"
def test_resolver_projects_agent_scope(self, mock_query):
"""Test binding scope projection through the resolver."""
event = QueryEntryAdapter.query_to_event(mock_query)
agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:test/plugin/runner"
)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
assert binding.scope.scope_type == "agent"
assert binding.scope.scope_id == mock_query.pipeline_uuid
assert binding.agent_id == mock_query.pipeline_uuid
def test_resolver_rejects_multiple_matching_agents(self, mock_query):
"""Event dispatch is single-Agent in v1."""
event = QueryEntryAdapter.query_to_event(mock_query)
first = QueryEntryAdapter.config_to_agent_config(
mock_query, "plugin:test/plugin/runner"
)
second = first.model_copy(update={"agent_id": "agent_2"})
with pytest.raises(AgentBindingResolutionError):
AgentBindingResolver().resolve_one(event, [first, second])
class TestAgentRunContextProtocolV1:
"""Test AgentRunContext Protocol v1 behavior."""
def test_sdk_context_event_required(self):
"""Test that event is required in Protocol v1 context."""
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(),
)
assert ctx.event is not None
assert ctx.event.event_type == "message.received"
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",
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(),
)
assert "messages" not in AgentRunContext.model_fields
assert "bootstrap" not in AgentRunContext.model_fields
assert not hasattr(ctx, "bootstrap")
class TestHostManagedHistoryNotInProtocol:
"""Test that Host-managed history payloads are not in Protocol v1."""
def test_messages_not_in_sdk_context_top_level(self):
"""AgentRunContext should not expose top-level history messages."""
ctx_fields = AgentRunContext.model_fields.keys()
assert "messages" not in ctx_fields
class TestSDKCapabilitiesProtocolV1:
"""Test SDK capabilities for Protocol v1."""
def test_self_managed_context_default_true(self):
"""Test self_managed_context defaults to True for Protocol v1."""
caps = AgentRunnerCapabilities()
assert caps.self_managed_context is True
def test_event_context_default_true(self):
"""Test event_context defaults to True for Protocol v1."""
caps = AgentRunnerCapabilities()
assert caps.event_context is True
class TestSDKPermissionsProtocolV1:
"""Test SDK permissions for Protocol v1."""
def test_permissions_new_fields(self):
"""Test new permission fields for Protocol v1."""
perms = AgentRunnerPermissions(
models=["invoke", "stream", "rerank"],
tools=["detail", "call"],
knowledge_bases=["list", "retrieve"],
history=["page", "search"],
events=["get", "page"],
artifacts=["metadata", "read"],
storage=["plugin", "workspace", "binding"],
)
assert perms.history == ["page", "search"]
assert perms.events == ["get", "page"]
assert perms.artifacts == ["metadata", "read"]
assert perms.storage == ["plugin", "workspace", "binding"]
class TestSDKResultProtocolV1:
"""Test SDK AgentRunResult for Protocol v1."""
def test_result_requires_run_id(self):
"""Test result requires run_id for Protocol v1."""
from langbot_plugin.api.entities.builtin.provider.message import Message
result = AgentRunResult.message_completed(
run_id="run_1",
message=Message(role="assistant", content="Hello"),
)
assert result.run_id == "run_1"
def test_artifact_created_result_type(self):
"""Test artifact.created result type."""
result = AgentRunResult.artifact_created(
run_id="run_1",
artifact_id="artifact_1",
artifact_type="image",
)
assert result.type == AgentRunResultType.ARTIFACT_CREATED
assert result.data["artifact_id"] == "artifact_1"
# Fixtures
@pytest.fixture
def mock_query():
"""Create a mock query for testing."""
query = Mock()
query.query_id = 123
query.bot_uuid = "bot-uuid-123"
query.pipeline_uuid = "pipeline-uuid-456"
query.launcher_type = Mock(value="person")
query.launcher_id = "launcher-123"
query.sender_id = "sender-123"
query.pipeline_config = {
"ai": {
"runner": "plugin:test/plugin/runner",
}
}
query.variables = {}
# Create a proper content element mock
content_elem = Mock(spec=['type', 'text', 'model_dump'])
content_elem.type = 'text'
content_elem.text = 'Hello world'
content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Hello world'})
query.user_message = Mock()
query.user_message.content = [content_elem]
# Create message_chain mock
message_chain = Mock()
message_chain.message_id = 789
message_chain.model_dump = Mock(return_value={'message_id': 789, 'components': []})
query.message_chain = message_chain
query.message_event = None
# Mock session with proper conversation
query.session = Mock()
query.session.launcher_type = Mock(value="person")
query.session.launcher_id = "launcher-123"
query.session.using_conversation = Mock()
query.session.using_conversation.uuid = "conv-uuid-123"
# Mock use_funcs (empty list by default)
query.use_funcs = []
query.use_llm_model_uuid = None
return query
@pytest.fixture
def mock_query_no_session():
"""Create a mock Query without session."""
query = Mock()
query.query_id = 456
query.bot_uuid = "bot-uuid-456"
query.pipeline_uuid = "pipeline-uuid-789"
query.launcher_type = Mock(value="person")
query.launcher_id = "launcher-456"
query.sender_id = "sender-456"
query.pipeline_config = {
"ai": {
"runner": "plugin:test/plugin/runner",
}
}
query.variables = {}
# Create a proper content element mock
content_elem = Mock(spec=['type', 'text', 'model_dump'])
content_elem.type = 'text'
content_elem.text = 'Test message'
content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Test message'})
query.user_message = Mock()
query.user_message.content = [content_elem]
message_chain = Mock()
message_chain.message_id = -1
message_chain.model_dump = Mock(return_value={'message_id': -1, 'components': []})
query.message_chain = message_chain
query.message_event = None
query.session = None
# Mock use_funcs
query.use_funcs = []
query.use_llm_model_uuid = None
return query

View File

@@ -0,0 +1,632 @@
"""Tests for EventLog, Transcript, and history/event APIs."""
from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.host_models import (
AgentEventEnvelope,
AgentBinding,
BindingScope,
ResourcePolicy,
StatePolicy,
DeliveryPolicy,
)
from langbot.pkg.agent.runner.event_log_store import EventLogStore
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot_plugin.api.entities.builtin.agent_runner.event import (
ActorContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
def make_event_envelope(
event_id: str = "evt_1",
event_type: str = "message.received",
conversation_id: str | None = "conv_1",
actor_id: str | None = "user_1",
input_text: str = "Hello",
) -> AgentEventEnvelope:
"""Create a test event envelope."""
return AgentEventEnvelope(
event_id=event_id,
event_type=event_type,
event_time=1700000000,
source="platform",
bot_id="bot_1",
workspace_id=None,
conversation_id=conversation_id,
thread_id=None,
actor=ActorContext(
actor_type="user",
actor_id=actor_id,
actor_name="Test User",
) if actor_id else None,
subject=None,
input=AgentInput(text=input_text),
delivery=DeliveryContext(surface="test"),
)
def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding:
"""Create a test binding."""
return AgentBinding(
binding_id="binding_1",
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
event_types=["message.received"],
runner_id=runner_id,
runner_config={},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(),
delivery_policy=DeliveryPolicy(),
enabled=True,
)
class TestEventLogStore:
"""Test EventLogStore operations."""
@pytest.mark.asyncio
async def test_append_event(self, mock_db_engine):
"""Test appending an event to EventLog."""
from unittest.mock import AsyncMock, MagicMock, patch
store = EventLogStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
event_id = await store.append_event(
event_id="evt_1",
event_type="message.received",
source="platform",
bot_id="bot_1",
conversation_id="conv_1",
actor_type="user",
actor_id="user_1",
input_summary="Hello world",
run_id="run_1",
runner_id="plugin:test/plugin/runner",
)
assert event_id == "evt_1"
@pytest.mark.asyncio
async def test_append_event_truncates_input_summary(self, mock_db_engine):
"""Test that long input summaries are truncated."""
from unittest.mock import AsyncMock, MagicMock, patch
store = EventLogStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
long_text = "x" * 2000
event_id = await store.append_event(
event_id="evt_2",
event_type="message.received",
source="platform",
input_summary=long_text,
)
assert event_id == "evt_2"
@pytest.mark.asyncio
async def test_page_events_with_conversation_filter(self, mock_db_engine):
"""Test paging events with conversation_id filter."""
from unittest.mock import AsyncMock, MagicMock, patch
store = EventLogStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, has_more = await store.page_events(
conversation_id="conv_1",
limit=10,
)
assert isinstance(items, list)
class TestTranscriptStore:
"""Test TranscriptStore operations."""
@pytest.mark.asyncio
async def test_append_transcript(self, mock_db_engine):
"""Test appending a transcript item."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
# Mock _get_next_seq
with patch.object(store, '_get_next_seq', return_value=1):
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id="evt_1",
conversation_id="conv_1",
role="user",
content="Hello",
)
assert transcript_id is not None
@pytest.mark.asyncio
async def test_append_transcript_with_artifacts(self, mock_db_engine):
"""Test appending transcript with artifact refs."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with patch.object(store, '_get_next_seq', return_value=1):
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id="evt_2",
conversation_id="conv_1",
role="assistant",
content="Here's an image",
artifact_refs=[
{"artifact_id": "art_1", "artifact_type": "image", "url": "http://example.com/img.png"}
],
)
assert transcript_id is not None
@pytest.mark.asyncio
async def test_page_transcript_backward(self, mock_db_engine):
"""Test paging transcript backward (older items)."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1",
limit=10,
direction="backward",
)
assert isinstance(items, list)
@pytest.mark.asyncio
async def test_page_transcript_has_hard_limit(self, mock_db_engine):
"""Test that transcript paging has a hard limit."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
# Request more than the hard limit
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1",
limit=200, # Request 200, but hard limit is 100
)
# The store should cap at 100
assert len(items) <= store.HARD_LIMIT
@pytest.mark.asyncio
async def test_search_transcript(self, mock_db_engine):
"""Test searching transcript."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
items = await store.search_transcript(
conversation_id="conv_1",
query_text="database",
top_k=10,
)
assert isinstance(items, list)
class TestHistoryPageAuthorization:
"""Test history.page authorization."""
@pytest.mark.asyncio
async def test_history_page_requires_run_id(self, mock_handler, mock_db_engine):
"""Test history.page requires run_id."""
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
# Mock call_action to simulate the handler
result = await mock_handler.call_action(
PluginToRuntimeAction.HISTORY_PAGE,
{"run_id": None},
)
# Should return error
assert result.get("ok") is False or "error" in str(result).lower()
@pytest.mark.asyncio
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
"""Test history.page only allows access to run's conversation."""
# This test verifies the authorization logic
# The actual implementation validates conversation_id matches session
session_registry = get_session_registry()
await session_registry.register(
run_id="run_1",
runner_id="plugin:test/plugin/runner",
query_id=None,
plugin_identity="test/plugin",
resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}},
conversation_id="conv_1",
)
session = await session_registry.get("run_1")
assert session is not None
assert session["authorization"]["conversation_id"] == "conv_1"
# Cleanup
await session_registry.unregister("run_1")
class TestEventGetAuthorization:
"""Test event.get authorization."""
@pytest.mark.asyncio
async def test_event_get_requires_run_id(self, mock_handler):
"""Test event.get requires run_id."""
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
result = await mock_handler.call_action(
PluginToRuntimeAction.EVENT_GET,
{"run_id": None, "event_id": "evt_1"},
)
# Should return error
assert result.get("ok") is False or "error" in str(result).lower()
class TestContextAccessPopulation:
"""Test ContextAccess population in build_context_from_event."""
@pytest.mark.asyncio
async def test_context_access_has_history_apis_when_permitted(self, mock_db_engine):
"""Test ContextAccess shows available APIs based on permissions."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
cursor = await store.get_latest_cursor("conv_1")
# Should return None or a cursor string
assert cursor is None or isinstance(cursor, str)
@pytest.mark.asyncio
async def test_context_access_shows_has_history_before(self, mock_db_engine):
"""Test ContextAccess indicates if history exists."""
from unittest.mock import AsyncMock, MagicMock, patch
store = TranscriptStore(mock_db_engine)
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
has_history = await store.has_history_before("conv_1", 10)
assert isinstance(has_history, bool)
class TestEventLogStoreRealSQLite:
"""Test EventLogStore with real SQLite database."""
@pytest.fixture
async def db_engine(self):
"""Create an in-memory SQLite database for testing."""
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.mark.asyncio
async def test_append_get_event_round_trip(self, db_engine):
"""Test append_event -> get_event round trip with real DB."""
store = EventLogStore(db_engine)
# Append event
event_id = await store.append_event(
event_id="evt_real_001",
event_type="message.received",
source="platform",
bot_id="bot_001",
conversation_id="conv_001",
actor_type="user",
actor_id="user_001",
actor_name="Test User",
input_summary="Hello world",
run_id="run_001",
runner_id="plugin:test/plugin/runner",
)
assert event_id == "evt_real_001"
# Get event
event = await store.get_event(event_id)
assert event is not None
assert event["event_id"] == "evt_real_001"
assert event["event_type"] == "message.received"
assert event["source"] == "platform"
assert event["conversation_id"] == "conv_001"
assert event["actor_type"] == "user"
assert event["actor_id"] == "user_001"
@pytest.mark.asyncio
async def test_page_events(self, db_engine):
"""Test page_events with real DB."""
store = EventLogStore(db_engine)
# Append multiple events
for i in range(5):
await store.append_event(
event_id=f"evt_real_{i:03d}",
event_type="message.received",
source="platform",
conversation_id="conv_001",
input_summary=f"Message {i}",
)
# Page events
items, next_seq, has_more = await store.page_events(
conversation_id="conv_001",
limit=3,
)
assert len(items) == 3
assert has_more is True
@pytest.mark.asyncio
async def test_get_latest_cursor(self, db_engine):
"""Test get_latest_cursor with real DB."""
store = EventLogStore(db_engine)
# Append events
for i in range(3):
await store.append_event(
event_id=f"evt_cursor_{i:03d}",
event_type="message.received",
source="platform",
conversation_id="conv_cursor",
)
# Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor")
assert cursor is not None
assert int(cursor) > 0
class TestTranscriptStoreRealSQLite:
"""Test TranscriptStore with real SQLite database."""
@pytest.fixture
async def db_engine(self):
"""Create an in-memory SQLite database for testing."""
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.mark.asyncio
async def test_append_page_transcript_round_trip(self, db_engine):
"""Test append_transcript -> page_transcript round trip with real DB."""
store = TranscriptStore(db_engine)
# Append transcript items
for i in range(3):
await store.append_transcript(
transcript_id=f"trans_real_{i:03d}",
event_id=f"evt_{i:03d}",
conversation_id="conv_001",
role="user" if i % 2 == 0 else "assistant",
content=f"Message {i}",
)
# Page transcript
items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_001",
limit=10,
)
assert len(items) == 3
assert items[0]["conversation_id"] == "conv_001"
@pytest.mark.asyncio
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
"""Transcript is the canonical source; legacy Pipeline readers get a Message view."""
store = TranscriptStore(db_engine)
await store.append_transcript(
transcript_id="trans_view_001",
event_id="evt_view_001",
conversation_id="conv_view",
role="user",
content="User text",
content_json={
"role": "user",
"content": [{"type": "text", "text": "User structured text"}],
},
)
await store.append_transcript(
transcript_id="trans_view_002",
event_id="evt_view_002",
conversation_id="conv_view",
role="tool",
item_type="tool_result",
content="ignored tool result",
)
await store.append_transcript(
transcript_id="trans_view_003",
event_id="evt_view_003",
conversation_id="conv_view",
role="assistant",
content="Assistant text",
)
messages = await store.get_legacy_provider_messages("conv_view")
assert [message.role for message in messages] == ["user", "assistant"]
assert messages[0].content[0].text == "User structured text"
assert messages[1].content == "Assistant text"
@pytest.mark.asyncio
async def test_search_transcript_real_db(self, db_engine):
"""Test search_transcript with real DB."""
store = TranscriptStore(db_engine)
# Append transcript items
await store.append_transcript(
transcript_id="trans_search_001",
event_id="evt_search_001",
conversation_id="conv_search",
role="user",
content="I want to learn about databases",
)
await store.append_transcript(
transcript_id="trans_search_002",
event_id="evt_search_002",
conversation_id="conv_search",
role="assistant",
content="Here is information about databases",
)
# Search for "database"
items = await store.search_transcript(
conversation_id="conv_search",
query_text="database",
)
# Should find at least one match
assert len(items) >= 1
@pytest.mark.asyncio
async def test_get_latest_cursor_real_db(self, db_engine):
"""Test get_latest_cursor with real DB."""
store = TranscriptStore(db_engine)
# Append transcript items
for i in range(3):
await store.append_transcript(
transcript_id=f"trans_cursor_{i:03d}",
event_id=f"evt_cursor_{i:03d}",
conversation_id="conv_cursor",
role="user",
content=f"Message {i}",
)
# Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor")
assert cursor is not None
assert int(cursor) > 0
# Fixtures
@pytest.fixture
def mock_db_engine():
"""Create a mock database engine for AsyncSession-based stores."""
from unittest.mock import MagicMock
from sqlalchemy.ext.asyncio import AsyncEngine
engine = MagicMock(spec=AsyncEngine)
return engine
@pytest.fixture
def mock_handler():
"""Create a mock handler for testing actions."""
from langbot_plugin.runtime.io.handler import Handler
class MockHandler(Handler):
def __init__(self):
self._responses = {}
async def call_action(self, action, data, timeout=30):
# Simulate error response for missing run_id
if not data.get("run_id"):
return {"ok": False, "message": "run_id is required"}
return {"ok": True, "data": {}}
return MockHandler()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
"""Tests for AgentRunner history/event pull API authorization."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from .conftest import make_resources
class FakeConnection:
pass
class FakeApplication:
def __init__(self, db_engine):
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@pytest.fixture
def session_registry(monkeypatch):
registry = AgentRunSessionRegistry()
monkeypatch.setattr(
'langbot.pkg.agent.runner.session_registry._global_registry',
registry,
)
return registry
@pytest.fixture
async def db_engine():
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
yield engine
await engine.dispose()
def _handler(db_engine, session_registry):
async def fake_disconnect():
return True
fake_app = FakeApplication(db_engine)
return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
async def _register_session(
session_registry,
*,
run_id='run_1',
conversation_id='conv_1',
permissions=None,
):
await session_registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_resources(),
conversation_id=conversation_id,
permissions=permissions or {},
)
@pytest.mark.asyncio
async def test_history_page_requires_manifest_permission(session_registry, db_engine):
await _register_session(session_registry, permissions={'history': []})
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
assert result.code != 0
assert 'not authorized' in result.message.lower()
@pytest.mark.asyncio
async def test_history_page_rejects_cross_conversation(session_registry, db_engine):
await _register_session(session_registry, permissions={'history': ['page']})
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
assert result.code != 0
assert 'not accessible' in result.message.lower()
@pytest.mark.asyncio
async def test_history_search_rejects_filter_conversation_override(session_registry, db_engine):
await _register_session(session_registry, permissions={'history': ['search']})
handler = _handler(db_engine, session_registry)
history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value]
result = await history_search({
'run_id': 'run_1',
'query': 'hello',
'filters': {'conversation_id': 'conv_other'},
'caller_plugin_identity': 'test/runner',
})
assert result.code != 0
assert 'not accessible' in result.message.lower()
@pytest.mark.asyncio
async def test_event_page_requires_manifest_permission(session_registry, db_engine):
await _register_session(session_registry, permissions={'events': []})
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
assert result.code != 0
assert 'not authorized' in result.message.lower()
@pytest.mark.asyncio
async def test_event_page_rejects_cross_conversation(session_registry, db_engine):
await _register_session(session_registry, permissions={'events': ['page']})
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
assert result.code != 0
assert 'not accessible' in result.message.lower()

View File

@@ -0,0 +1,137 @@
"""Tests for agent runner ID parsing and formatting."""
from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.id import (
parse_runner_id,
format_runner_id,
RunnerIdParts,
is_plugin_runner_id,
)
class TestRunnerIdParsing:
"""Tests for parse_runner_id."""
def test_parse_plugin_runner_id(self):
"""Parse valid plugin runner ID."""
runner_id = 'plugin:langbot/local-agent/default'
parts = parse_runner_id(runner_id)
assert parts.source == 'plugin'
assert parts.plugin_author == 'langbot'
assert parts.plugin_name == 'local-agent'
assert parts.runner_name == 'default'
def test_parse_plugin_runner_id_complex_names(self):
"""Parse plugin runner ID with complex names."""
runner_id = 'plugin:alice/helpdesk-agent/ticket-handler'
parts = parse_runner_id(runner_id)
assert parts.source == 'plugin'
assert parts.plugin_author == 'alice'
assert parts.plugin_name == 'helpdesk-agent'
assert parts.runner_name == 'ticket-handler'
def test_parse_invalid_plugin_runner_id_missing_parts(self):
"""Parse invalid plugin runner ID with missing parts."""
runner_id = 'plugin:langbot/local-agent'
with pytest.raises(ValueError) as exc_info:
parse_runner_id(runner_id)
assert 'Invalid plugin runner ID format' in str(exc_info.value)
def test_parse_invalid_plugin_runner_id_empty_parts(self):
"""Parse invalid plugin runner ID with empty parts."""
runner_id = 'plugin://default'
with pytest.raises(ValueError) as exc_info:
parse_runner_id(runner_id)
assert 'non-empty' in str(exc_info.value)
def test_parse_invalid_runner_id_not_plugin(self):
"""Parse invalid runner ID without plugin prefix."""
runner_id = 'local-agent'
with pytest.raises(ValueError) as exc_info:
parse_runner_id(runner_id)
assert 'Invalid runner ID format' in str(exc_info.value)
def test_parse_invalid_runner_id_empty_string(self):
"""Parse empty runner ID."""
runner_id = ''
with pytest.raises(ValueError):
parse_runner_id(runner_id)
class TestRunnerIdFormatting:
"""Tests for format_runner_id."""
def test_format_plugin_runner_id(self):
"""Format plugin runner ID."""
runner_id = format_runner_id(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
)
assert runner_id == 'plugin:langbot/local-agent/default'
def test_format_invalid_source(self):
"""Format runner ID with invalid source."""
with pytest.raises(ValueError) as exc_info:
format_runner_id(
source='builtin',
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
)
assert 'Invalid runner source' in str(exc_info.value)
class TestRunnerIdParts:
"""Tests for RunnerIdParts dataclass."""
def test_get_plugin_id(self):
"""Get plugin ID from parts."""
parts = RunnerIdParts(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
)
assert parts.to_plugin_id() == 'langbot/local-agent'
def test_frozen_dataclass(self):
"""RunnerIdParts should be immutable."""
parts = RunnerIdParts(
source='plugin',
plugin_author='langbot',
plugin_name='local-agent',
runner_name='default',
)
with pytest.raises(Exception): # FrozenInstanceError
parts.plugin_author = 'other'
class TestIsPluginRunnerId:
"""Tests for is_plugin_runner_id."""
def test_is_plugin_runner_id_true(self):
"""Check plugin runner ID returns True."""
assert is_plugin_runner_id('plugin:langbot/local-agent/default') is True
def test_is_plugin_runner_id_false(self):
"""Check non-plugin runner ID returns False."""
assert is_plugin_runner_id('local-agent') is False
assert is_plugin_runner_id('builtin:local-agent') is False
assert is_plugin_runner_id('') is False

View File

@@ -0,0 +1,658 @@
"""Tests for artifact.created handling in orchestrator."""
import pytest
import base64
from unittest.mock import AsyncMock, MagicMock, patch
import uuid
from langbot.pkg.agent.runner.orchestrator import (
AgentRunOrchestrator,
MAX_ARTIFACT_INLINE_BYTES,
)
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding
from langbot.pkg.agent.runner.errors import RunnerProtocolError
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot.pkg.core import app
class TestArtifactCreatedValidation:
"""Test artifact.created validation and protocol errors."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
ap = MagicMock(spec=app.Application)
ap.logger = MagicMock()
ap.plugin_connector = MagicMock()
ap.plugin_connector.is_enable_plugin = True
ap.persistence_mgr = MagicMock()
ap.persistence_mgr.get_db_engine = MagicMock()
return ap
@pytest.fixture
def mock_registry(self):
"""Create mock registry."""
registry = MagicMock()
registry.get = AsyncMock()
return registry
@pytest.fixture
def mock_event(self):
"""Create mock event envelope."""
event = MagicMock(spec=AgentEventEnvelope)
event.event_id = str(uuid.uuid4())
event.event_type = 'message.received'
event.source = 'test'
event.bot_id = str(uuid.uuid4())
event.workspace_id = str(uuid.uuid4())
event.conversation_id = str(uuid.uuid4())
event.thread_id = None
event.event_time = 1700000000
event.actor = MagicMock(spec=ActorContext)
event.actor.actor_type = 'user'
event.actor.actor_id = 'user-123'
event.actor.actor_name = 'Test User'
event.subject = None
event.input = MagicMock(spec=AgentInput)
event.input.text = 'Hello'
event.input.contents = []
event.input.attachments = []
return event
@pytest.mark.asyncio
async def test_run_id_mismatch_raises_protocol_error(
self, mock_app, mock_registry, mock_event
):
"""Test that run_id mismatch raises RunnerProtocolError."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
wrong_run_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': wrong_run_id,
'data': {
'artifact_type': 'image',
},
}
with pytest.raises(RunnerProtocolError) as exc_info:
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
assert 'run_id mismatch' in str(exc_info.value)
@pytest.mark.asyncio
async def test_missing_artifact_type_raises_protocol_error(
self, mock_app, mock_registry, mock_event
):
"""Test that missing artifact_type raises RunnerProtocolError."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_id': str(uuid.uuid4()),
# missing artifact_type
},
}
with pytest.raises(RunnerProtocolError) as exc_info:
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
assert 'missing required field' in str(exc_info.value)
@pytest.mark.asyncio
async def test_invalid_base64_raises_protocol_error(
self, mock_app, mock_registry, mock_event
):
"""Test that invalid base64 raises RunnerProtocolError."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_type': 'image',
'content_base64': '!!!invalid-base64!!!',
},
}
with pytest.raises(RunnerProtocolError) as exc_info:
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
assert 'invalid base64' in str(exc_info.value)
@pytest.mark.asyncio
async def test_oversized_content_raises_protocol_error(
self, mock_app, mock_registry, mock_event
):
"""Test that content exceeding limit raises RunnerProtocolError."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
# Create content larger than limit
oversized_content = b'x' * (MAX_ARTIFACT_INLINE_BYTES + 1)
content_base64 = base64.b64encode(oversized_content).decode('utf-8')
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_type': 'image',
'content_base64': content_base64,
},
}
with pytest.raises(RunnerProtocolError) as exc_info:
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
assert 'exceeds limit' in str(exc_info.value)
@pytest.mark.asyncio
async def test_artifact_store_failure_raises_protocol_error(
self, mock_app, mock_registry, mock_event
):
"""Test that ArtifactStore failure raises RunnerProtocolError."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_type': 'image',
},
}
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
mock_artifact_store = MagicMock()
mock_artifact_store.register_artifact = AsyncMock(
side_effect=Exception('DB connection failed')
)
MockArtifactStore.return_value = mock_artifact_store
with pytest.raises(RunnerProtocolError) as exc_info:
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
assert 'failed to register artifact' in str(exc_info.value)
class TestArtifactCreatedSuccess:
"""Test successful artifact.created handling."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
ap = MagicMock(spec=app.Application)
ap.logger = MagicMock()
ap.plugin_connector = MagicMock()
ap.plugin_connector.is_enable_plugin = True
ap.persistence_mgr = MagicMock()
ap.persistence_mgr.get_db_engine = MagicMock()
return ap
@pytest.fixture
def mock_registry(self):
"""Create mock registry."""
registry = MagicMock()
registry.get = AsyncMock()
return registry
@pytest.fixture
def mock_event(self):
"""Create mock event envelope."""
event = MagicMock(spec=AgentEventEnvelope)
event.event_id = str(uuid.uuid4())
event.event_type = 'message.received'
event.source = 'test'
event.bot_id = str(uuid.uuid4())
event.workspace_id = str(uuid.uuid4())
event.conversation_id = str(uuid.uuid4())
event.thread_id = None
event.event_time = 1700000000
event.actor = MagicMock(spec=ActorContext)
event.actor.actor_type = 'user'
event.actor.actor_id = 'user-123'
event.actor.actor_name = 'Test User'
event.subject = None
return event
@pytest.mark.asyncio
async def test_handle_artifact_created_registers_artifact(
self, mock_app, mock_registry, mock_event
):
"""Test that artifact.created registers artifact via ArtifactStore."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
runner_id = 'test-runner'
# Create artifact.created result
content = b'test artifact content'
content_base64 = base64.b64encode(content).decode('utf-8')
artifact_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_id': artifact_id,
'artifact_type': 'image',
'mime_type': 'image/png',
'name': 'test.png',
'size_bytes': len(content),
'content_base64': content_base64,
},
}
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
mock_artifact_store = MagicMock()
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
MockArtifactStore.return_value = mock_artifact_store
mock_event_log_store = MagicMock()
mock_event_log_store.append_event = AsyncMock()
MockEventLogStore.return_value = mock_event_log_store
# Call _handle_artifact_created
result = await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id=runner_id,
)
# Verify artifact was registered
mock_artifact_store.register_artifact.assert_called_once()
call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs
assert call_kwargs['artifact_id'] == artifact_id
assert call_kwargs['artifact_type'] == 'image'
assert call_kwargs['mime_type'] == 'image/png'
assert call_kwargs['name'] == 'test.png'
assert call_kwargs['content'] == content
assert call_kwargs['conversation_id'] == mock_event.conversation_id
assert call_kwargs['run_id'] == run_id
assert call_kwargs['runner_id'] == runner_id
# Verify EventLog was written
mock_event_log_store.append_event.assert_called_once()
event_kwargs = mock_event_log_store.append_event.call_args.kwargs
assert event_kwargs['event_type'] == 'artifact.created'
assert event_kwargs['run_id'] == run_id
# Verify artifact ref returned
assert result is not None
assert result['artifact_id'] == artifact_id
assert result['artifact_type'] == 'image'
@pytest.mark.asyncio
async def test_handle_artifact_created_metadata_only(
self, mock_app, mock_registry, mock_event
):
"""Test artifact.created without content (metadata-only)."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
artifact_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_id': artifact_id,
'artifact_type': 'file',
'mime_type': 'application/pdf',
'name': 'document.pdf',
'size_bytes': 1024,
'sha256': 'abc123',
'metadata': {'source': 'external'},
},
}
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
mock_artifact_store = MagicMock()
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
MockArtifactStore.return_value = mock_artifact_store
mock_event_log_store = MagicMock()
mock_event_log_store.append_event = AsyncMock()
MockEventLogStore.return_value = mock_event_log_store
result = await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
# Verify artifact was registered without content
call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs
assert call_kwargs['content'] is None
assert call_kwargs['sha256'] == 'abc123'
assert call_kwargs['metadata'] == {'source': 'external'}
assert result is not None
assert result['artifact_id'] == artifact_id
class TestArtifactRefsLifecycle:
"""Test artifact refs lifecycle in event-first flow."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
ap = MagicMock(spec=app.Application)
ap.logger = MagicMock()
ap.plugin_connector = MagicMock()
ap.plugin_connector.is_enable_plugin = True
ap.persistence_mgr = MagicMock()
ap.persistence_mgr.get_db_engine = MagicMock()
return ap
@pytest.fixture
def mock_registry(self):
"""Create mock registry."""
registry = MagicMock()
registry.get = AsyncMock()
return registry
def test_merge_artifact_refs_deduplicates(
self, mock_app, mock_registry
):
"""Test that _merge_artifact_refs deduplicates by artifact_id."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
pending_refs = [
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
{'artifact_id': 'artifact-2', 'artifact_type': 'file'},
]
result_dict = {
'type': 'message.completed',
'data': {
'message': {
'content': 'Hello',
'artifact_refs': [
{'artifact_id': 'artifact-2', 'artifact_type': 'file'}, # duplicate
{'artifact_id': 'artifact-3', 'artifact_type': 'voice'},
],
},
},
}
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
# Should have 3 unique artifacts
assert len(merged) == 3
artifact_ids = {ref['artifact_id'] for ref in merged}
assert artifact_ids == {'artifact-1', 'artifact-2', 'artifact-3'}
def test_merge_artifact_refs_empty_pending(
self, mock_app, mock_registry
):
"""Test merge with empty pending refs."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
pending_refs = []
result_dict = {
'type': 'message.completed',
'data': {
'message': {
'content': 'Hello',
'artifact_refs': [
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
],
},
},
}
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
assert len(merged) == 1
assert merged[0]['artifact_id'] == 'artifact-1'
def test_merge_artifact_refs_empty_message_refs(
self, mock_app, mock_registry
):
"""Test merge with no message artifact_refs."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
pending_refs = [
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
]
result_dict = {
'type': 'message.completed',
'data': {
'message': {
'content': 'Hello',
# no artifact_refs
},
},
}
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
assert len(merged) == 1
assert merged[0]['artifact_id'] == 'artifact-1'
class TestResultNormalizerArtifactCreated:
"""Test ResultNormalizer handling of artifact.created."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
ap = MagicMock(spec=app.Application)
ap.logger = MagicMock()
return ap
@pytest.fixture
def mock_descriptor(self):
"""Create mock descriptor."""
descriptor = MagicMock()
descriptor.id = 'test-runner'
return descriptor
@pytest.mark.asyncio
async def test_normalize_artifact_created_returns_none(
self, mock_app, mock_descriptor
):
"""Test that artifact.created is consumed (returns None)."""
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
normalizer = AgentResultNormalizer(mock_app)
result_dict = {
'type': 'artifact.created',
'run_id': 'test-run-id',
'data': {
'artifact_id': 'artifact-123',
'artifact_type': 'image',
},
}
result = await normalizer.normalize(result_dict, mock_descriptor)
# Should return None (consumed)
assert result is None
# Debug log should be written
mock_app.logger.debug.assert_called()
@pytest.mark.asyncio
async def test_normalize_unknown_type_warning(
self, mock_app, mock_descriptor
):
"""Test that unknown result types still produce warnings."""
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
normalizer = AgentResultNormalizer(mock_app)
result_dict = {
'type': 'unknown.type',
'data': {},
}
result = await normalizer.normalize(result_dict, mock_descriptor)
# Should return None
assert result is None
# Warning should be logged
mock_app.logger.warning.assert_called()
class TestEventLogTranscriptIntegration:
"""Test EventLog and Transcript integration with artifact.created."""
@pytest.fixture
def mock_app(self):
"""Create mock application."""
ap = MagicMock(spec=app.Application)
ap.logger = MagicMock()
ap.plugin_connector = MagicMock()
ap.plugin_connector.is_enable_plugin = True
ap.persistence_mgr = MagicMock()
ap.persistence_mgr.get_db_engine = MagicMock()
return ap
@pytest.fixture
def mock_registry(self):
"""Create mock registry."""
registry = MagicMock()
registry.get = AsyncMock()
return registry
@pytest.fixture
def mock_event(self):
"""Create mock event envelope."""
event = MagicMock(spec=AgentEventEnvelope)
event.event_id = str(uuid.uuid4())
event.event_type = 'message.received'
event.source = 'test'
event.bot_id = str(uuid.uuid4())
event.workspace_id = str(uuid.uuid4())
event.conversation_id = str(uuid.uuid4())
event.thread_id = None
event.event_time = 1700000000
event.actor = MagicMock(spec=ActorContext)
event.actor.actor_type = 'user'
event.actor.actor_id = 'user-123'
event.actor.actor_name = 'Test User'
event.subject = None
return event
@pytest.mark.asyncio
async def test_event_log_written_with_correct_event_type(
self, mock_app, mock_registry, mock_event
):
"""Test that EventLog is written with event_type='artifact.created'."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
artifact_id = str(uuid.uuid4())
result_dict = {
'type': 'artifact.created',
'run_id': run_id,
'data': {
'artifact_id': artifact_id,
'artifact_type': 'image',
},
}
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
mock_artifact_store = MagicMock()
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
MockArtifactStore.return_value = mock_artifact_store
mock_event_log_store = MagicMock()
mock_event_log_store.append_event = AsyncMock()
MockEventLogStore.return_value = mock_event_log_store
await orchestrator._handle_artifact_created(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
)
# Verify EventLog.append_event was called with correct event_type
mock_event_log_store.append_event.assert_called_once()
call_kwargs = mock_event_log_store.append_event.call_args.kwargs
assert call_kwargs['event_type'] == 'artifact.created'
assert call_kwargs['source'] == 'runner'
assert call_kwargs['conversation_id'] == mock_event.conversation_id
assert call_kwargs['run_id'] == run_id
@pytest.mark.asyncio
async def test_assistant_transcript_receives_artifact_refs(
self, mock_app, mock_registry, mock_event
):
"""Test that assistant transcript receives artifact refs from artifact.created."""
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
run_id = str(uuid.uuid4())
artifact_id = str(uuid.uuid4())
# Create pending artifact refs
pending_refs = [
{'artifact_id': artifact_id, 'artifact_type': 'image', 'mime_type': 'image/png'},
]
result_dict = {
'type': 'message.completed',
'data': {
'message': {
'content': 'Here is your image',
},
},
}
with patch('langbot.pkg.agent.runner.transcript_store.TranscriptStore') as MockTranscriptStore:
mock_transcript_store = MagicMock()
mock_transcript_store.append_transcript = AsyncMock()
MockTranscriptStore.return_value = mock_transcript_store
await orchestrator._write_assistant_transcript(
result_dict=result_dict,
event=mock_event,
run_id=run_id,
runner_id='test-runner',
artifact_refs=pending_refs,
)
# Verify transcript was written with artifact_refs
mock_transcript_store.append_transcript.assert_called_once()
call_kwargs = mock_transcript_store.append_transcript.call_args.kwargs
assert call_kwargs['artifact_refs'] == pending_refs

View File

@@ -0,0 +1,868 @@
"""Integration-style tests for AgentRunOrchestrator with a fake plugin runner."""
from __future__ import annotations
import asyncio
import datetime
import types
from unittest.mock import AsyncMock
import pytest
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.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
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
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot_plugin.api.entities.builtin.resource import tool as resource_tool
RUNNER_ID = "plugin:langbot/local-agent/default"
class FakeLogger:
def debug(self, msg):
pass
def info(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
pass
class FakeVersionManager:
def get_current_version(self):
return "test-version"
class FakeModel:
def __init__(self, model_type: str = "chat"):
self.model_entity = types.SimpleNamespace(model_type=model_type)
self.provider_entity = types.SimpleNamespace(name="fake-provider")
class FakeKnowledgeBase:
def __init__(self, kb_id: str):
self.kb_id = kb_id
self.knowledge_base_entity = types.SimpleNamespace(kb_type="fake")
def get_name(self):
return f"KB {self.kb_id}"
class FakePluginConnector:
is_enable_plugin = True
def __init__(self, results=None, error: Exception | None = None, delay: float = 0):
self.results = results or []
self.error = error
self.delay = delay
self.calls: list[dict] = []
self.contexts: list[dict] = []
self.sessions_during_run: list[dict | None] = []
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
"plugin_author": plugin_author,
"plugin_name": plugin_name,
"runner_name": runner_name,
}
)
self.contexts.append(context)
self.sessions_during_run.append(await get_session_registry().get(context["run_id"]))
if self.error:
raise self.error
for result in self.results:
if self.delay:
await asyncio.sleep(self.delay)
yield result
class FakeRegistry:
def __init__(self, descriptor: AgentRunnerDescriptor):
self.descriptor = descriptor
self.calls: list[dict] = []
async def get(self, runner_id, bound_plugins=None):
self.calls.append({"runner_id": runner_id, "bound_plugins": bound_plugins})
assert runner_id == self.descriptor.id
return self.descriptor
class FakePersistenceManager:
def __init__(self, db_engine: AsyncEngine):
self._db_engine = db_engine
def get_db_engine(self):
return self._db_engine
class FakeApplication:
def __init__(self, plugin_connector: FakePluginConnector, db_engine: AsyncEngine):
self.logger = FakeLogger()
self.ver_mgr = FakeVersionManager()
self.plugin_connector = plugin_connector
self.persistence_mgr = FakePersistenceManager(db_engine)
self.model_mgr = types.SimpleNamespace(
get_model_by_uuid=AsyncMock(return_value=FakeModel())
)
self.rag_mgr = types.SimpleNamespace(
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase("kb_001"))
)
class FakeConversation:
uuid = "conv_existing"
create_time = datetime.datetime(2026, 5, 15, 12, 0, 0)
def make_descriptor() -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
id=RUNNER_ID,
source="plugin",
label={"en_US": "Local Agent"},
plugin_author="langbot",
plugin_name="local-agent",
runner_name="default",
protocol_version="1",
capabilities={"streaming": True, "tool_calling": True, "knowledge_retrieval": True},
config_schema=[
{"name": "model", "type": "model-fallback-selector"},
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
],
permissions={
"models": ["invoke", "stream"],
"tools": ["list", "detail", "call"],
"knowledge_bases": ["list", "retrieve"],
"storage": ["plugin"],
"files": [],
},
)
def make_query():
async def fake_func(**kwargs):
return kwargs
message_chain = platform_message.MessageChain(
[
platform_message.Source(
id="msg_001",
time=datetime.datetime(2026, 5, 15, 12, 0, 0),
),
platform_message.Plain(text="hello"),
platform_message.File(name="spec.txt", url="https://example.com/spec.txt"),
]
)
sender = platform_entities.Friend(id="user_001", nickname="Alice", remark=None)
message_event = platform_events.FriendMessage(sender=sender, message_chain=message_chain, time=1_784_098_800.0)
session = types.SimpleNamespace(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id="user_001",
sender_id="user_001",
using_conversation=FakeConversation(),
)
return types.SimpleNamespace(
query_id=1001,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id="user_001",
sender_id="user_001",
message_event=message_event,
message_chain=message_chain,
bot_uuid="bot_001",
pipeline_uuid="pipeline_001",
pipeline_config={
"ai": {
"runner": {"id": RUNNER_ID},
"runner_config": {
RUNNER_ID: {
"model": {"primary": "model_primary", "fallbacks": ["model_fallback"]},
"knowledge-bases": ["kb_001"],
"timeout": 30,
},
},
},
},
session=session,
messages=[],
user_message=provider_message.Message(
role="user",
content=[
provider_message.ContentElement.from_text("hello"),
provider_message.ContentElement.from_file_url("https://example.com/spec.txt", "spec.txt"),
],
),
variables={
"_pipeline_bound_plugins": ["langbot/local-agent"],
"_fallback_model_uuids": ["model_fallback"],
"public_param": "visible",
},
use_llm_model_uuid="model_primary",
use_funcs=[
resource_tool.LLMTool(
name="langbot/test-tool/search",
human_desc="Search",
description="Search test data",
parameters={"type": "object", "properties": {"q": {"type": "string"}}},
func=fake_func,
)
],
)
def test_context_builder_includes_consumable_base64_attachments():
query = make_query()
query.user_message = provider_message.Message(
role="user",
content=[
provider_message.ContentElement.from_text("see attached"),
provider_message.ContentElement.from_image_base64("data:image/png;base64,aGVsbG8="),
provider_message.ContentElement.from_file_base64("data:text/plain;base64,aGVsbG8=", "hello.txt"),
],
)
query.message_chain = platform_message.MessageChain(
[platform_message.Image(base64="data:image/jpeg;base64,aGVsbG8=")]
)
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="
assert input_data.contents[2].file_base64 == "data:text/plain;base64,aGVsbG8="
artifact_types = [attachment.artifact_type for attachment in input_data.attachments]
assert artifact_types == ["image", "file", "image"]
assert input_data.attachments[1].name == "hello.txt"
@pytest.fixture(autouse=True)
async def clean_agent_state():
"""Reset all singleton stores and create a test database engine."""
from langbot.pkg.entity.persistence.base import Base
reset_persistent_state_store()
registry = get_session_registry()
for session in await registry.list_active_runs():
await registry.unregister(session["run_id"])
# Create in-memory SQLite engine for tests
test_engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield test_engine
# Cleanup
for session in await registry.list_active_runs():
await registry.unregister(session["run_id"])
reset_persistent_state_store()
await test_engine.dispose()
@pytest.mark.asyncio
async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent_state):
"""Test that orchestrator properly builds and passes authorized context to runner."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "fake response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert messages[0].content == "fake response"
assert plugin_connector.calls == [
{
"plugin_author": "langbot",
"plugin_name": "local-agent",
"runner_name": "default",
}
]
context = plugin_connector.contexts[0]
assert context["config"]["timeout"] == 30
assert context["runtime"]["deadline_at"] is not None
# Protocol v1: params is in adapter.extra
assert context["adapter"]["extra"]["params"] == {"public_param": "visible"}
assert context["event"]["event_type"] == "message.received"
# Note: source_event_type is in event.source_event_type, not event.data
# (event.data contains the raw event payload, not metadata)
assert context["actor"]["actor_id"] == "user_001"
assert context["actor"]["actor_name"] == "Alice"
assert context["subject"]["subject_id"] == "msg_001"
assert context["input"]["attachments"]
resources = context["resources"]
assert {m["model_id"] for m in resources["models"]} == {"model_primary", "model_fallback"}
assert resources["tools"][0]["tool_name"] == "langbot/test-tool/search"
assert resources["knowledge_bases"][0]["kb_id"] == "kb_001"
assert resources["storage"]["plugin_storage"] is True
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run["plugin_identity"] == "langbot/local-agent"
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
assert await get_session_registry().get(context["run_id"]) is None
@pytest.mark.asyncio
async def test_orchestrator_does_not_package_query_messages_into_context(clean_agent_state):
"""Host should not build an agent working-context window from query.messages."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "fake response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.pipeline_config["ai"]["runner_config"][RUNNER_ID]["custom-option"] = 2
query.messages = [
provider_message.Message(role="user", content="message 1"),
provider_message.Message(role="assistant", content="response 1"),
provider_message.Message(role="user", content="message 2"),
provider_message.Message(role="assistant", content="response 2"),
provider_message.Message(role="user", content="message 3"),
provider_message.Message(role="assistant", content="response 3"),
]
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
context = plugin_connector.contexts[0]
assert context["config"]["custom-option"] == 2
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",
"response 1",
"message 2",
"response 2",
"message 3",
"response 3",
]
@pytest.mark.asyncio
async def test_orchestrator_streams_fake_plugin_deltas(clean_agent_state):
"""Test that orchestrator properly streams message chunks."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "hel"}}},
{"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "hello"}}},
{"type": "run.completed", "data": {"finish_reason": "stop"}},
]
)
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor))
chunks = [message async for message in orchestrator.run_from_query(make_query())]
assert [chunk.content for chunk in chunks] == ["hel", "hello"]
@pytest.mark.asyncio
async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(clean_agent_state):
"""Test that state.updated events are applied and not yielded to pipeline."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "state.updated",
"data": {
"scope": "conversation",
"key": "external.conversation_id",
"value": "external_conv_123",
},
},
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "state saved"}},
},
]
)
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert [message.content for message in messages] == ["state saved"]
# State is persisted to the database via PersistentStateStore.
@pytest.mark.asyncio
async def test_orchestrator_unregisters_session_after_runner_failure(clean_agent_state):
"""Test that session is unregistered even when runner fails."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "run.failed",
"data": {"error": "boom", "code": "fake.error", "retryable": False},
}
]
)
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor))
with pytest.raises(RunnerExecutionError):
[message async for message in orchestrator.run_from_query(make_query())]
context = plugin_connector.contexts[0]
assert plugin_connector.sessions_during_run[0] is not None
assert await get_session_registry().get(context["run_id"]) is None
@pytest.mark.asyncio
async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
"""Test that orchestrator enforces total runner timeout."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "too late"}},
}
],
delay=0.05,
)
orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor))
query = make_query()
query.pipeline_config["ai"]["runner_config"][RUNNER_ID]["timeout"] = 0.01
with pytest.raises(RunnerExecutionError) as exc_info:
[message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True
assert "runner.timeout" in str(exc_info.value)
assert await get_session_registry().list_active_runs() == []
class TestQueryEntrySessionQueryId:
"""Tests for internal query_id entering session registry."""
@pytest.mark.asyncio
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(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
# Verify session during run had query_id
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run["query_id"] == query.query_id
@pytest.mark.asyncio
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
"""Pure event-first flow has query_id=None in session."""
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy, DeliveryPolicy, ResourcePolicy
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
# Create event and binding directly (not from Query)
event = AgentEventEnvelope(
event_id="evt_001",
event_type="message.received",
event_time=1234567890,
source="test",
bot_id="bot_001",
workspace_id=None,
conversation_id="conv_001",
thread_id=None,
actor=None,
subject=None,
input=AgentInput(text="hello", contents=[], attachments=[]),
delivery=DeliveryContext(surface="test", supports_streaming=True),
)
binding = AgentBinding(
binding_id="binding_001",
scope=BindingScope(scope_type="agent", scope_id="pipeline_001"),
event_types=["message.received"],
runner_id=RUNNER_ID,
runner_config={},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
enabled=True,
)
messages = [message async for message in orchestrator.run(event, binding)]
assert len(messages) == 1
# Verify session during run has query_id=None
session_during_run = plugin_connector.sessions_during_run[0]
assert session_during_run is not None
assert session_during_run["query_id"] is None
class TestQueryEntryAdapterParams:
"""Tests for params handling in Query entry adapter."""
@pytest.mark.asyncio
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
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
# Add prompt to query
query.prompt = provider_prompt.Prompt(
name="test_prompt",
messages=[
provider_message.Message(role="system", content="You are a helpful assistant."),
],
)
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
assert "prompt" not in context
assert "prompt" not in context["adapter"]["extra"]
assert context["context"]["available_apis"]["prompt_get"] is True
@pytest.mark.asyncio
async def test_params_filtering_keeps_public_param(self, clean_agent_state):
"""Public params are kept."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables = {
"public_param": "visible",
"another_param": 123,
}
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
assert context["adapter"]["extra"]["params"] == {
"public_param": "visible",
"another_param": 123,
}
@pytest.mark.asyncio
async def test_params_filtering_removes_internal_vars(self, clean_agent_state):
"""Internal variables (starting with _) are filtered."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables = {
"public_param": "visible",
"_internal_var": "should_be_filtered",
"_pipeline_bound_plugins": ["plugin1"],
}
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "_internal_var" not in params
assert "_pipeline_bound_plugins" not in params
@pytest.mark.asyncio
async def test_params_filtering_removes_sensitive_patterns(self, clean_agent_state):
"""Sensitive naming patterns are filtered."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables = {
"public_param": "visible",
"api_token": "secret123",
"secret_key": "secret456",
"password": "secret789",
"credential": "secret000",
}
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "api_token" not in params
assert "secret_key" not in params
assert "password" not in params
assert "credential" not in params
@pytest.mark.asyncio
async def test_params_filtering_removes_non_json_serializable(self, clean_agent_state):
"""Non-JSON-serializable values are filtered."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "response"}},
}
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
query.variables = {
"public_param": "visible",
"a_set": {1, 2, 3}, # set is not JSON-serializable
"a_lambda": lambda x: x, # function is not JSON-serializable
}
_messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "a_set" not in params
assert "a_lambda" not in params
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):
"""state.updated via Pipeline path writes to PersistentStateStore."""
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "state.updated",
"data": {
"scope": "conversation",
"key": "external.test_key",
"value": "test_value",
},
},
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "state saved"}},
},
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert messages[0].content == "state saved"
# Verify state was written to PersistentStateStore
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.query_entry_adapter import QueryEntryAdapter
event = QueryEntryAdapter.query_to_event(query)
agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
binding = AgentBindingResolver().resolve_one(event, [agent_config])
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot["conversation"]["external.test_key"] == "test_value"
@pytest.mark.asyncio
async def test_event_log_and_transcript_written(self, clean_agent_state):
"""EventLog and Transcript are written via Pipeline path."""
from langbot.pkg.agent.runner.event_log_store import EventLogStore
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
results=[
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "assistant response"}},
},
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
# Check EventLog has incoming event
event_log_store = EventLogStore(db_engine)
event_logs, _, _ = await event_log_store.page_events(
conversation_id=query.session.using_conversation.uuid,
limit=10,
)
assert len(event_logs) >= 1
# First event should be the incoming message.received
assert event_logs[0]["event_type"] == "message.received"
# Check Transcript has user and assistant messages
transcript_store = TranscriptStore(db_engine)
transcripts, _, _, _ = await transcript_store.page_transcript(
conversation_id=query.session.using_conversation.uuid,
limit=10,
)
assert len(transcripts) >= 2
# Find user and assistant messages
roles = [t["role"] for t in transcripts]
assert "user" in roles
assert "assistant" in roles
@pytest.mark.asyncio
async def test_artifact_created_via_event_first_path(self, clean_agent_state):
"""artifact.created via Pipeline path uses event-first ArtifactStore and EventLog."""
import base64
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
from langbot.pkg.agent.runner.event_log_store import EventLogStore
db_engine = clean_agent_state
descriptor = make_descriptor()
artifact_id = "artifact_001"
content = b"test artifact content"
content_base64 = base64.b64encode(content).decode('utf-8')
plugin_connector = FakePluginConnector(
results=[
{
"type": "artifact.created",
"data": {
"artifact_id": artifact_id,
"artifact_type": "file",
"mime_type": "text/plain",
"name": "test.txt",
"content_base64": content_base64,
},
},
{
"type": "message.completed",
"data": {"message": {"role": "assistant", "content": "artifact created"}},
},
]
)
ap = FakeApplication(plugin_connector, db_engine)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert messages[0].content == "artifact created"
# Verify artifact was registered in ArtifactStore
artifact_store = ArtifactStore(db_engine)
artifact = await artifact_store.get_metadata(artifact_id)
assert artifact is not None
assert artifact["artifact_type"] == "file"
assert artifact["name"] == "test.txt"
# Verify artifact.created event was written to EventLog
event_log_store = EventLogStore(db_engine)
event_logs, _, _ = await event_log_store.page_events(
conversation_id=query.session.using_conversation.uuid,
limit=10,
)
artifact_events = [e for e in event_logs if e["event_type"] == "artifact.created"]
assert len(artifact_events) >= 1

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