mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 13:26:03 +00:00
Compare commits
49 Commits
v4.10.0-be
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86f12c3f2 | ||
|
|
121a736e6a | ||
|
|
36292102f9 | ||
|
|
7053acfb1b | ||
|
|
08c51118c5 | ||
|
|
a850127893 | ||
|
|
4d0a2b117a | ||
|
|
8116acf462 | ||
|
|
4d4ccfabd5 | ||
|
|
93cd852061 | ||
|
|
bbe7666642 | ||
|
|
99afe8e999 | ||
|
|
93febbb342 | ||
|
|
6afe8b67f8 | ||
|
|
2d83c9928a | ||
|
|
13ff4fd9c1 | ||
|
|
ab96070036 | ||
|
|
cc911cc413 | ||
|
|
32e42f04ea | ||
|
|
24993ac700 | ||
|
|
cefd99e416 | ||
|
|
8aba467a42 | ||
|
|
201c805802 | ||
|
|
0a3bafae4b | ||
|
|
9086f77cc5 | ||
|
|
275a6d44d6 | ||
|
|
0ae6204fd8 | ||
|
|
0cb6d2187d | ||
|
|
472da29a38 | ||
|
|
363e3312f6 | ||
|
|
9d1f4c7598 | ||
|
|
7c7e517ac6 | ||
|
|
f2ff796800 | ||
|
|
b5f08eb1d4 | ||
|
|
88ed3851e2 | ||
|
|
548ea56f72 | ||
|
|
03cbe8043d | ||
|
|
cc3e9090b2 | ||
|
|
7661075294 | ||
|
|
c2037f9833 | ||
|
|
b1cde952e4 | ||
|
|
ef113bb7a7 | ||
|
|
e81a1af36c | ||
|
|
2fd126b0d7 | ||
|
|
47b1890a4b | ||
|
|
3812bd97eb | ||
|
|
98bbdf268f | ||
|
|
7c1ff5b841 | ||
|
|
5d3dfccae1 |
153
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
153
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Agent-owned Context 协议设计
|
||||
|
||||
本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 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 ref;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;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 result:message/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 handles;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 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)。
|
||||
252
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal file
252
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal 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 runner(Claude 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 output(Claude 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 已完成。
|
||||
92
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal file
92
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal 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 authorization;delivery 和 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 message;AgentRunner 根据 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。
|
||||
51
docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md
Normal file
51
docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md
Normal 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 API;runner 不直接写 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 API;storage 作为授权能力保留。 | 本分支持续维护底座;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/audit;runner 可选择 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 串行 turn,reader 独占 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 外化边界。
|
||||
264
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
264
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal 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 Gateway(Future Integration Point)
|
||||
|
||||
> EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。
|
||||
|
||||
Event Gateway 将把入口统一成 host event(IM 平台消息、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 source(AI 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` 进入 runner;runner 不应识别或硬编码执行环境 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 package,runner 通过 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 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume。
|
||||
|
||||
投影的具体形态(context 文件、skill 目录、MCP config、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;Claude 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 policy:PROTOCOL_V1 §4。
|
||||
- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
|
||||
- `AgentRunResult`:PROTOCOL_V1 §7。
|
||||
- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。
|
||||
151
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
151
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal 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 §13;CLI / 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 不支持并发 turn,runner 应按稳定 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 action;MCP tool schema 由注解和 Pydantic args model 生成。
|
||||
- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge;run 结束后 bridge 关闭。
|
||||
|
||||
第一批工具保持很小:当前事件快照、history page、knowledge retrieve、authorized tool call。新增工具必须先进入 SDK-owned annotated surface,再由 MCP adapter 自动投影。
|
||||
|
||||
## 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 的用户可见核心能力;代码结构和运行路径不需要相同。
|
||||
163
docs/agent-runner-pluginization/PROGRESS.md
Normal file
163
docs/agent-runner-pluginization/PROGRESS.md
Normal 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 CLI;context / 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 smoke;Claude 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 后续门槛
|
||||
669
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
669
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal 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 runner(Claude 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 context,Host 不应默认 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 可以是 system,subject 是 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` 告诉 runner:Host 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` 放小型 JSON(conversation / 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` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。
|
||||
- Host 可以取消 active run;Runtime 应尽力中断 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 runner,Host 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计;runner plugin 把授权后的 context/resource projection 适配为目标 harness 的形式;harness 的 native permission mode、allowed/disallowed tools 只是额外执行约束,不能替代 Host 授权。
|
||||
|
||||
> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** 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 复用。
|
||||
- 如果配置层出现多个匹配 AgentBinding,BindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 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 snapshot;runtime 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。
|
||||
153
docs/agent-runner-pluginization/README.md
Normal file
153
docs/agent-runner-pluginization/README.md
Normal 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 Host:Host 负责事件、绑定、授权、事实源和结果投递;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 绑定 Pipeline,Pipeline 携带 agent/provider/RAG/tool 等配置;后续应改为 bot 或 IM channel 绑定一个 Agent,Agent 携带 runner id、runner config、resource/state/delivery policy 等 agent 配置。
|
||||
|
||||
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;README 不复写这些约束。
|
||||
|
||||
## 当前入口关系
|
||||
|
||||
**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。**
|
||||
|
||||
主入口仍可由 Pipeline 触发,但内部已转换成 event-first path:`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Pipeline path 因此获得了 event-first host capabilities(EventLog / Transcript / ArtifactStore / PersistentStateStore 写入,History / Event / Artifact / State pull API 可用)。
|
||||
|
||||
详细实现进度、已验收能力和未完成收尾见 [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。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。
|
||||
|
||||
Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 按需拉取历史并自行管理 working context。
|
||||
|
||||
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
||||
|
||||
### 3. Event Based Agent(Future)
|
||||
|
||||
消息只是事件的一种。后续 `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 v2(Future)
|
||||
|
||||
当前 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)。
|
||||
228
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
228
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal 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 registry:runtime id、所属 workspace、所在机器、provider 能力、状态。
|
||||
- capability discovery:`claude` / `codex` / 其它 CLI 是否存在、版本、登录状态、执行隔离能力。
|
||||
- heartbeat / liveness:runtime 在线、忙闲、最后心跳、可用 slot。
|
||||
- task queue:enqueue、claim、start、progress、complete、fail、cancel。
|
||||
- workspace mapping:LangBot workspace / project 如何映射到 runtime 上的真实目录、仓库或挂载。
|
||||
- secret / env projection:按授权向 runtime 投影 token、代理、MCP 配置、技能和环境变量。
|
||||
- runtime audit:stdout、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,或未挂载认证文件 / workspace,CLI 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 是核心前端的一部分,还是完全由管理插件提供。
|
||||
74
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
74
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal 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 runner(Claude 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 isolation:workspace allowlist、路径规范化、防止 `..` 逃逸、context / artifact 清理。
|
||||
- Permission boundary:runner 能力声明、binding 级资源授权、run 级权限校验。
|
||||
- Secret handling:环境变量白名单、配置脱敏、日志和 transcript redaction。
|
||||
- MCP policy:MCP server allowlist、scoped token、tool allow / deny、危险工具审计。
|
||||
- Skill projection policy:skill 来源验证、只读投影、版本和摘要记录。
|
||||
- Process isolation:进程组管理、取消、超时、CPU / 内存 / 输出配额。
|
||||
- State lifecycle:session 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 的完整实现。
|
||||
@@ -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"
|
||||
|
||||
37
src/langbot/pkg/agent/__init__.py
Normal file
37
src/langbot/pkg/agent/__init__.py
Normal 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',
|
||||
]
|
||||
61
src/langbot/pkg/agent/runner/__init__.py
Normal file
61
src/langbot/pkg/agent/runner/__init__.py
Normal 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',
|
||||
]
|
||||
430
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
430
src/langbot/pkg/agent/runner/artifact_store.py
Normal 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),
|
||||
}
|
||||
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal 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,
|
||||
)
|
||||
95
src/langbot/pkg/agent/runner/config_migration.py
Normal file
95
src/langbot/pkg/agent/runner/config_migration.py
Normal 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
|
||||
222
src/langbot/pkg/agent/runner/config_schema.py
Normal file
222
src/langbot/pkg/agent/runner/config_schema.py
Normal 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
|
||||
420
src/langbot/pkg/agent/runner/context_builder.py
Normal file
420
src/langbot/pkg/agent/runner/context_builder.py
Normal 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,
|
||||
},
|
||||
}
|
||||
72
src/langbot/pkg/agent/runner/descriptor.py
Normal file
72
src/langbot/pkg/agent/runner/descriptor.py
Normal 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)
|
||||
37
src/langbot/pkg/agent/runner/errors.py
Normal file
37
src/langbot/pkg/agent/runner/errors.py
Normal 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}')
|
||||
255
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
255
src/langbot/pkg/agent/runner/event_log_store.py
Normal 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 {},
|
||||
}
|
||||
25
src/langbot/pkg/agent/runner/events.py
Normal file
25
src/langbot/pkg/agent/runner/events.py
Normal 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,
|
||||
}
|
||||
)
|
||||
207
src/langbot/pkg/agent/runner/host_models.py
Normal file
207
src/langbot/pkg/agent/runner/host_models.py
Normal 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."""
|
||||
91
src/langbot/pkg/agent/runner/id.py
Normal file
91
src/langbot/pkg/agent/runner/id.py
Normal 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:')
|
||||
888
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
888
src/langbot/pkg/agent/runner/orchestrator.py
Normal 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,
|
||||
},
|
||||
)
|
||||
431
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
431
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal 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
|
||||
585
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal file
585
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal 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
|
||||
293
src/langbot/pkg/agent/runner/registry.py
Normal file
293
src/langbot/pkg/agent/runner/registry.py
Normal 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
|
||||
268
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
268
src/langbot/pkg/agent/runner/resource_builder.py
Normal 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}')
|
||||
193
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
193
src/langbot/pkg/agent/runner/result_normalizer.py
Normal 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}')
|
||||
263
src/langbot/pkg/agent/runner/session_registry.py
Normal file
263
src/langbot/pkg/agent/runner/session_registry.py
Normal 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
|
||||
113
src/langbot/pkg/agent/runner/state_scope.py
Normal file
113
src/langbot/pkg/agent/runner/state_scope.py
Normal 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,
|
||||
}
|
||||
341
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
341
src/langbot/pkg/agent/runner/transcript_store.py
Normal 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)
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal file
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal 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'),
|
||||
)
|
||||
77
src/langbot/pkg/entity/persistence/artifact.py
Normal file
77
src/langbot/pkg/entity/persistence/artifact.py
Normal 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."""
|
||||
85
src/langbot/pkg/entity/persistence/event_log.py
Normal file
85
src/langbot/pkg/entity/persistence/event_log.py
Normal 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."""
|
||||
72
src/langbot/pkg/entity/persistence/transcript.py
Normal file
72
src/langbot/pkg/entity/persistence/transcript.py
Normal 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'),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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 ###
|
||||
@@ -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')
|
||||
@@ -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'] = [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -34,11 +34,5 @@
|
||||
"limit": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"msg-truncate": {
|
||||
"method": "round",
|
||||
"round": {
|
||||
"max-round": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
2
tests/unit_tests/agent/__init__.py
Normal file
2
tests/unit_tests/agent/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Tests for agent runner subsystem."""
|
||||
from __future__ import annotations
|
||||
94
tests/unit_tests/agent/conftest.py
Normal file
94
tests/unit_tests/agent/conftest.py
Normal 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,
|
||||
},
|
||||
}
|
||||
654
tests/unit_tests/agent/test_artifact_store.py
Normal file
654
tests/unit_tests/agent/test_artifact_store.py
Normal 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"
|
||||
605
tests/unit_tests/agent/test_chat_handler.py
Normal file
605
tests/unit_tests/agent/test_chat_handler.py
Normal 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
|
||||
152
tests/unit_tests/agent/test_config_migration.py
Normal file
152
tests/unit_tests/agent/test_config_migration.py
Normal 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'}
|
||||
129
tests/unit_tests/agent/test_config_migration_full.py
Normal file
129
tests/unit_tests/agent/test_config_migration_full.py
Normal 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 == {}
|
||||
162
tests/unit_tests/agent/test_context_builder_params_state.py
Normal file
162
tests/unit_tests/agent/test_context_builder_params_state.py
Normal 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}
|
||||
361
tests/unit_tests/agent/test_context_builder_state.py
Normal file
361
tests/unit_tests/agent/test_context_builder_state.py
Normal 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
|
||||
336
tests/unit_tests/agent/test_context_validation.py
Normal file
336
tests/unit_tests/agent/test_context_validation.py
Normal 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
|
||||
385
tests/unit_tests/agent/test_event_first_protocol.py
Normal file
385
tests/unit_tests/agent/test_event_first_protocol.py
Normal 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
|
||||
632
tests/unit_tests/agent/test_event_log_transcript.py
Normal file
632
tests/unit_tests/agent/test_event_log_transcript.py
Normal 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()
|
||||
2013
tests/unit_tests/agent/test_handler_auth.py
Normal file
2013
tests/unit_tests/agent/test_handler_auth.py
Normal file
File diff suppressed because it is too large
Load Diff
146
tests/unit_tests/agent/test_history_event_api_auth.py
Normal file
146
tests/unit_tests/agent/test_history_event_api_auth.py
Normal 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()
|
||||
137
tests/unit_tests/agent/test_id.py
Normal file
137
tests/unit_tests/agent/test_id.py
Normal 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
|
||||
658
tests/unit_tests/agent/test_orchestrator_artifact.py
Normal file
658
tests/unit_tests/agent/test_orchestrator_artifact.py
Normal 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
|
||||
868
tests/unit_tests/agent/test_orchestrator_integration.py
Normal file
868
tests/unit_tests/agent/test_orchestrator_integration.py
Normal 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
Reference in New Issue
Block a user