mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-14 01:36:03 +00:00
Compare commits
79 Commits
fix/litell
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09adf4c541 | ||
|
|
1153433693 | ||
|
|
735a0011b0 | ||
|
|
3683bfd793 | ||
|
|
313798bf0a | ||
|
|
d0b0a682c7 | ||
|
|
3984e0fe40 | ||
|
|
c4fa39f684 | ||
|
|
b97b831717 | ||
|
|
e7779bd16f | ||
|
|
2094993afb | ||
|
|
c9ef788072 | ||
|
|
ae98254e89 | ||
|
|
9cf99815ba | ||
|
|
682f776bf7 | ||
|
|
54a2f7060d | ||
|
|
f92bd95cc8 | ||
|
|
c10ce6cc2e | ||
|
|
86ec12a391 | ||
|
|
4e016ad23e | ||
|
|
fd60125182 | ||
|
|
edbb6c486f | ||
|
|
5831198f38 | ||
|
|
d47de946ec | ||
|
|
c00a3e1de9 | ||
|
|
7675f565ff | ||
|
|
54bba1a1f5 | ||
|
|
a6a90f7d1b | ||
|
|
4a8c1a76d7 | ||
|
|
2de6d15d07 | ||
|
|
d347df02f6 | ||
|
|
f1a44ea8a8 | ||
|
|
d81f687e94 | ||
|
|
3773e3dfaf | ||
|
|
23d3b7c279 | ||
|
|
058721cca3 | ||
|
|
e13a3b845c | ||
|
|
dc4cf5711e | ||
|
|
1384d328d6 | ||
|
|
16faeca508 | ||
|
|
4852b21f9b | ||
|
|
0b9778abd9 | ||
|
|
c296c187f4 | ||
|
|
f6deb4b322 | ||
|
|
94c0adc8a1 | ||
|
|
5c2026855c | ||
|
|
fc2dc34ecf | ||
|
|
da8e403172 | ||
|
|
819a2843e7 | ||
|
|
96fa9e1eeb | ||
|
|
b4ae049c54 | ||
|
|
d1e49a5b44 | ||
|
|
2e0343cb21 | ||
|
|
53c9199df8 | ||
|
|
bec11e5a18 | ||
|
|
a31f910f10 | ||
|
|
a968d7656b | ||
|
|
c1dc5e3970 | ||
|
|
d8d98b0838 | ||
|
|
651e28113e | ||
|
|
c97ea27d42 | ||
|
|
bbbbc05201 | ||
|
|
18cbe8570c | ||
|
|
752ac6e9d2 | ||
|
|
9dfddd4927 | ||
|
|
9f8dd6cbe4 | ||
|
|
d185712716 | ||
|
|
c601dc5908 | ||
|
|
54e925daa0 | ||
|
|
6d0e6dcc63 | ||
|
|
3baf899c20 | ||
|
|
fa19a453ba | ||
|
|
2123ef5816 | ||
|
|
811549e1c4 | ||
|
|
6ef40fbd68 | ||
|
|
45f150da2d | ||
|
|
94d3ebf137 | ||
|
|
90eb711a74 | ||
|
|
6d87b7927d |
150
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
150
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Agent-owned Context 协议设计
|
||||
|
||||
本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。
|
||||
|
||||
> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。
|
||||
|
||||
## 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 / SDK-owned MCP bridge / resource handles、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` 校验。
|
||||
|
||||
外部 harness 不能直接访问 LangBot 资源。无论是 history、event、artifact、state、model、tool、knowledge base,还是 LangBot skills,都必须通过 SDK runtime 转发到 Host API,并由 Host 按 active `run_id`、runner identity、binding resource policy 和 caller plugin identity 校验。harness 自己的 native tools 只属于 harness 执行环境,不能绕过 SDK runtime 访问 LangBot 内部资源。
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
返回 `HistoryPage`(schema 见 PROTOCOL_V1 §8)。
|
||||
|
||||
约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;返回 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(`artifact_metadata` / `artifact_read` / `artifact_read_range`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/file-key 读取,工具大结果也应 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
|
||||
|
||||
外部 harness 的总体边界以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8 为准。本节只描述 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 内部私有对象、secret 或资源内容。
|
||||
- `skills`:LangBot skills 不是直接投影给 harness native tool loop 的文件能力;已授权 skill 应由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools`、`AgentRunAPIProxy` 或 SDK-owned MCP bridge 暴露。
|
||||
- `MCP config`:只投影 per-run、scoped 的 SDK-owned bridge 或外部 MCP 连接配置;LangBot 资源访问必须回到 SDK runtime / Host API,不允许 harness 通过自带 MCP/native tool 直接读 Host 内部资源。
|
||||
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。
|
||||
|
||||
当前官方外部 harness 路径由 LiteLLM Agent Platform runner 承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。
|
||||
|
||||
## 5. Runner 上下文边界
|
||||
|
||||
Host 只给当前事件、当前输入和 context handles。Runner 是否能拉取历史、事件、artifact、state 或 storage,以运行时 `ctx.context.available_apis` 为准;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。
|
||||
|
||||
## 6. KV cache 友好的上下文管理
|
||||
|
||||
支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt:
|
||||
|
||||
- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。
|
||||
- 每轮只传 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 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。
|
||||
|
||||
外部 harness 的 native tools、shell、MCP 或 skill 机制不构成 LangBot 资源授权边界。只要访问的是 LangBot 持有的资源,就必须经 SDK runtime 转发并接受 Host 校验;完整边界见 HOST_SDK §4.8。
|
||||
|
||||
## 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_page()` / `api.history_search()` 读取,summary/checkpoint/外部 session id/用户偏好通过 `api.state_get()` / `api.state_set()` 或 storage 方法保存,图片/文件/工具大结果通过 `api.artifact_metadata()` / `api.artifact_read_range()` 读取,模型/工具/知识库通过 `api.invoke_llm()` / `api.call_tool()` / `api.retrieve_knowledge()` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
|
||||
227
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal file
227
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 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(当前为 LiteLLM Agent Platform 统一入口)能消费 event-first context,并把外部 session 指针写回 host-owned state。
|
||||
- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。
|
||||
|
||||
本指南不验证:
|
||||
|
||||
- Runtime Control Plane v2。
|
||||
- EventGateway / EventRouter 完整落地由外部 EBA 分支联调;本指南只验证本分支 Host 底座。
|
||||
- 发布级 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. LiteLLM Agent Platform 外部 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 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 |
|
||||
| LA-09 | steering / 运行中追加消息 | 使用支持 steering 的 runner,第一条消息触发长 run;run 未结束时在同 conversation 追加第二条消息。 | 第二条消息被 active run claim,不启动并发 run;runner 通过 `steering_pull` 看到追加输入;EventLog 有 `queued` -> `steering.injected`,若未消费则有 `steering.dropped` 终态;后续普通消息仍可处理。 |
|
||||
|
||||
Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。
|
||||
|
||||
## 7. LiteLLM Agent Platform Harness Smoke
|
||||
|
||||
这些测试用于验证 Claude Code / Codex 这类自管 runtime 经 LiteLLM Agent Platform 能走同一条 Host 协议路径。若 LiteLLM Agent Platform 服务不可用、目标 harness 没有 CLI/登录态/代理配置,标记 BLOCKED,不要伪造 PASS。
|
||||
|
||||
Smoke 前应优先保留一层轻量单测或 fixture 测试:LiteLLM Agent Platform HTTP session、消息发送、结果解析、`run_id` 提示词注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。
|
||||
|
||||
### 7.1 LiteLLM Agent Platform runner
|
||||
|
||||
步骤:
|
||||
|
||||
1. 确认 LiteLLM Agent Platform 服务可访问,目标 harness(例如 Claude Code 或 Codex)在该服务所在机器上可执行且已登录。
|
||||
2. 绑定 `plugin:langbot/litellm-agent-platform-agent/default`。
|
||||
3. 配置 `base-url`、`api-mode`、`agent-id` 或 `harness` 等必要字段。
|
||||
4. 在 Debug Chat 执行一次确定性真实 smoke。
|
||||
5. 检查 LangBot MCP gateway、`run_id` 回填和 host-owned state。
|
||||
|
||||
通过条件:
|
||||
|
||||
- WebUI 可见回复包含预期 sentinel。
|
||||
- 发送给 LiteLLM 的消息包含当前 LangBot `run_id` 和可访问资源摘要。
|
||||
- Harness 通过 gateway 调用 `langbot_history_page`、`langbot_retrieve_knowledge` 或 `langbot_call_tool` 时必须携带正确 `run_id`;错误 run id 被拒绝。
|
||||
- `external.session_id` 写入 host-owned state。
|
||||
- LiteLLM 服务错误、timeout、empty output 都转成受控 `run.failed`。
|
||||
- resume 到同一 external session 时,全局锁边界符合 PROTOCOL_V1 §13。
|
||||
|
||||
### 7.2 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)` 返回错误。 |
|
||||
| 未注册 Runtime 连接伪造插件身份被剥离 | SDK runtime forwarding 测试:请求自带 `caller_plugin_identity` 时,未注册连接转发前必须 `pop`,已注册连接必须覆盖为真实插件身份。 |
|
||||
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
|
||||
| steering claim 异常不杀 consumer loop | controller 单测:无效 runner / registry 异常只让当前消息回到普通 session 槽位路径,消息消费循环继续。 |
|
||||
| steering queue 未消费有终态 | session registry / orchestrator 单测:队列有上限;run unregister 时未 pull 项写 `steering.dropped` 审计。 |
|
||||
|
||||
如果这些单测失败,不能用 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. 历史高价值记录
|
||||
|
||||
历史高价值记录与当前 runner 验收状态见 [STATUS.md](./STATUS.md)。本指南只保留可重复执行的测试步骤和证据要求。
|
||||
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 接入设计
|
||||
|
||||
> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。
|
||||
>
|
||||
> 数据结构唯一定义在 [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;这些能力正在外部 EBA 分支联调。这里的目标是把协议边界说清楚,避免当前消息入口继续绑死 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 为准;本节只说明外部 EBA 分支的 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": "..."},
|
||||
"payload": {"reason": "policy matched"} } }
|
||||
```
|
||||
|
||||
Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。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 分支联调内容
|
||||
|
||||
外部 EBA 分支负责联调 EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。
|
||||
|
||||
当前底座已完成:① 把当前 Pipeline 消息入口适配成 `message.received` event → ② 增加 `AgentBinding` 抽象,先由 current config 生成 → ③ context builder 改为从 event + binding 构造 → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调:⑤ 非消息事件协议测试与真实事件来源 → ⑥ 真实 EventRouter、binding persistence / UI 和 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。EBA 完整事件网关与事件路由由外部 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 | 不实现。当前官方外部 harness 通过 LiteLLM Agent Platform runner 调用外部平台,不在本分支维护本机 subprocess worker。 | Runtime Control Plane v2。 | 第一阶段先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;完整 runtime registry、heartbeat、task queue、daemon claim、progress/audit 是后续可选阶段。 | 不把 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 | LiteLLM Agent Platform runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部平台调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider 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 外化边界。
|
||||
257
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
257
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# LangBot Host 与 SDK 基础设施设计
|
||||
|
||||
本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。
|
||||
|
||||
- SDK ↔ Host 的协议数据结构(`AgentRunContext`、`AgentRunnerManifest`、`AgentRunResult`、`AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。
|
||||
- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`AgentRunnerDescriptor`)不属于 SDK 协议字段。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
LangBot 要转为 agent host,而不是内置 runner 容器:
|
||||
|
||||
- 接收 IM、WebUI、API 和外部 EBA 分支 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 / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。
|
||||
|
||||
## 3. 分层架构
|
||||
|
||||
```text
|
||||
IM / WebUI / API / EventRouter (external EBA branch)
|
||||
|
|
||||
v
|
||||
Event Gateway (external EBA 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 / EventRouter 由外部 EBA 分支实现并联调。
|
||||
|
||||
## 4. LangBot 侧能力
|
||||
|
||||
### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point)
|
||||
|
||||
> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 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
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
runner_name: str
|
||||
capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3
|
||||
permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4
|
||||
config_schema: list[DynamicFormItemSchema]
|
||||
plugin_version: str | None = None
|
||||
raw_manifest: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 typed `AgentRunnerManifest`、输出 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),来自 manifest permissions 与 binding policy 的交集:
|
||||
|
||||
1. `descriptor.permissions` 声明 runner 需要的 LangBot 资源访问上限。
|
||||
2. binding / resource policy 允许的资源范围。
|
||||
3. Agent/runner config 中选择的模型、知识库、文件等资源。
|
||||
4. 当前 event / actor / bot / workspace 的实际权限。
|
||||
5. `ctx.context.available_apis` 暴露的 pull API 能力。
|
||||
|
||||
这次裁剪结果必须冻结为 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 才是安全边界。`spec.capabilities` 只帮助 Host 判断 runner 是否需要 tool / knowledge / skill 等资源投影,不能替代 permissions 或 binding policy。
|
||||
|
||||
资源裁剪应通用,不写死 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 / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runner;runner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。
|
||||
|
||||
### 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 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 bridge、skill-backed tool、artifact、history/state 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;所有 LangBot 资源访问必须经 SDK runtime / `AgentRunAPIProxy` / SDK-owned MCP bridge 转发并接受 Host 校验;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume,但不能用 native tools 绕过 Host 授权。
|
||||
|
||||
投影的具体形态(context 文件、resource handles、LangBot MCP gateway、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;当前 LiteLLM Agent Platform runner 形态见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。
|
||||
|
||||
## 5. SDK 侧协议
|
||||
|
||||
SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。
|
||||
|
||||
```python
|
||||
class AgentRunner(BaseComponent):
|
||||
__kind__ = "AgentRunner"
|
||||
|
||||
@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 / effective access:PROTOCOL_V1 §4。Capabilities 来自组件 manifest 的 `spec.capabilities`,不是 SDK 基类 classmethod。
|
||||
- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
|
||||
- `AgentRunResult`:PROTOCOL_V1 §7。
|
||||
- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。
|
||||
136
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
136
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 官方 AgentRunner 插件迁移计划
|
||||
|
||||
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [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 仓库
|
||||
litellm-agent-platform-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/litellm-agent-platform-agent` | `plugin:langbot/litellm-agent-platform-agent/default` |
|
||||
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
|
||||
| `deerflow-api` | `langbot/deerflow-agent` | `plugin:langbot/deerflow-agent/default` |
|
||||
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
|
||||
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
|
||||
| `weknora-api` | `langbot/weknora-agent` | `plugin:langbot/weknora-agent/default` |
|
||||
|
||||
每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`。
|
||||
|
||||
## 3. 迁移批次
|
||||
|
||||
- **Batch 1(打通协议)**:`local-agent`(能力最完整基准)、`litellm-agent-platform-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`、`deerflow-agent`、`weknora-agent`(平台特有响应格式、引用资料、文件/图片输入、外部 thread/session 状态)。
|
||||
|
||||
## 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:
|
||||
config: []
|
||||
capabilities: # 字段语义见 PROTOCOL_V1 §4.3
|
||||
streaming: 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 声明功能能力、LangBot 资源 permissions 和配置表单;实际授权来自 manifest permissions 与 binding resource policy、runner config、`ctx.context.available_apis` 和 Host run session snapshot 的交集。
|
||||
|
||||
### 5.1 Native Execution / Skills 后续接入
|
||||
|
||||
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools` 和 SDK runtime 转发暴露给 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 边界。总体边界见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8;context projection 形态见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) §4.5;发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
|
||||
本文件只补充官方 runner 的实现要求:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage;插件实例边界见 PROTOCOL_V1 §13;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。
|
||||
|
||||
实现结构应把 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 LangBot MCP gateway
|
||||
|
||||
外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,也不能用自己的 native tools 直接访问 LangBot 资源。当前 LiteLLM Agent Platform runner 通过稳定 HTTP MCP gateway 把 harness 的工具请求转回 SDK runtime / Host API:
|
||||
|
||||
- Gateway 由 runner 插件启动,暴露稳定的 `langbot_history_page`、`langbot_retrieve_knowledge`、`langbot_call_tool` 等最小工具面。
|
||||
- Harness 每次调用必须携带当前 LangBot `run_id`;Host 仍按 run session、caller identity 和授权快照校验。
|
||||
- Gateway 只转发 LangBot 资产访问,不承担外部 harness 的文件、进程或 native tool 权限边界。
|
||||
|
||||
第一批工具保持很小:history page、knowledge retrieve、authorized tool call。新增工具必须先有 Host action 权限与 run-scoped authorization,再由 gateway 投影。
|
||||
|
||||
## 7. LiteLLM Agent Platform runner 当前形态
|
||||
|
||||
`litellm-agent-platform-agent` 是当前外部 harness runner 的统一入口,用来把 Claude Code、Codex 等具体执行器交给 LiteLLM Agent Platform / lite-harness 管理,而不是在 LangBot 官方 runner 仓库中维护每个 CLI provider 的独立适配器。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
||||
|
||||
当前形态:
|
||||
|
||||
- Runner ID:`plugin:langbot/litellm-agent-platform-agent/default`。
|
||||
- Runner 通过 HTTP 调用 LiteLLM Agent Platform,外部 harness 的安装、登录态、workspace 和 provider-native 权限由该平台所在运行环境负责。
|
||||
- Runner 会把当前 LangBot `run_id`、可访问资源摘要和 gateway 使用规则注入本次消息;harness 通过 gateway 回填 `run_id` 后访问 LangBot 资产。
|
||||
- 外部 session id 写回 Host state,后续轮次可复用目标平台会话。
|
||||
|
||||
### 7.1 当前限制
|
||||
|
||||
这不是发布级安全边界实现;LangBot 只约束 LangBot 持有资产的访问,外部 harness 的文件、进程、workspace、provider-native MCP 和模型凭据由 LiteLLM Agent Platform 部署侧承担。当前 `run_id` 由系统提示词传递给 harness 并由 gateway 校验,后续若 LiteLLM 原生支持 run-scoped MCP session,可切换为平台级传递。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。
|
||||
- `litellm-agent-platform-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。
|
||||
- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。
|
||||
751
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
751
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# LangBot AgentRunner Protocol v1
|
||||
|
||||
本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。
|
||||
|
||||
- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.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. 协议演进
|
||||
|
||||
当前 AgentRunner 合同不暴露显式 `protocol_version` 字段。协议演进先按字段级兼容规则处理:
|
||||
|
||||
- 新增可选字段保持向后兼容。
|
||||
- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。
|
||||
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。
|
||||
- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;manifest、result payload、page/result 返回与错误模型偏严格,未知字段默认禁止。安全边界仍在 Host,SDK 校验只提升开发体验。
|
||||
|
||||
## 4. Discovery 协议
|
||||
|
||||
### 4.1 LIST_AGENT_RUNNERS
|
||||
|
||||
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回:
|
||||
|
||||
```python
|
||||
class ListAgentRunnersResponse(BaseModel):
|
||||
runners: list[AgentRunnerDiscovery]
|
||||
|
||||
class AgentRunnerDiscovery(BaseModel):
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
runner_name: str
|
||||
runner_description: I18nObject | None = None
|
||||
manifest: AgentRunnerManifest
|
||||
capabilities: AgentRunnerCapabilities # compatibility alias of manifest.capabilities
|
||||
permissions: AgentRunnerPermissions # compatibility alias of manifest.permissions
|
||||
config: list[DynamicFormItemSchema] = []
|
||||
```
|
||||
|
||||
`manifest` 是 SDK typed `AgentRunnerManifest`,由 Runtime 从插件组件 manifest 解析并校验后返回。`plugin_author` / `plugin_name` / `runner_name` 保留为 transport 寻址字段;Host 以它们生成稳定 runner id,并把 `manifest.id` 校验为 `plugin:author/name/runner`。单个 runner manifest 解析失败时 Runtime/Host 记录 warning 并跳过该 runner,不影响同一插件或其它插件的 runner discovery。
|
||||
|
||||
`capabilities` / `permissions` 顶层字段是兼容旧 discovery 消费方的冗余别名;新代码必须以 `manifest.capabilities` / `manifest.permissions` 为准。
|
||||
|
||||
### 4.2 AgentRunnerManifest
|
||||
|
||||
这里的 manifest 指 Runtime 返回给 Host 的 typed runner manifest:
|
||||
|
||||
```python
|
||||
class AgentRunnerManifest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
capabilities: AgentRunnerCapabilities = AgentRunnerCapabilities()
|
||||
permissions: AgentRunnerPermissions = AgentRunnerPermissions()
|
||||
config_schema: list[DynamicFormItemSchema] = []
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
- runner id 由 Host 生成,格式 `plugin:author/name/runner`。
|
||||
- `name` 是插件内 runner 名称,例如 `default`。
|
||||
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。
|
||||
- `capabilities` 是 Host 用于 UI 和资源投影的 typed bool model;它不是权限授予。
|
||||
- `permissions` 是 runner 申请的 LangBot 资源访问上限;实际授权仍必须与 binding policy 求交。
|
||||
- `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
|
||||
interrupt: bool = False
|
||||
steering: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
|
||||
- `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 facts 以及 skill authoring tools,例如 `activate` / `register_skill`。
|
||||
- `interrupt`: runner 支持取消或中断。
|
||||
- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。
|
||||
|
||||
Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。
|
||||
|
||||
### 4.4 Permissions 与 Effective Access
|
||||
|
||||
```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"]] = []
|
||||
files: list[Literal["config", "knowledge"]] = []
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
|
||||
平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。
|
||||
|
||||
Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照:
|
||||
|
||||
```text
|
||||
effective_access = manifest.permissions ∩ binding.resource_policy ∩ current scope/config
|
||||
```
|
||||
|
||||
具体落地:
|
||||
|
||||
1. `AgentResourceBuilder` 先用 manifest permissions 与 binding resource policy / runner config 求交,生成 `ctx.resources`。
|
||||
2. `AgentContextBuilder` 用 manifest permissions 与 binding state/storage policy 求交,生成 `ctx.context.available_apis`。
|
||||
3. `AgentRunSessionRegistry` 冻结 run-scoped resources 与 available APIs。
|
||||
4. Runtime handler / `AgentRunAPIProxy` 按 active `run_id`、runner identity、caller plugin identity、resource id、scope、payload size、rate limit 和 deadline 校验每次调用。
|
||||
|
||||
反承诺:manifest permissions **只约束 LangBot 持有的资源访问**。它不承诺限制外部 harness 的 native shell、文件系统、CLI、MCP、网络或本机权限;这些能力由 operator/runtime/sandbox 另行约束,见 HOST_SDK §4.8 与 SECURITY_HARDENING。
|
||||
|
||||
默认原则:
|
||||
|
||||
- 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
|
||||
sender_id: str | None = None
|
||||
bot_id: str | None = None
|
||||
workspace_id: str | None = None
|
||||
session_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] = []
|
||||
```
|
||||
|
||||
- 文本、多模态、附件都属于当前 event input。
|
||||
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。
|
||||
- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。
|
||||
|
||||
### 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):
|
||||
prompt_get: bool = False
|
||||
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
|
||||
steering_pull: bool = False
|
||||
```
|
||||
|
||||
`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。
|
||||
|
||||
### 5.9 AgentRuntimeContext
|
||||
|
||||
```python
|
||||
class AgentRuntimeContext(BaseModel):
|
||||
langbot_version: str | None = None
|
||||
trace_id: str | None = None
|
||||
deadline_at: float | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
### 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 SkillResource(BaseModel):
|
||||
skill_name: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
class AgentResources(BaseModel):
|
||||
models: list[ModelResource] = []
|
||||
tools: list[ToolResource] = []
|
||||
knowledge_bases: list[KnowledgeBaseResource] = []
|
||||
skills: list[SkillResource] = []
|
||||
files: list[FileResource] = []
|
||||
storage: StorageResource = StorageResource()
|
||||
platform_capabilities: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
`skills` 只包含本次 run 中 pipeline-visible 的 skill facts,例如 `skill_name`、`display_name` 和 `description`。Host 不把这些 facts 追加到 system prompt,也不把它们编排进工具描述;runner 可以自行决定是否放入 model prompt、转换成 MCP surface,或只在自己的策略层使用。
|
||||
|
||||
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 `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 AgentRunResult(BaseModel):
|
||||
run_id: str
|
||||
type: AgentRunResultType | str
|
||||
data: dict[str, Any] = {}
|
||||
sequence: int | None = None
|
||||
timestamp: int | None = None
|
||||
```
|
||||
|
||||
SDK 当前实现是单一 envelope:`type` 枚举 + `data` dict。Payload 由 SDK typed model 构造并 dump,但 wire 不改成 discriminated union;这样新旧版本偏斜时 Host 仍可按 §3 忽略未知 `type`。
|
||||
|
||||
Host 边界分级校验:
|
||||
|
||||
- `message.delta`、`message.completed`、`artifact.created`、`state.updated`、`action.requested`、`run.completed`、`run.failed` 属于会影响投递或 Host 副作用的严格 payload;校验失败时丢弃该 result 并记录 warning。
|
||||
- `tool.call.started`、`tool.call.completed` 当前只作为 telemetry,payload 宽松兼容。
|
||||
- 未知 `type` 忽略并记录 warning。
|
||||
|
||||
### 7.2 稳定 result payloads
|
||||
|
||||
| type | `data` payload |
|
||||
| --- | --- |
|
||||
| `message.delta` | `{ "chunk": MessageChunk }` |
|
||||
| `message.completed` | `{ "message": Message }` |
|
||||
| `tool.call.started` | `{ "tool_call_id": str, "tool_name": str, "parameters": dict }` |
|
||||
| `tool.call.completed` | `{ "tool_call_id": str, "tool_name": str, "result": dict \| None, "error": str \| None }` |
|
||||
| `artifact.created` | `{ "artifact_type": str, "artifact_id"?: str, "mime_type"?: str, "name"?: str, "size_bytes"?: int, "sha256"?: str, "metadata"?: dict, "content_base64"?: str }` |
|
||||
| `state.updated` | `{ "scope": "conversation" \| "actor" \| "subject" \| "runner", "key": str, "value": JSONValue }` |
|
||||
| `action.requested` | `{ "action": str, "target": dict \| None, "payload": dict \| None }` |
|
||||
| `run.completed` | `{ "finish_reason": str, "message"?: Message }` |
|
||||
| `run.failed` | `{ "code": str, "error": str, "retryable": bool }` |
|
||||
|
||||
`artifact.created.content_base64` 是小 artifact 的 inline 通道;Host 解码后写入 ArtifactStore,当前 hard cap 是 1 MiB。大 artifact 应使用外部存储 / file key / 后续上传通道,不应塞入 result event。
|
||||
|
||||
### 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 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。
|
||||
|
||||
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 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。
|
||||
- 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": "..."} } }
|
||||
```
|
||||
|
||||
## 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.invoke_llm(llm_model_uuid, messages, funcs=None, extra_args=None)
|
||||
await api.invoke_llm_with_usage(llm_model_uuid, messages, funcs=None, extra_args=None)
|
||||
async for chunk in api.invoke_llm_stream(llm_model_uuid, messages, funcs=None, extra_args=None):
|
||||
...
|
||||
async for event in api.invoke_llm_stream_events(llm_model_uuid, messages, funcs=None, extra_args=None):
|
||||
...
|
||||
await api.invoke_rerank(rerank_model_id, query, documents, top_k=None)
|
||||
|
||||
# Tool
|
||||
await api.get_tool_detail(tool_name)
|
||||
await api.call_tool(tool_name, parameters)
|
||||
|
||||
# Knowledge
|
||||
await api.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None)
|
||||
|
||||
# History(返回 Transcript projection,不返回原始平台 payload)
|
||||
await api.get_prompt()
|
||||
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.event_get(event_id)
|
||||
await api.event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50)
|
||||
await api.steering_pull(mode="all", limit=None)
|
||||
|
||||
# Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围)
|
||||
await api.artifact_metadata(artifact_id)
|
||||
await api.artifact_read(artifact_id, offset=0, limit=None)
|
||||
await api.artifact_read_range(artifact_id, offset=0, length=65536)
|
||||
|
||||
# State / Storage
|
||||
await api.state_get(scope, key); await api.state_set(scope, key, value); await api.state_delete(scope, key)
|
||||
await api.state_list(scope, prefix=None, limit=100)
|
||||
await api.get_plugin_storage(key); await api.set_plugin_storage(key, value); await api.delete_plugin_storage(key)
|
||||
await api.get_plugin_storage_keys()
|
||||
await api.get_workspace_storage(key); await api.set_workspace_storage(key, value); await api.delete_workspace_storage(key)
|
||||
await api.get_workspace_storage_keys()
|
||||
|
||||
# Files / Host info
|
||||
await api.get_file(file_key)
|
||||
await api.get_langbot_version()
|
||||
```
|
||||
|
||||
`invoke_llm()` / `invoke_llm_stream()` 的第一个参数在 SDK 中命名为
|
||||
`llm_model_uuid`,wire payload 字段也是 `llm_model_uuid`。该值对 runner
|
||||
仍是 opaque identifier,不应解析其内部格式。
|
||||
|
||||
`invoke_llm()` 和 `invoke_llm_stream()` 保持兼容:前者返回 `Message`,后者只
|
||||
yield `MessageChunk`。需要 provider 真实 token 计量的 runner 应使用
|
||||
`invoke_llm_with_usage()` 或 `invoke_llm_stream_events()`。Host response 可在
|
||||
原有 `{message: ...}` / `{chunk: ...}` 外额外携带可选 `usage` 字段;streaming
|
||||
场景允许在所有 chunk 之后追加一个 usage-only event。`usage` 至少保留
|
||||
OpenAI-compatible 的 `prompt_tokens`、`completion_tokens`、`total_tokens`,
|
||||
若 provider 返回 `prompt_tokens_details` / `completion_tokens_details` 或
|
||||
cache token counters,Host / SDK 不应丢弃这些字段。没有 usage 的 provider
|
||||
必须继续返回成功响应,SDK 将 usage 置为 `None`。
|
||||
|
||||
`get_prompt()` 返回当前 query-backed run 的 Host effective prompt messages:
|
||||
`list[Message]` 的 JSON 形式。该能力只在 `ctx.context.available_apis.prompt_get`
|
||||
为 true 时可用;没有 query 缓存、prompt 已过期或非 query entry run 时 Host
|
||||
可以返回错误或空列表。Runner 应在不可用时回退到自己的 config/prompt 策略。
|
||||
|
||||
`steering_pull(mode="all")` 是推荐默认:Host 按 claim 顺序返回全部 pending steering 输入并清空对应队列。`mode="one-at-a-time"` 仅用于 runner 主动节流,每次返回一条。Host 不合并多条用户消息;runner 负责在 turn 边界决定模型侧格式。
|
||||
|
||||
Steering 审计使用 EventLog 而不是 Transcript schema 扩展:被 active run 吸收的原始 `message.received` 事件保留原事件类型,并在 `metadata.steering` 标记 `status="queued"`、`trigger_behavior="absorbed_into_active_run"`、`claimed_by_run_id`、`claimed_runner_id`、`claimed_at`。Runner 成功 pull 后,Host 追加 `steering.injected` EventLog 记录,`metadata.steering.status="injected"` 并引用 `source_event_id`。若 run 结束时仍有已 claim 但未 pull 的 steering 输入,Host 追加 `steering.dropped` EventLog 记录,`metadata.steering.status="dropped"` 并引用 `source_event_id`;这不是用户消息事实的删除,只是 dispatch 终态。Transcript 继续只表示会话事实,不承担 dispatch 行为标记。
|
||||
|
||||
`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / subject / runner),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。
|
||||
|
||||
Compaction checkpoint 的推荐 state 约定:
|
||||
|
||||
- scope: `conversation`
|
||||
- key: `runner.compaction.checkpoint`
|
||||
- value:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "langbot.local_agent.compaction_checkpoint.v1",
|
||||
"summary": "<conversation_summary>...</conversation_summary>",
|
||||
"covers_until": "transcript-cursor-or-seq",
|
||||
"tokens_before": 12345,
|
||||
"created_at": 1710000000,
|
||||
"conversation_id": "conv-..."
|
||||
}
|
||||
```
|
||||
|
||||
`covers_until` 是摘要覆盖到的 transcript 游标锚点。Runner 读取 checkpoint 后应只拉取该游标之后的 transcript;若 checkpoint 缺失、schema 不匹配、conversation 不匹配或游标不可用,应回退到无 checkpoint 的尾部历史拉取行为。
|
||||
|
||||
Proxy 返回数据结构也属于本协议:
|
||||
|
||||
```python
|
||||
class TranscriptItem(BaseModel):
|
||||
transcript_id: str
|
||||
event_id: str
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
role: str
|
||||
item_type: str = "message"
|
||||
content: str | None = None
|
||||
content_json: dict[str, Any] | None = None
|
||||
artifact_refs: list[dict[str, Any]] = []
|
||||
seq: int | None = None
|
||||
cursor: str | None = None
|
||||
created_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class HistoryPage(BaseModel):
|
||||
items: list[TranscriptItem] = []
|
||||
next_cursor: str | None = None
|
||||
prev_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
total_count: int | None = None
|
||||
|
||||
class HistorySearchResult(BaseModel):
|
||||
items: list[TranscriptItem] = []
|
||||
total_count: int | None = None
|
||||
query: str
|
||||
|
||||
class AgentEventRecord(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
event_time: int | None = None
|
||||
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_ref: str | None = None
|
||||
raw_ref: str | None = None
|
||||
seq: int | None = None
|
||||
cursor: str | None = None
|
||||
created_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class EventPage(BaseModel):
|
||||
items: list[AgentEventRecord] = []
|
||||
next_cursor: str | None = None
|
||||
prev_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
total_count: int | None = None
|
||||
|
||||
class SteeringInputItem(BaseModel):
|
||||
claimed_run_id: str
|
||||
runner_id: str
|
||||
claimed_at: int | None = None
|
||||
event: AgentEventContext
|
||||
input: AgentInput
|
||||
conversation: ConversationContext | None = None
|
||||
actor: ActorContext | None = None
|
||||
subject: SubjectContext | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class SteeringPullResult(BaseModel):
|
||||
items: list[SteeringInputItem] = []
|
||||
|
||||
class ArtifactMetadata(BaseModel):
|
||||
artifact_id: str
|
||||
artifact_type: str
|
||||
mime_type: str | None = None
|
||||
name: str | None = None
|
||||
size_bytes: int | None = None
|
||||
sha256: str | None = None
|
||||
source: str
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
runner_id: str | None = None
|
||||
created_at: int | None = None
|
||||
expires_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class ArtifactReadResult(BaseModel):
|
||||
artifact_id: str
|
||||
mime_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
offset: int = 0
|
||||
length: int | None = None
|
||||
content_base64: str | None = None
|
||||
file_key: str | None = None
|
||||
has_more: bool = False
|
||||
```
|
||||
|
||||
## 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 或下游能力错误。 |
|
||||
|
||||
SDK runner-facing proxy 在 Host 返回结构化错误或畸形响应时抛出 `AgentAPIException`,其中 `error` 字段为 `AgentAPIError`。Legacy transport 只返回字符串错误时,SDK 使用 `host.action_error` 包装,避免 runner 继续依赖裸 `KeyError` 或字符串匹配。
|
||||
|
||||
Runner 失败使用 `run.failed`:
|
||||
|
||||
```json
|
||||
{ "type": "run.failed", "data": { "code": "runner.error", "error": "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。
|
||||
- Protocol v1 的 run 绑定当前 Host 进程和当前 runtime channel,不保证跨 Host 重启恢复。Host 重启、runtime channel 断开或 run session 丢失时,Runtime / external harness connector 必须 fail-fast 并尽力取消仍在执行的 runner,不得继续使用旧 `run_id` 调用 Host API。
|
||||
- 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 化;`artifact.created.content_base64` 只用于小 artifact,当前 Host hard cap 是 1 MiB。
|
||||
- Host 必须记录 run_id、runner_id、action、resource、scope、result。
|
||||
|
||||
Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。
|
||||
|
||||
外部 harness runner 的边界统一见 HOST_SDK §4.8。简言之:harness native permission mode、allowed/disallowed tools、shell/MCP 权限只是额外执行约束,不能替代 Host 对 LangBot 资源的授权。
|
||||
|
||||
> 发布级路径隔离、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。
|
||||
- 外部 harness runner 当前是 MVP / dev path,证明协议可接入,不代表发布级安全边界或 Docker 生产可用性完成。
|
||||
|
||||
## 14. 开放问题
|
||||
|
||||
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
|
||||
- ArtifactStore 是否复用现有 BinaryStorage backend,还是引入独立实体。
|
||||
- State 与 Storage 的边界是否需要更强类型。
|
||||
- platform 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"。
|
||||
- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.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。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调:
|
||||
|
||||
- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理
|
||||
- **Event subscription / Event notification**:事件订阅、推送通知
|
||||
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
|
||||
- **Scheduler / Background event source**:定时任务、后台事件源
|
||||
- **Runtime control plane v2 / Run Ledger**:先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;runtime registry、heartbeat、task queue 和 daemon claim 是后续可选阶段,不进入 Protocol v1 主线。
|
||||
|
||||
EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 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 可用)。
|
||||
|
||||
下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.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 的接入方向;完整网关和路由在外部 EBA 分支联调。 |
|
||||
| harness runner | LiteLLM Agent Platform、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 |
|
||||
| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 |
|
||||
| Runtime Control Plane | v2 Host 能力层,第一阶段重点是 Host-owned run/result ledger 与 control primitives;runtime registry、heartbeat、task queue 和 daemon claim 是后续可选阶段,不是 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 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 |
|
||||
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:第一阶段优先把 `AgentRun` / `AgentRunEvent` / run control 做成 Host 事实源;完整 runtime registry / daemon 管控是后续可选阶段。**标注为 future design note**。 |
|
||||
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
|
||||
| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 |
|
||||
| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 |
|
||||
| [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 投影和审计。 |
|
||||
|
||||
## 工作拆分
|
||||
|
||||
### 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(External Branch)
|
||||
|
||||
消息只是事件的一种。外部 EBA 分支中的 `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 侧新增持久 `AgentRun` / `AgentRunEvent`、result persistence、cancel/finalize/query 等通用 run control primitives。完整 runtime registry、heartbeat、task queue、daemon claim 和 runtime audit 只有在复用需求明确后再作为可选阶段下沉到 Host。
|
||||
|
||||
在这些 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)。
|
||||
520
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
520
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Agent Platform / Runtime Control Plane Decision Note
|
||||
|
||||
本文档记录 AgentRunner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `AgentRunner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。
|
||||
|
||||
> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
>
|
||||
> 实现状态说明:本文描述的是 Runtime Control Plane v2 的目标能力和分阶段落地建议。当前 AgentRunner 插件化主线已经具备 event-first context、run-scoped authorization、EventLog / Transcript / Artifact / State 等 Host capability,但尚未实现持久 `AgentRun` / `AgentRunEvent` ledger 和完整 run control API。当前实现状态以 [STATUS.md](./STATUS.md) 为准。
|
||||
|
||||
## 1. 当前决策
|
||||
|
||||
LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer layer**,而不是把某个完整 Agent Platform 产品固化进 core。
|
||||
|
||||
结论:
|
||||
|
||||
- **Agent Platform 产品形态做成插件**。插件负责 agent 管理、策略、业务队列、UI、编排、多 agent 协作和产品体验。
|
||||
- **Agent Platform 所需的基础事实源做进 Host**。当前 Host 已保存 event、artifact、state、transcript 和 active run 权限快照;后续应补齐持久 run、result、审计关联和通用控制状态。
|
||||
- **不在第一阶段把 runtime registry / daemon worker 管控做成 Host 必选能力**。远程 harness / daemon 可以先由 AgentRunner 插件和 SDK remote layer 自己维护连接、心跳和本地执行。
|
||||
- **不把业务调度写进 Host**。Host 提供通用 run/result/control primitives,Platform 插件决定哪些事件触发哪些 agent、如何排队、如何分配、是否 fan-out。
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
LangBot Host
|
||||
Current: EventLog / runtime AgentBinding / Artifact / State / Transcript / active run authorization
|
||||
Planned: Agent / Binding / Run / RunEvent / audit / result persistence / control primitives
|
||||
|
||||
Agent Platform plugin
|
||||
Agent management UI / project-task model / event routing policy
|
||||
Business queue / multi-agent orchestration / runtime selection policy
|
||||
|
||||
AgentRunner plugin / external harness runtime
|
||||
Connects LiteLLM Agent Platform / remote agent / subprocess / HTTP API
|
||||
Executes and converts provider-native events to AgentRunResult
|
||||
```
|
||||
|
||||
## 2. Platform 与非 Platform 的区别
|
||||
|
||||
当前 LangBot 已经具备 Agent Host 的核心特征:
|
||||
|
||||
- 抹平不同 AgentRunner。
|
||||
- 从 IM / Pipeline 入口触发 runner。
|
||||
- 有 event-first context 方向。
|
||||
- 有 Host-owned EventLog / Transcript / Artifact / State。
|
||||
- 有 runner config 下发和 active run-scoped authorization。
|
||||
- 有 `run_id` 串联 event、transcript、artifact、state 和内存授权上下文。
|
||||
|
||||
这还不是完整 Agent Platform。完整 Platform 至少还需要:
|
||||
|
||||
- 可管理的 agent 资产:agent profile、binding、resource policy、runner config、可用状态。
|
||||
- 可观察的执行生命周期:run status、result stream、失败原因、artifact、审计、回放。
|
||||
- 可运营的控制面:取消、重试、排队、并发、超时、恢复、诊断。
|
||||
- 可产品化的调度体验:事件订阅、路由策略、任务板、多 agent 协作、项目/工作区视图。
|
||||
|
||||
因此,区别不只是“有没有调度”,而是是否具备:
|
||||
|
||||
```text
|
||||
managed agent assets + observable run lifecycle + operational run control
|
||||
```
|
||||
|
||||
Host 负责这些能力的通用事实源和安全边界;Platform 插件负责把它们组装成具体产品。
|
||||
|
||||
### 2.1 当前实现边界
|
||||
|
||||
当前代码中的 `run_id` 已经是重要关联键,但还不是持久 Run 模型:
|
||||
|
||||
- `EventLog` 保存输入事件和审计入口,并记录 `run_id` / `runner_id`。
|
||||
- `Transcript` 保存对话历史投影,并用 `run_id` 关联 assistant 输出。
|
||||
- `ArtifactStore` 保存输入和 runner 产物,并用 `run_id` 做访问边界的一部分。
|
||||
- `PersistentStateStore` 保存 runner state,但不等同于 run lifecycle。
|
||||
- `AgentRunSessionRegistry` 保存 active run 的内存态授权快照,用于 proxy action 校验;进程结束或 run 结束后不作为可回放事实源。
|
||||
|
||||
因此本文后续提到的 `AgentRun` / `AgentRunEvent` / `run.create` / `run.append_result` / `run.cancel` 都是 Runtime Control Plane v2 应新增的能力,不应理解为当前已经存在的 API。
|
||||
|
||||
## 3. 基础概念
|
||||
|
||||
### 3.1 Event
|
||||
|
||||
Event 表示“发生了什么”:
|
||||
|
||||
```text
|
||||
message.received
|
||||
github.issue.opened
|
||||
scheduler.tick
|
||||
user.approved
|
||||
system.webhook.received
|
||||
```
|
||||
|
||||
EBA 负责把外部输入标准化成 event。Event 本身不是 queue,也不等同于一次 agent 执行。当前 `EventLog` 记录的是输入事件和审计事实;未来 `AgentRunEvent` 记录的是某次 run 的输出事件流,二者不能混用。
|
||||
|
||||
### 3.2 Run
|
||||
|
||||
Run 表示“某个 agent / binding / runner 针对某个 event 的一次执行”。
|
||||
|
||||
Run 应由 Host 持久化,成为执行状态、结果、权限和审计的事实源:
|
||||
|
||||
```text
|
||||
run_id
|
||||
event_id
|
||||
agent_id / binding_id
|
||||
runner_id
|
||||
status
|
||||
created_at / started_at / finished_at
|
||||
error / failure_reason
|
||||
delivery target
|
||||
metadata
|
||||
```
|
||||
|
||||
当前 `AgentRunSessionRegistry` 只保存 active run 的内存态授权信息,不足以支撑 Platform 的回放、审计、取消、重试和异步执行。
|
||||
|
||||
### 3.3 RunEvent / RunResult
|
||||
|
||||
RunEvent 是一次 run 过程中产生的结果事件流,对应 runner 返回的 `AgentRunResult`。它不同于 EBA/EventLog 的输入事件:
|
||||
|
||||
```text
|
||||
message.delta
|
||||
message.completed
|
||||
tool.call.started
|
||||
tool.call.completed
|
||||
artifact.created
|
||||
state.updated
|
||||
action.requested
|
||||
run.completed
|
||||
run.failed
|
||||
```
|
||||
|
||||
Host 应保存这些输出事件,按 `run_id + sequence` 可回放。Transcript、Artifact、State 可以由这些 result event 触发写入现有 store,并保留能回溯到 `AgentRunEvent` 的关联。
|
||||
|
||||
### 3.4 Queue
|
||||
|
||||
Queue 不是 EBA 的替代品。
|
||||
|
||||
EBA 负责产生 event;queue 负责处理“这个 event 对应的执行 work item 何时执行、谁来执行、如何取消/重试/恢复”。
|
||||
|
||||
队列可以分两层:
|
||||
|
||||
- **业务队列**:由 Platform 插件管理,例如项目任务、优先级、agent team、workflow、人工审批。
|
||||
- **执行队列 / run queue**:可选 Host 原语,例如 queued / running / completed / failed / cancelled、claim lease、dispatch timeout、orphan recovery。
|
||||
|
||||
第一阶段不要求 Host 内置完整执行队列。Platform 插件可以先管理业务队列;在 Phase 1 / Phase 2 能力落地前,插件仍只能通过现有 `AgentRunOrchestrator.run(...)` 同步执行路径和现有 Host stores 获得有限的 run 关联能力。
|
||||
|
||||
### 3.5 Runtime / Daemon
|
||||
|
||||
Runtime / daemon 表示执行位置或执行能力,例如某台机器上的 Claude Code / Codex CLI。
|
||||
|
||||
当前决策:
|
||||
|
||||
- Host 不在第一阶段维护完整 runtime registry。
|
||||
- AgentRunner 插件可以通过 SDK remote layer 与 daemon 保持连接、心跳和执行通道。
|
||||
- 外部 harness / agent 不应直接访问 LangBot Host 或数据库。访问 LangBot 资源必须通过 daemon / AgentRunner plugin / SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge,并接受 run-scoped authorization 校验。
|
||||
- 如果后续多个插件都需要共享 runtime 状态,再把薄的 `RuntimeLease` / registry 下沉为 Host 通用能力。
|
||||
|
||||
## 4. Host 应新增的最小能力
|
||||
|
||||
第一阶段最重要的不是 daemon registry,而是让 Host 成为 run/result 的事实源。
|
||||
|
||||
### 4.1 AgentRun Store
|
||||
|
||||
新增持久 `AgentRun`:
|
||||
|
||||
```text
|
||||
id / run_id
|
||||
event_id
|
||||
agent_id
|
||||
binding_id
|
||||
runner_id
|
||||
conversation_id / thread_id
|
||||
workspace_id / bot_id
|
||||
status
|
||||
status_reason
|
||||
created_at / started_at / finished_at / updated_at
|
||||
deadline_at
|
||||
cancel_requested_at
|
||||
metadata_json
|
||||
```
|
||||
|
||||
建议 status 至少包含:
|
||||
|
||||
```text
|
||||
created
|
||||
running
|
||||
completed
|
||||
failed
|
||||
cancelled
|
||||
timeout
|
||||
```
|
||||
|
||||
如果后续加执行队列,再引入:
|
||||
|
||||
```text
|
||||
queued
|
||||
claimed
|
||||
dispatching
|
||||
```
|
||||
|
||||
### 4.2 AgentRunEvent Store
|
||||
|
||||
新增持久 `AgentRunEvent`:
|
||||
|
||||
```text
|
||||
id
|
||||
run_id
|
||||
sequence
|
||||
type
|
||||
data_json
|
||||
created_at
|
||||
source
|
||||
artifact_refs_json
|
||||
metadata_json
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 同一 `run_id` 内 `sequence` 单调递增。
|
||||
- append 必须幂等,支持远程 daemon / plugin 重试。
|
||||
- 未知 result type 可保存但 Host 只对已知类型执行副作用。
|
||||
- 大 payload 仍应转 artifact,不直接塞入 result event。
|
||||
|
||||
### 4.3 Run Control API
|
||||
|
||||
Host 提供通用控制原语:
|
||||
|
||||
```text
|
||||
run.create
|
||||
run.get
|
||||
run.list
|
||||
run.events.page
|
||||
run.cancel
|
||||
run.append_result
|
||||
run.finalize
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `run.create` 创建 Host-owned run 和授权快照。
|
||||
- `run.append_result` 只允许受信 SDK/runtime 路径调用,必须绑定 run 创建时固化的授权快照,写入 `AgentRunEvent` 并触发 transcript/artifact/state/delivery 副作用。
|
||||
- `run.finalize` 关闭 run,更新 terminal status。
|
||||
- `run.cancel` 设置取消意图;同步 runner 通过 context/deadline 感知,远程 runner 通过插件/daemon 通道感知。
|
||||
|
||||
第一阶段可以只暴露给插件 runtime action,不一定先做公开 HTTP API。
|
||||
|
||||
### 4.4 Result Persistence In Orchestrator
|
||||
|
||||
当前 `AgentRunOrchestrator.run()` 已经处理:
|
||||
|
||||
```text
|
||||
event -> binding -> context -> runner invocation -> result normalization
|
||||
```
|
||||
|
||||
需要补齐:
|
||||
|
||||
- run 开始时创建 `AgentRun`。
|
||||
- 每个 `AgentRunResult` 进入 `AgentRunEvent`。
|
||||
- `run.completed` / 正常 generator 结束时标记 completed。
|
||||
- `run.failed` / exception / timeout 标记 failed 或 timeout。
|
||||
- `state.updated`、`artifact.created`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。
|
||||
|
||||
### 4.5 Authorization Snapshot
|
||||
|
||||
异步或远程执行时,run 创建时必须固化授权快照:
|
||||
|
||||
- runner identity
|
||||
- binding identity
|
||||
- caller plugin identity
|
||||
- resource policy
|
||||
- allowed tools/models/files/knowledge bases/storage scopes
|
||||
- state scopes
|
||||
- conversation/thread/workspace scope
|
||||
|
||||
后续 append result、state API、artifact API、history API 都以这个 snapshot 校验,不重新扩大权限。
|
||||
|
||||
## 5. SDK 侧应新增的最小能力
|
||||
|
||||
SDK 不需要马上定义完整 daemon registry,但需要让插件和 runner 使用 Host run/result 能力。
|
||||
|
||||
### 5.1 Entities
|
||||
|
||||
新增或补齐:
|
||||
|
||||
```text
|
||||
AgentRun
|
||||
AgentRunStatus
|
||||
AgentRunEvent
|
||||
RunEventPage
|
||||
RunCreateRequest / RunCreateResult
|
||||
RunAppendResultRequest
|
||||
```
|
||||
|
||||
这些是 Host control primitives,不替代 `AgentRunContext` / `AgentRunResult`。
|
||||
|
||||
### 5.2 Proxy Methods
|
||||
|
||||
在 SDK proxy 中提供:
|
||||
|
||||
```python
|
||||
create_run(...)
|
||||
get_run(run_id)
|
||||
list_runs(...)
|
||||
page_run_events(run_id, cursor=None, limit=...)
|
||||
cancel_run(run_id)
|
||||
append_run_result(run_id, result, sequence=None)
|
||||
finalize_run(run_id, status, error=None)
|
||||
```
|
||||
|
||||
访问边界:
|
||||
|
||||
- 普通 AgentRunner 在同步 `run(ctx)` 内不一定需要直接调用这些 API;Host orchestrator 可自动记录。
|
||||
- Platform 插件可以创建/查询/取消 run。
|
||||
- AgentRunner 插件或 daemon bridge 可以 append/finalize 自己负责的 run。
|
||||
- 外部 harness 仍不能直接调用 Host;必须经 SDK runtime / proxy / bridge。
|
||||
|
||||
### 5.3 Plugin-Daemon Heartbeat
|
||||
|
||||
远程 daemon 的初始心跳可以是 SDK / AgentRunner plugin 私有能力:
|
||||
|
||||
```text
|
||||
daemon <-> AgentRunner plugin / SDK remote layer <-> LangBot plugin runtime <-> Host
|
||||
```
|
||||
|
||||
Host 第一阶段只需要知道:
|
||||
|
||||
- 相关插件是否在线。
|
||||
- run 是否有 progress/result。
|
||||
- run 是否超时或取消。
|
||||
|
||||
如果后续需要跨插件共享 daemon 可用性,再把 heartbeat/registry 下沉为 Host 能力。
|
||||
|
||||
## 6. Platform 插件应负责什么
|
||||
|
||||
Agent Platform 插件可以负责:
|
||||
|
||||
- 管理哪些 agent 可用。
|
||||
- 维护产品层 agent profile、项目、任务板、workflow、team。
|
||||
- 订阅 EBA event,决定哪些 event 触发哪些 agent。
|
||||
- 维护业务 queue:优先级、重试策略、人工审批、分配规则。
|
||||
- 选择 runner / runtime / daemon。
|
||||
- 在 Run Control API 落地后,调用 Host run API 创建、取消、查询执行。
|
||||
- 展示 run status、result stream、artifact、失败原因和审计。
|
||||
|
||||
Platform 插件不应负责:
|
||||
|
||||
- 在 Host Run Ledger 落地后,私有保存通用 run/result 事实源。
|
||||
- 绕过 Host 直接写 transcript/artifact/state。
|
||||
- 让外部 harness 直接访问 LangBot DB 或 Host 内部资源。
|
||||
- 把某个业务队列语义强塞进 AgentRunner Protocol v1。
|
||||
|
||||
## 7. 与 EBA 的关系
|
||||
|
||||
EBA 做好后,事件流可以进入两种路径。
|
||||
|
||||
直接执行路径:
|
||||
|
||||
```text
|
||||
EventGateway
|
||||
-> EventRouter resolves AgentBinding
|
||||
-> AgentRunOrchestrator.run(event, binding)
|
||||
-> Host records AgentRun / AgentRunEvent (after Run Ledger lands)
|
||||
-> delivery
|
||||
```
|
||||
|
||||
Platform 插件编排路径:
|
||||
|
||||
```text
|
||||
EventGateway
|
||||
-> Platform plugin receives/subscribes event
|
||||
-> plugin applies policy / business queue
|
||||
-> plugin creates Host run (after Run Control API lands)
|
||||
-> runner/plugin/daemon executes
|
||||
-> Host records result and state
|
||||
-> plugin displays / Host delivers
|
||||
```
|
||||
|
||||
这两条路径最终应共享 Host run/result/artifact/state 事实源。当前阶段可共享的是 event/transcript/artifact/state 和同步执行链路;持久 run/result ledger 需要 Runtime Control Plane v2 Phase 1 补齐。区别在于是否有 Platform 插件参与产品化调度和业务队列。
|
||||
|
||||
## 8. 与 AgentRunner Protocol v1 的关系
|
||||
|
||||
本设计不改变 v1 的 runner 可见合同:
|
||||
|
||||
```text
|
||||
AgentRunContext -> AgentRunner.run(ctx) -> AgentRunResult stream
|
||||
```
|
||||
|
||||
必须保持:
|
||||
|
||||
- `AgentRunContext` 不塞入 daemon/worker/pod 细节。
|
||||
- `AgentRunResult` 仍是 runner 输出的统一事件流。
|
||||
- 普通 runner 不需要知道 task queue / runtime registry。
|
||||
- 远程 harness 可以自管 session、tool loop、MCP、上下文压缩,但访问 LangBot 资源必须通过 SDK proxy / bridge。
|
||||
- Runtime-managed execution 是 placement / transport 选择,不是普通 runner 协议的强制概念。
|
||||
|
||||
## 9. 分阶段实施建议
|
||||
|
||||
### Phase 1: Run Ledger
|
||||
|
||||
目标:Host 成为执行状态和结果事实源。
|
||||
|
||||
范围:
|
||||
|
||||
- `AgentRun` 表。
|
||||
- `AgentRunEvent` 表。
|
||||
- Orchestrator 自动创建/更新 run。
|
||||
- Journal 持久化每个 `AgentRunResult`。
|
||||
- Run 查询和事件分页 API。
|
||||
- SDK entities + proxy 方法。
|
||||
|
||||
复杂度:中等。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 12-20 个文件
|
||||
SDK: 4-8 个文件
|
||||
Tests: 8-15 个文件
|
||||
```
|
||||
|
||||
### Phase 2: Platform Plugin Queue On Host Run Primitives
|
||||
|
||||
目标:Platform 插件管理业务 queue,Host 提供 run/result/cancel 原语。
|
||||
|
||||
范围:
|
||||
|
||||
- `run.create`
|
||||
- `run.cancel`
|
||||
- `run.append_result`
|
||||
- `run.finalize`
|
||||
- result append 的 sequence/idempotency。
|
||||
- 受权限保护的远程 append/finalize。
|
||||
- Platform 插件可基于 Host run 构建任务板和调度体验。
|
||||
|
||||
复杂度:中等偏高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 20-35 个文件
|
||||
SDK: 8-14 个文件
|
||||
Tests: 15-25 个文件
|
||||
```
|
||||
|
||||
### Phase 3: Optional Host Execution Queue / Claim Lease
|
||||
|
||||
目标:当多个插件重复实现 claim/cancel/retry/recovery 时,再下沉执行队列到 Host。
|
||||
|
||||
范围:
|
||||
|
||||
- `queued/running/completed/failed/cancelled` 状态机扩展。
|
||||
- `claim_run` / `lease_until`。
|
||||
- dispatch timeout。
|
||||
- retry / orphan recovery。
|
||||
- cancel propagation。
|
||||
- 并发 claim 防重。
|
||||
|
||||
复杂度:高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 35-55 个文件
|
||||
SDK: 12-20 个文件
|
||||
Tests: 25-40 个文件
|
||||
```
|
||||
|
||||
### Phase 4: Optional Runtime Registry
|
||||
|
||||
目标:当 Host 需要统一管理多个 daemon / worker 时,再引入 runtime registry。
|
||||
|
||||
范围:
|
||||
|
||||
- runtime register / heartbeat / deregister。
|
||||
- capability report:provider、version、login status、workspace access、slot。
|
||||
- runtime online/offline。
|
||||
- runtime scoped auth。
|
||||
- runtime audit。
|
||||
- runtime gone recovery。
|
||||
- task wakeup / long polling / websocket。
|
||||
- 多 Host 实例下的 relay / distributed lock。
|
||||
|
||||
复杂度:很高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 55-80+ 个文件
|
||||
SDK: 18-30 个文件
|
||||
Tests: 40+ 个文件
|
||||
```
|
||||
|
||||
不建议现在直接进入此阶段。
|
||||
|
||||
## 10. 设计原则
|
||||
|
||||
- 先把 run/result 事实源做进 Host,再谈完整 runtime control plane。
|
||||
- Agent Platform 产品做插件;Host 做基础设施。
|
||||
- Host 不写业务调度策略,但要保存通用状态、结果、权限和审计。
|
||||
- EBA event 不是 queue;queue 是执行生命周期问题。
|
||||
- 业务 queue 可以先在 Platform 插件里;执行 queue 只有在复用需求明确后再下沉 Host。
|
||||
- Daemon registry 不应污染 AgentRunner Protocol v1。
|
||||
- 外部 harness 不直接访问 LangBot Host 或 DB。
|
||||
- 所有 LangBot 资源访问必须走 SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge。
|
||||
- Docker / remote / local subprocess 只是 runtime placement,不是 runner 协议差异。
|
||||
|
||||
## 11. 非目标
|
||||
|
||||
当前阶段不做:
|
||||
|
||||
- 完整 Multica 式 runtime registry。
|
||||
- Host 内置项目管理、任务板、agent team、workflow 产品逻辑。
|
||||
- 把 daemon heartbeat / worker liveness 放进 `AgentRunContext`。
|
||||
- 把业务 queue 定义为 AgentRunner Protocol 字段。
|
||||
- 让 Platform 插件私有保存 run/result 事实源。
|
||||
- 让外部 agent/harness 直连 Host 内部资源。
|
||||
|
||||
## 12. 待定问题
|
||||
|
||||
- Host 是否需要最小持久 `Agent` / `Binding` 模型,还是继续由 Pipeline / Platform 插件投影运行期 `AgentBinding`。
|
||||
- Platform 插件创建 run 时,是否传完整 `AgentBinding` snapshot,还是引用 Host-owned binding id。
|
||||
- `AgentRunEvent` 与现有 `EventLog` / `Transcript` 的查询关系:直接 join,还是通过专门 view 聚合。
|
||||
- `run.append_result` 的认证粒度:runner plugin identity、run token、scoped capability token,或 SDK runtime 内部 channel。
|
||||
- 取消语义:同步 runner、external harness runtime/session 如何统一感知 cancel。
|
||||
- 何时把插件私有 daemon heartbeat 提升为 Host `RuntimeLease`。
|
||||
- 若未来 Host 做 claim lease,Platform 插件业务 queue 与 Host execution queue 如何避免双队列混乱。
|
||||
154
docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md
Normal file
154
docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Run Steering 与 Compaction Checkpoint(Design Note)
|
||||
|
||||
本文档记录两项 Host/runner 协作能力:**运行中消息注入(steering / follow-up)**和
|
||||
**压缩摘要持久化(compaction checkpoint)**。两者来自官方 local-agent 对照
|
||||
Pi agent harness(`pi-mono/packages/agent`,下称 pi-agent-core)的差距分析:
|
||||
local-agent 已移植 Pi 的事件生命周期、并行工具语义、hook 扩展点和压缩预算模型,
|
||||
这两项需要 Host 协议、授权与 runner turn 边界协同才能闭环。
|
||||
|
||||
> 本文是设计备忘,不是 schema 事实源。涉及的数据结构最终落到
|
||||
> [PROTOCOL_V1.md](./PROTOCOL_V1.md);上下文边界语义以
|
||||
> [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 为准;
|
||||
> run 持久化与控制原语以 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 为准。
|
||||
|
||||
## 1. Run Steering / Follow-up(运行中消息注入)
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
IM 场景下用户在 agent 运行中追加消息非常常见(补充信息、纠正方向、"算了别查了")。
|
||||
当前主线是 `one event -> one AgentBinding -> one run_id -> one runner`
|
||||
(PROTOCOL_V1 §13):同会话的新消息要么等待当前 run 结束后触发新 run,
|
||||
要么并发触发独立 run。两种行为都无法把新消息送进**正在执行的 tool loop**,
|
||||
用户体验是"agent 自顾自跑完过期任务,然后才看到新消息"。
|
||||
|
||||
cancel(PROTOCOL_V1 §10)不解决这个问题:cancel 丢弃已完成的工作;
|
||||
steering 是在保留当前进度的前提下改变后续方向。
|
||||
|
||||
### 1.2 Pi 的参考语义
|
||||
|
||||
pi-agent-core 区分两个队列,注入时机都在 turn 边界,不打断进行中的模型流或工具执行:
|
||||
|
||||
- **steering**:运行中插入。当前 assistant 消息的全部 tool call 完成后、
|
||||
下一次模型调用前,注入排队的用户消息;模型在下一 turn 看到它们。
|
||||
- **follow-up**:排队后续工作。仅当没有 pending tool call 且没有 steering 消息、
|
||||
run 即将自然结束时检查;若有排队消息则注入并继续下一 turn,而不是结束 run。
|
||||
|
||||
两个队列各自支持 `one-at-a-time`(每次注入一条)和 `all`(一次注入全部)模式。
|
||||
|
||||
### 1.3 设计方向
|
||||
|
||||
职责划分遵循既有原则:Host 拥有事件路由和会话事实源,runner 拥有 turn 边界。
|
||||
|
||||
- **Host 侧**:BindingResolver / dispatch 层识别"同 conversation 存在 active run
|
||||
且 runner 声明支持 steering"的新消息事件,将其写入 run-scoped steering queue,
|
||||
并标记该事件已被在途 run 认领(不再触发新 run,避免破坏 §13 的基数约束)。
|
||||
事件仍照常进 EventLog / Transcript(事实源不变,改变的只是触发行为)。
|
||||
- **Runner 侧**:在 turn 边界(tool batch 完成后、下一次模型调用前,以及 run
|
||||
即将自然结束前)通过 run-scoped pull API 拉取 pending steering 输入,
|
||||
注入 working context。local-agent 的 `AgentLoopHooks.prepare_next_turn` /
|
||||
`should_stop_after_turn` 已预留了对应的注入点。
|
||||
- **能力协商**:runner manifest 声明 `steering` capability(参照 PROTOCOL_V1 §4.3);
|
||||
未声明的 runner 保持现状(新消息按现有规则另起 run)。
|
||||
- **回执**:被 steering 消费的事件通过 EventLog 审计。原始 `message.received`
|
||||
记录在 `metadata.steering` 标记 queued/absorbed 与 `claimed_by_run_id`;
|
||||
runner 成功 pull 后,Host 追加 `steering.injected` 记录并引用源事件。
|
||||
run 结束时仍未被 pull 的已 claim 输入,Host 追加 `steering.dropped` 记录作为
|
||||
dispatch 终态;原始 Transcript 事实不删除。
|
||||
Transcript 继续只表示会话事实,不扩展 dispatch 行为字段。
|
||||
|
||||
已落地的协议面(最终定义归 PROTOCOL_V1):
|
||||
|
||||
1. `ContextAccess.available_apis` 增加 steering pull 能力位。
|
||||
2. `AgentRunAPIProxy` 增加 steering 拉取 action:默认 `mode=all`,Host 保序返回全部
|
||||
pending 输入;`one-at-a-time` 仅作为 runner 主动节流选项。
|
||||
3. dispatch 层的"认领"规则:`message.received` 可被同 conversation 的 active run
|
||||
吸收,原事件写 EventLog / Transcript,dispatch 行为写入 EventLog metadata。
|
||||
4. Host 对单 run steering queue 设置内存上限,队列满时不再 claim 新消息,消息回到
|
||||
正常 dispatch 路径,避免 active run 无限吞入同会话输入。
|
||||
|
||||
### 1.4 边界
|
||||
|
||||
- 不引入 Host 替 runner 做 prompt 拼接:Host 只递队列,注入位置和格式由 runner 决定。
|
||||
- 不与 observer / fan-out 混淆:steering 仍是单 run 内的输入补充,不产生第二个 runner。
|
||||
- 远程 / 外部 harness runner(claude-code、codex 等)若其底层 session 自带
|
||||
steering 能力,adapter 可以直接转发;协议面保持一致。
|
||||
|
||||
## 2. Compaction Checkpoint 持久化
|
||||
|
||||
### 2.1 问题
|
||||
|
||||
local-agent 当前是无状态 runner:每次 run 重新拉取 transcript 尾部
|
||||
(默认 50 条)、重新估算 token、重新生成压缩摘要。后果:
|
||||
|
||||
- 长会话中每 run 重复压缩计算,摘要每次重新生成,不同 run 之间措辞漂移,
|
||||
对 provider KV cache 不友好(AGENT_CONTEXT_PROTOCOL §"Summary checkpoint 稳定"
|
||||
已写明期望:只有压缩发生时才产生新 checkpoint)。
|
||||
- 历史一旦超过 fetch limit,更早的内容永久不可见——没有 checkpoint 记录
|
||||
"已压缩到哪里、压缩出了什么"。
|
||||
|
||||
pi-agent-core 把 compaction 条目持久化进 session tree:摘要带
|
||||
`tokensBefore` 和覆盖范围,后续 turn 直接复用,只在再次越过阈值时增量压缩。
|
||||
|
||||
### 2.2 现状盘点
|
||||
|
||||
协议面和主消费路径已具备:
|
||||
|
||||
- State / Storage API 已定义(PROTOCOL_V1 §8 "State / Storage"),
|
||||
且 AGENT_CONTEXT_PROTOCOL 已点名 `summary.checkpoint` 是 state 的预期用法。
|
||||
- Host 会根据 binding state policy 暴露 `ContextAccess.available_apis.state`。
|
||||
- local-agent 会在 state API 可用时读取/写入 `runner.compaction.checkpoint`;
|
||||
缺失、schema 不匹配、conversation 不匹配或游标失败时回退尾部历史拉取。
|
||||
- LLM 生成摘要**不依赖**本项 Host 能力——runner 用已授权的 `invoke_llm`
|
||||
即可生成;checkpoint 只解决"存下来、下次复用"。
|
||||
|
||||
### 2.3 设计方向
|
||||
|
||||
- **存放位置**:state,scope=`conversation`(小 JSON,符合 PROTOCOL_V1 §8
|
||||
对 state/storage 的边界建议)。若未来摘要膨胀,超出部分放 storage 并在
|
||||
state 中留引用。
|
||||
- **key 约定**:`runner.compaction.checkpoint`(runner 命名空间内)。
|
||||
- **内容约定**(schema 落 PROTOCOL_V1 或 runner 文档,此处只列语义):
|
||||
- `schema_version`
|
||||
- `summary`:压缩摘要文本(LLM 生成或确定性生成)
|
||||
- `covers_until`:已被摘要覆盖的 transcript 游标(seq / message id),
|
||||
是增量压缩和"从哪继续拉历史"的锚点
|
||||
- `tokens_before` / `created_at`:诊断与失效判断
|
||||
- **消费流程**:run 开始时读 checkpoint → 只拉取 `covers_until` 之后的
|
||||
transcript → 压缩触发时基于旧摘要增量生成新摘要、写回新 checkpoint。
|
||||
checkpoint 缺失或解析失败时回退到现行为(全量拉尾部),保证向后兼容。
|
||||
- **失效规则**:`covers_until` 在 Host transcript 中不存在(会话被清理 / 重置)
|
||||
即作废;runner 不得信任跨 conversation 的 checkpoint。
|
||||
- **授权**:Host 对声明需要 state 的 runner binding 开启
|
||||
`available_apis.state`;校验沿用现有 run-scoped state 校验
|
||||
(scope、key、value 大小、JSON 可序列化,见 PROTOCOL_V1 §7.2 对
|
||||
`state.updated` 的要求)。
|
||||
|
||||
### 2.4 相关但独立的工作
|
||||
|
||||
- **tokenizer / usage metadata 透传**:runner 目前用 chars/4 启发式估 token,
|
||||
对 CJK 偏低 3-4 倍,压缩触发系统性偏晚。Host 应在模型响应或
|
||||
`ctx.runtime.metadata` 透传 provider usage(prompt/completion tokens)与
|
||||
model context window(LiteLLM model-info 工作)。该项不阻塞 checkpoint
|
||||
落地,但决定压缩触发的准确性。
|
||||
|
||||
## 3. 实施拆分
|
||||
|
||||
| 项 | 归属 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| steering queue、事件认领、基础审计 | LangBot Host(dispatch / binding 层) | 已落地,含队列上限与未消费 dropped 终态 |
|
||||
| steering pull API + capability 位 | PROTOCOL_V1 + SDK proxy | 已落地 |
|
||||
| turn 边界拉取与注入 | langbot-local-agent | 已落地 |
|
||||
| local-agent 对 state API 的 checkpoint 读写 | langbot-local-agent | 已落地 |
|
||||
| checkpoint key / 内容 / 失效约定 | PROTOCOL_V1 + local-agent README | 已落地 |
|
||||
| LLM 压缩摘要生成 | langbot-local-agent | 已落地(`invoke_llm`,失败回退确定性摘要) |
|
||||
| usage / context-window metadata 透传 | LangBot Host(model 层) | LiteLLM model-info |
|
||||
|
||||
剩余工作应优先补 usage / context-window metadata。streaming delivery 衔接依赖
|
||||
`ctx.delivery` 编辑/追加语义,不建议在协议能力缺失时硬编码。
|
||||
|
||||
## 4. 开放问题
|
||||
|
||||
- streaming delivery 下 steering 注入后,前序 turn 已流出的内容与新 turn
|
||||
输出在 IM 消息编辑面的衔接(涉及 `ctx.delivery` 能力,待 delivery 演进定)。
|
||||
- checkpoint 是否需要 Host 侧主动失效通知(如会话清空时删除对应 state key)。
|
||||
当前实现靠 runner 读取时校验并回退,功能不阻塞。
|
||||
111
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
111
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Agent Runner Security Hardening
|
||||
|
||||
本文档记录 agent-runner 插件化进入生产发布前需要补齐的安全与稳定加固项。
|
||||
|
||||
## 状态
|
||||
|
||||
**当前结论:暂不塞进本阶段 agent-runner plugin 协议闭环。**
|
||||
|
||||
本阶段目标是验证 LangBot 可以通过统一的 `run(event, binding)` 协议接入 `local-agent` 与外部 harness runner(当前官方路径为 LiteLLM Agent Platform runner),并能传递事件、上下文、资源句柄、状态和结果流。
|
||||
|
||||
安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。
|
||||
|
||||
> **硬规则**:能执行代码 / 访问工作目录的外部 harness runner(Claude Code、Codex、Kimi Code 等)不得在生产环境默认启用或隐式开启。self-host stdio / 容器内部署可以作为管理员显式 opt-in,并在配置或 UI 中标明 operator-owned execution risk;只有生产默认启用、托管云 runner 或 LangBot 承诺提供受管执行环境时,才要求完成本文 full Release Gate。
|
||||
|
||||
## Multica 对比结论
|
||||
|
||||
对照 Multica 当前 daemon / runtime 模型,可以采用类似边界:
|
||||
|
||||
- Multica 的 agent 不运行在 Multica server 上,而是由用户机器上的 daemon 调用本机已安装的 AI coding tool;runtime 不是 server,也不是 container。
|
||||
- 标准任务由 daemon 在 workspace root 下创建 per-task environment;但 `local_directory` 场景会直接在用户指定目录原地操作,只做绝对路径、路径清理、系统根目录 / home 黑名单、symlink realpath、读写能力和同路径串行锁校验。
|
||||
- 子进程通过 `exec.CommandContext`、timeout、cwd 和 env 运行;custom args 只过滤 protocol-critical flags,custom env 只阻止覆盖 daemon 内部变量和关键路径变量。它没有尝试阻止外部 CLI 读取该 OS 用户本来能访问的所有宿主路径。
|
||||
- MCP / secret 的约束更具体:Claude 走 `--mcp-config` + strict config;Codex 把 managed MCP 写入 per-task `$CODEX_HOME/config.toml`,避免 secret 出现在 argv / 日志;agent token 优先使用 task-scoped token。
|
||||
- Skill 安全边界也明确留给用户和目标工具:第三方 skill 不由 Multica 签名、审计或沙箱化。
|
||||
- provider-native sandbox 是 opportunistic guardrail,不是统一安全承诺。例如 Codex 在部分平台可写 managed sandbox config,但平台限制下也可能退回更宽松模式;Claude daemon mode 也会使用自动授权 / bypass 类能力以保证无人值守执行。
|
||||
|
||||
因此,LangBot 不应把“完整约束外部 harness 的宿主文件 / 进程 / CPU / 内存 / native tool 能力”作为当前协议闭环或 self-host opt-in 的前置条件。当前阶段应承认外部 harness 是 operator-owned execution,并把 LangBot 可控的最小护栏补齐。
|
||||
|
||||
## 启用级别
|
||||
|
||||
| 场景 | 当前策略 | LangBot 必须负责 | 不作为当前阶段目标 |
|
||||
| --- | --- | --- | --- |
|
||||
| self-host stdio 外部 harness | 管理员显式 opt-in,默认关闭。 | 风险提示、runner/binding 权限摘要、Host 资源授权、Host 生成路径约束、env / secret 过滤、MCP scoped projection、timeout / cancel / output bound、state / audit。 | 阻止该 CLI 访问同一 OS 用户本来可访问的任意宿主文件、进程或全局 CLI 配置。 |
|
||||
| 容器内部署外部 harness | operator 通过容器镜像、挂载、环境变量和网络策略承担执行边界。 | 不假设 privileged container;只投影授权资源;文档提示最小挂载和最小 env;沿用 self-host 最小护栏。 | 在容器内再实现一套完整 VM / cgroup / seccomp 策略。 |
|
||||
| managed/cloud/default external harness | 只有完成 full Release Gate 后才能默认启用。 | 受管 workspace、容器/VM/process isolation、CPU / memory / disk / network / output quotas、完整 lifecycle cleanup、first-class audit 和 admin control。 | 无。 |
|
||||
|
||||
## 责任边界
|
||||
|
||||
### LangBot Host 负责
|
||||
|
||||
- 资源授权:决定某个 `run_id` / binding 可以访问哪些模型、RAG、MCP、skill、artifact、history、state。
|
||||
- 资源投影:只把授权后的资源句柄、配置片段或上下文文件传给 runner。
|
||||
- 路径策略:限制 Host 生成的 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 仍必须在 Host 可控范围内完成资源授权、路径限制、secret 过滤和审计记录;stdio / 容器内显式启用时,外部 harness 对宿主 OS 的最终访问能力由 operator 的 CLI、账户、容器和挂载策略承担。
|
||||
|
||||
## 当前 MVP 可接受边界
|
||||
|
||||
当前阶段可以接受以下前提:
|
||||
|
||||
- 由可信管理员配置 runner binding,并显式启用外部 harness 风险模式。
|
||||
- 工作目录和 context 输出目录为显式配置或 host 生成路径。
|
||||
- 外部 runner 应尽量使用保守权限,例如 plan / no-write 模式或禁用高风险工具;具体 provider-native 高风险模式只能作为管理员显式 opt-in 的 dev / smoke path。
|
||||
- 通过 timeout、max turns、输出长度和进程取消降低失控风险。
|
||||
- 通过 host-owned state 保存 `external.session_id`、`external.working_directory` 等 resume 所需指针。
|
||||
|
||||
这些前提足够做本地 E2E 与协议验收,不等同于生产发布完成。
|
||||
|
||||
## Admin Opt-in Minimum Guardrails
|
||||
|
||||
外部 harness 如果只作为 self-host stdio / 容器内部署的管理员显式 opt-in,本阶段不要求完成 full OS sandbox,但至少需要:
|
||||
|
||||
- 默认关闭外部 harness binding;启用时显示 runner 权限、工作目录、MCP / skill 投影和危险权限提示。
|
||||
- Host 生成的 workspace / context / artifact 路径必须在 allowlist root 内;管理员显式工作目录必须做 absolute path、`realpath`、系统根目录 / home 黑名单、`..` 逃逸和 symlink 检查。
|
||||
- 子进程环境使用 allowlist 或强 denylist,禁止覆盖 LangBot 内部变量、token、workspace root、runner state root、`PATH` / `HOME` 等关键变量;日志、错误、transcript 和 artifact metadata 必须 redaction。
|
||||
- MCP 配置必须是 scoped projection;secret 不应出现在 argv 或普通日志;LangBot MCP bridge 只暴露当前 run 授权的 tool surface。
|
||||
- Skill 投影必须来自 Host 已授权资源;记录来源、版本 / hash 或摘要;投影目录在 run / workspace 生命周期内可清理。
|
||||
- CLI 参数需要过滤 protocol-critical flags;高风险 permission mode 必须是显式配置或显式 MVP 标记,不能作为用户不可见的安全承诺。
|
||||
- 子进程必须支持 timeout、cancel、进程组清理和输出上限;CPU / memory / container hard quota 仅对 managed/cloud/default external harness 强制。
|
||||
- state / workspace / artifact 至少要有 owner scope、session id 记录、cleanup path 和 audit-lite 事件。
|
||||
- 测试覆盖 path escape、env / secret 泄漏、MCP deny、timeout、cancel、resume、cleanup 和 audit 字段完整性。
|
||||
|
||||
## Release Gate Checklist
|
||||
|
||||
下表是进入“生产默认启用 / managed external harness / LangBot 承诺提供受管执行环境”前的 full gate。状态快照必须与 [STATUS.md](./STATUS.md) 的日期同步更新;“已补”只代表 self-host stdio / 容器内管理员显式 opt-in 的最小护栏,不代表 managed/default runner 已具备完整生产隔离。
|
||||
|
||||
| 项目 | 状态 | 当前已补 | 仍缺口 / 发布前要求 |
|
||||
| --- | --- | --- | --- |
|
||||
| Path isolation | Partial | ArtifactStore 对 file artifact 使用 `realpath` + root containment 复核;Host 侧 run/session 生命周期和 resource authorization 已建立。 | LiteLLM Agent Platform 所在机器的 workspace、挂载、CLI 可访问路径和 cleanup 由部署侧承担;Host 生成 workspace / context / artifact root 还缺统一 allowlist、mount 策略、TTL cleanup 和 orphan cleanup。 |
|
||||
| Permission boundary | Partial | Host 已有 manifest permissions 与 binding resource policy 交集、run-scoped authorization snapshot、`ctx.context.available_apis`、proxy action `caller_plugin_identity` 校验;LiteLLM gateway 回访 LangBot 资产时必须携带 `run_id` 并接受 Host 校验。 | 外部 harness 的 native 文件 / 进程 / tool 能力仍属于 operator-owned execution;manifest permissions 只约束 LangBot 持有资源,生产默认或 managed runner 需要容器/VM/OS 级隔离、tool allow/deny 和可审计审批。 |
|
||||
| Secret handling | Partial | LangBot 持有的资源访问不直接投影 secret 给 harness;LiteLLM gateway 使用 bearer token 保护入口,真实 LangBot 资产请求回到 Host action 校验。 | 仍缺 Host 全链路统一 redaction policy、transcript / artifact metadata / admin UI 脱敏规则、secret 来源与轮换策略、跨 runner 的配置脱敏审计;LiteLLM 部署侧的 provider token、CLI auth 和日志脱敏另行负责。 |
|
||||
| MCP policy | Partial | LiteLLM runner 暴露稳定 HTTP MCP gateway,只提供 history page、knowledge retrieve、authorized tool call 等最小工具面;错误或过期 `run_id` 会被 Host 拒绝。 | 缺 Host / Admin 级外部 MCP server allowlist、scoped token 生命周期、tool allow / deny 策略、危险工具审批和 MCP 调用审计;后续如 LiteLLM 原生支持 run-scoped MCP session,应改为平台级传递 run scope。 |
|
||||
| Skill access policy | Partial | Host resource builder 会按 runner capability 和 resource policy 暴露 skill-backed scoped tool;当前 code-agent runner 不再接受用户手写 `skills-json`,避免 runner binding 任意投影 skill;skill tool 路径和可见性已有部分单测。 | 缺 code-agent harness 的发布级 skill 来源验证、版本 / hash 记录、projection cleanup 和审计;如后续需要 harness-native skill 文件,也必须由 Host / sandbox 生成受限 tool surface,不能绕过 SDK runtime 访问 LangBot 资源。 |
|
||||
| Process isolation | Partial | Host runtime deadline 和 runner timeout 已有;LiteLLM runner 对 HTTP 调用设置 timeout 并把服务错误映射为受控失败。 | 外部 harness 子进程、取消、输出上限、CPU / 内存 / 文件 / 容器 hard quota、网络策略、长期 workspace GC 和平台级 cancel/audit 由 LiteLLM 部署侧或后续 managed/cloud/default external harness gate 负责。 |
|
||||
| State lifecycle | Partial | PersistentStateStore 有 runner / binding / scope 隔离、JSON size limit、state get / set / list / delete;LiteLLM runner 会写回外部 session id,避免把具体 provider 的内部路径当成 Host resume 事实。 | 缺 session / workspace / artifact TTL、过期清理、迁移策略、orphan cleanup 和 lifecycle audit;managed/default runner 需要 Host first-class workspace 生命周期。 |
|
||||
| Audit first-class | Partial | EventLog、Transcript、ArtifactStore、PersistentStateStore 已能记录主链路事实;proxy 校验失败会写 warning。 | 资源授权快照、外部命令、MCP tool 决策、secret redaction、cleanup、resume / workspace 生命周期还不是一等 audit surface。 |
|
||||
| UI / Admin control | Missing | 当前 Pipeline runner 配置能选择插件 runner。 | 缺管理员可见的 runner 权限摘要、风险提示、生产禁用 / 启用入口、resource binding 管理、MCP / skill / workspace 策略 UI。 |
|
||||
| Test matrix | Partial | 已有 run authorization、caller identity、artifact、state、history / event pull API、LiteLLM HTTP session、run_id prompt 注入、gateway MCP 回访、错误 run_id 拒绝、skill visibility 等单测;runner 仓库 `pytest` / `ruff` 应保持通过。 | 仍缺 Host UI smoke、真实 LiteLLM Agent Platform harness E2E、生产禁用入口、MCP deny / dangerous tool 审计、workspace cleanup / audit 完整性矩阵;CPU / memory / container quota 测试属于 managed/cloud/default full gate。 |
|
||||
|
||||
## 非当前范围
|
||||
|
||||
以下内容不属于本阶段协议闭环:
|
||||
|
||||
- 完整异步队列与 issue-centric 产品模型。
|
||||
- 复杂 workflow engine。
|
||||
- 具体 CLI provider 直连适配器全量接入。
|
||||
- EBA 分支的完整迁移由外部 EBA 分支联调;本阶段只复用其需要的 AgentRunner Host 底座。
|
||||
- 发布级安全 hardening 的完整实现。
|
||||
49
docs/agent-runner-pluginization/STATUS.md
Normal file
49
docs/agent-runner-pluginization/STATUS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# AgentRunner Pluginization Status
|
||||
|
||||
本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
|
||||
|
||||
状态快照日期:2026-06-12。
|
||||
|
||||
## 实现状态
|
||||
|
||||
| 领域 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| SDK manifest schema | Done | `AgentRunnerManifest` 包含 typed `capabilities` / `permissions`;未知 capability / permission key 禁止进入 typed model。 |
|
||||
| Runner discovery | Done | Runtime 返回 typed manifest;Host registry 校验单个 runner,失败 warning + skip,不影响其它 runner。 |
|
||||
| Host resource authorization | Done | `ctx.resources` 和 `ctx.context.available_apis` 由 manifest permissions 与 binding policy / run scope 求交后生成。 |
|
||||
| Run authorization snapshot | Done | active run session 冻结 run-scoped resources 与 available APIs;runtime handler 按 snapshot 校验 pull API。 |
|
||||
| Result payload validation | Done | Wire 保持 `{type, data}`;Host 对投递/副作用类 payload 严格校验,tool-call telemetry 宽松,未知 type 忽略并 warning。 |
|
||||
| Old built-in runners | Done | 旧 `src/langbot/pkg/provider/runners/*` 与 `RequestRunner` 路径已从本分支删除。 |
|
||||
| Official runner manifests | Done | `local-agent`、LiteLLM Agent Platform、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 |
|
||||
| Runtime Control Plane v2 | Future | 第一阶段设计为 Host-owned Run Ledger;runtime registry / heartbeat / daemon claim 是后续可选阶段。 |
|
||||
| Full release security gate | Future | self-host / container opt-in 可继续;managed/default external harness 需完成 SECURITY_HARDENING full gate。 |
|
||||
| Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
|
||||
| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 |
|
||||
|
||||
## Spec 与实现已知差距
|
||||
|
||||
- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。
|
||||
- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。
|
||||
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
|
||||
- Artifact 读取路径已检查 `expires_at`,EventLog / Transcript / Artifact 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和大 payload 去重仍是运维收尾项,应在 Runtime Control Plane Phase 1 前补齐。
|
||||
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。
|
||||
- Managed/cloud/default external harness 的 OS/process/network quota、workspace GC、完整 audit/admin control 仍是发布门槛,不是 Protocol v1 已完成能力。
|
||||
|
||||
## Runner 验收状态
|
||||
|
||||
| Runner | 状态 | 最近证据 |
|
||||
| --- | --- | --- |
|
||||
| `plugin:langbot/local-agent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 |
|
||||
| `plugin:langbot/litellm-agent-platform-agent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 HTTP session、run_id prompt 注入和 LangBot MCP gateway;真实 harness E2E 取决于 LiteLLM Agent Platform 部署和 provider 登录态。 |
|
||||
| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
|
||||
|
||||
## 历史高价值记录
|
||||
|
||||
历史报告已合并为本状态页和 QA 指南,不再保留单独进度文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。
|
||||
|
||||
截至 2026-05-29,已有本地 smoke 证明:
|
||||
|
||||
- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。
|
||||
- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 LiteLLM Agent Platform runner,具体 Claude Code / Codex CLI provider 不再由本仓库直接维护。
|
||||
|
||||
这些记录只证明本地协议闭环可用,不代表发布级 security hardening 已完成。
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.4",
|
||||
"langbot-plugin==0.4.3",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
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',
|
||||
]
|
||||
63
src/langbot/pkg/agent/runner/__init__.py
Normal file
63
src/langbot/pkg/agent/runner/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""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 .default_config import AgentRunnerDefaultConfigService
|
||||
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',
|
||||
'AgentRunnerDefaultConfigService',
|
||||
'AgentBindingResolver',
|
||||
'AgentBindingResolutionError',
|
||||
'AgentRunSessionRegistry',
|
||||
'AgentRunSession',
|
||||
'RunAuthorizationSnapshot',
|
||||
'get_session_registry',
|
||||
'MESSAGE_RECEIVED',
|
||||
'MESSAGE_RECALLED',
|
||||
'GROUP_MEMBER_JOINED',
|
||||
'FRIEND_REQUEST_RECEIVED',
|
||||
'RESERVED_EVENT_TYPES',
|
||||
]
|
||||
535
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
535
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""Artifact store for managing Host-owned artifacts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import datetime
|
||||
import typing
|
||||
import uuid
|
||||
import base64
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
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'
|
||||
_ARTIFACT_THREAD_METADATA_KEY = '_langbot_thread_id'
|
||||
UTC = datetime.timezone.utc
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(UTC)
|
||||
|
||||
|
||||
def _as_utc(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _datetime_to_epoch(value: datetime.datetime | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return int(_as_utc(value).timestamp())
|
||||
|
||||
|
||||
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,
|
||||
thread_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,
|
||||
thread_id=thread_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,
|
||||
thread_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
|
||||
thread_id: Thread ID stored as Host-only metadata
|
||||
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())
|
||||
|
||||
metadata_payload = dict(metadata or {})
|
||||
if thread_id is not None:
|
||||
metadata_payload[_ARTIFACT_THREAD_METADATA_KEY] = thread_id
|
||||
|
||||
# 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=_utc_now(),
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.dumps(metadata_payload) if metadata_payload 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
|
||||
if self._is_expired(row):
|
||||
return None
|
||||
return self._row_to_public_dict(row)
|
||||
|
||||
async def get_authorization_metadata(
|
||||
self,
|
||||
artifact_id: str,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Get artifact metadata with Host-only scope fields for authorization."""
|
||||
row = await self._get_internal_record(artifact_id)
|
||||
if row is None:
|
||||
return None
|
||||
metadata = self._row_to_public_dict(row)
|
||||
metadata.update({
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_id,
|
||||
'thread_id': self._load_metadata(row.metadata_json).get(_ARTIFACT_THREAD_METADATA_KEY),
|
||||
})
|
||||
return metadata
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
record = result.scalars().first()
|
||||
if record is not None and self._is_expired(record):
|
||||
return None
|
||||
return record
|
||||
|
||||
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 await 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 cleanup_expired_artifacts(
|
||||
self,
|
||||
*,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> int:
|
||||
"""Delete expired artifact metadata and Host-owned binary blobs.
|
||||
|
||||
Returns the number of artifact metadata rows removed. External/file
|
||||
storage references are only dereferenced from LangBot metadata; their
|
||||
backing lifecycle remains owned by the storage provider.
|
||||
"""
|
||||
if now is None:
|
||||
now = _utc_now()
|
||||
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentArtifact).where(
|
||||
AgentArtifact.expires_at.is_not(None),
|
||||
AgentArtifact.expires_at <= now,
|
||||
)
|
||||
)
|
||||
expired = result.scalars().all()
|
||||
if not expired:
|
||||
return 0
|
||||
|
||||
binary_storage_keys = [
|
||||
artifact.storage_key
|
||||
for artifact in expired
|
||||
if artifact.storage_type == 'binary_storage' and artifact.storage_key
|
||||
]
|
||||
if binary_storage_keys:
|
||||
await session.execute(
|
||||
sqlalchemy.delete(BinaryStorage).where(
|
||||
BinaryStorage.unique_key.in_(binary_storage_keys)
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
sqlalchemy.delete(AgentArtifact).where(
|
||||
AgentArtifact.id.in_([artifact.id for artifact in expired])
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return len(expired)
|
||||
|
||||
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
|
||||
|
||||
async 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:
|
||||
async with aiofiles.open(real_path, 'rb') as f:
|
||||
await f.seek(offset)
|
||||
content = await 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)
|
||||
metadata.pop(_ARTIFACT_THREAD_METADATA_KEY, None)
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _is_expired(
|
||||
row: AgentArtifact,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> bool:
|
||||
if row.expires_at is None:
|
||||
return False
|
||||
if now is None:
|
||||
now = _utc_now()
|
||||
return _as_utc(row.expires_at) <= _as_utc(now)
|
||||
|
||||
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': _datetime_to_epoch(row.created_at),
|
||||
'expires_at': _datetime_to_epoch(row.expires_at),
|
||||
'metadata': self._public_metadata(row.metadata_json),
|
||||
}
|
||||
70
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
70
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""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.
|
||||
|
||||
Callers that source agents from bot/workspace/global configuration must
|
||||
pre-filter candidates to the event scope before calling this resolver.
|
||||
The current AgentConfig model represents one already-selected product
|
||||
Agent and does not carry enough scope metadata to make that decision
|
||||
safely here.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
171
src/langbot/pkg/agent/runner/config_migration.py
Normal file
171
src/langbot/pkg/agent/runner/config_migration.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Helpers for the current AgentRunner config shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
LEGACY_RUNNER_ID_MAP: dict[str, str] = {
|
||||
'local-agent': 'plugin:langbot/local-agent/default',
|
||||
'dify-service-api': 'plugin:langbot/dify-agent/default',
|
||||
'n8n-service-api': 'plugin:langbot/n8n-agent/default',
|
||||
'coze-api': 'plugin:langbot/coze-agent/default',
|
||||
'dashscope-app-api': 'plugin:langbot/dashscope-agent/default',
|
||||
'deerflow-api': 'plugin:langbot/deerflow-agent/default',
|
||||
'langflow-api': 'plugin:langbot/langflow-agent/default',
|
||||
'tbox-app-api': 'plugin:langbot/tbox-agent/default',
|
||||
'weknora-api': 'plugin:langbot/weknora-agent/default',
|
||||
}
|
||||
|
||||
|
||||
class ConfigMigration:
|
||||
"""Configuration helper for agent runner IDs.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve runner ID from ai.runner.id
|
||||
- Migrate legacy ai.runner.runner + ai.<runner-name> blocks
|
||||
- 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
|
||||
|
||||
legacy_runner = runner_config.get('runner')
|
||||
if isinstance(legacy_runner, str):
|
||||
return LEGACY_RUNNER_ID_MAP.get(legacy_runner)
|
||||
|
||||
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]
|
||||
|
||||
legacy_runner = ConfigMigration._legacy_runner_name_for_id(runner_id)
|
||||
if legacy_runner and isinstance(ai_config.get(legacy_runner), dict):
|
||||
return ConfigMigration._normalize_legacy_runner_config(
|
||||
legacy_runner,
|
||||
ai_config[legacy_runner],
|
||||
)
|
||||
|
||||
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', {}))
|
||||
|
||||
legacy_runner = runner_config.get('runner')
|
||||
mapped_runner_id = None
|
||||
if isinstance(legacy_runner, str):
|
||||
mapped_runner_id = LEGACY_RUNNER_ID_MAP.get(legacy_runner)
|
||||
|
||||
if mapped_runner_id and not runner_config.get('id'):
|
||||
runner_config = {
|
||||
key: value
|
||||
for key, value in runner_config.items()
|
||||
if key != 'runner'
|
||||
}
|
||||
runner_config['id'] = mapped_runner_id
|
||||
|
||||
if mapped_runner_id and mapped_runner_id not in runner_configs:
|
||||
legacy_config = ai_config.get(legacy_runner)
|
||||
if isinstance(legacy_config, dict):
|
||||
runner_configs[mapped_runner_id] = ConfigMigration._normalize_legacy_runner_config(
|
||||
legacy_runner,
|
||||
legacy_config,
|
||||
)
|
||||
|
||||
ai_config['runner'] = runner_config
|
||||
ai_config['runner_config'] = runner_configs
|
||||
if mapped_runner_id and legacy_runner in ai_config:
|
||||
ai_config.pop(legacy_runner, None)
|
||||
new_config['ai'] = ai_config
|
||||
|
||||
return new_config
|
||||
|
||||
@staticmethod
|
||||
def _legacy_runner_name_for_id(runner_id: str) -> str | None:
|
||||
for legacy_runner, mapped_runner_id in LEGACY_RUNNER_ID_MAP.items():
|
||||
if mapped_runner_id == runner_id:
|
||||
return legacy_runner
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_legacy_runner_config(
|
||||
legacy_runner: str,
|
||||
legacy_config: dict[str, typing.Any],
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Normalize legacy runner config blocks to current plugin schema quirks."""
|
||||
normalized = dict(legacy_config)
|
||||
|
||||
if legacy_runner == 'local-agent':
|
||||
model = normalized.get('model')
|
||||
if isinstance(model, str):
|
||||
normalized['model'] = {
|
||||
'primary': model,
|
||||
'fallbacks': [],
|
||||
}
|
||||
knowledge_base = normalized.pop('knowledge-base', None)
|
||||
if 'knowledge-bases' not in normalized and isinstance(knowledge_base, str):
|
||||
normalized['knowledge-bases'] = [] if knowledge_base in {'', '__none__', '__none'} else [knowledge_base]
|
||||
|
||||
return normalized
|
||||
243
src/langbot/pkg/agent/runner/config_schema.py
Normal file
243
src/langbot/pkg/agent/runner/config_schema.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Helpers for interpreting AgentRunner DynamicForm configuration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
|
||||
|
||||
FORM_ITEM_TYPE_ALIASES = {
|
||||
'select-llm-model': 'llm-model-selector',
|
||||
'select-knowledge-bases': 'knowledge-base-multi-selector',
|
||||
}
|
||||
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
|
||||
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
||||
FILE_SELECTOR_TYPES = {'file', 'array[file]'}
|
||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
||||
|
||||
|
||||
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
|
||||
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
|
||||
if not isinstance(item_type, str):
|
||||
return item_type
|
||||
return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type)
|
||||
|
||||
|
||||
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 normalize_schema_item_type(item.get('type')) in field_types:
|
||||
yield item
|
||||
|
||||
|
||||
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should resolve model resources for this runner."""
|
||||
return 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()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether the runner wants Host skill-authoring tools."""
|
||||
if descriptor is None:
|
||||
return False
|
||||
return descriptor.capabilities.skill_authoring
|
||||
|
||||
|
||||
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'))
|
||||
item_type = normalize_schema_item_type(item.get('type'))
|
||||
if item_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_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 extract_config_file_resources(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Extract uploaded config file resources from schema-defined file fields."""
|
||||
files: list[dict[str, typing.Any]] = []
|
||||
|
||||
def append_file(value: typing.Any) -> None:
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
file_key = value.get('file_key') or value.get('file_id')
|
||||
if not isinstance(file_key, str) or file_key in NONE_SENTINELS:
|
||||
return
|
||||
files.append({
|
||||
'file_id': file_key,
|
||||
'file_name': value.get('file_name') or value.get('name'),
|
||||
'mime_type': value.get('mime_type') or value.get('mimetype'),
|
||||
'source': 'config',
|
||||
})
|
||||
|
||||
for item in iter_schema_items(descriptor, FILE_SELECTOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
if not field_name:
|
||||
continue
|
||||
value = runner_config.get(field_name, item.get('default'))
|
||||
item_type = normalize_schema_item_type(item.get('type'))
|
||||
if item_type == 'file':
|
||||
append_file(value)
|
||||
elif isinstance(value, list):
|
||||
for entry in value:
|
||||
append_file(entry)
|
||||
|
||||
deduped: dict[str, dict[str, typing.Any]] = {}
|
||||
for file_resource in files:
|
||||
deduped.setdefault(file_resource['file_id'], file_resource)
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
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 = normalize_schema_item_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 = normalize_schema_item_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
|
||||
481
src/langbot/pkg/agent/runner/context_builder.py
Normal file
481
src/langbot/pkg/agent/runner/context_builder.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""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]]
|
||||
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
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class ToolResource(typing.TypedDict):
|
||||
"""Tool resource payload."""
|
||||
|
||||
tool_name: str
|
||||
tool_type: str | None
|
||||
description: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class KnowledgeBaseResource(typing.TypedDict):
|
||||
"""Knowledge base resource payload."""
|
||||
|
||||
kb_id: str
|
||||
kb_name: str | None
|
||||
kb_type: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class SkillResource(typing.TypedDict):
|
||||
"""Skill resource payload."""
|
||||
|
||||
skill_name: str
|
||||
display_name: str | None
|
||||
description: str | None
|
||||
|
||||
|
||||
class FileResource(typing.TypedDict):
|
||||
"""File resource payload."""
|
||||
|
||||
file_id: str
|
||||
file_name: str | None
|
||||
mime_type: str | None
|
||||
source: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
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]
|
||||
skills: list[SkillResource]
|
||||
files: list[FileResource]
|
||||
storage: StorageResource
|
||||
platform_capabilities: dict[str, typing.Any]
|
||||
|
||||
|
||||
class AgentRuntimeContext(typing.TypedDict):
|
||||
"""Agent runtime context."""
|
||||
|
||||
langbot_version: str | None
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _positive_int(value: typing.Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
parsed_value = int(value)
|
||||
if parsed_value > 0:
|
||||
return parsed_value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_llm_model_resource(model_resource: ModelResource) -> bool:
|
||||
operations = model_resource.get('operations')
|
||||
if isinstance(operations, list) and operations:
|
||||
return bool({'invoke', 'stream'} & {str(operation) for operation in operations})
|
||||
return model_resource.get('model_type') != 'rerank'
|
||||
|
||||
async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None:
|
||||
model_mgr = getattr(self.ap, 'model_mgr', None)
|
||||
if model_mgr is None:
|
||||
return None
|
||||
|
||||
for model_resource in resources.get('models', []):
|
||||
if not self._is_llm_model_resource(model_resource):
|
||||
continue
|
||||
|
||||
model_uuid = model_resource.get('model_id')
|
||||
if not isinstance(model_uuid, str) or not model_uuid:
|
||||
continue
|
||||
|
||||
try:
|
||||
model = await model_mgr.get_model_by_uuid(model_uuid)
|
||||
except Exception as exc:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.debug(f'Failed to resolve model context window for {model_uuid}: {exc}')
|
||||
continue
|
||||
|
||||
model_entity = getattr(model, 'model_entity', None)
|
||||
context_length = self._positive_int(getattr(model_entity, 'context_length', None))
|
||||
return context_length
|
||||
|
||||
return None
|
||||
|
||||
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],
|
||||
'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)
|
||||
|
||||
model_context_window_tokens = await self._build_model_context_window_tokens(resources)
|
||||
|
||||
# Build runtime context
|
||||
runtime: AgentRuntimeContext = {
|
||||
'langbot_version': self.ap.ver_mgr.get_current_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': model_context_window_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
# 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
|
||||
permissions = descriptor.permissions
|
||||
history_perms = set(permissions.history)
|
||||
event_perms = set(permissions.events)
|
||||
artifact_perms = set(permissions.artifacts)
|
||||
storage_perms = set(permissions.storage)
|
||||
|
||||
history_page_enabled = 'page' in history_perms and conversation_id is not None
|
||||
history_search_enabled = 'search' in history_perms and conversation_id is not None
|
||||
event_get_enabled = 'get' in event_perms
|
||||
event_page_enabled = 'page' in event_perms and conversation_id is not None
|
||||
artifact_metadata_enabled = 'metadata' in artifact_perms
|
||||
artifact_read_enabled = 'read' in artifact_perms
|
||||
steering_pull_enabled = bool(getattr(descriptor.capabilities, 'steering', False)) and conversation_id is not None
|
||||
|
||||
# Determine state API availability based on binding state_policy.
|
||||
state_enabled = False
|
||||
storage_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
|
||||
|
||||
resource_policy = binding.resource_policy
|
||||
storage_enabled = (
|
||||
('plugin' in storage_perms and resource_policy.allow_plugin_storage)
|
||||
or ('workspace' in storage_perms and resource_policy.allow_workspace_storage)
|
||||
)
|
||||
|
||||
# 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': 'current_event_only',
|
||||
},
|
||||
'available_apis': {
|
||||
'prompt_get': False,
|
||||
'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': storage_enabled,
|
||||
'steering_pull': steering_pull_enabled,
|
||||
},
|
||||
}
|
||||
72
src/langbot/pkg/agent/runner/default_config.py
Normal file
72
src/langbot/pkg/agent/runner/default_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Default AgentRunner binding configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...core import app
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
from . import config_schema
|
||||
from .config_migration import ConfigMigration
|
||||
|
||||
|
||||
class AgentRunnerDefaultConfigService:
|
||||
"""Apply AgentRunner schema-defined defaults to host binding config."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
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, model_uuid: str) -> bool:
|
||||
"""Set model_uuid into the default pipeline runner config when the selector is empty."""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
if pipeline is None:
|
||||
return False
|
||||
|
||||
return await self.set_pipeline_llm_model_if_empty(pipeline, model_uuid)
|
||||
|
||||
async def set_pipeline_llm_model_if_empty(
|
||||
self,
|
||||
pipeline: persistence_pipeline.LegacyPipeline,
|
||||
model_uuid: str,
|
||||
) -> bool:
|
||||
"""Set model_uuid into a pipeline's schema-defined LLM selector if it is empty."""
|
||||
pipeline_config = pipeline.config
|
||||
if not isinstance(pipeline_config, dict):
|
||||
return False
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
if not runner_id:
|
||||
return False
|
||||
|
||||
descriptor = await self._get_runner_descriptor(runner_id)
|
||||
if descriptor is None:
|
||||
return False
|
||||
|
||||
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 False
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config})
|
||||
return True
|
||||
82
src/langbot/pkg/agent/runner/descriptor.py
Normal file
82
src/langbot/pkg/agent/runner/descriptor.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Agent runner descriptor."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerCapabilities,
|
||||
AgentRunnerPermissions,
|
||||
)
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
|
||||
"""Configuration schema using DynamicForm format"""
|
||||
|
||||
capabilities: AgentRunnerCapabilities = pydantic.Field(
|
||||
default_factory=AgentRunnerCapabilities
|
||||
)
|
||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
||||
|
||||
permissions: AgentRunnerPermissions = pydantic.Field(
|
||||
default_factory=AgentRunnerPermissions
|
||||
)
|
||||
"""Requested LangBot resource permissions."""
|
||||
|
||||
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""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.streaming
|
||||
|
||||
def supports_tool_calling(self) -> bool:
|
||||
"""Check if runner supports tool calling."""
|
||||
return self.capabilities.tool_calling
|
||||
|
||||
def supports_knowledge_retrieval(self) -> bool:
|
||||
"""Check if runner supports knowledge retrieval."""
|
||||
return self.capabilities.knowledge_retrieval
|
||||
|
||||
def supports_steering(self) -> bool:
|
||||
"""Check if runner supports run steering/follow-up input."""
|
||||
return bool(getattr(self.capabilities, 'steering', 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}')
|
||||
315
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
315
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""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
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(UTC)
|
||||
|
||||
|
||||
def _datetime_to_epoch(value: datetime.datetime | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
value = value.astimezone(UTC)
|
||||
return int(value.timestamp())
|
||||
|
||||
|
||||
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=_utc_now(),
|
||||
)
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> 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)
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
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)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> 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:
|
||||
query = (
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(EventLog)
|
||||
.where(EventLog.conversation_id == conversation_id, EventLog.id < seq)
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
result = await session.execute(query)
|
||||
count = result.scalar()
|
||||
return count > 0
|
||||
|
||||
def _apply_scope_filters(
|
||||
self,
|
||||
query: typing.Any,
|
||||
bot_id: str | None,
|
||||
workspace_id: str | None,
|
||||
thread_id: str | None,
|
||||
strict_thread: bool,
|
||||
) -> typing.Any:
|
||||
if bot_id is not None:
|
||||
query = query.where(EventLog.bot_id == bot_id)
|
||||
if workspace_id is not None:
|
||||
query = query.where(EventLog.workspace_id == workspace_id)
|
||||
if strict_thread:
|
||||
if thread_id is None:
|
||||
query = query.where(EventLog.thread_id.is_(None))
|
||||
else:
|
||||
query = query.where(EventLog.thread_id == thread_id)
|
||||
return query
|
||||
|
||||
async def cleanup_events_older_than(
|
||||
self,
|
||||
before: datetime.datetime,
|
||||
) -> int:
|
||||
"""Delete EventLog rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 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': _datetime_to_epoch(row.event_time),
|
||||
'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': _datetime_to_epoch(row.created_at),
|
||||
'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,
|
||||
}
|
||||
)
|
||||
210
src/langbot/pkg/agent/runner/host_models.py
Normal file
210
src/langbot/pkg/agent/runner/host_models.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""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."""
|
||||
|
||||
allowed_skill_names: list[str] | None = None
|
||||
"""Allowed skill names. None means all currently visible skills are allowed."""
|
||||
|
||||
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:')
|
||||
131
src/langbot/pkg/agent/runner/invoker.py
Normal file
131
src/langbot/pkg/agent/runner/invoker.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Plugin-runtime invocation for AgentRunner executions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
|
||||
|
||||
from ...core import app
|
||||
from .context_builder import AgentRunContextPayload
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .errors import RunnerExecutionError
|
||||
|
||||
|
||||
class AgentRunnerInvoker:
|
||||
"""Invoke an AgentRunner through the plugin runtime.
|
||||
|
||||
This keeps runtime transport, deadline enforcement, and transport error
|
||||
mapping out of the orchestration state machine.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""Invoke the runner and yield raw result dictionaries."""
|
||||
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:
|
||||
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}')
|
||||
476
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
476
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Agent run orchestrator for coordinating runner execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
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 ...core import app
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .context_builder import AgentRunContextBuilder, AgentRunContextPayload
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .invoker import AgentRunnerInvoker
|
||||
from .query_bridge import QueryRunBridge
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
from .state_scope import build_state_context
|
||||
from ...provider.tools.loaders import skill as skill_loader
|
||||
|
||||
|
||||
MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES
|
||||
|
||||
|
||||
class AgentRunOrchestrator:
|
||||
"""Coordinate one AgentRunner execution.
|
||||
|
||||
The orchestrator keeps the run state machine readable and delegates
|
||||
transport, Query bridging, and persistence side effects to narrower
|
||||
collaborators.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
registry: AgentRunnerRegistry
|
||||
context_builder: AgentRunContextBuilder
|
||||
resource_builder: AgentResourceBuilder
|
||||
result_normalizer: AgentResultNormalizer
|
||||
binding_resolver: AgentBindingResolver
|
||||
query_bridge: QueryRunBridge
|
||||
invoker: AgentRunnerInvoker
|
||||
journal: AgentRunJournal
|
||||
_session_registry: AgentRunSessionRegistry
|
||||
|
||||
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()
|
||||
self.query_bridge = QueryRunBridge(self.binding_resolver)
|
||||
self.invoker = AgentRunnerInvoker(ap)
|
||||
self.journal = AgentRunJournal(ap)
|
||||
self._session_registry = get_session_registry()
|
||||
|
||||
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 an AgentRunner from an event-first envelope."""
|
||||
runner_id = binding.runner_id
|
||||
descriptor = await self.registry.get(runner_id, bound_plugins)
|
||||
|
||||
resources = await self.resource_builder.build_resources_from_binding(
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
context = await self.context_builder.build_context_from_event(
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
session_query_id = None
|
||||
if adapter_context:
|
||||
query = adapter_context.get('_query')
|
||||
if query is not None:
|
||||
skill_loader.restore_activated_skills_from_state(
|
||||
self.ap,
|
||||
query,
|
||||
context.get('state', {}),
|
||||
)
|
||||
session_query_id = adapter_context.get('query_id')
|
||||
if query is not None or session_query_id is not None:
|
||||
context['context']['available_apis']['prompt_get'] = True
|
||||
if 'params' in adapter_context:
|
||||
context['adapter']['extra']['params'] = adapter_context['params']
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
|
||||
pending_artifact_refs: list[dict[str, typing.Any]] = []
|
||||
seen_sequences: set[int] = set()
|
||||
last_sequence = 0
|
||||
assistant_transcript_written = False
|
||||
|
||||
try:
|
||||
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,
|
||||
available_apis=context.get('context', {}).get('available_apis'),
|
||||
conversation_id=event.conversation_id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
state_policy={
|
||||
'enable_state': binding.state_policy.enable_state,
|
||||
'state_scopes': list(binding.state_policy.state_scopes),
|
||||
},
|
||||
state_context=state_context,
|
||||
)
|
||||
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
event=event,
|
||||
binding=binding,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
await self.journal.register_input_artifacts(
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
if event.event_type == 'message.received' and event.conversation_id:
|
||||
await self.journal.write_user_transcript(
|
||||
event=event,
|
||||
event_log_id=event_log_id,
|
||||
)
|
||||
|
||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
||||
sequence = result_dict.get('sequence')
|
||||
if sequence is not None:
|
||||
try:
|
||||
sequence_int = int(sequence)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned invalid result sequence: {sequence}'
|
||||
)
|
||||
else:
|
||||
if sequence_int in seen_sequences:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned duplicate result sequence '
|
||||
f'{sequence_int} for run {run_id}; dropping duplicate'
|
||||
)
|
||||
continue
|
||||
if sequence_int <= 0:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned non-positive result sequence '
|
||||
f'{sequence_int} for run {run_id}'
|
||||
)
|
||||
elif last_sequence and sequence_int != last_sequence + 1:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} result sequence gap or out-of-order '
|
||||
f'for run {run_id}: previous={last_sequence}, current={sequence_int}'
|
||||
)
|
||||
seen_sequences.add(sequence_int)
|
||||
last_sequence = max(last_sequence, sequence_int)
|
||||
|
||||
result_type = result_dict.get('type')
|
||||
if result_type and not self.result_normalizer.validate_payload(
|
||||
result_type,
|
||||
result_dict.get('data', {}),
|
||||
descriptor,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'artifact.created':
|
||||
artifact_ref = await self.journal.handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
pending_artifact_refs.append(artifact_ref)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'state.updated':
|
||||
await self.journal.handle_state_updated_event(
|
||||
result_dict,
|
||||
event,
|
||||
binding,
|
||||
descriptor,
|
||||
run_id=run_id,
|
||||
)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
has_completed_message = (
|
||||
result_type == 'message.completed'
|
||||
or (
|
||||
result_type == 'run.completed'
|
||||
and isinstance(result_dict.get('data'), dict)
|
||||
and bool(result_dict['data'].get('message'))
|
||||
)
|
||||
)
|
||||
if has_completed_message and event.conversation_id and not assistant_transcript_written:
|
||||
merged_refs = self.journal.merge_artifact_refs(
|
||||
pending_artifact_refs,
|
||||
result_dict,
|
||||
)
|
||||
pending_artifact_refs.clear()
|
||||
|
||||
await self.journal.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,
|
||||
)
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
if result is not None:
|
||||
yield result
|
||||
finally:
|
||||
session = await self._session_registry.unregister(run_id)
|
||||
pending_steering = session.get('steering_queue', []) if session else []
|
||||
if pending_steering:
|
||||
try:
|
||||
await self.journal.write_steering_dropped_audits(
|
||||
pending_steering,
|
||||
run_id,
|
||||
descriptor.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to write dropped steering audit for run {run_id}: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def run_from_query(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run an AgentRunner from the current Pipeline Query entry point."""
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
adapter_context = dict(plan.adapter_context)
|
||||
adapter_context['_query'] = query
|
||||
async for result in self.run(
|
||||
plan.event,
|
||||
plan.binding,
|
||||
bound_plugins=plan.bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
yield result
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return self.query_bridge.resolve_runner_id_for_telemetry(query)
|
||||
|
||||
async def try_claim_steering_from_query(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> bool:
|
||||
"""Claim a query as steering input for an active run when possible."""
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
event = plan.event
|
||||
binding = plan.binding
|
||||
|
||||
if event.event_type != 'message.received' or not event.conversation_id:
|
||||
return False
|
||||
|
||||
descriptor = await self.registry.get(binding.runner_id, plan.bound_plugins)
|
||||
if not descriptor.supports_steering():
|
||||
return False
|
||||
|
||||
target_run_id = await self._session_registry.find_steering_target(
|
||||
conversation_id=event.conversation_id,
|
||||
runner_id=descriptor.id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
)
|
||||
if target_run_id is None:
|
||||
return False
|
||||
|
||||
steering_item = self._build_steering_item(event, target_run_id, descriptor.id)
|
||||
if not await self._session_registry.enqueue_steering(target_run_id, steering_item):
|
||||
return False
|
||||
|
||||
try:
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
event=event,
|
||||
binding=binding,
|
||||
run_id=target_run_id,
|
||||
runner_id=descriptor.id,
|
||||
metadata={
|
||||
'steering': {
|
||||
'status': 'queued',
|
||||
'trigger_behavior': 'absorbed_into_active_run',
|
||||
'claimed_by_run_id': target_run_id,
|
||||
'claimed_runner_id': descriptor.id,
|
||||
'claimed_at': steering_item.get('claimed_at'),
|
||||
},
|
||||
},
|
||||
)
|
||||
await self.journal.register_input_artifacts(
|
||||
event=event,
|
||||
run_id=target_run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
await self.journal.write_user_transcript(event, event_log_id)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to persist steering event {event.event_id} for run {target_run_id}: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Claimed event {event.event_id} as steering input for run {target_run_id}'
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_steering_item(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Build the run-scoped steering item returned by the Host pull API."""
|
||||
return {
|
||||
'claimed_run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'claimed_at': int(time.time()),
|
||||
'event': {
|
||||
'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,
|
||||
},
|
||||
'conversation': {
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
},
|
||||
'actor': event.actor.model_dump(mode='json') if event.actor else None,
|
||||
'subject': event.subject.model_dump(mode='json') if event.subject else None,
|
||||
'input': {
|
||||
'text': event.input.text if event.input else None,
|
||||
'contents': [
|
||||
c.model_dump(mode='json') if hasattr(c, 'model_dump') else c
|
||||
for c in (event.input.contents if event.input else [])
|
||||
],
|
||||
'attachments': [
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a
|
||||
for a in (event.input.attachments if event.input else [])
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async def _invoke_runner(
|
||||
self,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""Compatibility delegate for older tests and internal callers."""
|
||||
async for result in self.invoker.invoke(descriptor, context):
|
||||
yield result
|
||||
|
||||
async def _next_with_deadline(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> dict[str, typing.Any]:
|
||||
return await self.invoker._next_with_deadline(gen, descriptor, context)
|
||||
|
||||
def _remaining_deadline_seconds(
|
||||
self,
|
||||
context: AgentRunContextPayload,
|
||||
) -> float | None:
|
||||
return self.invoker._remaining_deadline_seconds(context)
|
||||
|
||||
def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool:
|
||||
return self.invoker._is_deadline_exhausted(context)
|
||||
|
||||
async def _close_generator(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> None:
|
||||
await self.invoker._close_generator(gen, descriptor)
|
||||
|
||||
async def _handle_state_updated_event(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> None:
|
||||
await self.journal.handle_state_updated_event(result_dict, event, binding, descriptor)
|
||||
|
||||
async def _write_event_log(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> str:
|
||||
return await self.journal.write_event_log(event, binding, run_id, runner_id)
|
||||
|
||||
async def _register_input_artifacts(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> None:
|
||||
await self.journal.register_input_artifacts(event, run_id, runner_id)
|
||||
|
||||
def _decode_attachment_content(
|
||||
self,
|
||||
content: typing.Any,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
return self.journal.decode_attachment_content(content)
|
||||
|
||||
async def _write_user_transcript(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
event_log_id: str,
|
||||
) -> None:
|
||||
await self.journal.write_user_transcript(event, event_log_id)
|
||||
|
||||
async def _handle_artifact_created(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
return await self.journal.handle_artifact_created(result_dict, event, run_id, runner_id)
|
||||
|
||||
def _merge_artifact_refs(
|
||||
self,
|
||||
pending_refs: list[dict[str, typing.Any]],
|
||||
result_dict: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
return self.journal.merge_artifact_refs(pending_refs, result_dict)
|
||||
|
||||
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:
|
||||
await self.journal.write_assistant_transcript(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
artifact_refs=artifact_refs,
|
||||
)
|
||||
435
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
435
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""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 sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
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 def _upsert_state_row(
|
||||
self,
|
||||
conn: typing.Any,
|
||||
values: dict[str, typing.Any],
|
||||
) -> None:
|
||||
"""Insert or update a state row by the logical scope/key identity."""
|
||||
update_values = {
|
||||
'value_json': values['value_json'],
|
||||
'updated_at': values['updated_at'],
|
||||
}
|
||||
constraint_columns = ['scope_key', 'state_key']
|
||||
dialect_name = self._db_engine.dialect.name
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
stmt = sqlite_insert(AgentRunnerState).values(**values)
|
||||
await conn.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=constraint_columns,
|
||||
set_=update_values,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if dialect_name == 'postgresql':
|
||||
stmt = postgresql_insert(AgentRunnerState).values(**values)
|
||||
await conn.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=constraint_columns,
|
||||
set_=update_values,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await conn.execute(sqlalchemy.insert(AgentRunnerState).values(**values))
|
||||
except IntegrityError:
|
||||
await conn.execute(
|
||||
update(AgentRunnerState)
|
||||
.where(AgentRunnerState.scope_key == values['scope_key'])
|
||||
.where(AgentRunnerState.state_key == values['state_key'])
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
# ========== 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)
|
||||
|
||||
now = datetime.utcnow()
|
||||
async with self._db_engine.begin() as conn:
|
||||
await self._upsert_state_row(
|
||||
conn,
|
||||
{
|
||||
'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 {}
|
||||
|
||||
now = datetime.utcnow()
|
||||
async with self._db_engine.begin() as conn:
|
||||
await self._upsert_state_row(
|
||||
conn,
|
||||
{
|
||||
'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)
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
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
|
||||
56
src/langbot/pkg/agent/runner/query_bridge.py
Normal file
56
src/langbot/pkg/agent/runner/query_bridge.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Pipeline Query bridge for AgentRunner execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .config_migration import ConfigMigration
|
||||
from .errors import RunnerNotFoundError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class QueryRunPlan:
|
||||
"""Projected event-first execution request for a Query-backed run."""
|
||||
|
||||
event: AgentEventEnvelope
|
||||
binding: AgentBinding
|
||||
bound_plugins: list[str] | None
|
||||
adapter_context: dict[str, typing.Any]
|
||||
|
||||
|
||||
class QueryRunBridge:
|
||||
"""Project the current Pipeline Query entry point into Protocol v1 inputs."""
|
||||
|
||||
binding_resolver: AgentBindingResolver
|
||||
|
||||
def __init__(self, binding_resolver: AgentBindingResolver):
|
||||
self.binding_resolver = binding_resolver
|
||||
|
||||
def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan:
|
||||
"""Build an event-first run plan from a Pipeline Query."""
|
||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
if not runner_id:
|
||||
raise RunnerNotFoundError('no runner configured')
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
|
||||
binding = self.binding_resolver.resolve_one(event, [agent_config])
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins')
|
||||
adapter_context = QueryEntryAdapter.build_adapter_context(query, binding)
|
||||
|
||||
return QueryRunPlan(
|
||||
event=event,
|
||||
binding=binding,
|
||||
bound_plugins=bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
)
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
661
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal file
661
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal file
@@ -0,0 +1,661 @@
|
||||
"""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 .config_migration import ConfigMigration
|
||||
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')
|
||||
EVENT_DATA_MAX_STRING_BYTES = 512
|
||||
|
||||
@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 {}
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, 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),
|
||||
allowed_skill_names=cls._extract_allowed_skills(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),
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
# 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:
|
||||
raw_event_data = message_event.model_dump(mode='json')
|
||||
except TypeError:
|
||||
raw_event_data = message_event.model_dump()
|
||||
except Exception:
|
||||
raw_event_data = {}
|
||||
if isinstance(raw_event_data, dict):
|
||||
event_data = cls._compact_event_data(raw_event_data)
|
||||
|
||||
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 _compact_event_data(
|
||||
cls,
|
||||
event_data: dict[str, typing.Any],
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Keep only small scalar source-event metadata in event.data."""
|
||||
compact: dict[str, typing.Any] = {}
|
||||
for key, value in event_data.items():
|
||||
if key == 'source_platform_object' or key.startswith('_'):
|
||||
continue
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
compact[key] = value
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
|
||||
compact[key] = value
|
||||
continue
|
||||
return compact
|
||||
|
||||
@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 not contents:
|
||||
message_chain = getattr(query, 'message_chain', None) or []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
component_text = getattr(component, 'text', '')
|
||||
if component_text:
|
||||
text_parts.append(component_text)
|
||||
contents.append({'type': 'text', 'text': component_text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
image_base64 = getattr(component, 'base64', None)
|
||||
image_url = getattr(component, 'url', None)
|
||||
if image_base64:
|
||||
contents.append({'type': 'image_base64', 'image_base64': image_base64})
|
||||
elif image_url:
|
||||
contents.append({'type': 'image_url', 'image_url': {'url': image_url}})
|
||||
|
||||
if text_parts:
|
||||
text = ''.join(text_parts)
|
||||
|
||||
attachments = cls._build_attachments(query, contents)
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
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]] = []
|
||||
seen_keys: dict[tuple[str, str, str], set[str]] = {}
|
||||
|
||||
def add_attachment(attachment: dict[str, typing.Any]) -> None:
|
||||
key = cls._attachment_dedupe_key(attachment)
|
||||
if key is not None:
|
||||
source = str(attachment.get('source') or '')
|
||||
sources = seen_keys.setdefault(key, set())
|
||||
if source and sources and source not in sources:
|
||||
return
|
||||
if source:
|
||||
sources.add(source)
|
||||
attachments.append(attachment)
|
||||
|
||||
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 {}
|
||||
add_attachment({
|
||||
'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':
|
||||
add_attachment({
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'image',
|
||||
'source': 'base64',
|
||||
'content': elem.get('image_base64'),
|
||||
})
|
||||
elif elem_type == 'file_url':
|
||||
add_attachment({
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'file',
|
||||
'source': 'url',
|
||||
'url': elem.get('file_url'),
|
||||
'name': elem.get('file_name'),
|
||||
})
|
||||
elif elem_type == 'file_base64':
|
||||
add_attachment({
|
||||
'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):
|
||||
image_id = component.image_id or None
|
||||
image_url = component.url or None
|
||||
image_base64 = component.base64 or None
|
||||
add_attachment({
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'image',
|
||||
'source': 'message_chain',
|
||||
'id': image_id,
|
||||
'url': image_url,
|
||||
'content': image_base64,
|
||||
})
|
||||
elif isinstance(component, platform_message.File):
|
||||
add_attachment({
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'file',
|
||||
'source': 'message_chain',
|
||||
'id': component.id or None,
|
||||
'name': component.name or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
})
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
add_attachment({
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'voice',
|
||||
'source': 'message_chain',
|
||||
'id': component.voice_id or None,
|
||||
'url': component.url or None,
|
||||
'content': component.base64 or None,
|
||||
})
|
||||
|
||||
return attachments
|
||||
|
||||
@classmethod
|
||||
def _attachment_dedupe_key(
|
||||
cls,
|
||||
attachment: dict[str, typing.Any],
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Return a stable key for the same attachment across content sources."""
|
||||
artifact_type = attachment.get('artifact_type')
|
||||
if not artifact_type:
|
||||
return None
|
||||
for field in ('id', 'url', 'content'):
|
||||
value = attachment.get(field)
|
||||
if value:
|
||||
if field == 'content':
|
||||
value = hashlib.sha256(str(value).encode('utf-8')).hexdigest()
|
||||
return str(artifact_type), field, str(value)
|
||||
return None
|
||||
|
||||
@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
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_skills(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
) -> list[str] | None:
|
||||
"""Extract pipeline-visible skill names from query."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not variables or '_pipeline_bound_skills' not in variables:
|
||||
return None
|
||||
bound_skills = variables.get('_pipeline_bound_skills')
|
||||
if bound_skills is None:
|
||||
return None
|
||||
if not isinstance(bound_skills, list):
|
||||
return []
|
||||
return [str(skill_name) for skill_name in bound_skills if skill_name]
|
||||
348
src/langbot/pkg/agent/runner/registry.py
Normal file
348
src/langbot/pkg/agent/runner/registry.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Agent runner registry for discovering and caching runner descriptors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerManifest,
|
||||
)
|
||||
|
||||
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 (typed AgentRunnerManifest or legacy component manifest)
|
||||
- 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', {})
|
||||
runner_id = format_runner_id(
|
||||
source='plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
is_typed_manifest = self._looks_like_typed_manifest(manifest)
|
||||
if is_typed_manifest:
|
||||
typed_manifest = AgentRunnerManifest.model_validate(manifest)
|
||||
else:
|
||||
typed_manifest = self._build_typed_manifest_from_legacy_data(
|
||||
runner_id=runner_id,
|
||||
runner_name=runner_name,
|
||||
runner_data=runner_data,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
if runner_data.get('config'):
|
||||
config_schema = runner_data['config']
|
||||
elif not is_typed_manifest and isinstance(manifest.get('spec'), dict):
|
||||
config_schema = manifest['spec'].get('config', [])
|
||||
else:
|
||||
config_schema = [
|
||||
item.model_dump(mode='json') for item in typed_manifest.config_schema
|
||||
]
|
||||
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label=typed_manifest.label,
|
||||
description=typed_manifest.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'),
|
||||
config_schema=config_schema,
|
||||
capabilities=typed_manifest.capabilities,
|
||||
permissions=typed_manifest.permissions,
|
||||
raw_manifest=manifest,
|
||||
)
|
||||
|
||||
def _looks_like_typed_manifest(self, manifest: dict[str, typing.Any]) -> bool:
|
||||
"""Return whether manifest is the SDK typed AgentRunnerManifest shape."""
|
||||
return (
|
||||
isinstance(manifest, dict)
|
||||
and 'id' in manifest
|
||||
and 'name' in manifest
|
||||
and 'label' in manifest
|
||||
)
|
||||
|
||||
def _build_typed_manifest_from_legacy_data(
|
||||
self,
|
||||
*,
|
||||
runner_id: str,
|
||||
runner_name: str,
|
||||
runner_data: dict[str, typing.Any],
|
||||
manifest: dict[str, typing.Any],
|
||||
) -> AgentRunnerManifest:
|
||||
"""Validate legacy raw component manifest data as typed runner manifest."""
|
||||
|
||||
# Validate kind
|
||||
kind = manifest.get('kind', '')
|
||||
if kind != 'AgentRunner':
|
||||
raise ValueError(f'Invalid AgentRunner kind: {kind or "<missing>"}')
|
||||
|
||||
# Validate metadata
|
||||
metadata = manifest.get('metadata', {})
|
||||
name = metadata.get('name', '')
|
||||
if not name:
|
||||
raise ValueError('Missing AgentRunner metadata.name')
|
||||
|
||||
# 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.
|
||||
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', {})
|
||||
|
||||
try:
|
||||
return AgentRunnerManifest(
|
||||
id=runner_id,
|
||||
name=runner_name,
|
||||
label=label,
|
||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
||||
capabilities=capabilities,
|
||||
permissions=permissions,
|
||||
config_schema=config_schema,
|
||||
)
|
||||
except pydantic.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError(f'Invalid AgentRunner manifest: {exc}') from exc
|
||||
|
||||
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
|
||||
331
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
331
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""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,
|
||||
SkillResource,
|
||||
FileResource,
|
||||
StorageResource,
|
||||
)
|
||||
from . import config_schema
|
||||
from .host_models import AgentEventEnvelope, AgentBinding
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
"""Builder for constructing run-scoped AgentResources with permission filtering.
|
||||
|
||||
Responsibilities:
|
||||
- Apply manifest permissions intersected with binding resource policy
|
||||
- Build models list from authorized models
|
||||
- Build tools list from bound plugins/MCP servers
|
||||
- Build knowledge_bases list from config
|
||||
- Build storage and files access 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
|
||||
- SkillResource: skill_name, display_name, description
|
||||
- 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 capabilities, permissions, and config schema
|
||||
|
||||
Returns:
|
||||
AgentResources dict with filtered resource lists
|
||||
"""
|
||||
resource_policy = binding.resource_policy
|
||||
runner_config = binding.runner_config
|
||||
manifest_perms = descriptor.permissions
|
||||
|
||||
# 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, descriptor
|
||||
)
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
skills = self._build_skills_from_binding(
|
||||
resource_policy, descriptor
|
||||
)
|
||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||
files = self._build_files_from_binding(manifest_perms, descriptor, runner_config)
|
||||
|
||||
return {
|
||||
'models': models,
|
||||
'tools': tools,
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'skills': skills,
|
||||
'files': files,
|
||||
'storage': storage,
|
||||
'platform_capabilities': {}, # Reserved for EBA
|
||||
}
|
||||
|
||||
async def _build_models_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
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 = set(manifest_perms.models)
|
||||
include_llm = bool({'invoke', 'stream'} & model_perms)
|
||||
include_rerank = 'rerank' in model_perms
|
||||
llm_operations = [operation for operation in ('invoke', 'stream') if operation in model_perms]
|
||||
if not include_llm and not include_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=include_llm,
|
||||
include_rerank=include_rerank,
|
||||
llm_operations=llm_operations,
|
||||
)
|
||||
|
||||
# Add explicitly allowed models
|
||||
if allowed_uuids and include_llm:
|
||||
for model_uuid in allowed_uuids:
|
||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations)
|
||||
|
||||
return models
|
||||
|
||||
async def _build_tools_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[ToolResource]:
|
||||
"""Build tools list from binding."""
|
||||
tools: list[ToolResource] = []
|
||||
tool_perms = set(manifest_perms.tools)
|
||||
if not ({'detail', 'call'} & tool_perms):
|
||||
return tools
|
||||
|
||||
if not config_schema.uses_host_tools(descriptor):
|
||||
return tools
|
||||
|
||||
# Get tool names from resource policy
|
||||
allowed_names = resource_policy.allowed_tool_names
|
||||
tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms]
|
||||
|
||||
if allowed_names:
|
||||
for tool_name in allowed_names:
|
||||
tools.append({
|
||||
'tool_name': tool_name,
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'operations': tool_operations,
|
||||
})
|
||||
|
||||
return tools
|
||||
|
||||
async def _build_knowledge_bases_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[KnowledgeBaseResource]:
|
||||
"""Build knowledge bases list from binding."""
|
||||
kb_resources: list[KnowledgeBaseResource] = []
|
||||
kb_perms = set(manifest_perms.knowledge_bases)
|
||||
if not ({'list', 'retrieve'} & kb_perms):
|
||||
return kb_resources
|
||||
kb_operations = [operation for operation in ('list', 'retrieve') if operation in kb_perms]
|
||||
|
||||
if not config_schema.uses_host_knowledge_bases(descriptor):
|
||||
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,
|
||||
'operations': kb_operations,
|
||||
})
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
|
||||
|
||||
return kb_resources
|
||||
|
||||
def _build_skills_from_binding(
|
||||
self,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[SkillResource]:
|
||||
"""Build pipeline-visible skill resource facts."""
|
||||
if not config_schema.supports_skill_authoring(descriptor):
|
||||
return []
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
loaded_skills = getattr(skill_mgr, 'skills', {}) or {}
|
||||
allowed_names = resource_policy.allowed_skill_names
|
||||
if allowed_names is None:
|
||||
names = sorted(loaded_skills.keys())
|
||||
else:
|
||||
names = sorted(name for name in allowed_names if name in loaded_skills)
|
||||
|
||||
skills: list[SkillResource] = []
|
||||
for skill_name in names:
|
||||
skill_data = loaded_skills.get(skill_name) or {}
|
||||
skills.append({
|
||||
'skill_name': skill_name,
|
||||
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||
'description': skill_data.get('description') or None,
|
||||
})
|
||||
return skills
|
||||
|
||||
def _build_files_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[FileResource]:
|
||||
"""Build config/knowledge file resources selected in runner config."""
|
||||
file_perms = set(manifest_perms.files)
|
||||
operations = [operation for operation in ('config', 'knowledge') if operation in file_perms]
|
||||
if not operations:
|
||||
return []
|
||||
|
||||
files: list[FileResource] = []
|
||||
if 'config' in file_perms:
|
||||
for file_resource in config_schema.extract_config_file_resources(descriptor, runner_config):
|
||||
files.append({
|
||||
**file_resource,
|
||||
'operations': ['config'],
|
||||
})
|
||||
return files
|
||||
|
||||
def _build_storage_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
binding: AgentBinding,
|
||||
) -> StorageResource:
|
||||
"""Build storage access summary from manifest and binding policy."""
|
||||
resource_policy = binding.resource_policy
|
||||
storage_perms = set(manifest_perms.storage)
|
||||
|
||||
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,
|
||||
llm_operations: list[str],
|
||||
) -> 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, llm_operations)
|
||||
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,
|
||||
operations: list[str],
|
||||
) -> 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,
|
||||
'operations': operations,
|
||||
})
|
||||
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,
|
||||
'operations': ['rerank'],
|
||||
})
|
||||
seen_model_ids.add(model_uuid)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')
|
||||
241
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
241
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
ActionRequestedPayload,
|
||||
ArtifactCreatedPayload,
|
||||
MessageCompletedPayload,
|
||||
MessageDeltaPayload,
|
||||
RunCompletedPayload,
|
||||
RunFailedPayload,
|
||||
StateUpdatedPayload,
|
||||
)
|
||||
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
|
||||
|
||||
STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
|
||||
'message.delta': MessageDeltaPayload,
|
||||
'message.completed': MessageCompletedPayload,
|
||||
'state.updated': StateUpdatedPayload,
|
||||
'artifact.created': ArtifactCreatedPayload,
|
||||
'action.requested': ActionRequestedPayload,
|
||||
'run.completed': RunCompletedPayload,
|
||||
'run.failed': RunFailedPayload,
|
||||
}
|
||||
|
||||
|
||||
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 not self.validate_payload(result_type, data, descriptor):
|
||||
return None
|
||||
|
||||
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 validate_payload(
|
||||
self,
|
||||
result_type: str,
|
||||
data: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> bool:
|
||||
"""Validate typed payloads that affect Host state or delivery.
|
||||
|
||||
Tool-call telemetry stays intentionally loose so older runners can keep
|
||||
emitting diagnostic fields. Unknown result types are handled by the
|
||||
caller and are not validated here.
|
||||
"""
|
||||
payload_model = STRICT_RESULT_PAYLOADS.get(result_type)
|
||||
if payload_model is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
payload_model.model_validate(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}'
|
||||
)
|
||||
return False
|
||||
|
||||
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}')
|
||||
571
src/langbot/pkg/agent/runner/run_journal.py
Normal file
571
src/langbot/pkg/agent/runner/run_journal.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""Run-side effects for AgentRunner executions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .errors import RunnerProtocolError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .persistent_state_store import PersistentStateStore, get_persistent_state_store
|
||||
|
||||
|
||||
# Maximum inline artifact content size (1MB)
|
||||
MAX_ARTIFACT_INLINE_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
class AgentRunJournal:
|
||||
"""Persist run events, transcript records, artifacts, and state updates."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
_persistent_state_store: PersistentStateStore | None
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self._persistent_state_store = None
|
||||
|
||||
@staticmethod
|
||||
def _to_plain_dict(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if hasattr(value, 'model_dump'):
|
||||
value = value.model_dump(mode='json')
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _sanitize_content_item(cls, value: typing.Any) -> typing.Any:
|
||||
item = cls._to_plain_dict(value)
|
||||
if not item:
|
||||
return value
|
||||
item_type = item.get('type')
|
||||
if item_type == 'image_base64' and item.get('image_base64'):
|
||||
item['image_base64'] = None
|
||||
item['content_redacted'] = True
|
||||
elif item_type == 'file_base64' and item.get('file_base64'):
|
||||
item['file_base64'] = None
|
||||
item['content_redacted'] = True
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _sanitize_attachment_ref(cls, value: typing.Any) -> dict[str, typing.Any]:
|
||||
item = cls._to_plain_dict(value)
|
||||
if item.get('content'):
|
||||
item['content'] = None
|
||||
item['content_redacted'] = True
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _sanitize_contents(cls, contents: typing.Iterable[typing.Any]) -> list[typing.Any]:
|
||||
return [cls._sanitize_content_item(content) for content in contents]
|
||||
|
||||
@classmethod
|
||||
def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]:
|
||||
return [cls._sanitize_attachment_ref(attachment) for attachment in attachments]
|
||||
|
||||
async def handle_state_updated_event(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Handle state.updated result in event-first mode."""
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
result_run_id = result_dict.get('run_id')
|
||||
if run_id and result_run_id and result_run_id != run_id:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
f'state.updated run_id mismatch: expected {run_id}, got {result_run_id}',
|
||||
)
|
||||
|
||||
scope = data.get('scope')
|
||||
if not scope:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
'state.updated missing required field: scope',
|
||||
)
|
||||
|
||||
key = data.get('key')
|
||||
value = data.get('value')
|
||||
|
||||
if not key:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
'state.updated missing required field: key',
|
||||
)
|
||||
|
||||
if self._persistent_state_store is None:
|
||||
self._persistent_state_store = get_persistent_state_store(
|
||||
self.ap.persistence_mgr.get_db_engine()
|
||||
)
|
||||
|
||||
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,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
) -> str:
|
||||
"""Write incoming event to EventLog."""
|
||||
import datetime
|
||||
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
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': self._sanitize_contents(event.input.contents),
|
||||
'attachments': self._sanitize_attachments(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, datetime.timezone.utc)
|
||||
if event.event_time
|
||||
else None
|
||||
),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
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,
|
||||
thread_id=event.thread_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."""
|
||||
from .transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
content = event.input.text if event.input else None
|
||||
content_json = None
|
||||
if event.input:
|
||||
content_json = {
|
||||
'role': 'user',
|
||||
'content': self._sanitize_contents(event.input.contents) if event.input.contents else [],
|
||||
}
|
||||
|
||||
artifact_refs = []
|
||||
if event.input and event.input.attachments:
|
||||
for a in event.input.attachments:
|
||||
artifact_refs.append(self._sanitize_attachment_ref(a))
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id=None,
|
||||
event_id=event_log_id,
|
||||
conversation_id=event.conversation_id,
|
||||
role='user',
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
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."""
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
from .artifact_store import ArtifactStore
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
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}',
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
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}',
|
||||
)
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
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,
|
||||
thread_id=event.thread_id,
|
||||
metadata=metadata,
|
||||
content=content,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
f'artifact.created failed to register artifact: {e}',
|
||||
)
|
||||
|
||||
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_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 a message's own refs."""
|
||||
merged = list(pending_refs)
|
||||
seen_ids = {ref.get('artifact_id') for ref in pending_refs if ref.get('artifact_id')}
|
||||
|
||||
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_steering_dropped_audits(
|
||||
self,
|
||||
items: list[dict[str, typing.Any]],
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
*,
|
||||
reason: str = 'run_ended',
|
||||
) -> None:
|
||||
"""Write terminal audit events for steering items left unconsumed."""
|
||||
if not items:
|
||||
return
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
for item in items:
|
||||
event = item.get('event') if isinstance(item.get('event'), dict) else {}
|
||||
input_data = item.get('input') if isinstance(item.get('input'), dict) else {}
|
||||
conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {}
|
||||
actor = item.get('actor') if isinstance(item.get('actor'), dict) else {}
|
||||
subject = item.get('subject') if isinstance(item.get('subject'), dict) else {}
|
||||
|
||||
text = input_data.get('text')
|
||||
input_summary = text[:1000] if isinstance(text, str) and text else 'Unconsumed steering input dropped'
|
||||
event_time = None
|
||||
raw_event_time = event.get('event_time')
|
||||
if raw_event_time:
|
||||
try:
|
||||
event_time = datetime.datetime.fromtimestamp(
|
||||
raw_event_time,
|
||||
datetime.timezone.utc,
|
||||
)
|
||||
except (TypeError, ValueError, OSError):
|
||||
event_time = None
|
||||
|
||||
await store.append_event(
|
||||
event_id=str(uuid.uuid4()),
|
||||
event_type='steering.dropped',
|
||||
source='host',
|
||||
bot_id=conversation.get('bot_id'),
|
||||
workspace_id=conversation.get('workspace_id'),
|
||||
conversation_id=conversation.get('conversation_id'),
|
||||
thread_id=conversation.get('thread_id'),
|
||||
actor_type=actor.get('actor_type'),
|
||||
actor_id=actor.get('actor_id'),
|
||||
actor_name=actor.get('actor_name'),
|
||||
subject_type=subject.get('subject_type'),
|
||||
subject_id=subject.get('subject_id'),
|
||||
input_summary=input_summary,
|
||||
input_json={
|
||||
'text': text,
|
||||
'contents': self._sanitize_contents(input_data.get('contents') or []),
|
||||
'attachments': self._sanitize_attachments(input_data.get('attachments') or []),
|
||||
},
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
event_time=event_time,
|
||||
metadata={
|
||||
'steering': {
|
||||
'status': 'dropped',
|
||||
'reason': reason,
|
||||
'original_event_id': event.get('event_id'),
|
||||
'claimed_run_id': item.get('claimed_run_id'),
|
||||
'claimed_runner_id': item.get('runner_id'),
|
||||
'claimed_at': item.get('claimed_at'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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."""
|
||||
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', {})
|
||||
|
||||
content = None
|
||||
content_json = None
|
||||
|
||||
if isinstance(message.get('content'), str):
|
||||
content = message['content']
|
||||
content_json = message
|
||||
elif isinstance(message.get('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,
|
||||
'content': self._sanitize_contents(message['content']),
|
||||
}
|
||||
|
||||
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',
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
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/session_registry.py
Normal file
431
src/langbot/pkg/agent/runner/session_registry.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""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
|
||||
|
||||
|
||||
MAX_STEERING_QUEUE_ITEMS = 100
|
||||
|
||||
DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
|
||||
'model': {'invoke', 'stream', 'rerank'},
|
||||
'tool': {'detail', 'call'},
|
||||
'knowledge_base': {'list', 'retrieve'},
|
||||
'file': {'config', 'knowledge'},
|
||||
'skill': {'activate'},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
available_apis: dict[str, bool]
|
||||
conversation_id: str | None
|
||||
bot_id: str | None
|
||||
workspace_id: str | None
|
||||
thread_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
authorized_ids: dict[str, set[str]]
|
||||
authorized_operations: dict[str, dict[str, set[str]]]
|
||||
|
||||
|
||||
SteeringQueueItem = dict[str, typing.Any]
|
||||
|
||||
|
||||
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
|
||||
steering_queue: list[SteeringQueueItem]
|
||||
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
available_apis: dict[str, bool] | 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
|
||||
bot_id: Bot UUID for history/event access
|
||||
workspace_id: Workspace ID for history/event access
|
||||
thread_id: Thread ID for history/event access
|
||||
available_apis: Run-scoped pull APIs exposed in AgentRunContext
|
||||
state_policy: State policy from binding (enable_state, state_scopes)
|
||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
||||
"""
|
||||
if not isinstance(plugin_identity, str) or not plugin_identity.strip():
|
||||
raise ValueError('plugin_identity is required for agent run sessions')
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
available_apis = copy.deepcopy(available_apis 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,
|
||||
'available_apis': available_apis,
|
||||
'conversation_id': conversation_id,
|
||||
'bot_id': bot_id,
|
||||
'workspace_id': workspace_id,
|
||||
'thread_id': thread_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
'authorized_operations': self._build_authorized_operations(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,
|
||||
},
|
||||
'steering_queue': [],
|
||||
}
|
||||
|
||||
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', [])},
|
||||
'skill': {s.get('skill_name') for s in resources.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||
}
|
||||
|
||||
def _build_authorized_operations(
|
||||
self,
|
||||
resources: AgentResources,
|
||||
) -> dict[str, dict[str, set[str]]]:
|
||||
"""Pre-compute resource operations for runtime action validation."""
|
||||
return {
|
||||
'model': {
|
||||
m.get('model_id'): self._resource_operations('model', m)
|
||||
for m in resources.get('models', [])
|
||||
if m.get('model_id')
|
||||
},
|
||||
'tool': {
|
||||
t.get('tool_name'): self._resource_operations('tool', t)
|
||||
for t in resources.get('tools', [])
|
||||
if t.get('tool_name')
|
||||
},
|
||||
'knowledge_base': {
|
||||
kb.get('kb_id'): self._resource_operations('knowledge_base', kb)
|
||||
for kb in resources.get('knowledge_bases', [])
|
||||
if kb.get('kb_id')
|
||||
},
|
||||
'skill': {
|
||||
s.get('skill_name'): self._resource_operations('skill', s)
|
||||
for s in resources.get('skills', [])
|
||||
if s.get('skill_name')
|
||||
},
|
||||
'file': {
|
||||
f.get('file_id'): self._resource_operations('file', f)
|
||||
for f in resources.get('files', [])
|
||||
if f.get('file_id')
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resource_operations(resource_type: str, resource: dict[str, typing.Any]) -> set[str]:
|
||||
"""Return explicit operations or the compatibility default for old resources."""
|
||||
operations = resource.get('operations')
|
||||
if isinstance(operations, list) and operations:
|
||||
return {str(operation) for operation in operations}
|
||||
return set(DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set()))
|
||||
|
||||
async def unregister(self, run_id: str) -> AgentRunSession | None:
|
||||
"""Unregister an agent run session.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
|
||||
Returns:
|
||||
The removed session, if one existed. Callers can inspect any
|
||||
pending in-memory queues before they are discarded.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._sessions.pop(run_id, None)
|
||||
|
||||
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())
|
||||
|
||||
async def find_steering_target(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
runner_id: str,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Find the oldest active run that can accept steering for a conversation."""
|
||||
async with self._lock:
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for run_id, session in self._sessions.items():
|
||||
authorization = session['authorization']
|
||||
if session.get('runner_id') != runner_id:
|
||||
continue
|
||||
if authorization.get('conversation_id') != conversation_id:
|
||||
continue
|
||||
if authorization.get('bot_id') != bot_id:
|
||||
continue
|
||||
if authorization.get('workspace_id') != workspace_id:
|
||||
continue
|
||||
if authorization.get('thread_id') != thread_id:
|
||||
continue
|
||||
if not authorization.get('available_apis', {}).get('steering_pull', False):
|
||||
continue
|
||||
candidates.append((session['status'].get('started_at', 0), run_id))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda item: item[0])
|
||||
return candidates[0][1]
|
||||
|
||||
async def enqueue_steering(
|
||||
self,
|
||||
run_id: str,
|
||||
item: SteeringQueueItem,
|
||||
) -> bool:
|
||||
"""Append one steering item to an active run queue."""
|
||||
async with self._lock:
|
||||
session = self._sessions.get(run_id)
|
||||
if session is None:
|
||||
return False
|
||||
if len(session['steering_queue']) >= MAX_STEERING_QUEUE_ITEMS:
|
||||
return False
|
||||
session['steering_queue'].append(copy.deepcopy(item))
|
||||
session['status']['last_activity_at'] = int(time.time())
|
||||
return True
|
||||
|
||||
async def pull_steering(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
mode: str = 'all',
|
||||
limit: int | None = None,
|
||||
) -> list[SteeringQueueItem]:
|
||||
"""Pop pending steering items from a run queue."""
|
||||
async with self._lock:
|
||||
session = self._sessions.get(run_id)
|
||||
if session is None:
|
||||
return []
|
||||
|
||||
queue = session['steering_queue']
|
||||
if not queue:
|
||||
return []
|
||||
|
||||
normalized_mode = str(mode or 'all').lower()
|
||||
if normalized_mode in {'one', 'one-at-a-time', 'one_at_a_time'}:
|
||||
count = 1
|
||||
elif isinstance(limit, int) and limit > 0:
|
||||
count = min(limit, len(queue))
|
||||
else:
|
||||
count = len(queue)
|
||||
|
||||
count = max(0, min(count, len(queue), 100))
|
||||
items = [copy.deepcopy(item) for item in queue[:count]]
|
||||
del queue[:count]
|
||||
session['status']['last_activity_at'] = int(time.time())
|
||||
return items
|
||||
|
||||
def is_resource_allowed(
|
||||
self,
|
||||
session: AgentRunSession,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
operation: str | None = None,
|
||||
) -> 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)
|
||||
operation: Optional operation to check within the authorized resource
|
||||
|
||||
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', 'skill', 'file'):
|
||||
if resource_id not in authorized_ids.get(resource_type, set()):
|
||||
return False
|
||||
if operation is None:
|
||||
return True
|
||||
operation_map = authorization.get('authorized_operations', {})
|
||||
operations = operation_map.get(resource_type, {}).get(resource_id)
|
||||
if not operations:
|
||||
operations = DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set())
|
||||
return operation in operations
|
||||
|
||||
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
|
||||
136
src/langbot/pkg/agent/runner/state_scope.py
Normal file
136
src/langbot/pkg/agent/runner/state_scope.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""State scope key helpers for AgentRunner host-owned state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
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 _scope_hash(scope: str, parts: dict[str, typing.Any]) -> str:
|
||||
"""Encode state scope dimensions without separator ambiguity."""
|
||||
payload = {
|
||||
'version': 2,
|
||||
'scope': scope,
|
||||
**parts,
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
return f'{scope}:v2:{hashlib.sha256(raw.encode("utf-8")).hexdigest()}'
|
||||
|
||||
|
||||
def _base_scope_parts(
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'runner_id': descriptor.id,
|
||||
'binding_identity': get_binding_identity(binding),
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
base_parts = _base_scope_parts(event, binding, descriptor)
|
||||
|
||||
if scope == 'conversation':
|
||||
if not event.conversation_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
})
|
||||
|
||||
if scope == 'actor':
|
||||
if not event.actor or not event.actor.actor_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'actor_type': event.actor.actor_type or 'user',
|
||||
'actor_id': event.actor.actor_id,
|
||||
})
|
||||
|
||||
if scope == 'subject':
|
||||
if not event.subject or not event.subject.subject_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'subject_type': event.subject.subject_type or 'unknown',
|
||||
'subject_id': event.subject.subject_id,
|
||||
})
|
||||
|
||||
if scope == 'runner':
|
||||
return _scope_hash(scope, base_parts)
|
||||
|
||||
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,
|
||||
}
|
||||
426
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
426
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""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
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(UTC)
|
||||
|
||||
|
||||
def _datetime_to_epoch(value: datetime.datetime | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
value = value.astimezone(UTC)
|
||||
return int(value.timestamp())
|
||||
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
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)
|
||||
bot_id: Bot UUID scope
|
||||
workspace_id: Workspace scope
|
||||
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,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_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=_utc_now(),
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: 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
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
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
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> 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
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
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}%"),
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
# 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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> 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",
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
strict_thread=strict_thread,
|
||||
)
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> 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:
|
||||
query = (
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(Transcript)
|
||||
.where(Transcript.conversation_id == conversation_id, Transcript.seq < seq)
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
result = await session.execute(query)
|
||||
count = result.scalar()
|
||||
return count > 0
|
||||
|
||||
def _apply_scope_filters(
|
||||
self,
|
||||
query: typing.Any,
|
||||
bot_id: str | None,
|
||||
workspace_id: str | None,
|
||||
thread_id: str | None,
|
||||
strict_thread: bool,
|
||||
) -> typing.Any:
|
||||
if bot_id is not None:
|
||||
query = query.where(Transcript.bot_id == bot_id)
|
||||
if workspace_id is not None:
|
||||
query = query.where(Transcript.workspace_id == workspace_id)
|
||||
if strict_thread:
|
||||
if thread_id is None:
|
||||
query = query.where(Transcript.thread_id.is_(None))
|
||||
else:
|
||||
query = query.where(Transcript.thread_id == thread_id)
|
||||
return query
|
||||
|
||||
async def cleanup_transcripts_older_than(
|
||||
self,
|
||||
before: datetime.datetime,
|
||||
) -> int:
|
||||
"""Delete Transcript rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(Transcript).where(Transcript.created_at < before)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 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,
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_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': _datetime_to_epoch(row.created_at),
|
||||
'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)
|
||||
@@ -271,20 +271,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/logs',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
try:
|
||||
limit = int(quart.request.args.get('limit', 200))
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
level = quart.request.args.get('level') or None
|
||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||
return self.success(data={'logs': logs})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -12,7 +12,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器列表"""
|
||||
"""List MCP servers or create a new MCP server."""
|
||||
if quart.request.method == 'GET':
|
||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""获取、更新或删除MCP服务器配置"""
|
||||
"""Get, update, or delete an MCP server configuration."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
server_name = unquote(server_name)
|
||||
@@ -59,7 +59,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""测试MCP服务器连接"""
|
||||
"""Test an MCP server connection."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
server_name = unquote(server_name)
|
||||
|
||||
@@ -137,7 +137,7 @@ class MCPService:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||
|
||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||
"""Test an MCP server connection and return the task ID."""
|
||||
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -151,23 +150,9 @@ 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
|
||||
)
|
||||
)
|
||||
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)
|
||||
default_config_service = getattr(self.ap, 'agent_runner_default_config_service', None)
|
||||
if default_config_service is not None:
|
||||
await default_config_service.auto_set_default_pipeline_llm_model(model_data['uuid'])
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
@@ -13,7 +14,6 @@ default_stage_order = [
|
||||
'BanSessionCheckStage', # 封禁会话检查
|
||||
'PreContentFilterStage', # 内容过滤前置阶段
|
||||
'PreProcessor', # 预处理器
|
||||
'ConversationMessageTruncator', # 会话消息截断器
|
||||
'RequireRateLimitOccupancy', # 请求速率限制占用
|
||||
'MessageProcessor', # 处理器
|
||||
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
||||
@@ -30,11 +30,100 @@ 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 = 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
|
||||
|
||||
# Use the registry order as the default order. If no runner is available, leave
|
||||
# the default unset so the UI can recommend installing an AgentRunner plugin.
|
||||
if runner_options and 'default' not in config_item:
|
||||
config_item['default'] = runner_options[0]['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 +163,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 +176,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 +198,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)
|
||||
|
||||
@@ -236,12 +236,18 @@ class BoxService:
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
template = '{launcher_type}_{launcher_id}'
|
||||
pipeline_config = query.pipeline_config or {}
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner_selector = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_id = runner_selector.get('id') if isinstance(runner_selector, dict) else None
|
||||
runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {}
|
||||
configured_template = (
|
||||
runner_config.get('box-session-id-template') if isinstance(runner_config, dict) else None
|
||||
)
|
||||
if isinstance(configured_template, str) and configured_template:
|
||||
template = configured_template
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
@@ -803,8 +809,8 @@ class BoxService:
|
||||
def get_system_guidance(self) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
||||
All execution-specific prompt text is kept here so that callers stay
|
||||
free of box domain knowledge.
|
||||
"""
|
||||
guidance = (
|
||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
|
||||
@@ -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
|
||||
@@ -198,18 +204,29 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
if [ -d "$_LB_LOCK_DIR" ] && [ ! -f "$_LB_LOCK_DIR/pid" ]; then
|
||||
echo "Clearing stale Python environment lock without owner: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
@@ -225,7 +242,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, AgentRunnerDefaultConfigService
|
||||
|
||||
|
||||
class Application:
|
||||
"""Runtime application object and context"""
|
||||
@@ -165,6 +169,13 @@ class Application:
|
||||
|
||||
maintenance_service: maintenance_service.MaintenanceService = None
|
||||
|
||||
# Agent runner subsystem
|
||||
agent_runner_registry: AgentRunnerRegistry = None
|
||||
|
||||
agent_runner_default_config_service: AgentRunnerDefaultConfigService = None
|
||||
|
||||
agent_run_orchestrator: AgentRunOrchestrator = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -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, AgentRunnerDefaultConfigService
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -194,5 +195,15 @@ 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_runner_default_config_service_inst = AgentRunnerDefaultConfigService(ap)
|
||||
ap.agent_runner_default_config_service = agent_runner_default_config_service_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)
|
||||
"""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."""
|
||||
79
src/langbot/pkg/entity/persistence/transcript.py
Normal file
79
src/langbot/pkg/entity/persistence/transcript.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""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."""
|
||||
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Bot UUID this item belongs to."""
|
||||
|
||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Workspace this item belongs to."""
|
||||
|
||||
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)
|
||||
"""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'),
|
||||
sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'),
|
||||
)
|
||||
@@ -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, # noqa: F401
|
||||
apikey, # noqa: F401
|
||||
artifact, # noqa: F401
|
||||
bot, # noqa: F401
|
||||
bstorage, # noqa: F401
|
||||
event_log, # noqa: F401
|
||||
mcp, # noqa: F401
|
||||
metadata, # noqa: F401
|
||||
model, # noqa: F401
|
||||
monitoring, # noqa: F401
|
||||
pipeline, # noqa: F401
|
||||
plugin, # noqa: F401
|
||||
rag, # noqa: F401
|
||||
transcript, # noqa: F401
|
||||
user, # noqa: F401
|
||||
vector, # noqa: F401
|
||||
webhook, # noqa: F401
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Normalize AgentRunner config containers
|
||||
|
||||
Revision ID: 0005_migrate_runner_config
|
||||
Revises: 0005_add_llm_context_length
|
||||
Create Date: 2026-05-10
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
revision = '0005_migrate_runner_config'
|
||||
down_revision = '0005_add_llm_context_length'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def migrate_pipeline_config(config: dict) -> dict:
|
||||
"""Migrate persisted pipeline config to the AgentRunner plugin shape."""
|
||||
return ConfigMigration.migrate_pipeline_config(config)
|
||||
|
||||
|
||||
def _load_config(config_value):
|
||||
if isinstance(config_value, dict):
|
||||
return config_value
|
||||
if isinstance(config_value, str):
|
||||
return json.loads(config_value)
|
||||
return None
|
||||
|
||||
|
||||
def _update_config(conn, table_name: str, pipeline_uuid: str, migrated_config: dict) -> None:
|
||||
"""Write JSON config using a dialect-compatible bind."""
|
||||
config_json = json.dumps(migrated_config)
|
||||
if conn.dialect.name == 'postgresql':
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f'UPDATE {table_name} '
|
||||
'SET config = CAST(:config AS JSON) '
|
||||
'WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': config_json, 'uuid': pipeline_uuid},
|
||||
)
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE {table_name} SET config = :config WHERE uuid = :uuid'),
|
||||
{'config': config_json, 'uuid': pipeline_uuid},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Normalize existing pipeline config containers."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
table_name = 'legacy_pipelines'
|
||||
|
||||
# Check if pipeline table exists (may not exist in fresh install)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
# Get all pipelines
|
||||
result = conn.execute(sa.text(f'SELECT uuid, config FROM {table_name}'))
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline_uuid, config_json in pipelines:
|
||||
if not config_json:
|
||||
continue
|
||||
|
||||
try:
|
||||
config = _load_config(config_json)
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
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):
|
||||
_update_config(conn, table_name, pipeline_uuid, migrated_config)
|
||||
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,148 @@
|
||||
"""add_event_log_and_transcript_tables
|
||||
|
||||
Revision ID: 58846a8d7a81
|
||||
Revises: 0005_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 = '0005_migrate_runner_config'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||
if not _table_exists(table_name) or _column_exists(table_name, column.name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.add_column(column)
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create event_log table
|
||||
if not _table_exists('event_log'):
|
||||
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
|
||||
_create_index_if_missing('event_log', 'ix_event_log_event_id', ['event_id'], unique=True)
|
||||
_create_index_if_missing('event_log', 'ix_event_log_event_type', ['event_type'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_run_id', ['run_id'])
|
||||
|
||||
# Create transcript table
|
||||
if not _table_exists('transcript'):
|
||||
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('bot_id', sa.String(255), nullable=True),
|
||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
||||
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),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True))
|
||||
_add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True))
|
||||
|
||||
# Create indexes for transcript
|
||||
_create_index_if_missing('transcript', 'ix_transcript_transcript_id', ['transcript_id'], unique=True)
|
||||
_create_index_if_missing('transcript', 'ix_transcript_event_id', ['event_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_seq', ['conversation_id', 'seq'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_created', ['conversation_id', 'created_at'])
|
||||
_create_index_if_missing(
|
||||
'transcript',
|
||||
'ix_transcript_scope_seq',
|
||||
['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'],
|
||||
)
|
||||
_create_index_if_missing('transcript', 'ix_transcript_run_id', ['run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop transcript table
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_run_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_scope_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_created')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_bot_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_event_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_transcript_id')
|
||||
|
||||
if _table_exists('transcript'):
|
||||
op.drop_table('transcript')
|
||||
|
||||
# Drop event_log table
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_run_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_conversation_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_bot_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_event_type')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_event_id')
|
||||
|
||||
if _table_exists('event_log'):
|
||||
op.drop_table('event_log')
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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 _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if not _table_exists('agent_runner_state'):
|
||||
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')
|
||||
)
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_actor_id', ['actor_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_binding_identity', ['binding_identity'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing(
|
||||
'agent_runner_state',
|
||||
'ix_agent_runner_state_runner_binding',
|
||||
['runner_id', 'binding_identity'],
|
||||
)
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_runner_id', ['runner_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope', ['scope'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_binding')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_conversation_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_bot_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_binding_identity')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_actor_id')
|
||||
|
||||
if _table_exists('agent_runner_state'):
|
||||
op.drop_table('agent_runner_state')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add transcript scope columns
|
||||
|
||||
Revision ID: 7b2c1d9e4f30
|
||||
Revises: 6dfd3dd7f0c7
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7b2c1d9e4f30'
|
||||
down_revision = '6dfd3dd7f0c7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||
if not _table_exists(table_name) or _column_exists(table_name, column.name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.add_column(column)
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
if not set(columns).issubset(existing_columns):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True))
|
||||
_add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True))
|
||||
_create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id'])
|
||||
_create_index_if_missing(
|
||||
'transcript',
|
||||
'ix_transcript_scope_seq',
|
||||
['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'],
|
||||
)
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key')
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup')
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key', ['scope_key'])
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_scope_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_bot_id')
|
||||
if not _table_exists('transcript'):
|
||||
return
|
||||
existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns('transcript')}
|
||||
with op.batch_alter_table('transcript', schema=None) as batch_op:
|
||||
if 'workspace_id' in existing_columns:
|
||||
batch_op.drop_column('workspace_id')
|
||||
if 'bot_id' in existing_columns:
|
||||
batch_op.drop_column('bot_id')
|
||||
@@ -0,0 +1,37 @@
|
||||
"""ensure mcp_servers readme column exists
|
||||
|
||||
Revision ID: 8f24d6c9b1a0
|
||||
Revises: 7b2c1d9e4f30
|
||||
Create Date: 2026-06-13
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '8f24d6c9b1a0'
|
||||
down_revision = '7b2c1d9e4f30'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists('mcp_servers') or _column_exists('mcp_servers', 'readme'):
|
||||
return
|
||||
with op.batch_alter_table('mcp_servers', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('readme', sa.Text(), nullable=False, server_default=''))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _table_exists('mcp_servers') or not _column_exists('mcp_servers', 'readme'):
|
||||
return
|
||||
with op.batch_alter_table('mcp_servers', schema=None) as batch_op:
|
||||
batch_op.drop_column('readme')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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 _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create agent_artifact table
|
||||
if not _table_exists('agent_artifact'):
|
||||
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
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_artifact_id', ['artifact_id'], unique=True)
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_run_id', ['run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop agent_artifact table
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_run_id')
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_conversation_id')
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_artifact_id')
|
||||
|
||||
if _table_exists('agent_artifact'):
|
||||
op.drop_table('agent_artifact')
|
||||
@@ -11,6 +11,7 @@ from ...entity.persistence import (
|
||||
pipeline as persistence_pipeline,
|
||||
bot as persistence_bot,
|
||||
)
|
||||
from ...agent.runner.config_migration import LEGACY_RUNNER_ID_MAP
|
||||
|
||||
|
||||
@migration.migration_class(1)
|
||||
@@ -114,21 +115,28 @@ class DBMigrateV3Config(migration.DBMigration):
|
||||
pipeline_config = default_pipeline['config']
|
||||
|
||||
# ai
|
||||
pipeline_config['ai']['runner'] = {
|
||||
'runner': self.ap.provider_cfg.data['runner'],
|
||||
ai_config = pipeline_config.setdefault('ai', {})
|
||||
runner_name = self.ap.provider_cfg.data['runner']
|
||||
runner_id = LEGACY_RUNNER_ID_MAP.get(runner_name, '')
|
||||
ai_config['runner'] = {
|
||||
'id': runner_id,
|
||||
}
|
||||
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'
|
||||
]
|
||||
runner_configs = ai_config.setdefault('runner_config', {})
|
||||
|
||||
pipeline_config['ai']['local-agent']['prompt'] = [
|
||||
local_agent_runner_id = LEGACY_RUNNER_ID_MAP['local-agent']
|
||||
local_agent_config = runner_configs.setdefault(local_agent_runner_id, {})
|
||||
local_agent_config['model'] = {
|
||||
'primary': model_uuid,
|
||||
'fallbacks': [],
|
||||
}
|
||||
|
||||
local_agent_config['prompt'] = [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': self.ap.provider_cfg.data['prompt']['default'],
|
||||
}
|
||||
]
|
||||
pipeline_config['ai']['dify-service-api'] = {
|
||||
runner_configs[LEGACY_RUNNER_ID_MAP['dify-service-api']] = {
|
||||
'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'],
|
||||
'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'],
|
||||
'api-key': self.ap.provider_cfg.data['dify-service-api'][
|
||||
@@ -139,7 +147,7 @@ class DBMigrateV3Config(migration.DBMigration):
|
||||
self.ap.provider_cfg.data['dify-service-api']['app-type']
|
||||
]['timeout'],
|
||||
}
|
||||
pipeline_config['ai']['dashscope-app-api'] = {
|
||||
runner_configs[LEGACY_RUNNER_ID_MAP['dashscope-app-api']] = {
|
||||
'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'],
|
||||
'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'],
|
||||
'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][
|
||||
|
||||
@@ -21,11 +21,45 @@ class Controller:
|
||||
self.ap = ap
|
||||
self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])
|
||||
|
||||
async def _try_claim_steering_before_session_slot(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> bool:
|
||||
"""Claim steering while the normal per-session slot is still busy.
|
||||
|
||||
Follow-up input must be claimed before it waits behind the session
|
||||
semaphore; otherwise the active run can finish before the query reaches
|
||||
ChatMessageHandler.try_claim_steering_from_query.
|
||||
"""
|
||||
try:
|
||||
pipeline_uuid = query.pipeline_uuid
|
||||
if not pipeline_uuid:
|
||||
return False
|
||||
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
if not pipeline:
|
||||
return False
|
||||
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
query.session = session
|
||||
query.pipeline_config = pipeline.pipeline_entity.config
|
||||
query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins
|
||||
query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers
|
||||
|
||||
return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to claim query {query.query_id} as steering input: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
async def consumer(self):
|
||||
"""事件处理循环"""
|
||||
try:
|
||||
while True:
|
||||
selected_query: pipeline_query.Query = None
|
||||
claimed_steering_query: pipeline_query.Query = None
|
||||
|
||||
# 取请求
|
||||
async with self.ap.query_pool:
|
||||
@@ -36,6 +70,13 @@ class Controller:
|
||||
# Debug logging removed from tight loop to prevent excessive log generation
|
||||
# that can cause memory overflow in high-traffic scenarios
|
||||
|
||||
if session._semaphore.locked():
|
||||
if await self._try_claim_steering_before_session_slot(query):
|
||||
claimed_steering_query = query
|
||||
self.ap.logger.debug(f'Claimed query {query.query_id} as steering before session slot')
|
||||
break
|
||||
continue
|
||||
|
||||
if not session._semaphore.locked():
|
||||
selected_query = query
|
||||
await session._semaphore.acquire()
|
||||
@@ -44,7 +85,12 @@ class Controller:
|
||||
|
||||
break
|
||||
|
||||
if selected_query: # 找到了
|
||||
if claimed_steering_query:
|
||||
queries.remove(claimed_steering_query)
|
||||
self.ap.query_pool.cached_queries.pop(claimed_steering_query.query_id, None)
|
||||
self.ap.query_pool.condition.notify_all()
|
||||
continue
|
||||
elif selected_query: # 找到了
|
||||
queries.remove(selected_query)
|
||||
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
||||
await self.ap.query_pool.condition.wait()
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -278,8 +276,10 @@ class RuntimePipeline:
|
||||
|
||||
# Get runner name from pipeline config
|
||||
runner_name = None
|
||||
if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:
|
||||
runner_name = query.pipeline_config['ai']['runner'].get('runner')
|
||||
if query.pipeline_config:
|
||||
from ..agent.runner.config_migration import ConfigMigration
|
||||
|
||||
runner_name = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
|
||||
# Record query start and store message_id
|
||||
message_id = ''
|
||||
@@ -438,6 +438,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,170 @@ 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.multimodal_input
|
||||
|
||||
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 or [])
|
||||
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = 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),
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
strict_thread=True,
|
||||
)
|
||||
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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
) -> list[provider_message.Message]:
|
||||
transcript_messages = await self._load_agent_runner_history_messages(
|
||||
runner_id,
|
||||
getattr(conversation, 'uuid', None),
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=getattr(conversation, 'thread_id', 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
|
||||
)
|
||||
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 +207,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)
|
||||
@@ -99,20 +224,21 @@ class PreProcessor(stage.PipelineStage):
|
||||
# time instead of the first message/creation time.
|
||||
conversation.update_time = now
|
||||
|
||||
# 设置query
|
||||
# Attach resolved session state to the query.
|
||||
query.session = session
|
||||
query.prompt = conversation.prompt.copy()
|
||||
query.messages = conversation.messages.copy()
|
||||
query.messages = await self._resolve_history_messages(
|
||||
runner_id,
|
||||
conversation,
|
||||
bot_id=query.bot_uuid,
|
||||
)
|
||||
|
||||
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 'func_call' in (llm_model.model_entity.abilities or []):
|
||||
# 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 'func_call' in (llm_model.model_entity.abilities or []):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
@@ -125,14 +251,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 +291,25 @@ 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 'vision' not in (llm_model.model_entity.abilities or []):
|
||||
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 'vision' in (llm_model.model_entity.abilities or [])
|
||||
):
|
||||
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):
|
||||
# 转成文件链接,让下游 runner 上传到目标模型
|
||||
# Convert voice input into file content for downstream model upload.
|
||||
if me.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
|
||||
elif me.url:
|
||||
@@ -197,9 +324,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 'vision' in (llm_model.model_entity.abilities or [])
|
||||
):
|
||||
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):
|
||||
@@ -219,16 +344,14 @@ 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
|
||||
# Emit PromptPreProcessing before the runner receives the query.
|
||||
|
||||
event = events.PromptPreProcessing(
|
||||
session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
@@ -244,19 +367,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 ===========
|
||||
# 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
|
||||
# to KNOW which skills exist to make that choice, so we:
|
||||
# 1. resolve the pipeline's bound skills and stash them in
|
||||
# ``query.variables['_pipeline_bound_skills']`` for downstream
|
||||
# visibility checks (skill loader, native exec workdir);
|
||||
# 2. inject a short ``Available Skills`` index (name + description
|
||||
# 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 include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
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)
|
||||
@@ -268,43 +379,4 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
|
||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
if skill_addition:
|
||||
# Append to the first system message; create one if the
|
||||
# prompt has none. Handles both plain-string and
|
||||
# content-element (list) message bodies.
|
||||
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
||||
head = query.prompt.messages[0]
|
||||
if isinstance(head.content, str):
|
||||
head.content = head.content + skill_addition
|
||||
elif isinstance(head.content, list):
|
||||
appended = False
|
||||
for ce in head.content:
|
||||
if getattr(ce, 'type', None) == 'text':
|
||||
ce.text = (ce.text or '') + skill_addition
|
||||
appended = True
|
||||
break
|
||||
if not appended:
|
||||
head.content.append(provider_message.ContentElement(type='text', text=skill_addition))
|
||||
else:
|
||||
query.prompt.messages.insert(
|
||||
0,
|
||||
provider_message.Message(role='system', content=skill_addition.strip()),
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Skill index injected into system prompt: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'bound_skills={bound_skills or "all"} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.skills)}'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for prompt injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.skills)} '
|
||||
f'bound_skills={bound_skills}'
|
||||
)
|
||||
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
@@ -9,30 +9,36 @@ 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 ....agent.runner.config_migration import ConfigMigration
|
||||
from ....agent.runner import config_schema
|
||||
from ....utils import constants, runner as runner_utils
|
||||
from ....telemetry import features as telemetry_features
|
||||
from ....provider import runners
|
||||
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
|
||||
@@ -53,7 +59,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:
|
||||
@@ -79,40 +85,51 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
text_length = 0
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
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
|
||||
try_claim_steering = getattr(
|
||||
self.ap.agent_run_orchestrator,
|
||||
'try_claim_steering_from_query',
|
||||
None,
|
||||
)
|
||||
if try_claim_steering and await try_claim_steering(query):
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||
return
|
||||
|
||||
async for result in runner.run(query):
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
# Create a single resp_message_id for the entire streaming response
|
||||
resp_message_id = uuid.uuid4()
|
||||
chunk_count = 0
|
||||
|
||||
# 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:
|
||||
@@ -123,46 +140,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,
|
||||
@@ -172,7 +202,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
|
||||
@@ -180,16 +210,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)
|
||||
@@ -199,7 +227,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
|
||||
)
|
||||
|
||||
# Feature usage collected during query processing (tool calls,
|
||||
@@ -223,7 +251,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 events on successful non-WebSocket responses
|
||||
@@ -233,5 +260,70 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
# Counts toward the bot_response_success_100 milestone event
|
||||
await self.ap.survey.record_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')
|
||||
@@ -197,10 +206,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
task_context: taskmgr.TaskContext | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Extract plugin identity and dependency metadata from a plugin package."""
|
||||
plugin_author = None
|
||||
plugin_name = None
|
||||
plugin_version = None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
@@ -209,6 +219,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
metadata = manifest.get('metadata', {})
|
||||
plugin_author = metadata.get('author')
|
||||
plugin_name = metadata.get('name')
|
||||
plugin_version = metadata.get('version')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -227,7 +238,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return plugin_author, plugin_name
|
||||
return plugin_author, plugin_name, plugin_version
|
||||
|
||||
async def _install_mcp_from_marketplace(
|
||||
self,
|
||||
@@ -369,6 +380,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
):
|
||||
plugin_author = install_info.get('plugin_author')
|
||||
plugin_name = install_info.get('plugin_name')
|
||||
plugin_file_transferred = False
|
||||
|
||||
if install_source == PluginInstallSource.MARKETPLACE:
|
||||
# Handle marketplace plugin/mcp/skill installation
|
||||
@@ -463,9 +475,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
)
|
||||
|
||||
file_bytes = download_resp.content
|
||||
self._inspect_plugin_package(file_bytes, task_context)
|
||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(
|
||||
file_bytes,
|
||||
task_context,
|
||||
)
|
||||
if task_context is not None and plugin_author and plugin_name:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
if task_context is not None and plugin_version:
|
||||
task_context.metadata['plugin_version'] = plugin_version
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
install_source = PluginInstallSource.LOCAL
|
||||
plugin_file_transferred = True
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
# Continue to install via runtime
|
||||
else:
|
||||
@@ -481,12 +502,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
mcp_resp.raise_for_status()
|
||||
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
||||
|
||||
if install_source == PluginInstallSource.LOCAL:
|
||||
if install_source == PluginInstallSource.LOCAL and not plugin_file_transferred:
|
||||
# transfer file before install
|
||||
file_bytes = install_info['plugin_file']
|
||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
||||
if task_context is not None and plugin_author and plugin_name:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
if task_context is not None and plugin_version:
|
||||
task_context.metadata['plugin_version'] = plugin_version
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
@@ -523,9 +546,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||
|
||||
file_bytes = b''.join(chunks)
|
||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
||||
if task_context is not None and plugin_author and plugin_name:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
if task_context is not None and plugin_version:
|
||||
task_context.metadata['plugin_version'] = plugin_version
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
@@ -550,6 +575,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,
|
||||
@@ -568,6 +594,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,
|
||||
@@ -592,6 +620,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.
|
||||
|
||||
@@ -689,16 +719,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||
|
||||
async def get_plugin_logs(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
limit: int = 200,
|
||||
level: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Not cached: logs are live and change constantly.
|
||||
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
|
||||
|
||||
@alru_cache(ttl=5 * 60)
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
@@ -792,6 +812,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
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlalchemy
|
||||
import traceback
|
||||
|
||||
@@ -84,8 +85,19 @@ class ModelManager:
|
||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||
return
|
||||
|
||||
sync_timeout = space_config.get('models_sync_timeout')
|
||||
try:
|
||||
await self.sync_new_models_from_space()
|
||||
if sync_timeout:
|
||||
await asyncio.wait_for(
|
||||
self.sync_new_models_from_space(),
|
||||
timeout=float(sync_timeout),
|
||||
)
|
||||
else:
|
||||
await self.sync_new_models_from_space()
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(
|
||||
f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.'
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||
self.ap.logger.warning(f' - Error: {e}')
|
||||
|
||||
@@ -12,6 +12,19 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
|
||||
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
|
||||
|
||||
|
||||
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
|
||||
"""Store the latest provider usage on the query for upstream action handlers."""
|
||||
if query is None or not usage_info:
|
||||
return
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
query.variables[LLM_USAGE_QUERY_VARIABLE] = dict(usage_info)
|
||||
|
||||
|
||||
class RuntimeProvider:
|
||||
"""运行时模型提供商"""
|
||||
|
||||
@@ -67,6 +80,7 @@ class RuntimeProvider:
|
||||
if isinstance(result, tuple):
|
||||
msg, usage_info = result
|
||||
if usage_info:
|
||||
_store_llm_usage(query, usage_info)
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
return msg
|
||||
@@ -146,11 +160,12 @@ class RuntimeProvider:
|
||||
if query:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
if '_stream_usage' in query.variables:
|
||||
usage_info = query.variables['_stream_usage']
|
||||
if STREAM_USAGE_QUERY_VARIABLE in query.variables:
|
||||
usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||
_store_llm_usage(query, usage_info)
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
del query.variables['_stream_usage']
|
||||
del query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||
except Exception as e:
|
||||
status = 'error'
|
||||
error_message = str(e)
|
||||
|
||||
@@ -75,33 +75,22 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
continue
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _positive_int(value: typing.Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
parsed_value = int(value)
|
||||
if parsed_value > 0:
|
||||
return parsed_value
|
||||
return None
|
||||
|
||||
def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None:
|
||||
if not model_payload:
|
||||
return None
|
||||
|
||||
for field_name in ('context_length', 'context_window', 'max_context_length'):
|
||||
context_length = self._positive_int(model_payload.get(field_name))
|
||||
if context_length is not None:
|
||||
return context_length
|
||||
value = model_payload.get(field_name)
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
parsed_value = int(value)
|
||||
if parsed_value > 0:
|
||||
return parsed_value
|
||||
return None
|
||||
|
||||
def _context_length_from_litellm_model_info(self, model_info: typing.Any) -> int | None:
|
||||
if isinstance(model_info, dict):
|
||||
return self._positive_int(model_info.get('max_input_tokens'))
|
||||
return self._positive_int(getattr(model_info, 'max_input_tokens', None))
|
||||
|
||||
def _metadata_provider_candidates(self, model_name: str) -> list[str]:
|
||||
normalized_model_name = (model_name or '').lower()
|
||||
candidates = []
|
||||
@@ -137,7 +126,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
return None
|
||||
|
||||
def _safe_context_length(self, model_name: str) -> int | None:
|
||||
helper = getattr(litellm, 'get_model_info', None)
|
||||
helper = getattr(litellm, 'get_max_tokens', None)
|
||||
if not callable(helper):
|
||||
return self._known_context_length_fallback(model_name)
|
||||
|
||||
@@ -154,12 +143,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
continue
|
||||
tried_candidates.append(candidate)
|
||||
try:
|
||||
model_info = helper(candidate)
|
||||
max_tokens = helper(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
context_length = self._context_length_from_litellm_model_info(model_info)
|
||||
if context_length is not None:
|
||||
return context_length
|
||||
if isinstance(max_tokens, int) and max_tokens > 0:
|
||||
return max_tokens
|
||||
return self._known_context_length_fallback(model_name)
|
||||
|
||||
def _supports_function_calling(self, model_name: str) -> bool:
|
||||
@@ -262,32 +250,81 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
- dict with the same keys
|
||||
- missing ``total_tokens`` (derived from prompt + completion)
|
||||
- ``None`` / partially-populated usage (defaults to 0)
|
||||
- provider-specific token details, including cache token counters
|
||||
"""
|
||||
if usage is None:
|
||||
return {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
||||
def _plain_value(value: typing.Any) -> typing.Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return {k: _plain_value(v) for k, v in value.items() if v is not None}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_plain_value(v) for v in value]
|
||||
|
||||
def _get(key: str) -> typing.Any:
|
||||
if isinstance(usage, dict):
|
||||
return usage.get(key)
|
||||
return getattr(usage, key, None)
|
||||
model_dump = getattr(value, 'model_dump', None)
|
||||
if callable(model_dump):
|
||||
try:
|
||||
dumped = model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
return _plain_value(dumped)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt_tokens = _get('prompt_tokens') or 0
|
||||
completion_tokens = _get('completion_tokens') or 0
|
||||
total_tokens = _get('total_tokens') or 0
|
||||
return value
|
||||
|
||||
def _usage_dict(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
plain = _plain_value(value)
|
||||
if isinstance(plain, dict):
|
||||
return plain
|
||||
|
||||
def _is_mock_attr(attr: typing.Any) -> bool:
|
||||
return type(attr).__module__.startswith('unittest.mock')
|
||||
|
||||
data: dict[str, typing.Any] = {}
|
||||
for key in (
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'total_tokens',
|
||||
'prompt_tokens_details',
|
||||
'completion_tokens_details',
|
||||
'cache_creation_input_tokens',
|
||||
'cache_read_input_tokens',
|
||||
'input_token_details',
|
||||
'output_token_details',
|
||||
):
|
||||
attr_value = getattr(value, key, None)
|
||||
if attr_value is not None and not _is_mock_attr(attr_value):
|
||||
data[key] = _plain_value(attr_value)
|
||||
return data
|
||||
|
||||
def _to_int(value: typing.Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
normalized = _usage_dict(usage)
|
||||
|
||||
prompt_tokens = _to_int(normalized.get('prompt_tokens'))
|
||||
completion_tokens = _to_int(normalized.get('completion_tokens'))
|
||||
total_tokens = _to_int(normalized.get('total_tokens'))
|
||||
|
||||
# Some providers omit total_tokens in streaming usage; derive it.
|
||||
if not total_tokens:
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
|
||||
return {
|
||||
'prompt_tokens': int(prompt_tokens),
|
||||
'completion_tokens': int(completion_tokens),
|
||||
'total_tokens': int(total_tokens),
|
||||
}
|
||||
normalized['prompt_tokens'] = prompt_tokens
|
||||
normalized['completion_tokens'] = completion_tokens
|
||||
normalized['total_tokens'] = total_tokens
|
||||
return normalized
|
||||
|
||||
def _extract_usage(self, response) -> dict:
|
||||
def _extract_usage(self, response) -> dict | None:
|
||||
"""Extract usage info from a non-streaming LiteLLM response."""
|
||||
return self._normalize_usage(getattr(response, 'usage', None))
|
||||
usage = getattr(response, 'usage', None)
|
||||
if usage is None:
|
||||
return None
|
||||
return self._normalize_usage(usage)
|
||||
|
||||
@staticmethod
|
||||
def _as_dict(value: typing.Any) -> dict:
|
||||
@@ -486,7 +523,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if query is not None:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
query.variables['_stream_usage'] = usage_info
|
||||
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info
|
||||
|
||||
if not hasattr(chunk, 'choices') or not chunk.choices:
|
||||
continue
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import typing
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
preregistered_runners: list[typing.Type[RequestRunner]] = []
|
||||
|
||||
|
||||
def runner_class(name: str):
|
||||
"""注册一个请求运行器"""
|
||||
|
||||
def decorator(cls: typing.Type[RequestRunner]) -> typing.Type[RequestRunner]:
|
||||
cls.name = name
|
||||
preregistered_runners.append(cls)
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class RequestRunner(abc.ABC):
|
||||
"""请求运行器"""
|
||||
|
||||
name: str = None
|
||||
|
||||
ap: app.Application
|
||||
|
||||
pipeline_config: dict
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
@abc.abstractmethod
|
||||
async def run(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""运行请求"""
|
||||
pass
|
||||
@@ -1,288 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import base64
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.utils import image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.coze_server_api.client import AsyncCozeAPIClient
|
||||
|
||||
|
||||
@runner.runner_class('coze-api')
|
||||
class CozeAPIRunner(runner.RequestRunner):
|
||||
"""Coze API 对话请求器"""
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.pipeline_config = pipeline_config
|
||||
self.ap = ap
|
||||
self.agent_token = pipeline_config['ai']['coze-api']['api-key']
|
||||
self.bot_id = pipeline_config['ai']['coze-api'].get('bot-id')
|
||||
self.chat_timeout = pipeline_config['ai']['coze-api'].get('timeout')
|
||||
self.auto_save_history = pipeline_config['ai']['coze-api'].get('auto_save_history')
|
||||
self.api_base = pipeline_config['ai']['coze-api'].get('api-base')
|
||||
|
||||
self.coze = AsyncCozeAPIClient(self.agent_token, self.api_base)
|
||||
|
||||
def _process_thinking_content(
|
||||
self,
|
||||
content: str,
|
||||
) -> tuple[str, str]:
|
||||
"""处理思维链内容
|
||||
|
||||
Args:
|
||||
content: 原始内容
|
||||
Returns:
|
||||
(处理后的内容, 提取的思维链内容)
|
||||
"""
|
||||
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
thinking_content = ''
|
||||
# 从 content 中提取 <think> 标签内容
|
||||
if content and '<think>' in content and '</think>' in content:
|
||||
import re
|
||||
|
||||
think_pattern = r'<think>(.*?)</think>'
|
||||
think_matches = re.findall(think_pattern, content, re.DOTALL)
|
||||
if think_matches:
|
||||
thinking_content = '\n'.join(think_matches)
|
||||
# 移除 content 中的 <think> 标签
|
||||
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||
|
||||
# 根据 remove_think 参数决定是否保留思维链
|
||||
if remove_think:
|
||||
return content, ''
|
||||
else:
|
||||
# 如果有思维链内容,将其以 <think> 格式添加到 content 开头
|
||||
if thinking_content:
|
||||
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
|
||||
return content, thinking_content
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""预处理用户消息,转换为Coze消息格式
|
||||
|
||||
Returns:
|
||||
list[dict]: Coze消息列表
|
||||
"""
|
||||
messages = []
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
# 多模态消息处理
|
||||
content_parts = []
|
||||
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
content_parts.append({'type': 'text', 'text': ce.text})
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
file_id = await self._get_file_id(file_bytes)
|
||||
content_parts.append({'type': 'image', 'file_id': file_id})
|
||||
elif ce.type == 'file':
|
||||
# 处理文件,上传到Coze
|
||||
file_id = await self._get_file_id(ce.file)
|
||||
content_parts.append({'type': 'file', 'file_id': file_id})
|
||||
|
||||
# 创建多模态消息
|
||||
if content_parts:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'user',
|
||||
'content': json.dumps(content_parts),
|
||||
'content_type': 'object_string',
|
||||
'meta_data': None,
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(query.user_message.content, str):
|
||||
# 纯文本消息
|
||||
messages.append(
|
||||
{'role': 'user', 'content': query.user_message.content, 'content_type': 'text', 'meta_data': None}
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
async def _get_file_id(self, file) -> str:
|
||||
"""上传文件到Coze服务
|
||||
Args:
|
||||
file: 文件
|
||||
Returns:
|
||||
str: 文件ID
|
||||
"""
|
||||
file_id = await self.coze.upload(file=file)
|
||||
return file_id
|
||||
|
||||
async def _chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用聊天助手(非流式)
|
||||
|
||||
注意:由于cozepy没有提供非流式API,这里使用流式API并在结束后一次性返回完整内容
|
||||
"""
|
||||
user_id = f'{query.launcher_type.value}_{query.launcher_id}'
|
||||
|
||||
# 预处理用户消息
|
||||
additional_messages = await self._preprocess_user_message(query)
|
||||
|
||||
# 获取会话ID
|
||||
conversation_id = None
|
||||
|
||||
# 收集完整内容
|
||||
full_content = ''
|
||||
full_reasoning = ''
|
||||
|
||||
try:
|
||||
# 调用Coze API流式接口
|
||||
async for chunk in self.coze.chat_messages(
|
||||
bot_id=self.bot_id,
|
||||
user_id=user_id,
|
||||
additional_messages=additional_messages,
|
||||
conversation_id=conversation_id,
|
||||
timeout=self.chat_timeout,
|
||||
auto_save_history=self.auto_save_history,
|
||||
stream=True,
|
||||
):
|
||||
self.ap.logger.debug(f'coze-chat-stream: {chunk}')
|
||||
|
||||
event_type = chunk.get('event')
|
||||
data = chunk.get('data', {})
|
||||
# Removed debug print statement to avoid cluttering logs in production
|
||||
|
||||
if event_type == 'conversation.message.delta':
|
||||
# 收集内容
|
||||
if 'content' in data:
|
||||
full_content += data.get('content', '')
|
||||
|
||||
# 收集推理内容(如果有)
|
||||
if 'reasoning_content' in data:
|
||||
full_reasoning += data.get('reasoning_content', '')
|
||||
|
||||
elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done
|
||||
# 保存会话ID
|
||||
if 'conversation_id' in data:
|
||||
conversation_id = data.get('conversation_id')
|
||||
|
||||
elif event_type == 'error':
|
||||
# 处理错误
|
||||
error_msg = f'Coze API错误: {data.get("message", "未知错误")}'
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=error_msg,
|
||||
)
|
||||
return
|
||||
|
||||
# 处理思维链内容
|
||||
content, thinking_content = self._process_thinking_content(full_content)
|
||||
if full_reasoning:
|
||||
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
if not remove_think:
|
||||
content = f'<think>\n{full_reasoning}\n</think>\n{content}'.strip()
|
||||
|
||||
# 一次性返回完整内容
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
|
||||
# 保存会话ID
|
||||
if conversation_id and query.session.using_conversation:
|
||||
query.session.using_conversation.uuid = conversation_id
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Coze API错误: {str(e)}')
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=f'Coze API调用失败: {str(e)}',
|
||||
)
|
||||
|
||||
async def _chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用聊天助手(流式)"""
|
||||
user_id = f'{query.launcher_type.value}_{query.launcher_id}'
|
||||
|
||||
# 预处理用户消息
|
||||
additional_messages = await self._preprocess_user_message(query)
|
||||
|
||||
# 获取会话ID
|
||||
conversation_id = None
|
||||
|
||||
start_reasoning = False
|
||||
stop_reasoning = False
|
||||
message_idx = 1
|
||||
is_final = False
|
||||
full_content = ''
|
||||
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
|
||||
try:
|
||||
# 调用Coze API流式接口
|
||||
async for chunk in self.coze.chat_messages(
|
||||
bot_id=self.bot_id,
|
||||
user_id=user_id,
|
||||
additional_messages=additional_messages,
|
||||
conversation_id=conversation_id,
|
||||
timeout=self.chat_timeout,
|
||||
auto_save_history=self.auto_save_history,
|
||||
stream=True,
|
||||
):
|
||||
self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')
|
||||
|
||||
event_type = chunk.get('event')
|
||||
data = chunk.get('data', {})
|
||||
content = ''
|
||||
|
||||
if event_type == 'conversation.message.delta':
|
||||
message_idx += 1
|
||||
# 处理内容增量
|
||||
if 'reasoning_content' in data and not remove_think:
|
||||
reasoning_content = data.get('reasoning_content', '')
|
||||
if reasoning_content and not start_reasoning:
|
||||
content = '<think/>\n'
|
||||
start_reasoning = True
|
||||
content += reasoning_content
|
||||
|
||||
if 'content' in data:
|
||||
if data.get('content', ''):
|
||||
content += data.get('content', '')
|
||||
if not stop_reasoning and start_reasoning:
|
||||
content = f'</think>\n{content}'
|
||||
stop_reasoning = True
|
||||
|
||||
elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done
|
||||
# 保存会话ID
|
||||
if 'conversation_id' in data:
|
||||
conversation_id = data.get('conversation_id')
|
||||
if query.session.using_conversation:
|
||||
query.session.using_conversation.uuid = conversation_id
|
||||
is_final = True
|
||||
|
||||
elif event_type == 'error':
|
||||
# 处理错误
|
||||
error_msg = f'Coze API错误: {data.get("message", "未知错误")}'
|
||||
yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error')
|
||||
return
|
||||
full_content += content
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
if full_content:
|
||||
yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final)
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Coze API流式调用错误: {str(e)}')
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant', content=f'Coze API流式调用失败: {str(e)}', finish_reason='error'
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行"""
|
||||
msg_seq = 0
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
async for msg in self._chat_messages_chunk(query):
|
||||
if isinstance(msg, provider_message.MessageChunk):
|
||||
msg_seq += 1
|
||||
msg.msg_sequence = msg_seq
|
||||
yield msg
|
||||
else:
|
||||
async for msg in self._chat_messages(query):
|
||||
yield msg
|
||||
@@ -1,355 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import re
|
||||
|
||||
import dashscope
|
||||
|
||||
from .. import runner
|
||||
from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class DashscopeAPIError(Exception):
|
||||
"""Dashscope API 请求失败"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@runner.runner_class('dashscope-app-api')
|
||||
class DashScopeAPIRunner(runner.RequestRunner):
|
||||
"阿里云百炼DashsscopeAPI对话请求器"
|
||||
|
||||
# 运行器内部使用的配置
|
||||
app_type: str # 应用类型
|
||||
app_id: str # 应用ID
|
||||
api_key: str # API Key
|
||||
references_quote: (
|
||||
str # 引用资料提示(当展示回答来源功能开启时,这个变量会作为引用资料名前的提示,可在provider.json中配置)
|
||||
)
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
"""初始化"""
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
valid_app_types = ['agent', 'workflow']
|
||||
self.app_type = self.pipeline_config['ai']['dashscope-app-api']['app-type']
|
||||
# 检查配置文件中使用的应用类型是否支持
|
||||
if self.app_type not in valid_app_types:
|
||||
raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}')
|
||||
|
||||
# 初始化Dashscope 参数配置
|
||||
self.app_id = self.pipeline_config['ai']['dashscope-app-api']['app-id']
|
||||
self.api_key = self.pipeline_config['ai']['dashscope-app-api']['api-key']
|
||||
self.references_quote = self.pipeline_config['ai']['dashscope-app-api']['references_quote']
|
||||
|
||||
def _replace_references(self, text, references_dict):
|
||||
"""阿里云百炼平台的自定义应用支持资料引用,此函数可以将引用标签替换为参考资料"""
|
||||
|
||||
# 匹配 <ref>[index_id]</ref> 形式的字符串
|
||||
pattern = re.compile(r'<ref>\[(.*?)\]</ref>')
|
||||
|
||||
def replacement(match):
|
||||
# 获取引用编号
|
||||
ref_key = match.group(1)
|
||||
if ref_key in references_dict:
|
||||
# 如果有对应的参考资料按照provider.json中的reference_quote返回提示,来自哪个参考资料文件
|
||||
return f'({self.references_quote} {references_dict[ref_key]})'
|
||||
else:
|
||||
# 如果没有对应的参考资料,保留原样
|
||||
return match.group(0)
|
||||
|
||||
# 使用 re.sub() 进行替换
|
||||
return pattern.sub(replacement, text)
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
|
||||
"""预处理用户消息,提取纯文本,阿里云提供的上传文件方法过于复杂,暂不支持上传文件(包括图片)"""
|
||||
plain_text = ''
|
||||
image_ids = []
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
# 暂时不支持上传图片,保留代码以便后续扩展
|
||||
# elif ce.type == "image_base64":
|
||||
# image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
# file_bytes = base64.b64decode(image_b64)
|
||||
# file = ("img.png", file_bytes, f"image/{image_format}")
|
||||
# file_upload_resp = await self.dify_client.upload_file(
|
||||
# file,
|
||||
# f"{query.session.launcher_type.value}_{query.session.launcher_id}",
|
||||
# )
|
||||
# image_id = file_upload_resp["id"]
|
||||
# image_ids.append(image_id)
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
return plain_text, image_ids
|
||||
|
||||
async def _agent_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""Dashscope 智能体对话请求"""
|
||||
|
||||
# 局部变量
|
||||
chunk = None # 流式传输的块
|
||||
pending_content = '' # 待处理的Agent输出内容
|
||||
references_dict = {} # 用于存储引用编号和对应的参考资料
|
||||
plain_text = '' # 用户输入的纯文本信息
|
||||
image_ids = [] # 用户输入的图片ID列表 (暂不支持)
|
||||
|
||||
think_start = False
|
||||
think_end = False
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
has_thoughts = True # 获取思考过程
|
||||
remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think')
|
||||
if remove_think:
|
||||
has_thoughts = False
|
||||
# 发送对话请求
|
||||
response = dashscope.Application.call(
|
||||
api_key=self.api_key, # 智能体应用的API Key
|
||||
app_id=self.app_id, # 智能体应用的ID
|
||||
prompt=plain_text, # 用户输入的文本信息
|
||||
stream=True, # 流式输出
|
||||
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
|
||||
session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
|
||||
enable_thinking=has_thoughts,
|
||||
has_thoughts=has_thoughts,
|
||||
# rag_options={ # 主要用于文件交互,暂不支持
|
||||
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
|
||||
# }
|
||||
)
|
||||
idx_chunk = 0
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
if is_stream:
|
||||
for chunk in response:
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
)
|
||||
if not chunk:
|
||||
continue
|
||||
idx_chunk += 1
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{stream_think[0].get("thought")}'
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
# 是否是流式最后一个chunk
|
||||
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
|
||||
|
||||
# 获取模型传出的参考资料列表
|
||||
references_dict_list = stream_output.get('doc_references', [])
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
|
||||
if idx_chunk % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
is_final=is_final,
|
||||
)
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
else:
|
||||
for chunk in response:
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
)
|
||||
if not chunk:
|
||||
continue
|
||||
idx_chunk += 1
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{stream_think[0].get("thought")}'
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
|
||||
# 获取模型传出的参考资料列表
|
||||
references_dict_list = stream_output.get('doc_references', [])
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
)
|
||||
|
||||
async def _workflow_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""Dashscope 工作流对话请求"""
|
||||
|
||||
# 局部变量
|
||||
chunk = None # 流式传输的块
|
||||
pending_content = '' # 待处理的Agent输出内容
|
||||
references_dict = {} # 用于存储引用编号和对应的参考资料
|
||||
plain_text = '' # 用户输入的纯文本信息
|
||||
image_ids = [] # 用户输入的图片ID列表 (暂不支持)
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
|
||||
biz_params = {}
|
||||
biz_params.update(query.variables)
|
||||
|
||||
# 发送对话请求
|
||||
response = dashscope.Application.call(
|
||||
api_key=self.api_key, # 智能体应用的API Key
|
||||
app_id=self.app_id, # 智能体应用的ID
|
||||
prompt=plain_text, # 用户输入的文本信息
|
||||
stream=True, # 流式输出
|
||||
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
|
||||
session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
|
||||
biz_params=biz_params, # 工作流应用的自定义输入参数传递
|
||||
flow_stream_mode='message_format', # 消息模式,输出/结束节点的流式结果
|
||||
# rag_options={ # 主要用于文件交互,暂不支持
|
||||
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
|
||||
# }
|
||||
)
|
||||
|
||||
# 处理API返回的流式输出
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
idx_chunk = 0
|
||||
if is_stream:
|
||||
for chunk in response:
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
)
|
||||
if not chunk:
|
||||
continue
|
||||
idx_chunk += 1
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
if stream_output.get('workflow_message') is not None:
|
||||
pending_content += stream_output.get('workflow_message').get('message').get('content')
|
||||
# if stream_output.get('text') is not None:
|
||||
# pending_content += stream_output.get('text')
|
||||
|
||||
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
|
||||
|
||||
# 获取模型传出的参考资料列表
|
||||
references_dict_list = stream_output.get('doc_references', [])
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
if idx_chunk % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
|
||||
else:
|
||||
for chunk in response:
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
)
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
|
||||
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
|
||||
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
|
||||
# 获取模型传出的参考资料列表
|
||||
references_dict_list = stream_output.get('doc_references', [])
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行"""
|
||||
msg_seq = 0
|
||||
if self.app_type == 'agent':
|
||||
async for msg in self._agent_messages(query):
|
||||
if isinstance(msg, provider_message.MessageChunk):
|
||||
msg_seq += 1
|
||||
msg.msg_sequence = msg_seq
|
||||
yield msg
|
||||
elif self.app_type == 'workflow':
|
||||
async for msg in self._workflow_messages(query):
|
||||
if isinstance(msg, provider_message.MessageChunk):
|
||||
msg_seq += 1
|
||||
msg.msg_sequence = msg_seq
|
||||
yield msg
|
||||
else:
|
||||
raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}')
|
||||
@@ -1,511 +0,0 @@
|
||||
"""DeerFlow LangGraph API Runner
|
||||
|
||||
参考 astrbot 的 deerflow_agent_runner 实现,适配 LangBot 的 Runner 接口。
|
||||
|
||||
特点:
|
||||
- 使用 LangGraph HTTP API 接入 deer-flow 后端
|
||||
- 自动管理 thread_id(按 session 隔离)
|
||||
- 支持 SSE 流式响应解析
|
||||
- 支持 streaming/非流式两种输出
|
||||
- 处理 values / messages-tuple / custom 三种事件
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.deerflow_api import client, errors, stream_utils
|
||||
|
||||
|
||||
_MAX_VALUES_HISTORY = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamState:
|
||||
"""流式状态跟踪"""
|
||||
|
||||
latest_text: str = ''
|
||||
prev_text_for_streaming: str = ''
|
||||
clarification_text: str = ''
|
||||
task_failures: list[str] = field(default_factory=list)
|
||||
seen_message_ids: set[str] = field(default_factory=set)
|
||||
seen_message_order: deque[str] = field(default_factory=deque)
|
||||
no_id_message_fingerprints: dict[int, str] = field(default_factory=dict)
|
||||
baseline_initialized: bool = False
|
||||
has_values_text: bool = False
|
||||
run_values_messages: list[dict[str, typing.Any]] = field(default_factory=list)
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@runner.runner_class('deerflow-api')
|
||||
class DeerFlowAPIRunner(runner.RequestRunner):
|
||||
"""DeerFlow LangGraph API 对话请求器"""
|
||||
|
||||
deerflow_client: client.AsyncDeerFlowClient
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
super().__init__(ap, pipeline_config)
|
||||
|
||||
cfg = self.pipeline_config['ai']['deerflow-api']
|
||||
|
||||
api_base = cfg.get('api-base', '').strip()
|
||||
if not api_base or not api_base.startswith(('http://', 'https://')):
|
||||
raise errors.DeerFlowAPIError(
|
||||
message='DeerFlow API Base URL 格式错误,必须以 http:// 或 https:// 开头',
|
||||
)
|
||||
|
||||
self.api_base = api_base
|
||||
self.api_key = cfg.get('api-key', '')
|
||||
self.auth_header = cfg.get('auth-header', '')
|
||||
self.assistant_id = cfg.get('assistant-id', 'lead_agent')
|
||||
self.model_name = cfg.get('model-name', '')
|
||||
self.thinking_enabled = bool(cfg.get('thinking-enabled', False))
|
||||
self.plan_mode = bool(cfg.get('plan-mode', False))
|
||||
self.subagent_enabled = bool(cfg.get('subagent-enabled', False))
|
||||
self.max_concurrent_subagents = int(cfg.get('max-concurrent-subagents', 3))
|
||||
self.timeout = int(cfg.get('timeout', 300))
|
||||
self.recursion_limit = int(cfg.get('recursion-limit', 1000))
|
||||
|
||||
self.deerflow_client = client.AsyncDeerFlowClient(
|
||||
api_base=self.api_base,
|
||||
api_key=self.api_key,
|
||||
auth_header=self.auth_header,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_message(self, message: dict[str, typing.Any]) -> str:
|
||||
try:
|
||||
raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
raw = repr(message)
|
||||
return hashlib.sha1(raw.encode('utf-8', errors='ignore')).hexdigest()
|
||||
|
||||
def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None:
|
||||
if not msg_id or msg_id in state.seen_message_ids:
|
||||
return
|
||||
state.seen_message_ids.add(msg_id)
|
||||
state.seen_message_order.append(msg_id)
|
||||
while len(state.seen_message_order) > _MAX_VALUES_HISTORY:
|
||||
dropped = state.seen_message_order.popleft()
|
||||
state.seen_message_ids.discard(dropped)
|
||||
|
||||
def _extract_new_messages_from_values(
|
||||
self,
|
||||
values_messages: list[typing.Any],
|
||||
state: _StreamState,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
new_messages: list[dict[str, typing.Any]] = []
|
||||
no_id_indexes_seen: set[int] = set()
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = stream_utils.get_message_id(msg)
|
||||
if msg_id:
|
||||
if msg_id in state.seen_message_ids:
|
||||
continue
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
new_messages.append(msg)
|
||||
continue
|
||||
|
||||
no_id_indexes_seen.add(idx)
|
||||
fp = self._fingerprint_message(msg)
|
||||
if state.no_id_message_fingerprints.get(idx) == fp:
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = fp
|
||||
new_messages.append(msg)
|
||||
|
||||
for idx in list(state.no_id_message_fingerprints.keys()):
|
||||
if idx not in no_id_indexes_seen:
|
||||
state.no_id_message_fingerprints.pop(idx, None)
|
||||
return new_messages
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 用户输入处理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_user_content(
|
||||
self,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
) -> typing.Any:
|
||||
"""构建 LangGraph 兼容的 user content(支持多模态)"""
|
||||
if not image_urls:
|
||||
return prompt
|
||||
|
||||
content: list[dict[str, typing.Any]] = []
|
||||
if prompt:
|
||||
content.append({'type': 'text', 'text': prompt})
|
||||
for url in image_urls:
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith(('http://', 'https://', 'data:')):
|
||||
content.append({'type': 'image_url', 'image_url': {'url': url}})
|
||||
return content if content else prompt
|
||||
|
||||
def _preprocess_user_message(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""提取用户消息的纯文本与图片 URL 列表"""
|
||||
plain_text = ''
|
||||
image_urls: list[str] = []
|
||||
|
||||
if isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
elif isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
# 转换为 data URI 形式
|
||||
b64 = getattr(ce, 'image_base64', '')
|
||||
if b64:
|
||||
if not b64.startswith('data:'):
|
||||
b64 = f'data:image/png;base64,{b64}'
|
||||
image_urls.append(b64)
|
||||
elif ce.type == 'image_url':
|
||||
url = getattr(ce, 'image_url', '')
|
||||
if url:
|
||||
image_urls.append(url)
|
||||
|
||||
return plain_text, image_urls
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 请求构造
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str = '',
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
messages: list[dict[str, typing.Any]] = []
|
||||
if system_prompt:
|
||||
messages.append({'role': 'system', 'content': system_prompt})
|
||||
messages.append(
|
||||
{
|
||||
'role': 'user',
|
||||
'content': self._build_user_content(prompt, image_urls),
|
||||
}
|
||||
)
|
||||
return messages
|
||||
|
||||
def _build_runtime_configurable(self, thread_id: str) -> dict[str, typing.Any]:
|
||||
cfg: dict[str, typing.Any] = {
|
||||
'thread_id': thread_id,
|
||||
'thinking_enabled': self.thinking_enabled,
|
||||
'is_plan_mode': self.plan_mode,
|
||||
'subagent_enabled': self.subagent_enabled,
|
||||
}
|
||||
if self.subagent_enabled:
|
||||
cfg['max_concurrent_subagents'] = self.max_concurrent_subagents
|
||||
if self.model_name:
|
||||
cfg['model_name'] = self.model_name
|
||||
return cfg
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
thread_id: str,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str = '',
|
||||
) -> dict[str, typing.Any]:
|
||||
runtime_configurable = self._build_runtime_configurable(thread_id)
|
||||
return {
|
||||
'assistant_id': self.assistant_id,
|
||||
'input': {
|
||||
'messages': self._build_messages(prompt, image_urls, system_prompt),
|
||||
},
|
||||
'stream_mode': ['values', 'messages-tuple', 'custom'],
|
||||
# DeerFlow 2.0 从 config.configurable 读取运行时覆盖
|
||||
# 同时保留 context 字段做向后兼容
|
||||
'context': dict(runtime_configurable),
|
||||
'config': {
|
||||
'recursion_limit': self.recursion_limit,
|
||||
'configurable': runtime_configurable,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session/Thread 管理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_thread_id(self, query: pipeline_query.Query) -> str:
|
||||
"""从 query.session 取/创建 deerflow thread_id
|
||||
|
||||
LangBot 使用 `query.session.using_conversation.uuid` 持久化 conversation id,
|
||||
我们复用这个字段存储 deerflow thread_id(与 Dify Runner 同样做法)。
|
||||
"""
|
||||
thread_id = query.session.using_conversation.uuid or ''
|
||||
if thread_id:
|
||||
return thread_id
|
||||
|
||||
thread = await self.deerflow_client.create_thread(timeout=min(30, self.timeout))
|
||||
thread_id = thread.get('thread_id', '')
|
||||
if not thread_id:
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow create thread 返回数据缺少 thread_id: {thread}')
|
||||
|
||||
query.session.using_conversation.uuid = thread_id
|
||||
return thread_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 流式事件处理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_values_event(
|
||||
self,
|
||||
data: typing.Any,
|
||||
state: _StreamState,
|
||||
) -> str | None:
|
||||
"""处理 values 事件,返回新的完整文本(增量基础上的全量)"""
|
||||
values_messages = stream_utils.extract_messages_from_values_data(data)
|
||||
if not values_messages:
|
||||
return None
|
||||
|
||||
new_messages: list[dict[str, typing.Any]] = []
|
||||
if not state.baseline_initialized:
|
||||
state.baseline_initialized = True
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
new_messages.append(msg)
|
||||
msg_id = stream_utils.get_message_id(msg)
|
||||
if msg_id:
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg)
|
||||
else:
|
||||
new_messages = self._extract_new_messages_from_values(values_messages, state)
|
||||
|
||||
latest_text = ''
|
||||
if new_messages:
|
||||
state.run_values_messages.extend(new_messages)
|
||||
if len(state.run_values_messages) > _MAX_VALUES_HISTORY:
|
||||
state.run_values_messages = state.run_values_messages[-_MAX_VALUES_HISTORY:]
|
||||
latest_text = stream_utils.extract_latest_ai_text(state.run_values_messages)
|
||||
if latest_text:
|
||||
state.has_values_text = True
|
||||
latest_clarification = stream_utils.extract_latest_clarification_text(
|
||||
state.run_values_messages,
|
||||
)
|
||||
if latest_clarification:
|
||||
state.clarification_text = latest_clarification
|
||||
|
||||
return latest_text or None
|
||||
|
||||
def _handle_message_event(
|
||||
self,
|
||||
data: typing.Any,
|
||||
state: _StreamState,
|
||||
) -> str | None:
|
||||
"""处理 messages-tuple 事件,返回增量文本
|
||||
|
||||
当 values 事件已经提供完整文本时,跳过 messages-tuple 的增量
|
||||
"""
|
||||
delta = stream_utils.extract_ai_delta_from_event_data(data)
|
||||
if delta and not state.has_values_text:
|
||||
state.latest_text += delta
|
||||
return delta
|
||||
|
||||
maybe_clar = stream_utils.extract_clarification_from_event_data(data)
|
||||
if maybe_clar:
|
||||
state.clarification_text = maybe_clar
|
||||
return None
|
||||
|
||||
def _build_final_text(self, state: _StreamState) -> str:
|
||||
"""构建最终输出文本"""
|
||||
if state.clarification_text:
|
||||
return state.clarification_text
|
||||
|
||||
# 优先使用最后一条 AI message 的文本
|
||||
latest_ai = stream_utils.extract_latest_ai_message(state.run_values_messages)
|
||||
if latest_ai:
|
||||
text = stream_utils.extract_text(latest_ai.get('content'))
|
||||
if text:
|
||||
if state.timed_out:
|
||||
text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。'
|
||||
return text
|
||||
|
||||
if state.latest_text:
|
||||
text = state.latest_text
|
||||
if state.timed_out:
|
||||
text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。'
|
||||
return text
|
||||
|
||||
# 提取任务失败信息作兜底
|
||||
failure_text = stream_utils.build_task_failure_summary(state.task_failures)
|
||||
if failure_text:
|
||||
return failure_text
|
||||
|
||||
return 'DeerFlow 返回空响应'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _stream_messages_chunk(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""流式输出生成器"""
|
||||
plain_text, image_urls = self._preprocess_user_message(query)
|
||||
|
||||
system_prompt = ''
|
||||
# LangBot 的 pipeline 通常通过 prompt-preprocess 已注入 system prompt
|
||||
# 这里保持空,让 prompt-preprocess 的内容作为 user message 一并送给 deerflow
|
||||
|
||||
thread_id = await self._ensure_thread_id(query)
|
||||
payload = self._build_payload(
|
||||
thread_id=thread_id,
|
||||
prompt=plain_text or 'continue',
|
||||
image_urls=image_urls,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
|
||||
state = _StreamState()
|
||||
prev_text = ''
|
||||
message_idx = 0
|
||||
|
||||
try:
|
||||
async for event in self.deerflow_client.stream_run(
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = event.get('event')
|
||||
data = event.get('data')
|
||||
|
||||
if event_type == 'values':
|
||||
new_full = self._handle_values_event(data, state)
|
||||
if new_full and new_full != prev_text:
|
||||
delta = new_full[len(prev_text) :] if new_full.startswith(prev_text) else new_full
|
||||
prev_text = new_full
|
||||
if delta:
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=new_full,
|
||||
is_final=False,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type in {'messages-tuple', 'messages', 'message'}:
|
||||
delta = self._handle_message_event(data, state)
|
||||
if delta:
|
||||
prev_text = state.latest_text
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=prev_text,
|
||||
is_final=False,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'custom':
|
||||
state.task_failures.extend(
|
||||
stream_utils.extract_task_failures_from_custom_event(data),
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'error':
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}')
|
||||
|
||||
if event_type == 'end':
|
||||
break
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}')
|
||||
state.timed_out = True
|
||||
|
||||
# 最终消息
|
||||
final_text = self._build_final_text(state)
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=final_text,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def _messages(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""非流式聚合输出"""
|
||||
plain_text, image_urls = self._preprocess_user_message(query)
|
||||
|
||||
thread_id = await self._ensure_thread_id(query)
|
||||
payload = self._build_payload(
|
||||
thread_id=thread_id,
|
||||
prompt=plain_text or 'continue',
|
||||
image_urls=image_urls,
|
||||
)
|
||||
|
||||
state = _StreamState()
|
||||
|
||||
try:
|
||||
async for event in self.deerflow_client.stream_run(
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = event.get('event')
|
||||
data = event.get('data')
|
||||
|
||||
if event_type == 'values':
|
||||
self._handle_values_event(data, state)
|
||||
continue
|
||||
|
||||
if event_type in {'messages-tuple', 'messages', 'message'}:
|
||||
self._handle_message_event(data, state)
|
||||
continue
|
||||
|
||||
if event_type == 'custom':
|
||||
state.task_failures.extend(
|
||||
stream_utils.extract_task_failures_from_custom_event(data),
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'error':
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}')
|
||||
|
||||
if event_type == 'end':
|
||||
break
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}')
|
||||
state.timed_out = True
|
||||
|
||||
final_text = self._build_final_text(state)
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=final_text,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""主入口:根据 adapter 是否支持流式输出,选择流式或非流式"""
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
msg_idx = 0
|
||||
async for msg in self._stream_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
else:
|
||||
async for msg in self._messages(query):
|
||||
yield msg
|
||||
@@ -1,775 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.utils import image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
import httpx
|
||||
|
||||
|
||||
@runner.runner_class('dify-service-api')
|
||||
class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
"""Dify Service API 对话请求器"""
|
||||
|
||||
dify_client: client.AsyncDifyServiceClient
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
valid_app_types = ['chat', 'agent', 'workflow']
|
||||
if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types:
|
||||
raise errors.DifyAPIError(
|
||||
f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}'
|
||||
)
|
||||
|
||||
api_key = self.pipeline_config['ai']['dify-service-api']['api-key']
|
||||
|
||||
self.dify_client = client.AsyncDifyServiceClient(
|
||||
api_key=api_key,
|
||||
base_url=self.pipeline_config['ai']['dify-service-api']['base-url'],
|
||||
)
|
||||
|
||||
def _process_thinking_content(
|
||||
self,
|
||||
content: str,
|
||||
) -> tuple[str, str]:
|
||||
"""处理思维链内容
|
||||
|
||||
Args:
|
||||
content: 原始内容
|
||||
Returns:
|
||||
(处理后的内容, 提取的思维链内容)
|
||||
"""
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
thinking_content = ''
|
||||
# 从 content 中提取 <think> 标签内容
|
||||
if content and '<think>' in content and '</think>' in content:
|
||||
import re
|
||||
|
||||
think_pattern = r'<think>(.*?)</think>'
|
||||
think_matches = re.findall(think_pattern, content, re.DOTALL)
|
||||
if think_matches:
|
||||
thinking_content = '\n'.join(think_matches)
|
||||
# 移除 content 中的 <think> 标签
|
||||
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||
|
||||
# 3. 根据 remove_think 参数决定是否保留思维链
|
||||
if remove_think:
|
||||
return content, ''
|
||||
else:
|
||||
# 如果有思维链内容,将其以 <think> 格式添加到 content 开头
|
||||
if thinking_content:
|
||||
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
|
||||
return content, thinking_content
|
||||
|
||||
def _extract_dify_text_output(self, value: typing.Any) -> str:
|
||||
"""Extract text content from Dify output payload."""
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, dict):
|
||||
content = value.get('content')
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return ''
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get('content'), str):
|
||||
return parsed['content']
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]:
|
||||
"""预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务
|
||||
|
||||
Returns:
|
||||
tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id)
|
||||
"""
|
||||
plain_text = ''
|
||||
upload_files: list[dict] = []
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str:
|
||||
file_name = file_name or 'file'
|
||||
content_type = content_type or 'application/octet-stream'
|
||||
file = (file_name, file_bytes, content_type)
|
||||
resp = await self.dify_client.upload_file(file, user_tag)
|
||||
return resp['id']
|
||||
|
||||
async def download_file(file_url: str) -> tuple[bytes, str]:
|
||||
"""Download file from url (supports data url)."""
|
||||
|
||||
async with httpx.AsyncClient() as client_session:
|
||||
resp = await client_session.get(file_url)
|
||||
resp.raise_for_status()
|
||||
content_type = (
|
||||
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
|
||||
)
|
||||
return resp.content, content_type
|
||||
|
||||
def _detect_file_type(content_type: str) -> str:
|
||||
"""Map MIME to dify file type."""
|
||||
if content_type and content_type.startswith('image/'):
|
||||
return 'image'
|
||||
if content_type and content_type.startswith('audio/'):
|
||||
return 'audio'
|
||||
if content_type and content_type.startswith('video/'):
|
||||
return 'video'
|
||||
return 'document'
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
|
||||
upload_files.append({'type': 'image', 'id': image_id})
|
||||
elif ce.type == 'file_url':
|
||||
file_url = getattr(ce, 'file_url', None)
|
||||
file_name = getattr(ce, 'file_name', None) or 'file'
|
||||
try:
|
||||
file_bytes, content_type = await download_file(file_url)
|
||||
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
|
||||
file_type = _detect_file_type(content_type)
|
||||
upload_files.append({'type': file_type, 'id': file_id})
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'dify file upload failed: {e}')
|
||||
elif ce.type == 'file_base64':
|
||||
file_name = getattr(ce, 'file_name', None) or 'file'
|
||||
|
||||
header, b64_data = ce.file_base64.split(',', 1)
|
||||
content_type = 'application/octet-stream'
|
||||
if ';' in header:
|
||||
content_type = header.split(';')[0][5:] or content_type
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
|
||||
file_type = _detect_file_type(content_type)
|
||||
upload_files.append({'type': file_type, 'id': file_id})
|
||||
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']
|
||||
|
||||
return plain_text, upload_files
|
||||
|
||||
async def _chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
mode = 'basic' # 标记是基础编排还是工作流编排
|
||||
|
||||
basic_mode_pending_chunk = ''
|
||||
|
||||
inputs = {}
|
||||
|
||||
inputs.update(query.variables)
|
||||
|
||||
chunk = None # 初始化chunk变量,防止在没有响应时引用错误
|
||||
|
||||
async for chunk in self.dify_client.chat_messages(
|
||||
inputs=inputs,
|
||||
query=plain_text,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
conversation_id=cov_id,
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-chat-chunk: ' + str(chunk))
|
||||
|
||||
if chunk['event'] == 'workflow_started':
|
||||
mode = 'workflow'
|
||||
|
||||
if mode == 'workflow':
|
||||
if chunk['event'] == 'node_finished':
|
||||
if chunk['data']['node_type'] == 'answer':
|
||||
answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer'))
|
||||
content, _ = self._process_thinking_content(answer)
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
elif mode == 'basic':
|
||||
if chunk['event'] == 'message':
|
||||
basic_mode_pending_chunk += chunk['answer']
|
||||
elif chunk['event'] == 'message_end':
|
||||
content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
basic_mode_pending_chunk = ''
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
query.session.using_conversation.uuid = chunk['conversation_id']
|
||||
|
||||
async def _agent_chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = []
|
||||
|
||||
inputs = {}
|
||||
|
||||
inputs.update(query.variables)
|
||||
|
||||
pending_agent_message = ''
|
||||
|
||||
chunk = None # 初始化chunk变量,防止在没有响应时引用错误
|
||||
|
||||
async for chunk in self.dify_client.chat_messages(
|
||||
inputs=inputs,
|
||||
query=plain_text,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
response_mode='streaming',
|
||||
conversation_id=cov_id,
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-agent-chunk: ' + str(chunk))
|
||||
|
||||
if chunk['event'] in ignored_events:
|
||||
continue
|
||||
|
||||
if chunk['event'] == 'agent_message' or chunk['event'] == 'message':
|
||||
pending_agent_message += chunk['answer']
|
||||
else:
|
||||
if pending_agent_message.strip() != '':
|
||||
pending_agent_message = pending_agent_message.replace('</details>Action:', '</details>')
|
||||
content, _ = self._process_thinking_content(pending_agent_message)
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
pending_agent_message = ''
|
||||
|
||||
if chunk['event'] == 'agent_thought':
|
||||
if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过
|
||||
continue
|
||||
|
||||
if chunk['tool']:
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk['id'],
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=chunk['tool'],
|
||||
arguments=json.dumps({}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
yield msg
|
||||
if chunk['event'] == 'message_file':
|
||||
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
|
||||
# 检查URL是否已经是完整的连接
|
||||
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
|
||||
image_url = chunk['url']
|
||||
else:
|
||||
base_url = self.dify_client.base_url
|
||||
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
image_url = base_url + chunk['url']
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=[provider_message.ContentElement.from_image_url(image_url)],
|
||||
)
|
||||
if chunk['event'] == 'error':
|
||||
raise errors.DifyAPIError('dify 服务错误: ' + chunk['message'])
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
query.session.using_conversation.uuid = chunk['conversation_id']
|
||||
|
||||
async def _workflow_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用工作流"""
|
||||
|
||||
if not query.session.using_conversation.uuid:
|
||||
query.session.using_conversation.uuid = str(uuid.uuid4())
|
||||
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = ['text_chunk', 'workflow_started']
|
||||
|
||||
inputs = { # these variables are legacy variables, we need to keep them for compatibility
|
||||
'langbot_user_message_text': plain_text,
|
||||
'langbot_session_id': query.variables['session_id'],
|
||||
'langbot_conversation_id': query.variables['conversation_id'],
|
||||
'langbot_msg_create_time': query.variables['msg_create_time'],
|
||||
}
|
||||
|
||||
inputs.update(query.variables)
|
||||
|
||||
async for chunk in self.dify_client.workflow_run(
|
||||
inputs=inputs,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk))
|
||||
if chunk['event'] in ignored_events:
|
||||
continue
|
||||
|
||||
if chunk['event'] == 'node_started':
|
||||
if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end':
|
||||
continue
|
||||
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content=None,
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk['data']['node_id'],
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=chunk['data']['title'],
|
||||
arguments=json.dumps({}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
yield msg
|
||||
|
||||
elif chunk['event'] == 'workflow_finished':
|
||||
if chunk['data']['error']:
|
||||
raise errors.DifyAPIError(chunk['data']['error'])
|
||||
content, _ = self._process_thinking_content(chunk['data']['outputs']['summary'])
|
||||
|
||||
msg = provider_message.Message(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
|
||||
yield msg
|
||||
|
||||
async def _chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
mode = 'basic'
|
||||
basic_mode_pending_chunk = ''
|
||||
|
||||
inputs = {}
|
||||
|
||||
inputs.update(query.variables)
|
||||
message_idx = 0
|
||||
|
||||
chunk = None # 初始化chunk变量,防止在没有响应时引用错误
|
||||
|
||||
is_final = False
|
||||
think_start = False
|
||||
think_end = False
|
||||
yielded_final = False
|
||||
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
async for chunk in self.dify_client.chat_messages(
|
||||
inputs=inputs,
|
||||
query=plain_text,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
conversation_id=cov_id,
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-chat-chunk: ' + str(chunk))
|
||||
|
||||
if chunk['event'] == 'workflow_started':
|
||||
mode = 'workflow'
|
||||
elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'):
|
||||
# Some Dify deployments may omit workflow_started in streamed chunks.
|
||||
mode = 'workflow'
|
||||
|
||||
if chunk['event'] == 'message':
|
||||
message_idx += 1
|
||||
if remove_think:
|
||||
if '<think>' in chunk['answer'] and not think_start:
|
||||
think_start = True
|
||||
continue
|
||||
if '</think>' in chunk['answer'] and not think_end:
|
||||
import re
|
||||
|
||||
content = re.sub(r'^\n</think>', '', chunk['answer'])
|
||||
basic_mode_pending_chunk += content
|
||||
think_end = True
|
||||
elif think_end:
|
||||
basic_mode_pending_chunk += chunk['answer']
|
||||
if think_start:
|
||||
continue
|
||||
|
||||
else:
|
||||
basic_mode_pending_chunk += chunk['answer']
|
||||
|
||||
if chunk['event'] == 'message_end':
|
||||
is_final = True
|
||||
elif chunk['event'] == 'workflow_finished':
|
||||
is_final = True
|
||||
if chunk['data'].get('error'):
|
||||
raise errors.DifyAPIError(chunk['data']['error'])
|
||||
|
||||
if mode == 'workflow' and chunk['event'] == 'node_finished':
|
||||
if chunk['data'].get('node_type') == 'answer':
|
||||
answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer'))
|
||||
if answer:
|
||||
basic_mode_pending_chunk = answer
|
||||
|
||||
if (
|
||||
not yielded_final
|
||||
and (is_final or message_idx % 8 == 0)
|
||||
and (basic_mode_pending_chunk != '' or is_final)
|
||||
):
|
||||
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=basic_mode_pending_chunk,
|
||||
is_final=is_final,
|
||||
)
|
||||
if is_final:
|
||||
yielded_final = True
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
query.session.using_conversation.uuid = chunk['conversation_id']
|
||||
|
||||
async def _agent_chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = []
|
||||
|
||||
inputs = {}
|
||||
|
||||
inputs.update(query.variables)
|
||||
|
||||
pending_agent_message = ''
|
||||
|
||||
chunk = None # 初始化chunk变量,防止在没有响应时引用错误
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
think_start = False
|
||||
think_end = False
|
||||
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
async for chunk in self.dify_client.chat_messages(
|
||||
inputs=inputs,
|
||||
query=plain_text,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
response_mode='streaming',
|
||||
conversation_id=cov_id,
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-agent-chunk: ' + str(chunk))
|
||||
|
||||
if chunk['event'] in ignored_events:
|
||||
continue
|
||||
|
||||
if chunk['event'] == 'agent_message':
|
||||
message_idx += 1
|
||||
if remove_think:
|
||||
if '<think>' in chunk['answer'] and not think_start:
|
||||
think_start = True
|
||||
continue
|
||||
if '</think>' in chunk['answer'] and not think_end:
|
||||
import re
|
||||
|
||||
content = re.sub(r'^\n</think>', '', chunk['answer'])
|
||||
pending_agent_message += content
|
||||
think_end = True
|
||||
elif think_end or not think_start:
|
||||
pending_agent_message += chunk['answer']
|
||||
if think_start and not think_end:
|
||||
continue
|
||||
|
||||
else:
|
||||
pending_agent_message += chunk['answer']
|
||||
elif chunk['event'] == 'message_end':
|
||||
is_final = True
|
||||
else:
|
||||
if chunk['event'] == 'agent_thought':
|
||||
if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过
|
||||
continue
|
||||
message_idx += 1
|
||||
if chunk['tool']:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk['id'],
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=chunk['tool'],
|
||||
arguments=json.dumps({}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
yield msg
|
||||
if chunk['event'] == 'message_file':
|
||||
message_idx += 1
|
||||
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
|
||||
# 检查URL是否已经是完整的连接
|
||||
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
|
||||
image_url = chunk['url']
|
||||
else:
|
||||
base_url = self.dify_client.base_url
|
||||
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
image_url = base_url + chunk['url']
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=[provider_message.ContentElement.from_image_url(image_url)],
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
if chunk['event'] == 'error':
|
||||
raise errors.DifyAPIError('dify 服务错误: ' + chunk['message'])
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_agent_message,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
query.session.using_conversation.uuid = chunk['conversation_id']
|
||||
|
||||
async def _workflow_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用工作流"""
|
||||
|
||||
if not query.session.using_conversation.uuid:
|
||||
query.session.using_conversation.uuid = str(uuid.uuid4())
|
||||
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = ['workflow_started']
|
||||
|
||||
inputs = { # these variables are legacy variables, we need to keep them for compatibility
|
||||
'langbot_user_message_text': plain_text,
|
||||
'langbot_session_id': query.variables['session_id'],
|
||||
'langbot_conversation_id': query.variables['conversation_id'],
|
||||
'langbot_msg_create_time': query.variables['msg_create_time'],
|
||||
}
|
||||
|
||||
inputs.update(query.variables)
|
||||
messsage_idx = 0
|
||||
is_final = False
|
||||
think_start = False
|
||||
think_end = False
|
||||
workflow_contents = ''
|
||||
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
async for chunk in self.dify_client.workflow_run(
|
||||
inputs=inputs,
|
||||
user=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
files=files,
|
||||
timeout=120,
|
||||
):
|
||||
self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk))
|
||||
if chunk['event'] in ignored_events:
|
||||
continue
|
||||
if chunk['event'] == 'workflow_finished':
|
||||
is_final = True
|
||||
if chunk['data']['error']:
|
||||
raise errors.DifyAPIError(chunk['data']['error'])
|
||||
|
||||
if chunk['event'] == 'text_chunk':
|
||||
messsage_idx += 1
|
||||
if remove_think:
|
||||
if '<think>' in chunk['data']['text'] and not think_start:
|
||||
think_start = True
|
||||
continue
|
||||
if '</think>' in chunk['data']['text'] and not think_end:
|
||||
import re
|
||||
|
||||
content = re.sub(r'^\n</think>', '', chunk['data']['text'])
|
||||
workflow_contents += content
|
||||
think_end = True
|
||||
elif think_end:
|
||||
workflow_contents += chunk['data']['text']
|
||||
if think_start:
|
||||
continue
|
||||
|
||||
else:
|
||||
workflow_contents += chunk['data']['text']
|
||||
|
||||
if chunk['event'] == 'node_started':
|
||||
if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end':
|
||||
continue
|
||||
messsage_idx += 1
|
||||
msg = provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=None,
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk['data']['node_id'],
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=chunk['data']['title'],
|
||||
arguments=json.dumps({}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
yield msg
|
||||
|
||||
if messsage_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=workflow_contents,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行请求"""
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
msg_idx = 0
|
||||
if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat':
|
||||
async for msg in self._chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent':
|
||||
async for msg in self._agent_chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow':
|
||||
async for msg in self._workflow_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
else:
|
||||
raise errors.DifyAPIError(
|
||||
f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}'
|
||||
)
|
||||
else:
|
||||
if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat':
|
||||
async for msg in self._chat_messages(query):
|
||||
yield msg
|
||||
elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent':
|
||||
async for msg in self._agent_chat_messages(query):
|
||||
yield msg
|
||||
elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow':
|
||||
async for msg in self._workflow_messages(query):
|
||||
yield msg
|
||||
else:
|
||||
raise errors.DifyAPIError(
|
||||
f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}'
|
||||
)
|
||||
@@ -1,180 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import httpx
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
from .. import runner
|
||||
from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
@runner.runner_class('langflow-api')
|
||||
class LangflowAPIRunner(runner.RequestRunner):
|
||||
"""Langflow API 对话请求器"""
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
async def _build_request_payload(self, query: pipeline_query.Query) -> dict:
|
||||
"""构建请求负载
|
||||
|
||||
Args:
|
||||
query: 用户查询对象
|
||||
|
||||
Returns:
|
||||
dict: 请求负载
|
||||
"""
|
||||
# 获取用户消息文本
|
||||
user_message_text = ''
|
||||
if isinstance(query.user_message.content, str):
|
||||
user_message_text = query.user_message.content
|
||||
elif isinstance(query.user_message.content, list):
|
||||
for item in query.user_message.content:
|
||||
if item.type == 'text':
|
||||
user_message_text += item.text
|
||||
|
||||
# 从配置中获取 input_type 和 output_type,如果未配置则使用默认值
|
||||
input_type = self.pipeline_config['ai']['langflow-api'].get('input_type', 'chat')
|
||||
output_type = self.pipeline_config['ai']['langflow-api'].get('output_type', 'chat')
|
||||
|
||||
# 构建基本负载
|
||||
payload = {
|
||||
'output_type': output_type,
|
||||
'input_type': input_type,
|
||||
'input_value': user_message_text,
|
||||
'session_id': str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
# 如果配置中有tweaks,则添加到负载中
|
||||
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))
|
||||
if tweaks:
|
||||
payload['tweaks'] = tweaks
|
||||
|
||||
return payload
|
||||
|
||||
async def run(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""运行请求
|
||||
|
||||
Args:
|
||||
query: 用户查询对象
|
||||
|
||||
Yields:
|
||||
Message: 回复消息
|
||||
"""
|
||||
# 检查是否支持流式输出
|
||||
is_stream = False
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
# 从配置中获取API参数
|
||||
base_url = self.pipeline_config['ai']['langflow-api']['base-url']
|
||||
api_key = self.pipeline_config['ai']['langflow-api']['api-key']
|
||||
flow_id = self.pipeline_config['ai']['langflow-api']['flow-id']
|
||||
|
||||
# 构建API URL
|
||||
url = f'{base_url.rstrip("/")}/api/v1/run/{flow_id}'
|
||||
|
||||
# 构建请求负载
|
||||
payload = await self._build_request_payload(query)
|
||||
|
||||
# 设置请求头
|
||||
headers = {'Content-Type': 'application/json', 'x-api-key': api_key}
|
||||
|
||||
# 发送请求
|
||||
async with httpx.AsyncClient() as client:
|
||||
if is_stream:
|
||||
# 流式请求
|
||||
async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
accumulated_content = ''
|
||||
message_count = 0
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
data_str = line
|
||||
|
||||
if data_str.startswith('data: '):
|
||||
data_str = data_str[6:] # 移除 "data: " 前缀
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
|
||||
# 提取消息内容
|
||||
message_text = ''
|
||||
if 'outputs' in data and len(data['outputs']) > 0:
|
||||
output = data['outputs'][0]
|
||||
if 'outputs' in output and len(output['outputs']) > 0:
|
||||
inner_output = output['outputs'][0]
|
||||
if 'outputs' in inner_output and 'message' in inner_output['outputs']:
|
||||
message_data = inner_output['outputs']['message']
|
||||
if 'message' in message_data:
|
||||
message_text = message_data['message']
|
||||
|
||||
# 如果没有找到消息,尝试其他可能的路径
|
||||
if not message_text and 'messages' in data:
|
||||
messages = data['messages']
|
||||
if messages and len(messages) > 0:
|
||||
message_text = messages[0].get('message', '')
|
||||
|
||||
if message_text:
|
||||
# 更新累积内容
|
||||
accumulated_content = message_text
|
||||
message_count += 1
|
||||
|
||||
# 每8条消息或有新内容时生成一个chunk
|
||||
if message_count % 8 == 0 or len(message_text) > 0:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant', content=accumulated_content, is_final=False
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON,跳过这一行
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
# 发送最终消息
|
||||
yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True)
|
||||
else:
|
||||
# 非流式请求
|
||||
response = await client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
response_data = response.json()
|
||||
|
||||
# 提取消息内容
|
||||
# 根据Langflow API文档,响应结构可能在outputs[0].outputs[0].outputs.message.message中
|
||||
message_text = ''
|
||||
if 'outputs' in response_data and len(response_data['outputs']) > 0:
|
||||
output = response_data['outputs'][0]
|
||||
if 'outputs' in output and len(output['outputs']) > 0:
|
||||
inner_output = output['outputs'][0]
|
||||
if 'outputs' in inner_output and 'message' in inner_output['outputs']:
|
||||
message_data = inner_output['outputs']['message']
|
||||
if 'message' in message_data:
|
||||
message_text = message_data['message']
|
||||
|
||||
# 如果没有找到消息,尝试其他可能的路径
|
||||
if not message_text and 'messages' in response_data:
|
||||
messages = response_data['messages']
|
||||
if messages and len(messages) > 0:
|
||||
message_text = messages[0].get('message', '')
|
||||
|
||||
# 如果仍然没有找到消息,返回完整响应的字符串表示
|
||||
if not message_text:
|
||||
message_text = json.dumps(response_data, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成回复消息
|
||||
if is_stream:
|
||||
yield provider_message.MessageChunk(role='assistant', content=message_text, is_final=True)
|
||||
else:
|
||||
reply_message = provider_message.Message(role='assistant', content=message_text)
|
||||
yield reply_message
|
||||
@@ -1,519 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import copy
|
||||
import typing
|
||||
from .. import runner
|
||||
from ...telemetry import features as telemetry_features
|
||||
from ..modelmgr import requester as modelmgr_requester
|
||||
from ..tools.loaders.native import EXEC_TOOL_NAME
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.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.
|
||||
Respond in the same language as the user's input.
|
||||
|
||||
<context>
|
||||
{rag_context}
|
||||
</context>
|
||||
|
||||
<user_message>
|
||||
{user_message}
|
||||
</user_message>
|
||||
"""
|
||||
|
||||
SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec'
|
||||
SANDBOX_EXEC_SYSTEM_GUIDANCE = (
|
||||
'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
||||
'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec '
|
||||
'and then answer from the tool result.'
|
||||
)
|
||||
|
||||
|
||||
# Hard cap on tool-call rounds within a single agent turn. A looping or
|
||||
# adversarial model can otherwise emit tool calls indefinitely (each potentially
|
||||
# a sandbox exec), yielding a non-terminating request and runaway cost. Set
|
||||
# generously so it never interrupts legitimate multi-step agentic workflows.
|
||||
MAX_TOOL_CALL_ROUNDS = 128
|
||||
|
||||
|
||||
def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str) -> bool:
|
||||
return ability in (model.model_entity.abilities or [])
|
||||
|
||||
|
||||
class _StreamAccumulator:
|
||||
"""Accumulate streamed content and fragmented OpenAI-style tool calls."""
|
||||
|
||||
def __init__(self, msg_sequence: int = 0, initial_content: str | None = None):
|
||||
self.tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||
self.msg_idx = 0
|
||||
self.accumulated_content = initial_content or ''
|
||||
self.last_role = 'assistant'
|
||||
self.msg_sequence = msg_sequence
|
||||
|
||||
def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None:
|
||||
self.msg_idx += 1
|
||||
|
||||
if msg.role:
|
||||
self.last_role = msg.role
|
||||
|
||||
if msg.content:
|
||||
self.accumulated_content += msg.content
|
||||
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id not in self.tool_calls_map:
|
||||
self.tool_calls_map[tool_call.id] = provider_message.ToolCall(
|
||||
id=tool_call.id,
|
||||
type=tool_call.type,
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_call.function.name if tool_call.function else '',
|
||||
arguments='',
|
||||
),
|
||||
)
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
if self.msg_idx % 8 == 0 or msg.is_final:
|
||||
self.msg_sequence += 1
|
||||
return provider_message.MessageChunk(
|
||||
role=self.last_role,
|
||||
content=self.accumulated_content,
|
||||
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=self.msg_sequence,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def final_message(self) -> provider_message.MessageChunk:
|
||||
return provider_message.MessageChunk(
|
||||
role=self.last_role,
|
||||
content=self.accumulated_content,
|
||||
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
||||
msg_sequence=self.msg_sequence,
|
||||
)
|
||||
|
||||
|
||||
@runner.runner_class('local-agent')
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
"""Local agent request runner"""
|
||||
|
||||
def _build_request_messages(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
user_message: provider_message.Message,
|
||||
) -> list[provider_message.Message]:
|
||||
req_messages = query.prompt.messages.copy() + query.messages.copy()
|
||||
|
||||
if any(getattr(tool, 'name', None) == EXEC_TOOL_NAME for tool in query.use_funcs or []):
|
||||
req_messages.append(
|
||||
provider_message.Message(
|
||||
role='system',
|
||||
content=self.ap.box_service.get_system_guidance(),
|
||||
)
|
||||
)
|
||||
|
||||
req_messages.append(user_message)
|
||||
return req_messages
|
||||
|
||||
async def _get_model_candidates(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> list[modelmgr_requester.RuntimeLLMModel]:
|
||||
"""Build ordered list of models to try: primary model + fallback models."""
|
||||
candidates = []
|
||||
|
||||
# Primary model
|
||||
if query.use_llm_model_uuid:
|
||||
try:
|
||||
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
candidates.append(primary)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||
|
||||
# Fallback models
|
||||
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||
for fb_uuid in fallback_uuids:
|
||||
try:
|
||||
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||
candidates.append(fb_model)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||
|
||||
return candidates
|
||||
|
||||
async def _invoke_with_fallback(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||
messages: list,
|
||||
funcs: list,
|
||||
remove_think: bool,
|
||||
) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:
|
||||
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
|
||||
last_error = None
|
||||
for model in candidates:
|
||||
try:
|
||||
msg = await model.provider.invoke_llm(
|
||||
query,
|
||||
model,
|
||||
messages,
|
||||
funcs if _model_has_ability(model, 'func_call') else [],
|
||||
extra_args=model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
return msg, model
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')
|
||||
raise last_error or RuntimeError('No model candidates available')
|
||||
|
||||
async def _invoke_stream_with_fallback(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||
messages: list,
|
||||
funcs: list,
|
||||
remove_think: bool,
|
||||
) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:
|
||||
"""Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).
|
||||
|
||||
Fallback is only possible before any chunks have been yielded to the client.
|
||||
Once streaming starts, the model is committed.
|
||||
"""
|
||||
last_error = None
|
||||
for model in candidates:
|
||||
try:
|
||||
stream = model.provider.invoke_llm_stream(
|
||||
query,
|
||||
model,
|
||||
messages,
|
||||
funcs if _model_has_ability(model, 'func_call') else [],
|
||||
extra_args=model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
# Attempt to get the first chunk to verify the stream works
|
||||
first_chunk = await stream.__anext__()
|
||||
|
||||
async def _chain_stream(first, rest):
|
||||
yield first
|
||||
async for chunk in rest:
|
||||
yield chunk
|
||||
|
||||
return _chain_stream(first_chunk, stream), model
|
||||
except StopAsyncIteration:
|
||||
# Empty stream — treat as success (model returned nothing)
|
||||
async def _empty_stream():
|
||||
return
|
||||
yield # make it a generator
|
||||
|
||||
return _empty_stream(), model
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')
|
||||
raise last_error or RuntimeError('No model candidates available')
|
||||
|
||||
async def run(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run request"""
|
||||
pending_tool_calls = []
|
||||
initial_response_emitted = False
|
||||
|
||||
# Get knowledge bases list from query variables (set by PreProcessor,
|
||||
# may have been modified by plugins during PromptPreProcessing)
|
||||
kb_uuids = query.variables.get('_knowledge_base_uuids', [])
|
||||
|
||||
user_message = copy.deepcopy(query.user_message)
|
||||
|
||||
user_message_text = ''
|
||||
|
||||
if isinstance(user_message.content, str):
|
||||
user_message_text = user_message.content
|
||||
elif isinstance(user_message.content, list):
|
||||
for ce in user_message.content:
|
||||
if ce.type == 'text':
|
||||
user_message_text += ce.text
|
||||
break
|
||||
|
||||
if kb_uuids and user_message_text:
|
||||
# only support text for now
|
||||
all_results: list[rag_context.RetrievalResultEntry] = []
|
||||
|
||||
kb_engine_plugins: set[str] = set()
|
||||
|
||||
# Retrieve from each knowledge base
|
||||
for kb_uuid in kb_uuids:
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
|
||||
if not kb:
|
||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||
continue
|
||||
|
||||
try:
|
||||
engine_plugin_id = kb.get_knowledge_engine_plugin_id() or 'builtin'
|
||||
except Exception:
|
||||
engine_plugin_id = 'builtin'
|
||||
kb_engine_plugins.add(engine_plugin_id)
|
||||
|
||||
result = await kb.retrieve(
|
||||
user_message_text,
|
||||
settings={
|
||||
'bot_uuid': query.bot_uuid or '',
|
||||
'sender_id': str(query.sender_id),
|
||||
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
},
|
||||
)
|
||||
|
||||
if result:
|
||||
all_results.extend(result)
|
||||
|
||||
# Telemetry: knowledge base usage (counts and engine categories only)
|
||||
telemetry_features.set_value(
|
||||
query,
|
||||
'kb',
|
||||
{
|
||||
'kb_count': len(kb_uuids),
|
||||
'engine_plugins': sorted(kb_engine_plugins),
|
||||
'retrieved_entries': len(all_results),
|
||||
},
|
||||
)
|
||||
|
||||
# Rerank step: re-score results using a rerank model if configured
|
||||
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||
rerank_model_uuid = local_agent_config.get('rerank-model', '')
|
||||
if rerank_model_uuid == '__none__':
|
||||
rerank_model_uuid = ''
|
||||
self.ap.logger.info(
|
||||
f'Rerank config: model_uuid={rerank_model_uuid!r}, '
|
||||
f'results={len(all_results)}, '
|
||||
f'local_agent_keys={list(local_agent_config.keys())}'
|
||||
)
|
||||
if all_results and rerank_model_uuid:
|
||||
try:
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
|
||||
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
|
||||
|
||||
doc_texts = []
|
||||
for entry in all_results:
|
||||
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
|
||||
doc_texts.append(text)
|
||||
|
||||
doc_texts_capped = doc_texts[:64]
|
||||
scores = await rerank_model.provider.invoke_rerank(
|
||||
model=rerank_model,
|
||||
query=user_message_text,
|
||||
documents=doc_texts_capped,
|
||||
)
|
||||
|
||||
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
|
||||
all_results = [all_results[i] for i in top_indices]
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})'
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Rerank failed, using original order: {e}')
|
||||
|
||||
final_user_message_text = ''
|
||||
|
||||
if all_results:
|
||||
texts = []
|
||||
idx = 1
|
||||
for entry in all_results:
|
||||
for content in entry.content:
|
||||
if content.type == 'text' and content.text is not None:
|
||||
texts.append(f'[{idx}] {content.text}')
|
||||
idx += 1
|
||||
rag_context_text = '\n\n'.join(texts)
|
||||
final_user_message_text = rag_combined_prompt_template.format(
|
||||
rag_context=rag_context_text, user_message=user_message_text
|
||||
)
|
||||
|
||||
else:
|
||||
final_user_message_text = user_message_text
|
||||
|
||||
self.ap.logger.debug(f'Final user message text: {final_user_message_text}')
|
||||
|
||||
for ce in user_message.content:
|
||||
if ce.type == 'text':
|
||||
ce.text = final_user_message_text
|
||||
break
|
||||
|
||||
req_messages = self._build_request_messages(query, user_message)
|
||||
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
# Build ordered candidate list (primary + fallbacks)
|
||||
candidates = await self._get_model_candidates(query)
|
||||
if not candidates:
|
||||
raise RuntimeError('No LLM model configured for local-agent runner')
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||
f'candidates={[m.model_entity.name for m in candidates]}'
|
||||
)
|
||||
|
||||
if not is_stream:
|
||||
# Non-streaming: invoke with fallback
|
||||
msg, use_llm_model = await self._invoke_with_fallback(
|
||||
query,
|
||||
candidates,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
remove_think,
|
||||
)
|
||||
final_msg = msg
|
||||
else:
|
||||
# Streaming: invoke with fallback
|
||||
stream_accumulator = _StreamAccumulator(msg_sequence=1)
|
||||
|
||||
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||
query,
|
||||
candidates,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
remove_think,
|
||||
)
|
||||
async for msg in stream_src:
|
||||
chunk = stream_accumulator.add(msg)
|
||||
if chunk:
|
||||
yield chunk
|
||||
initial_response_emitted = True
|
||||
|
||||
final_msg = stream_accumulator.final_message()
|
||||
|
||||
pending_tool_calls = final_msg.tool_calls
|
||||
first_content = final_msg.content
|
||||
if isinstance(final_msg, provider_message.MessageChunk):
|
||||
first_end_sequence = final_msg.msg_sequence
|
||||
|
||||
if not is_stream:
|
||||
yield final_msg
|
||||
elif not initial_response_emitted:
|
||||
yield final_msg
|
||||
initial_response_emitted = True
|
||||
|
||||
req_messages.append(final_msg)
|
||||
|
||||
# Once a model succeeds, commit to it for the tool call loop
|
||||
# (no fallback mid-conversation — different models may interpret tool results differently)
|
||||
tool_call_round = 0
|
||||
while pending_tool_calls:
|
||||
tool_call_round += 1
|
||||
telemetry_features.set_value(query, 'tool_call_rounds', tool_call_round)
|
||||
if tool_call_round > MAX_TOOL_CALL_ROUNDS:
|
||||
self.ap.logger.warning(
|
||||
f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap '
|
||||
f'(query_id={query.query_id}); stopping to avoid a non-terminating request.'
|
||||
)
|
||||
break
|
||||
for tool_call in pending_tool_calls:
|
||||
try:
|
||||
func = tool_call.function
|
||||
|
||||
if func.arguments:
|
||||
parameters = json.loads(func.arguments)
|
||||
else:
|
||||
parameters = {}
|
||||
|
||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
||||
|
||||
# Handle return value content
|
||||
tool_content = None
|
||||
if (
|
||||
isinstance(func_ret, list)
|
||||
and len(func_ret) > 0
|
||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||
):
|
||||
tool_content = func_ret
|
||||
else:
|
||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||
|
||||
if is_stream:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
else:
|
||||
msg = provider_message.Message(
|
||||
role='tool',
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
|
||||
yield msg
|
||||
|
||||
req_messages.append(msg)
|
||||
except Exception as e:
|
||||
if is_stream:
|
||||
err_msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
content=f'err: {e}',
|
||||
tool_call_id=tool_call.id,
|
||||
is_final=True,
|
||||
)
|
||||
else:
|
||||
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
||||
|
||||
yield err_msg
|
||||
|
||||
req_messages.append(err_msg)
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||
f'use_llm_model={use_llm_model.model_entity.name}'
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
stream_accumulator = _StreamAccumulator(
|
||||
msg_sequence=first_end_sequence,
|
||||
initial_content=first_content,
|
||||
)
|
||||
|
||||
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
async for msg in tool_stream_src:
|
||||
chunk = stream_accumulator.add(msg)
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
final_msg = stream_accumulator.final_message()
|
||||
else:
|
||||
# Non-streaming: use committed model directly (no fallback in tool loop)
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
query,
|
||||
use_llm_model,
|
||||
req_messages,
|
||||
query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [],
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
|
||||
yield msg
|
||||
final_msg = msg
|
||||
|
||||
pending_tool_calls = final_msg.tool_calls
|
||||
|
||||
req_messages.append(final_msg)
|
||||
@@ -1,277 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import uuid
|
||||
import aiohttp
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
from .. import runner
|
||||
from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class N8nAPIError(Exception):
|
||||
"""N8n API 请求失败"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@runner.runner_class('n8n-service-api')
|
||||
class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
"""N8n Service API 工作流请求器"""
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
# 获取webhook URL
|
||||
self.webhook_url = self.pipeline_config['ai']['n8n-service-api']['webhook-url']
|
||||
|
||||
# 获取超时设置,默认为120秒
|
||||
self.timeout = self.pipeline_config['ai']['n8n-service-api'].get('timeout', 120)
|
||||
|
||||
# 获取输出键名,默认为response
|
||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
|
||||
|
||||
# 获取认证类型,默认为none
|
||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
||||
|
||||
# 根据认证类型获取相应的认证信息
|
||||
if self.auth_type == 'basic':
|
||||
self.basic_username = self.pipeline_config['ai']['n8n-service-api'].get('basic-username', '')
|
||||
self.basic_password = self.pipeline_config['ai']['n8n-service-api'].get('basic-password', '')
|
||||
elif self.auth_type == 'jwt':
|
||||
self.jwt_secret = self.pipeline_config['ai']['n8n-service-api'].get('jwt-secret', '')
|
||||
self.jwt_algorithm = self.pipeline_config['ai']['n8n-service-api'].get('jwt-algorithm', 'HS256')
|
||||
elif self.auth_type == 'header':
|
||||
self.header_name = self.pipeline_config['ai']['n8n-service-api'].get('header-name', '')
|
||||
self.header_value = self.pipeline_config['ai']['n8n-service-api'].get('header-value', '')
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> str:
|
||||
"""预处理用户消息,提取纯文本
|
||||
|
||||
Returns:
|
||||
str: 纯文本消息
|
||||
"""
|
||||
plain_text = ''
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
# 注意:n8n webhook目前不支持直接处理图片,如需支持可在此扩展
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _process_response(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""处理响应——支持流式格式和普通 JSON 格式"""
|
||||
full_content = ''
|
||||
full_text = ''
|
||||
chunk_idx = 0
|
||||
is_final = False
|
||||
message_idx = 0
|
||||
|
||||
buffer = ''
|
||||
decoder = json.JSONDecoder()
|
||||
|
||||
async for raw_chunk in response.content.iter_chunked(1024):
|
||||
if not raw_chunk:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 将 bytes 解码为字符串(容忍错误)
|
||||
if isinstance(raw_chunk, (bytes, bytearray)):
|
||||
chunk_str = raw_chunk.decode('utf-8', errors='replace')
|
||||
else:
|
||||
chunk_str = str(raw_chunk)
|
||||
|
||||
full_text += chunk_str
|
||||
buffer += chunk_str
|
||||
|
||||
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
|
||||
while buffer:
|
||||
buffer = buffer.lstrip()
|
||||
if not buffer:
|
||||
break
|
||||
try:
|
||||
obj, idx = decoder.raw_decode(buffer)
|
||||
buffer = buffer[idx:]
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
# 忽略非字典类型的顶级 JSON
|
||||
continue
|
||||
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
chunk_idx += 1
|
||||
content = obj['content']
|
||||
full_content += content
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
|
||||
if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0):
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=full_content,
|
||||
is_final=is_final,
|
||||
msg_sequence=message_idx,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# buffer 末尾可能是一个不完整的 JSON,等待更多数据
|
||||
break
|
||||
except Exception as e:
|
||||
# 记录解析失败并继续接收后续 chunk
|
||||
try:
|
||||
preview = chunk_str[:200]
|
||||
except Exception:
|
||||
preview = '<unavailable>'
|
||||
self.ap.logger.warning(f'Failed to process chunk: {e}; chunk preview: {preview}')
|
||||
|
||||
# 流结束后,尝试解析残余 buffer
|
||||
if buffer:
|
||||
try:
|
||||
buffer = buffer.strip()
|
||||
if buffer:
|
||||
obj, _ = decoder.raw_decode(buffer)
|
||||
if isinstance(obj, dict):
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
chunk_idx += 1
|
||||
full_content += obj['content']
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=full_content,
|
||||
is_final=is_final,
|
||||
msg_sequence=message_idx,
|
||||
)
|
||||
except Exception as e:
|
||||
preview = buffer[:200]
|
||||
self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}')
|
||||
|
||||
# n8n 返回普通 JSON 格式(无任何流式 type:item 内容)
|
||||
if chunk_idx == 0:
|
||||
output_content = ''
|
||||
try:
|
||||
response_data = json.loads(full_text.strip())
|
||||
if isinstance(response_data, dict):
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
else:
|
||||
output_content = full_text
|
||||
except json.JSONDecodeError:
|
||||
output_content = full_text
|
||||
self.ap.logger.debug(f'n8n webhook response (non-stream): {full_text[:200]}')
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
is_final=True,
|
||||
msg_sequence=message_idx + 1,
|
||||
)
|
||||
|
||||
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用n8n webhook"""
|
||||
# 生成会话ID(如果不存在)
|
||||
if not query.session.using_conversation.uuid:
|
||||
query.session.using_conversation.uuid = str(uuid.uuid4())
|
||||
|
||||
# Keep query variables in sync with the generated/new conversation id.
|
||||
# query.variables is later merged into payload and would otherwise
|
||||
# overwrite the generated conversation_id with the stale preprocessor
|
||||
# value (usually None for a new conversation).
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
# 预处理用户消息
|
||||
plain_text = await self._preprocess_user_message(query)
|
||||
|
||||
# 准备请求数据
|
||||
payload = {
|
||||
# 基本消息内容
|
||||
'chatInput': plain_text, # 考虑到之前用户直接用的message model这里添加新键
|
||||
'message': plain_text,
|
||||
'user_message_text': plain_text,
|
||||
'conversation_id': query.session.using_conversation.uuid,
|
||||
'session_id': query.variables.get('session_id', ''),
|
||||
'user_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
'msg_create_time': query.variables.get('msg_create_time', ''),
|
||||
}
|
||||
|
||||
# 添加所有变量到payload
|
||||
payload.update(query.variables)
|
||||
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
try:
|
||||
# 准备请求头和认证信息
|
||||
headers = {}
|
||||
auth = None
|
||||
|
||||
# 根据认证类型设置相应的认证信息
|
||||
if self.auth_type == 'basic':
|
||||
# 使用Basic认证
|
||||
auth = aiohttp.BasicAuth(self.basic_username, self.basic_password)
|
||||
self.ap.logger.debug(f'using basic auth: {self.basic_username}')
|
||||
elif self.auth_type == 'jwt':
|
||||
# 使用JWT认证
|
||||
import jwt
|
||||
import time
|
||||
|
||||
# 创建JWT令牌
|
||||
payload_jwt = {
|
||||
'exp': int(time.time()) + 3600, # 1小时过期
|
||||
'iat': int(time.time()),
|
||||
'sub': 'n8n-webhook',
|
||||
}
|
||||
token = jwt.encode(payload_jwt, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||
|
||||
# 添加到Authorization头
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
self.ap.logger.debug('using jwt auth')
|
||||
elif self.auth_type == 'header':
|
||||
# 使用自定义请求头认证
|
||||
headers[self.header_name] = self.header_value
|
||||
self.ap.logger.debug(f'using header auth: {self.header_name}')
|
||||
else:
|
||||
self.ap.logger.debug('no auth')
|
||||
|
||||
# 调用webhook
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
async for chunk in self._process_response(response):
|
||||
if is_stream:
|
||||
yield chunk
|
||||
elif chunk.is_final:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=chunk.content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
|
||||
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行请求"""
|
||||
async for msg in self._call_webhook(query):
|
||||
yield msg
|
||||
@@ -1,202 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import base64
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from tboxsdk.tbox import TboxClient
|
||||
from tboxsdk.model.file import File, FileType
|
||||
|
||||
from .. import runner
|
||||
from ...core import app
|
||||
from ...utils import image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class TboxAPIError(Exception):
|
||||
"""TBox API 请求失败"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@runner.runner_class('tbox-app-api')
|
||||
class TboxAPIRunner(runner.RequestRunner):
|
||||
"蚂蚁百宝箱API对话请求器"
|
||||
|
||||
# 运行器内部使用的配置
|
||||
app_id: str # 蚂蚁百宝箱平台中的应用ID
|
||||
api_key: str # 在蚂蚁百宝箱平台中申请的令牌
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
"""初始化"""
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
# 初始化Tbox 参数配置
|
||||
self.app_id = self.pipeline_config['ai']['tbox-app-api']['app-id']
|
||||
self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key']
|
||||
|
||||
# 初始化Tbox client
|
||||
self.tbox_client = TboxClient(authorization=self.api_key)
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
|
||||
"""预处理用户消息,提取纯文本,并将图片上传到 Tbox 服务
|
||||
|
||||
Returns:
|
||||
tuple[str, list[str]]: 纯文本和图片的 Tbox 文件ID
|
||||
"""
|
||||
plain_text = ''
|
||||
image_ids = []
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
# 创建临时文件
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file:
|
||||
tmp_file.write(file_bytes)
|
||||
tmp_file_path = tmp_file.name
|
||||
file_upload_resp = self.tbox_client.upload_file(tmp_file_path)
|
||||
image_id = file_upload_resp.get('data', '')
|
||||
image_ids.append(image_id)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(tmp_file_path):
|
||||
os.unlink(tmp_file_path)
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
return plain_text, image_ids
|
||||
|
||||
async def _agent_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""TBox 智能体对话请求"""
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think')
|
||||
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
# 获取Tbox的conversation_id
|
||||
conversation_id = query.session.using_conversation.uuid or None
|
||||
|
||||
files = None
|
||||
if image_ids:
|
||||
files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids]
|
||||
|
||||
# 发送对话请求
|
||||
response = self.tbox_client.chat(
|
||||
app_id=self.app_id, # Tbox中智能体应用的ID
|
||||
user_id=query.bot_uuid, # 用户ID
|
||||
query=plain_text, # 用户输入的文本信息
|
||||
stream=is_stream, # 是否流式输出
|
||||
conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话
|
||||
files=files, # 图片内容
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
# 解析Tbox流式输出内容,并发送给上游
|
||||
for chunk in self._process_stream_message(response, query, remove_think):
|
||||
yield chunk
|
||||
else:
|
||||
message = self._process_non_stream_message(response, query, remove_think)
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=message,
|
||||
)
|
||||
|
||||
def _process_non_stream_message(self, response: typing.Dict, query: pipeline_query.Query, remove_think: bool):
|
||||
if response.get('errorCode') != '0':
|
||||
raise TboxAPIError(f'Tbox API 请求失败: {response.get("errorMsg", "")}')
|
||||
payload = response.get('data', {})
|
||||
conversation_id = payload.get('conversationId', '')
|
||||
query.session.using_conversation.uuid = conversation_id
|
||||
thinking_content = payload.get('reasoningContent', [])
|
||||
result = ''
|
||||
if thinking_content and not remove_think:
|
||||
result += f'<think>\n{thinking_content[0].get("text", "")}\n</think>\n'
|
||||
content = payload.get('result', [])
|
||||
if content:
|
||||
result += content[0].get('chunk', '')
|
||||
return result
|
||||
|
||||
def _process_stream_message(
|
||||
self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool
|
||||
):
|
||||
idx_msg = 0
|
||||
pending_content = ''
|
||||
conversation_id = None
|
||||
think_start = False
|
||||
think_end = False
|
||||
for chunk in response:
|
||||
if chunk.get('type', '') == 'chunk':
|
||||
"""
|
||||
Tbox返回的消息内容chunk结构
|
||||
{'lane': 'default', 'payload': {'conversationId': '20250918tBI947065406', 'messageId': '20250918TB1f53230954', 'text': '️'}, 'type': 'chunk'}
|
||||
"""
|
||||
# 如果包含思考过程,拼接</think>
|
||||
if think_start and not think_end:
|
||||
pending_content += '\n</think>\n'
|
||||
think_end = True
|
||||
|
||||
payload = chunk.get('payload', {})
|
||||
if not conversation_id:
|
||||
conversation_id = payload.get('conversationId')
|
||||
query.session.using_conversation.uuid = conversation_id
|
||||
if payload.get('text'):
|
||||
idx_msg += 1
|
||||
pending_content += payload.get('text')
|
||||
elif chunk.get('type', '') == 'thinking' and not remove_think:
|
||||
"""
|
||||
Tbox返回的思考过程chunk结构
|
||||
{'payload': '{"ext_data":{"text":"日期"},"event":"flow.node.llm.thinking","entity":{"node_type":"text-completion","execute_id":"6","group_id":0,"parent_execute_id":"6","node_name":"模型推理","node_id":"TC_5u6gl0"}}', 'type': 'thinking'}
|
||||
"""
|
||||
payload = json.loads(chunk.get('payload', '{}'))
|
||||
if payload.get('ext_data', {}).get('text'):
|
||||
idx_msg += 1
|
||||
content = payload.get('ext_data', {}).get('text')
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{content}'
|
||||
else:
|
||||
pending_content += content
|
||||
elif chunk.get('type', '') == 'error':
|
||||
raise TboxAPIError(
|
||||
f'Tbox API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
)
|
||||
|
||||
if idx_msg % 8 == 0:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
is_final=False,
|
||||
)
|
||||
|
||||
# Tbox不返回END事件,默认发一个最终消息
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_content,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行"""
|
||||
msg_seq = 0
|
||||
async for msg in self._agent_messages(query):
|
||||
if isinstance(msg, provider_message.MessageChunk):
|
||||
msg_seq += 1
|
||||
msg.msg_sequence = msg_seq
|
||||
yield msg
|
||||
@@ -1,351 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.weknora_api import client, errors
|
||||
|
||||
|
||||
@runner.runner_class('weknora-api')
|
||||
class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
"""WeKnora API 对话请求器"""
|
||||
|
||||
weknora_client: client.AsyncWeKnoraClient
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
super().__init__(ap, pipeline_config)
|
||||
|
||||
valid_app_types = ['chat', 'agent']
|
||||
if self.pipeline_config['ai']['weknora-api']['app-type'] not in valid_app_types:
|
||||
raise errors.WeKnoraAPIError(
|
||||
f'不支持的 WeKnora 应用类型: {self.pipeline_config["ai"]["weknora-api"]["app-type"]}'
|
||||
)
|
||||
|
||||
api_key = self.pipeline_config['ai']['weknora-api'].get('api-key', '').strip()
|
||||
if not api_key:
|
||||
raise errors.WeKnoraAPIError(
|
||||
'WeKnora API Key 未配置,请在流水线的 WeKnora API 配置中填入 API Key '
|
||||
'(从 WeKnora 前端 设置 → API Keys 生成)'
|
||||
)
|
||||
|
||||
base_url = self.pipeline_config['ai']['weknora-api'].get('base-url', '').strip()
|
||||
if not base_url:
|
||||
raise errors.WeKnoraAPIError('WeKnora Base URL 未配置,请填入服务器地址,例如 http://localhost:8080/api/v1')
|
||||
|
||||
self.weknora_client = client.AsyncWeKnoraClient(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
async def _extract_plain_text(self, query: pipeline_query.Query) -> str:
|
||||
"""从用户消息中提取纯文本内容"""
|
||||
plain_text = ''
|
||||
if isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
elif isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
|
||||
if not plain_text:
|
||||
plain_text = self.pipeline_config['ai']['weknora-api'].get('base-prompt', '')
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _ensure_session(self, query: pipeline_query.Query) -> str:
|
||||
"""确保会话存在,如果不存在则创建"""
|
||||
session_id = query.session.using_conversation.uuid or ''
|
||||
|
||||
if not session_id:
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
session_id = await self.weknora_client.create_session(title=f'IM Chat - {user_tag}')
|
||||
query.session.using_conversation.uuid = session_id
|
||||
|
||||
return session_id
|
||||
|
||||
async def _agent_chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用 Agent 智能对话(非流式聚合输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-smart-reasoning')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
web_search_enabled = config.get('web-search-enabled', False)
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
full_answer = ''
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.agent_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
|
||||
if response_type == 'tool_call':
|
||||
# 工具调用
|
||||
tool_data = chunk.get('data', {})
|
||||
tool_name = tool_data.get('tool_name', '')
|
||||
if tool_name:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk.get('id', ''),
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_name,
|
||||
arguments=json.dumps(tool_data.get('arguments', {})),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if full_answer:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=full_answer,
|
||||
)
|
||||
|
||||
async def _chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用知识库 RAG 问答(非流式聚合输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-quick-answer')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
full_answer = ''
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.knowledge_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
|
||||
if response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if full_answer:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=full_answer,
|
||||
)
|
||||
|
||||
async def _agent_chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用 Agent 智能对话(流式输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-smart-reasoning')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
web_search_enabled = config.get('web-search-enabled', False)
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
pending_answer = ''
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.agent_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
done = chunk.get('done', False)
|
||||
|
||||
if response_type == 'tool_call':
|
||||
tool_data = chunk.get('data', {})
|
||||
tool_name = tool_data.get('tool_name', '')
|
||||
if tool_name:
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk.get('id', ''),
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_name,
|
||||
arguments=json.dumps(tool_data.get('arguments', {})),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
|
||||
# 每 8 个 chunk 输出一次,或最终输出
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
# 确保最终消息已发出
|
||||
if not is_final and pending_answer:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def _chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用知识库 RAG 问答(流式输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-quick-answer')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
pending_answer = ''
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.knowledge_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
done = chunk.get('done', False)
|
||||
|
||||
if response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if not is_final and pending_answer:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行请求"""
|
||||
app_type = self.pipeline_config['ai']['weknora-api']['app-type']
|
||||
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
msg_idx = 0
|
||||
if app_type == 'agent':
|
||||
async for msg in self._agent_chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
elif app_type == 'chat':
|
||||
async for msg in self._chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
else:
|
||||
raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}')
|
||||
else:
|
||||
if app_type == 'agent':
|
||||
async for msg in self._agent_chat_messages(query):
|
||||
yield msg
|
||||
elif app_type == 'chat':
|
||||
async for msg in self._chat_messages(query):
|
||||
yield msg
|
||||
else:
|
||||
raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}')
|
||||
6
src/langbot/pkg/provider/tools/errors.py
Normal file
6
src/langbot/pkg/provider/tools/errors.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class ToolNotFoundError(ValueError):
|
||||
"""Raised when a requested tool cannot be found in any active loader."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
super().__init__(f'Tool not found: {name}')
|
||||
@@ -4,12 +4,15 @@ import abc
|
||||
import typing
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
|
||||
ToolLookupResult = resource_tool.LLMTool | ComponentManifest
|
||||
|
||||
|
||||
preregistered_loaders: list[typing.Type[ToolLoader]] = []
|
||||
|
||||
@@ -43,6 +46,17 @@ class ToolLoader(abc.ABC):
|
||||
"""获取所有工具"""
|
||||
pass
|
||||
|
||||
async def get_tool(self, name: str) -> ToolLookupResult | None:
|
||||
"""Get one tool by name.
|
||||
|
||||
Loaders with a cheaper direct lookup can override this method. The
|
||||
default keeps simple loaders working by searching their public list.
|
||||
"""
|
||||
for tool in await self.get_tools():
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
@abc.abstractmethod
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
|
||||
18
src/langbot/pkg/provider/tools/loaders/availability.py
Normal file
18
src/langbot/pkg/provider/tools/loaders/availability.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def is_box_backend_available(ap: Any) -> bool:
|
||||
"""Return whether the configured Box backend is ready for tool execution."""
|
||||
box_service = getattr(ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return bool(backend_info.get('available', False))
|
||||
except Exception:
|
||||
return False
|
||||
@@ -30,7 +30,7 @@ class MCPSessionStatus(enum.Enum):
|
||||
|
||||
|
||||
class RuntimeMCPSession:
|
||||
"""运行时 MCP 会话"""
|
||||
"""Runtime MCP session."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
@@ -384,12 +384,12 @@ class RuntimeMCPSession:
|
||||
return info
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭会话并清理资源"""
|
||||
"""Close the session and release resources."""
|
||||
try:
|
||||
# 设置shutdown事件,通知lifecycle任务退出
|
||||
# Signal the lifecycle task to exit.
|
||||
self._shutdown_event.set()
|
||||
|
||||
# 等待lifecycle任务完成(带超时)
|
||||
# Wait for the lifecycle task with a bounded timeout.
|
||||
if self._lifecycle_task and not self._lifecycle_task.done():
|
||||
try:
|
||||
await asyncio.wait_for(self._lifecycle_task, timeout=5.0)
|
||||
@@ -448,9 +448,9 @@ class RuntimeMCPSession:
|
||||
|
||||
# @loader.loader_class('mcp')
|
||||
class MCPLoader(loader.ToolLoader):
|
||||
"""MCP 工具加载器。
|
||||
"""MCP tool loader.
|
||||
|
||||
在此加载器中管理所有与 MCP Server 的连接。
|
||||
This loader owns all active MCP server connections.
|
||||
"""
|
||||
|
||||
sessions: dict[str, RuntimeMCPSession]
|
||||
@@ -505,14 +505,14 @@ class MCPLoader(loader.ToolLoader):
|
||||
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
|
||||
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
|
||||
"""加载 MCP 服务器到运行时
|
||||
"""Load an MCP server into the runtime.
|
||||
|
||||
Args:
|
||||
server_config: 服务器配置字典,必须包含:
|
||||
- name: 服务器名称
|
||||
- mode: 连接模式 (stdio/sse/http)
|
||||
- enable: 是否启用
|
||||
- extra_args: 额外的配置参数 (可选)
|
||||
server_config: Server config dict. Must include:
|
||||
- name: Server name.
|
||||
- mode: Connection mode (stdio/sse/http).
|
||||
- enable: Whether the server is enabled.
|
||||
- extra_args: Optional extra config.
|
||||
"""
|
||||
uuid_ = server_config.get('uuid')
|
||||
is_transient = False
|
||||
@@ -560,15 +560,30 @@ class MCPLoader(loader.ToolLoader):
|
||||
return all_functions
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
"""Return whether a loaded MCP tool exists."""
|
||||
for session in self.sessions.values():
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
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:
|
||||
"""Invoke a loaded MCP tool."""
|
||||
for session in self.sessions.values():
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
@@ -584,7 +599,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
raise ValueError(f'Tool not found: {name}')
|
||||
|
||||
async def remove_mcp_server(self, server_name: str):
|
||||
"""移除 MCP 服务器"""
|
||||
"""Remove an MCP server from the runtime."""
|
||||
if server_name not in self.sessions:
|
||||
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
|
||||
return
|
||||
@@ -594,24 +609,24 @@ class MCPLoader(loader.ToolLoader):
|
||||
self.ap.logger.info(f'Removed MCP server: {server_name}')
|
||||
|
||||
def get_session(self, server_name: str) -> RuntimeMCPSession | None:
|
||||
"""获取指定名称的 MCP 会话"""
|
||||
"""Get an MCP session by server name."""
|
||||
return self.sessions.get(server_name)
|
||||
|
||||
def has_session(self, server_name: str) -> bool:
|
||||
"""检查是否存在指定名称的 MCP 会话"""
|
||||
"""Return whether a session exists for the server name."""
|
||||
return server_name in self.sessions
|
||||
|
||||
def get_all_server_names(self) -> list[str]:
|
||||
"""获取所有已加载的 MCP 服务器名称"""
|
||||
"""Return all loaded MCP server names."""
|
||||
return list(self.sessions.keys())
|
||||
|
||||
def get_server_tool_count(self, server_name: str) -> int:
|
||||
"""获取指定服务器的工具数量"""
|
||||
"""Return the number of tools exposed by one MCP server."""
|
||||
session = self.get_session(server_name)
|
||||
return len(session.get_tools()) if session else 0
|
||||
|
||||
def get_all_servers_info(self) -> dict[str, dict]:
|
||||
"""获取所有服务器的信息"""
|
||||
"""Return runtime information for all loaded MCP servers."""
|
||||
info = {}
|
||||
for server_name, session in self.sessions.items():
|
||||
tools = session.get_tools()
|
||||
@@ -625,7 +640,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return info
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
"""Shut down all MCP sessions."""
|
||||
self.ap.logger.info('Shutting down all MCP sessions...')
|
||||
for server_name, session in list(self.sessions.items()):
|
||||
try:
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import shlex
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pydantic
|
||||
@@ -18,12 +19,26 @@ from ....box.workspace import (
|
||||
rewrite_mounted_path,
|
||||
rewrite_venv_command,
|
||||
unwrap_venv_path,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .mcp import RuntimeMCPSession
|
||||
|
||||
|
||||
_WORKSPACE_COPY_LOCKS: dict[str, threading.Lock] = {}
|
||||
_WORKSPACE_COPY_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _workspace_copy_lock(path: str) -> threading.Lock:
|
||||
with _WORKSPACE_COPY_LOCKS_GUARD:
|
||||
lock = _WORKSPACE_COPY_LOCKS.get(path)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_WORKSPACE_COPY_LOCKS[path] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class MCPSessionErrorPhase(enum.Enum):
|
||||
"""Which phase of the MCP lifecycle failed."""
|
||||
|
||||
@@ -49,7 +64,7 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
||||
host_path: str | None = None
|
||||
host_path_mode: str = 'ro' # MCP servers default to read-write mount only when explicitly requested
|
||||
env: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
startup_timeout_sec: int = 120 # Longer default to allow dependency bootstrap
|
||||
startup_timeout_sec: int = 300 # First Docker bootstrap may need to build a venv and install MCP deps.
|
||||
cpus: float | None = None
|
||||
memory_mb: int | None = None
|
||||
pids_limit: int | None = None
|
||||
@@ -128,6 +143,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 +184,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:
|
||||
@@ -253,14 +271,32 @@ class BoxStdioSessionRuntime:
|
||||
|
||||
@staticmethod
|
||||
def _copy_workspace_tree(source_path: str, process_host_root: str, process_host_workspace: str) -> None:
|
||||
shutil.rmtree(process_host_root, ignore_errors=True)
|
||||
os.makedirs(process_host_root, exist_ok=True)
|
||||
shutil.copytree(
|
||||
source_path,
|
||||
process_host_workspace,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns('.git', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache'),
|
||||
)
|
||||
# Docker-backed bootstrap writes root-owned runtime directories such as
|
||||
# .venv/.tmp into the staged workspace. The host process may not be able
|
||||
# to delete them, so refresh source files in place and preserve runtime
|
||||
# directories instead of rmtree'ing the whole staging root.
|
||||
with _workspace_copy_lock(process_host_root):
|
||||
os.makedirs(process_host_workspace, exist_ok=True)
|
||||
shutil.copytree(
|
||||
source_path,
|
||||
process_host_workspace,
|
||||
symlinks=True,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'.env',
|
||||
'.cache',
|
||||
'.tmp',
|
||||
'.langbot',
|
||||
),
|
||||
)
|
||||
|
||||
async def _cleanup_staged_workspace(self) -> None:
|
||||
if not self.resolve_host_path():
|
||||
@@ -343,23 +379,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,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
from ..errors import ToolNotFoundError
|
||||
from .availability import is_box_backend_available
|
||||
from . import skill as skill_loader
|
||||
|
||||
EXEC_TOOL_NAME = 'exec'
|
||||
@@ -21,6 +24,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):
|
||||
@@ -42,18 +54,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
|
||||
async def _check_backend_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
# Check if backend is truly available via get_status
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return backend_info.get('available', False)
|
||||
except Exception:
|
||||
return False
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._is_sandbox_available():
|
||||
@@ -90,7 +91,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return await self._invoke_glob(parameters, query)
|
||||
if name == GREP_TOOL_NAME:
|
||||
return await self._invoke_grep(parameters, query)
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
@@ -138,6 +139,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 +228,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 +354,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 +412,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 +474,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 +500,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 +679,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 +858,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 +917,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 +968,285 @@ 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),
|
||||
workspace_id=authorization.get('workspace_id'),
|
||||
thread_id=authorization.get('thread_id'),
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
@@ -39,13 +40,18 @@ class PluginToolLoader(loader.ToolLoader):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _get_tool(self, name: str) -> resource_tool.LLMTool:
|
||||
async def get_tool(self, name: str) -> ComponentManifest | None:
|
||||
for tool in await self.ap.plugin_connector.list_tools():
|
||||
if tool.metadata.name == name:
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ if typing.TYPE_CHECKING:
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills'
|
||||
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
||||
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
||||
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
||||
@@ -72,6 +73,116 @@ def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> N
|
||||
activated[skill_name] = skill_data
|
||||
|
||||
|
||||
def _normalize_skill_names(value: typing.Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
for item in value:
|
||||
skill_name = str(item or '').strip()
|
||||
if skill_name and skill_name not in names:
|
||||
names.append(skill_name)
|
||||
return names
|
||||
|
||||
|
||||
def restore_activated_skills_from_state(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
state: dict[str, dict[str, typing.Any]],
|
||||
) -> list[str]:
|
||||
"""Restore persisted activated skill names into Query variables.
|
||||
|
||||
The state value stores names only. Full skill metadata is rebuilt from the
|
||||
current pipeline-visible skill cache so removed or unbound skills remain
|
||||
unavailable to native exec/write/edit.
|
||||
"""
|
||||
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
|
||||
skill_names = _normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||
restored: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
skill_data = get_visible_skill(ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
continue
|
||||
register_activated_skill(query, skill_data)
|
||||
restored.append(skill_name)
|
||||
return restored
|
||||
|
||||
|
||||
def _get_agent_run_authorization(query: pipeline_query.Query) -> dict[str, typing.Any] | None:
|
||||
session = getattr(query, '_agent_run_session', None)
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
authorization = session.get('authorization')
|
||||
return authorization if isinstance(authorization, dict) else None
|
||||
|
||||
|
||||
def _get_conversation_state_target(query: pipeline_query.Query) -> tuple[str, str, str, dict[str, typing.Any]] | None:
|
||||
session = getattr(query, '_agent_run_session', None)
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
authorization = _get_agent_run_authorization(query)
|
||||
if authorization is None:
|
||||
return None
|
||||
|
||||
state_policy = authorization.get('state_policy') or {}
|
||||
if not state_policy.get('enable_state', True):
|
||||
return None
|
||||
state_scopes = state_policy.get('state_scopes', ['conversation', 'actor'])
|
||||
if 'conversation' not in state_scopes:
|
||||
return None
|
||||
|
||||
state_context = authorization.get('state_context') or {}
|
||||
scope_keys = state_context.get('scope_keys') or {}
|
||||
scope_key = scope_keys.get('conversation')
|
||||
if not scope_key:
|
||||
return None
|
||||
|
||||
runner_id = str(session.get('runner_id') or 'unknown')
|
||||
binding_identity = str(state_context.get('binding_identity') or 'unknown')
|
||||
return scope_key, runner_id, binding_identity, state_context
|
||||
|
||||
|
||||
async def persist_activated_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> bool:
|
||||
"""Persist activated skill names for the current AgentRunner conversation.
|
||||
|
||||
Returns False when the call is outside an AgentRunner run or state policy
|
||||
does not expose a conversation scope. The in-memory Query activation still
|
||||
remains valid for the current turn.
|
||||
"""
|
||||
target = _get_conversation_state_target(query)
|
||||
if target is None:
|
||||
return False
|
||||
|
||||
persistence_mgr = getattr(ap, 'persistence_mgr', None)
|
||||
if persistence_mgr is None or not hasattr(persistence_mgr, 'get_db_engine'):
|
||||
return False
|
||||
|
||||
from ....agent.runner.persistent_state_store import get_persistent_state_store
|
||||
|
||||
scope_key, runner_id, binding_identity, state_context = target
|
||||
store = get_persistent_state_store(persistence_mgr.get_db_engine())
|
||||
existing_names = _normalize_skill_names(await store.state_get(scope_key, ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||
if skill_name not in existing_names:
|
||||
existing_names.append(skill_name)
|
||||
|
||||
success, error = await store.state_set(
|
||||
scope_key=scope_key,
|
||||
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
value=existing_names,
|
||||
runner_id=runner_id,
|
||||
binding_identity=binding_identity,
|
||||
scope='conversation',
|
||||
context=state_context,
|
||||
logger=getattr(ap, 'logger', None),
|
||||
)
|
||||
if not success:
|
||||
logger = getattr(ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Failed to persist activated skill "{skill_name}": {error}')
|
||||
return success
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
|
||||
@@ -6,6 +6,7 @@ import typing
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
from .availability import is_box_backend_available
|
||||
|
||||
# Align with Claude Code's Skill tool design:
|
||||
# - activate: Activate a skill via Tool Call, returns SKILL.md content
|
||||
@@ -45,18 +46,7 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
# Check if backend is truly available via get_status
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return backend_info.get('available', False)
|
||||
except Exception:
|
||||
return False
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._is_available():
|
||||
@@ -92,17 +82,17 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
if not skill_name:
|
||||
raise ValueError('skill_name is required')
|
||||
|
||||
skill_mgr = self.ap.skill_mgr
|
||||
skill_data = skill_mgr.get_skill_by_name(skill_name)
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||
|
||||
# Register activated skill for sandbox mount path resolution
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_loader.register_activated_skill(query, skill_data)
|
||||
await skill_loader.persist_activated_skill(self.ap, query, skill_name)
|
||||
|
||||
# Return SKILL.md content as Tool Result (injects into context)
|
||||
instructions = skill_data.get('instructions', '')
|
||||
@@ -201,13 +191,13 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||
human_desc='Activate a skill',
|
||||
description=self._build_activate_tool_description(),
|
||||
description='Activate a pipeline-visible skill by name and return its instructions as a tool result.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'skill_name': {
|
||||
'type': 'string',
|
||||
'description': 'The skill name to activate (no arguments). E.g., "pdf" or "data-analysis"',
|
||||
'description': 'The skill name to activate.',
|
||||
},
|
||||
},
|
||||
'required': ['skill_name'],
|
||||
@@ -255,50 +245,3 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_activate_tool_description(self) -> str:
|
||||
"""Build tool description with embedded available_skills list."""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
skills = getattr(skill_mgr, 'skills', {})
|
||||
if not skills:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
# Build <available_skills> section
|
||||
available_skills_lines = ['<available_skills>']
|
||||
for skill_name, skill_data in sorted(skills.items()):
|
||||
description = skill_data.get('description', '')
|
||||
available_skills_lines.append('<skill>')
|
||||
available_skills_lines.append(f'<name>{skill_name}</name>')
|
||||
available_skills_lines.append(f'<description>{description}</description>')
|
||||
available_skills_lines.append('</skill>')
|
||||
available_skills_lines.append('</available_skills>')
|
||||
|
||||
available_skills_block = '\n'.join(available_skills_lines)
|
||||
|
||||
return f"""Activate a skill within the main conversation.
|
||||
|
||||
<skills_instructions>
|
||||
When users ask you to perform tasks, check if any of the available skills
|
||||
below can help complete the task more effectively. Skills provide specialized
|
||||
capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
- Invoke skills using this tool with the skill name only (no arguments)
|
||||
- When you invoke a skill, you will see <command-message>
|
||||
The skill is activated
|
||||
</command-message>
|
||||
- The skill's instructions will be provided in the tool result
|
||||
- Examples:
|
||||
- skill_name: "pdf" - invoke the pdf skill
|
||||
- skill_name: "data-analysis" - invoke the data-analysis skill
|
||||
|
||||
Important:
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already running
|
||||
- To create a new skill: prepare it in /workspace, then use register_skill tool
|
||||
</skills_instructions>
|
||||
|
||||
{available_skills_block}"""
|
||||
|
||||
@@ -6,6 +6,9 @@ from typing import TYPE_CHECKING
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from . import loader as tool_loader
|
||||
from .errors import ToolNotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
@@ -67,6 +70,27 @@ class ToolManager:
|
||||
|
||||
return all_functions
|
||||
|
||||
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
||||
"""Get tool by name from any active loader.
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
|
||||
Returns:
|
||||
LLMTool if found, None otherwise
|
||||
"""
|
||||
for active_loader in (
|
||||
self.native_tool_loader,
|
||||
self.plugin_tool_loader,
|
||||
self.mcp_tool_loader,
|
||||
self.skill_tool_loader,
|
||||
):
|
||||
tool = await active_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
|
||||
return None
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
@@ -83,7 +107,21 @@ class ToolManager:
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
function_schema = {
|
||||
'name': function.name,
|
||||
'description': function.description,
|
||||
'input_schema': function.parameters,
|
||||
}
|
||||
tools.append(function_schema)
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query | None) -> typing.Any:
|
||||
"""Execute a tool call through the active tool loaders."""
|
||||
from langbot.pkg.telemetry import features as telemetry_features
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
@@ -98,7 +136,7 @@ class ToolManager:
|
||||
if await self.skill_tool_loader.has_tool(name):
|
||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
async def shutdown(self):
|
||||
await self.native_tool_loader.shutdown()
|
||||
|
||||
@@ -93,50 +93,3 @@ class SkillManager:
|
||||
def get_skill_by_name(self, name: str) -> dict | None:
|
||||
"""Get skill data by name."""
|
||||
return self.skills.get(name)
|
||||
|
||||
def get_skill_index(self, bound_skills: list[str] | None = None) -> str:
|
||||
"""Render the pipeline-visible skills as a short ``name: description``
|
||||
index suitable for the system prompt.
|
||||
|
||||
``bound_skills`` follows the same convention as
|
||||
``query.variables['_pipeline_bound_skills']``: ``None`` means every
|
||||
loaded skill is exposed; an explicit list filters to that subset.
|
||||
Returns an empty string when no skills are visible.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for skill in self.skills.values():
|
||||
name = skill.get('name')
|
||||
if not name:
|
||||
continue
|
||||
if bound_skills is not None and name not in bound_skills:
|
||||
continue
|
||||
display = skill.get('display_name') or name
|
||||
description = (skill.get('description') or '').strip().replace('\n', ' ')
|
||||
lines.append(f'- {name} ({display}): {description}')
|
||||
|
||||
if not lines:
|
||||
return ''
|
||||
return 'Available Skills:\n' + '\n'.join(lines)
|
||||
|
||||
def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str:
|
||||
"""Build the system-prompt addendum that makes the LLM aware of the
|
||||
pipeline-visible skills.
|
||||
|
||||
Only metadata (name + description) is injected — the full SKILL.md is
|
||||
loaded later via the ``activate`` Tool Call, protecting KV cache and
|
||||
matching Claude Code's progressive disclosure pattern. Returns an
|
||||
empty string when no skills are visible (no prompt change at all).
|
||||
"""
|
||||
skill_index = self.get_skill_index(bound_skills)
|
||||
if not skill_index:
|
||||
return ''
|
||||
return (
|
||||
'\n\n'
|
||||
f'{skill_index}\n\n'
|
||||
"When the user's request clearly matches one or more skills "
|
||||
'based on their descriptions above, call the `activate` tool with '
|
||||
'the skill name to load its full instructions. Only the name and '
|
||||
'description are visible here; the actual instructions arrive as '
|
||||
'the tool result. If no skill is a clear match, respond normally '
|
||||
'without activating any skill.'
|
||||
)
|
||||
|
||||
@@ -5,10 +5,10 @@ import typing
|
||||
|
||||
|
||||
def import_modules_in_pkg(pkg: typing.Any) -> None:
|
||||
"""
|
||||
导入一个包内的所有模块
|
||||
"""Import all Python modules inside a package.
|
||||
|
||||
Args:
|
||||
pkg: 要导入的包对象
|
||||
pkg: Package object to import from.
|
||||
"""
|
||||
pkg_path = os.path.dirname(pkg.__file__)
|
||||
import_dir(pkg_path)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user