mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 15:26:03 +00:00
Compare commits
21 Commits
feat/agent
...
feat/litel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b82db2b7f8 | ||
|
|
573e1fe36e | ||
|
|
7fb3cfa638 | ||
|
|
39673444d2 | ||
|
|
d450226701 | ||
|
|
926e0c0854 | ||
|
|
89bcf82518 | ||
|
|
7ea1ce2fd3 | ||
|
|
31ad85517b | ||
|
|
a62fce1cf7 | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f | ||
|
|
fee7d48dc3 | ||
|
|
8811fb647f | ||
|
|
37b017459d | ||
|
|
4889a3881b | ||
|
|
fe4f95b9a3 |
16
Dockerfile
16
Dockerfile
@@ -14,10 +14,22 @@ COPY . .
|
|||||||
|
|
||||||
COPY --from=node /app/web/dist ./web/dist
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt-get update \
|
||||||
&& apt install gcc -y \
|
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||||
|
# Install the Docker CLI (client only) so the optional langbot_box
|
||||||
|
# service can drive the mounted host Docker socket and create sandbox
|
||||||
|
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||||
|
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync \
|
||||||
|
&& apt-get purge -y --auto-remove curl gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|
||||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Agent-owned Context 协议设计
|
|
||||||
|
|
||||||
本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。
|
|
||||||
|
|
||||||
> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。实现进度见 [PROGRESS.md](./PROGRESS.md)。
|
|
||||||
|
|
||||||
## 1. 设计原则
|
|
||||||
|
|
||||||
### 1.1 Agent 拥有上下文策略
|
|
||||||
|
|
||||||
不同 runner 背后的 runtime 差异很大:
|
|
||||||
|
|
||||||
- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。
|
|
||||||
- Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。
|
|
||||||
- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。
|
|
||||||
|
|
||||||
因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / artifact / state API、可投影给外部 harness 的 scoped context / 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class HistoryPage(BaseModel):
|
|
||||||
items: list[TranscriptItem]
|
|
||||||
next_cursor: str | None
|
|
||||||
prev_cursor: str | None
|
|
||||||
has_more: bool
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 manifest permission + binding policy;返回 artifact ref,不默认返回大文件内容。
|
|
||||||
|
|
||||||
### 4.2 Search
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.history_search(query="用户之前提到的数据库连接信息",
|
|
||||||
filters={"conversation_id": ..., "event_types": ["message.received"]},
|
|
||||||
top_k=10)
|
|
||||||
```
|
|
||||||
|
|
||||||
Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。
|
|
||||||
|
|
||||||
### 4.3 Event / Artifact / State
|
|
||||||
|
|
||||||
- Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。
|
|
||||||
- Artifact API(`artifacts.metadata` / `read_range` / `open_stream`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/stream 读取,工具大结果也应 artifact 化。
|
|
||||||
- State API(`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id`、`summary.checkpoint`。
|
|
||||||
|
|
||||||
### 4.4 大文件与工具协作
|
|
||||||
|
|
||||||
大文件、多模态输入和工具产物不要内联进 prompt 或 tool result:message/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref(`artifact_id`、`mime_type`、`size`、`digest`、`summary`、`expires_at`、`permissions`)。工具之间传递大结果时传 artifact ref,不传完整 blob。Host 校验 artifact 是否属于当前 run / scope,默认不允许插件直接读任意本地路径;临时文件应有 TTL 和清理机制。
|
|
||||||
|
|
||||||
### 4.5 External harness context projection
|
|
||||||
|
|
||||||
Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态:
|
|
||||||
|
|
||||||
- `agent-context.json`:结构化 JSON,包含 `run_id`、`event`、`actor`、`subject`、`input`、`delivery`、`resources`、`context`、`state`、`runtime`。
|
|
||||||
- `LANGBOT_CONTEXT.md`:人类可读摘要。
|
|
||||||
- `resources`:只包含本次 run 授权后的资源句柄和能力摘要,不暴露 Host 内部私有对象、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 保存。
|
|
||||||
|
|
||||||
当前 Claude Code runner 使用 schema `langbot.agent_runner.external_harness_context.v1`(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。
|
|
||||||
|
|
||||||
## 5. Runner manifest 中的上下文声明
|
|
||||||
|
|
||||||
`AgentRunnerContextPolicy`(PROTOCOL_V1 §4.5)声明 runner 的上下文能力:`supports_history_pull` / `supports_history_search` / `supports_artifact_pull` / `owns_compaction` / `wants_static_context_refs`。它表示 Host 只给当前事件和 context handles;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。
|
|
||||||
|
|
||||||
## 6. KV cache 友好的上下文管理
|
|
||||||
|
|
||||||
支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt:
|
|
||||||
|
|
||||||
- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。
|
|
||||||
- 静态内容使用 `ref + version/hash`(`ctx.runtime.static_refs`):system prompt、resource manifest、tool schema、platform policy。
|
|
||||||
- 每轮只传 delta:当前 event、artifact refs、少量 runtime metadata。
|
|
||||||
- 历史 append-only:不要每轮改写同一段 history 文本。
|
|
||||||
- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。
|
|
||||||
- 大文件和工具结果 artifact 化。
|
|
||||||
- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。
|
|
||||||
- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。
|
|
||||||
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。
|
|
||||||
|
|
||||||
稳定 session key 的用途是隔离外部 runtime 的 resume/cache/state,不是改变 PROTOCOL_V1 §13 定义的 Agent 复用和 dispatch 边界。只有当某个外部 harness 的同一 native session 不支持并发 turn 时,runner 或 future runtime control plane 才应按 external session key 做 turn-level 串行化。
|
|
||||||
|
|
||||||
对长期运行的 external harness / daemon,推荐运行形态是 reader 与 writer 分离:一个 session reader 独占读取 stdout/SSE/native event stream,并把 native event 转成 `AgentRunResult` 或 task progress;用户输入只作为 turn write 进入该 session。当前一次性 CLI subprocess runner 可以继续在单次 `run(ctx)` 内同步收集 stdout,但后续改成长连接时不应让多个 request 同时读取同一 native stream。
|
|
||||||
|
|
||||||
## 7. Host guardrail
|
|
||||||
|
|
||||||
Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / artifact read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。
|
|
||||||
|
|
||||||
外部 harness 的 native tools、shell、MCP 或 skill 机制不构成 LangBot 资源授权边界。只要访问的是 LangBot 持有的资源,就必须经 SDK runtime 转发并接受 Host 校验;绕过 SDK runtime 的访问应被视为未授权。
|
|
||||||
|
|
||||||
## 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)。
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
# Agent Runner QA 指南
|
|
||||||
|
|
||||||
本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。
|
|
||||||
|
|
||||||
目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。
|
|
||||||
|
|
||||||
## 1. 测试边界
|
|
||||||
|
|
||||||
当前主线验证的是 AgentRunner Protocol v1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event -> binding -> runner.run(ctx) -> result stream
|
|
||||||
```
|
|
||||||
|
|
||||||
本指南验证:
|
|
||||||
|
|
||||||
- Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。
|
|
||||||
- Runner 来自插件 registry,而不是旧内置 runner 分支。
|
|
||||||
- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。
|
|
||||||
- 外部 harness runner(Claude Code / Codex)能消费 event-first context,并把 session / working directory 等指针写回 host-owned state。
|
|
||||||
- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。
|
|
||||||
|
|
||||||
本指南不验证:
|
|
||||||
|
|
||||||
- Runtime Control Plane v2。
|
|
||||||
- EventGateway / EventRouter 完整落地由外部 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. Claude Code / Codex 外部 harness smoke。
|
|
||||||
5. 权限和错误路径补充检查。
|
|
||||||
6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。
|
|
||||||
|
|
||||||
用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。
|
|
||||||
|
|
||||||
## 4. 必跑基线
|
|
||||||
|
|
||||||
### 4.1 单测基线
|
|
||||||
|
|
||||||
在 LangBot 仓库运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --frozen pytest tests/unit_tests/agent
|
|
||||||
```
|
|
||||||
|
|
||||||
如果本次改动只触及默认配置或 API service,也至少补跑相关目标测试,例如:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py
|
|
||||||
```
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- agent 单测全 PASS,或失败项已确认与本次 agent-runner 路径无关。
|
|
||||||
- 若失败来自 `context_builder`、`orchestrator`、`session_registry`、`resource_builder`、`plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。
|
|
||||||
|
|
||||||
### 4.2 环境基线
|
|
||||||
|
|
||||||
用 `langbot-skills` 做环境检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd "$LANGBOT_SKILLS_REPO"
|
|
||||||
bin/lbs env doctor
|
|
||||||
bin/lbs case list
|
|
||||||
```
|
|
||||||
|
|
||||||
`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case,而不是临时发明测试路径。
|
|
||||||
|
|
||||||
推荐首批 case:
|
|
||||||
|
|
||||||
- `webui-login-state`
|
|
||||||
- `pipeline-debug-chat`
|
|
||||||
- `local-agent-basic-debug-chat`
|
|
||||||
- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge)
|
|
||||||
- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy)
|
|
||||||
|
|
||||||
## 5. WebUI 主链路 Smoke
|
|
||||||
|
|
||||||
### 5.1 Runner registry
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 打开 WebUI Pipeline 配置页。
|
|
||||||
2. 查看 AI runner 下拉列表。
|
|
||||||
3. 选择 `plugin:langbot/local-agent/default`。
|
|
||||||
4. 保存并刷新页面。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- runner 选项来自插件 registry。
|
|
||||||
- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。
|
|
||||||
- `runner_config` 表示 Agent/runner config,不表示插件实例状态。
|
|
||||||
- 不读取或回写旧 `ai.runner.runner` 字段。
|
|
||||||
- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。
|
|
||||||
- 插件没有循环重启或 metadata 加载失败。
|
|
||||||
|
|
||||||
### 5.2 主聊天路径
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 使用绑定 `plugin:langbot/local-agent/default` 的 Pipeline。
|
|
||||||
2. 在 Debug Chat 发送确定性普通文本。
|
|
||||||
3. 查看 WebUI 回复和后端日志。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- 用户可见回复正常。
|
|
||||||
- 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`。
|
|
||||||
- 不走旧内置 local-agent 主执行分支。
|
|
||||||
- conversation transcript 写入用户消息和助手消息。
|
|
||||||
|
|
||||||
## 6. `local-agent` 高价值测试
|
|
||||||
|
|
||||||
只保留最能覆盖架构边界的场景。
|
|
||||||
|
|
||||||
| ID | 场景 | 操作 | 通过条件 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 |
|
|
||||||
| LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 inline history window。 |
|
|
||||||
| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 |
|
|
||||||
| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 |
|
|
||||||
| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 |
|
|
||||||
| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 |
|
|
||||||
| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 |
|
|
||||||
| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 |
|
|
||||||
|
|
||||||
Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。
|
|
||||||
|
|
||||||
## 7. 外部 Harness Runner Smoke
|
|
||||||
|
|
||||||
这些测试用于验证 Claude Code / Codex 这类自管 runtime 能走同一条 Host 协议路径。若本机没有 CLI、登录态或代理配置,标记 BLOCKED,不要伪造 PASS。
|
|
||||||
|
|
||||||
Smoke 前应优先保留一层轻量单测或 fixture 测试:provider-native output(Claude stream-json、Codex JSONL、外部 API SSE / JSON)必须能稳定转换成 `AgentRunResult`,未知 native event 只记录诊断,不导致解析器崩溃。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。
|
|
||||||
|
|
||||||
### 7.1 Claude Code runner
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 确认 `claude` CLI 在 LangBot runtime host 上可执行。
|
|
||||||
2. 绑定 `plugin:langbot/claude-code-agent/default`。
|
|
||||||
3. 使用保守权限模式和确定性 prompt。
|
|
||||||
4. 在 Debug Chat 执行一次真实 smoke。
|
|
||||||
5. 检查 context / SDK-owned MCP bridge / skill-backed scoped tools 和 host-owned state。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- WebUI 可见回复包含预期 sentinel。
|
|
||||||
- context JSON schema 为 `langbot.agent_runner.external_harness_context.v1` 或当前文档声明的等价 schema。
|
|
||||||
- context 包含 event、input、delivery、resources、context、state。
|
|
||||||
- 如启用 LangBot skills / MCP,Claude Code 只能通过 SDK-owned MCP bridge 或 skill-backed scoped tools 访问 LangBot 资源;不能用 native tools 直接访问。
|
|
||||||
- `external.session_id` / `external.working_directory` 写入 host-owned state。
|
|
||||||
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`。
|
|
||||||
- resume 到同一 `external.session_id` 时,不并发写入同一 native session;全局锁边界符合 PROTOCOL_V1 §13。
|
|
||||||
|
|
||||||
### 7.2 Codex runner
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 确认 `codex` CLI 在 LangBot runtime host 上可执行。
|
|
||||||
2. 绑定 `plugin:langbot/codex-agent/default`。
|
|
||||||
3. 如需要代理,使用 Agent/runner config 的 `environment-json` 显式传入。
|
|
||||||
4. 在 Debug Chat 执行一次真实 smoke。
|
|
||||||
5. 检查 JSONL 事件、last message、host-owned state。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- WebUI 可见回复包含预期 sentinel。
|
|
||||||
- Codex JSONL 至少包含 thread/session 起始事件、agent message、turn completed。
|
|
||||||
- `external.session_id` / `external.working_directory` 写入 host-owned state。
|
|
||||||
- timeout/cancel 不遗留 orphan CLI 子进程。
|
|
||||||
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`。
|
|
||||||
- resume 到同一 `thread_id` / `external.session_id` 时,不并发写入同一 native session;全局锁边界符合 PROTOCOL_V1 §13。
|
|
||||||
|
|
||||||
### 7.3 API 型外部 runner
|
|
||||||
|
|
||||||
Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- runner 可选,配置可保存。
|
|
||||||
- 请求成功,或外部服务错误被清晰返回。
|
|
||||||
- 外部服务凭据缺失时标记 BLOCKED,并记录缺失项。
|
|
||||||
|
|
||||||
## 8. 权限与隔离补充
|
|
||||||
|
|
||||||
以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。
|
|
||||||
|
|
||||||
| 场景 | 推荐证据 |
|
|
||||||
| --- | --- |
|
|
||||||
| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 |
|
|
||||||
| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 |
|
|
||||||
| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 |
|
|
||||||
| run_id 结束后复用被拒绝 | session registry 注销测试。 |
|
|
||||||
| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 |
|
|
||||||
| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 |
|
|
||||||
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
|
|
||||||
|
|
||||||
如果这些单测失败,不能用 WebUI 正常回复替代。
|
|
||||||
|
|
||||||
## 9. 证据要求
|
|
||||||
|
|
||||||
每轮测试报告至少记录:
|
|
||||||
|
|
||||||
- LangBot commit、SDK commit、相关 runner 插件 commit。
|
|
||||||
- Pipeline UUID/name、runner id、关键 runner config 摘要。
|
|
||||||
- WebUI 截图或 Playwright 操作记录。
|
|
||||||
- 后端日志中对应 query id / run id 的关键行。
|
|
||||||
- `langbot-skills` case/report 路径。
|
|
||||||
- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。
|
|
||||||
- FAIL/BLOCKED 的复现步骤和归属仓库建议。
|
|
||||||
|
|
||||||
报告结论必须回答:
|
|
||||||
|
|
||||||
- 是否建议继续进入下一阶段测试。
|
|
||||||
- 是否存在主聊天路径阻塞。
|
|
||||||
- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。
|
|
||||||
- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。
|
|
||||||
|
|
||||||
## 10. 历史高价值记录
|
|
||||||
|
|
||||||
历史报告已合并为本指南,不再保留单独文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。
|
|
||||||
|
|
||||||
截至 2026-05-29,已有本地 smoke 证明:
|
|
||||||
|
|
||||||
- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。
|
|
||||||
- Claude Code runner 可以通过同一条 `run(event, binding)` 路径执行。
|
|
||||||
- Claude Code runner 可以读取 LangBot event-first context,并通过 SDK-owned MCP bridge / skill-backed scoped tools 访问授权资源,随后写回 `external.session_id` / `external.working_directory`。
|
|
||||||
- Codex runner 可以通过同一条路径执行,并把 Codex `thread_id` 写回 host-owned state。
|
|
||||||
|
|
||||||
这些记录只证明本地协议闭环可用,不代表发布级 security hardening 已完成。
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Event Based Agent 接入设计
|
|
||||||
|
|
||||||
> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。实现进度见 [PROGRESS.md](./PROGRESS.md)。
|
|
||||||
>
|
|
||||||
> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。
|
|
||||||
> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
|
||||||
|
|
||||||
本文描述 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 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": "..."},
|
|
||||||
"reason": "policy matched" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Host 必须校验:runner manifest 是否声明 `platform_api` capability、binding 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。
|
|
||||||
|
|
||||||
Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。
|
|
||||||
|
|
||||||
## 7. 与 Context 协议的关系
|
|
||||||
|
|
||||||
EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/artifact ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。
|
|
||||||
|
|
||||||
## 8. EBA 分支联调内容
|
|
||||||
|
|
||||||
外部 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。
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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 | 不实现。当前 Claude Code / Codex 是本机 subprocess MVP path。 | Runtime Control Plane v2。 | Host 新增 runtime registry、heartbeat、task queue、daemon claim、progress/audit;runner 可选择 runtime-managed 执行模式。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 |
|
|
||||||
| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 |
|
|
||||||
| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage/artifact 基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 |
|
|
||||||
| External harness native session | 已支持 external session id / working directory state handoff 和 resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 一次性 CLI runner 可继续走 `runner.run(ctx)`;长连接/daemon 模式按 external session key 串行 turn,reader 独占 native stream。 | 不把 Claude/Codex native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 |
|
|
||||||
|
|
||||||
## 3. 后续分支接入规则
|
|
||||||
|
|
||||||
外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则:
|
|
||||||
|
|
||||||
- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。
|
|
||||||
- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。
|
|
||||||
- Host-owned facts 继续写入 EventLog / Transcript / Artifact / State;产品层可以新增更高阶视图,但不能替代这些事实源。
|
|
||||||
- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。
|
|
||||||
- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。
|
|
||||||
- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。
|
|
||||||
|
|
||||||
## 4. 与 LiteLLM Agent Platform 的关系
|
|
||||||
|
|
||||||
这里的 LiteLLM Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。
|
|
||||||
|
|
||||||
LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# LangBot Host 与 SDK 基础设施设计
|
|
||||||
|
|
||||||
本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。
|
|
||||||
|
|
||||||
- SDK ↔ Host 的协议数据结构(`AgentRunContext`、`AgentRunnerManifest`、`AgentRunResult`、`AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。
|
|
||||||
- 实现进度见 [PROGRESS.md](./PROGRESS.md)。
|
|
||||||
- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`AgentRunnerDescriptor`)不属于 SDK 协议字段。
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
LangBot 要转为 agent host,而不是内置 runner 容器:
|
|
||||||
|
|
||||||
- 接收 IM、WebUI、API 和外部 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
|
|
||||||
capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3
|
|
||||||
permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4
|
|
||||||
config_schema: list[DynamicFormItemSchema]
|
|
||||||
plugin: PluginRef | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 manifest(`kind == AgentRunner`、`metadata.name/label` 存在、`spec.*` 类型正确)、输出 descriptor、缓存 discovery 结果并提供 `refresh()`。单个插件 manifest 失败只记 warning,不影响其它 runner。`plugin:author/name/runner` 是稳定 id 格式;插件实例边界见 PROTOCOL_V1 §13。
|
|
||||||
|
|
||||||
Host 内置 runner / adapter 不能作为 `AgentRunnerDescriptor.source` 绕过插件
|
|
||||||
runtime、`run_id`、`ctx.resources` 和 `AgentRunAPIProxy` 权限链。若需要
|
|
||||||
开发期调试 adapter,应放在 Host 内部测试入口,不进入可选 runner 列表。
|
|
||||||
|
|
||||||
刷新触发点:插件安装/卸载/升级/重启后;Pipeline metadata 请求时发现缓存为空;可选 TTL(优先保证正确性)。
|
|
||||||
|
|
||||||
### 4.4 AgentRunOrchestrator
|
|
||||||
|
|
||||||
Orchestrator 是唯一运行入口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
run(event, binding)
|
|
||||||
-> resolve runner descriptor
|
|
||||||
-> build resources
|
|
||||||
-> build context
|
|
||||||
-> register run session
|
|
||||||
-> call plugin runtime
|
|
||||||
-> normalize result stream
|
|
||||||
-> update state
|
|
||||||
-> unregister run session
|
|
||||||
```
|
|
||||||
|
|
||||||
它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。
|
|
||||||
|
|
||||||
典型 run 时序:
|
|
||||||
|
|
||||||
```text
|
|
||||||
QueryEntryAdapter / EventRouter
|
|
||||||
-> AgentRunOrchestrator.run(event, binding)
|
|
||||||
-> AgentRunnerRegistry.resolve(runner_id)
|
|
||||||
-> AgentResourceBuilder.freeze_snapshot(binding, event)
|
|
||||||
-> AgentRunSessionRegistry.register(run_id, runner_id, snapshot)
|
|
||||||
-> AgentContextBuilder.build(event, binding, snapshot)
|
|
||||||
-> PluginRuntimeConnector.run_agent(ctx)
|
|
||||||
-> AgentRunAPIProxy action
|
|
||||||
-> validate active run session + caller identity + snapshot
|
|
||||||
-> Host API / Store
|
|
||||||
<- AgentRunResult stream
|
|
||||||
-> apply state.updated to PersistentStateStore
|
|
||||||
-> write message.completed / artifact.created to Transcript / ArtifactStore
|
|
||||||
-> render delivery or raise RunnerExecutionError
|
|
||||||
-> AgentRunSessionRegistry.unregister(run_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;跨请求持久化状态必须走授权 storage / 外部服务。
|
|
||||||
|
|
||||||
### 4.5 Resource Authorization(三层裁剪)
|
|
||||||
|
|
||||||
LangBot 在每次 run 前生成 `ctx.resources`(PROTOCOL_V1 §6),来自三层约束:
|
|
||||||
|
|
||||||
1. runner manifest 声明的 `permissions`(最大能力)。
|
|
||||||
2. binding / resource policy 允许的资源范围。
|
|
||||||
3. 当前 event / actor / bot / workspace 的实际权限。
|
|
||||||
|
|
||||||
这次裁剪结果必须冻结为 run-scoped authorization snapshot,并由
|
|
||||||
`AgentRunSessionRegistry` 按 `run_id` 保存。`ctx.resources` 是投影给 runner
|
|
||||||
看的同一份授权结果;运行期每个 proxy action 只依据该 snapshot 校验 active
|
|
||||||
run session、caller plugin identity、resource id、scope、payload size、rate
|
|
||||||
limit 和 deadline。Handler 不应重新执行三层裁剪,否则 build-time 与 runtime
|
|
||||||
授权逻辑会漂移。
|
|
||||||
|
|
||||||
SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 才是安全边界。
|
|
||||||
|
|
||||||
资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
|
|
||||||
|
|
||||||
执行/文件/skill/MCP 等能力的接入方向:先由 Host / 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、SDK-owned MCP bridge、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;Claude Code / Codex 当前实现见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。
|
|
||||||
|
|
||||||
## 5. SDK 侧协议
|
|
||||||
|
|
||||||
SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunner(BaseComponent):
|
|
||||||
__kind__ = "AgentRunner"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_capabilities(cls) -> AgentRunnerCapabilities: ... # PROTOCOL_V1 §4.3
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_config_schema(cls) -> list[dict]: ...
|
|
||||||
|
|
||||||
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ...
|
|
||||||
# ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7
|
|
||||||
```
|
|
||||||
|
|
||||||
- Manifest / capabilities / permissions / context policy:PROTOCOL_V1 §4。
|
|
||||||
- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
|
|
||||||
- `AgentRunResult`:PROTOCOL_V1 §7。
|
|
||||||
- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# 官方 AgentRunner 插件迁移计划
|
|
||||||
|
|
||||||
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。验收状态见 [PROGRESS.md](./PROGRESS.md),QA 入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
|
||||||
|
|
||||||
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。
|
|
||||||
|
|
||||||
## 1. 仓库组织
|
|
||||||
|
|
||||||
官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。
|
|
||||||
|
|
||||||
当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局:
|
|
||||||
|
|
||||||
```text
|
|
||||||
langbot-app/
|
|
||||||
langbot-local-agent/ # plugin:langbot/local-agent/default
|
|
||||||
manifest.yaml
|
|
||||||
components/agent_runner/default.{yaml,py}
|
|
||||||
langbot-agent-runner/ # 外部服务 runner 仓库
|
|
||||||
claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ...
|
|
||||||
```
|
|
||||||
|
|
||||||
后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。
|
|
||||||
|
|
||||||
## 2. 插件命名和 runner id
|
|
||||||
|
|
||||||
| 旧 runner | 官方插件 | runner id |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` |
|
|
||||||
| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` |
|
|
||||||
| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` |
|
|
||||||
| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` |
|
|
||||||
| - | `langbot/claude-code-agent` | `plugin:langbot/claude-code-agent/default` |
|
|
||||||
| - | `langbot/codex-agent` | `plugin:langbot/codex-agent/default` |
|
|
||||||
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
|
|
||||||
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
|
|
||||||
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
|
|
||||||
|
|
||||||
每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`。
|
|
||||||
|
|
||||||
## 3. 迁移批次
|
|
||||||
|
|
||||||
- **Batch 1(打通协议)**:`local-agent`(能力最完整基准)、`claude-code-agent` / `codex-agent`(外部 code-agent harness 边界)、`dify-agent`(传统 service API runner)。
|
|
||||||
- **Batch 2(外部 workflow)**:`n8n-agent`、`langflow-agent`(webhook/workflow 输入输出、timeout、外部 conversation id)。
|
|
||||||
- **Batch 3(平台 Agent API)**:`coze-agent`、`dashscope-agent`、`tbox-agent`(平台特有响应格式、引用资料、文件/图片输入)。
|
|
||||||
|
|
||||||
## 4. 每个官方插件的组件要求
|
|
||||||
|
|
||||||
每个插件至少包含一个 `AgentRunner` 组件,manifest 示例:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: langbot/v1
|
|
||||||
kind: AgentRunner
|
|
||||||
metadata:
|
|
||||||
name: default
|
|
||||||
label: { en_US: Dify Agent, zh_Hans: Dify Agent }
|
|
||||||
description:
|
|
||||||
en_US: Run a Dify application as a LangBot AgentRunner.
|
|
||||||
zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。
|
|
||||||
spec:
|
|
||||||
config: []
|
|
||||||
capabilities: # 字段语义见 PROTOCOL_V1 §4.3
|
|
||||||
streaming: true
|
|
||||||
event_context: true
|
|
||||||
stateful_session: true
|
|
||||||
permissions: # 字段语义见 PROTOCOL_V1 §4.4
|
|
||||||
storage: ["plugin"]
|
|
||||||
context: # 字段语义见 PROTOCOL_V1 §4.5
|
|
||||||
supports_history_pull: true
|
|
||||||
owns_compaction: true
|
|
||||||
execution:
|
|
||||||
python: { path: ./main.py, attr: DefaultAgentRunner }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. local-agent 插件方向
|
|
||||||
|
|
||||||
`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
|
|
||||||
|
|
||||||
迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。
|
|
||||||
|
|
||||||
责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束:
|
|
||||||
|
|
||||||
- 从 `ctx.config` 读取静态绑定 `prompt`,**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。
|
|
||||||
- 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。
|
|
||||||
- `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。
|
|
||||||
- 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。
|
|
||||||
- manifest 声明自管上下文能力(`context.supports_history_pull/search`、`owns_compaction` 等)。
|
|
||||||
|
|
||||||
### 5.1 Native Execution / Skills 后续接入
|
|
||||||
|
|
||||||
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host / 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 边界:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;授权资源只投影为 harness 可读的 context、资源句柄、SDK-owned MCP bridge 配置、受限环境变量或 CLI 参数(投影形态见 AGENT_CONTEXT_PROTOCOL §4.5);访问任何 LangBot 资源都必须通过 SDK runtime / `AgentRunAPIProxy` / SDK-owned MCP bridge 转发,不能由 harness native tools 直接访问;外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage;插件实例边界见 PROTOCOL_V1 §13;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射;harness 的 permission mode / allow-deny / MCP 配置只是一层执行约束,Host 仍负责调用前的资源授权、路径策略、secret 过滤和审计(发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
|
|
||||||
|
|
||||||
实现结构应把 provider-native output 解析与 LangBot result stream 组装分开:Claude stream-json、Codex JSONL、Kimi / OpenCode 事件等只在 runner adapter 内解析,输出统一归一为 `AgentRunResult`(`message.completed` / `message.delta`、`state.updated`、`artifact.created`、`run.completed` / `run.failed`)。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。
|
|
||||||
|
|
||||||
并发约束应按外部 session 粒度表达,而不是按 Agent / runner id / 插件实例表达;Agent 复用和全局锁边界见 PROTOCOL_V1 §13。若 runner 使用 `external.session_id` / `thread_id` resume 到同一 native session,且该 harness 不支持并发 turn,runner 应按稳定 external session key 串行写入;一次性 subprocess runner 可以只在单次 `run(ctx)` 内处理,长连接/daemon runner 则应采用 reader 独占 native stream、turn writer 串行写入的结构。
|
|
||||||
|
|
||||||
### 6.2 SDK-owned LangBot MCP bridge
|
|
||||||
|
|
||||||
外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,也不能用自己的 native tools 直接访问 LangBot 资源。当前轻量方案是由 SDK 提供一层 per-run MCP bridge,把 harness 的工具请求转回 SDK runtime / Host API:
|
|
||||||
|
|
||||||
- `AgentRunner.create_external_mcp_bridge(ctx)` 是 runner 父类入口。
|
|
||||||
- Bridge 由 `AgentRunAPIProxy` 和 `AgentRunContext` 构造,生命周期只覆盖当前 run。
|
|
||||||
- Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是导出全部 SDK action;MCP tool schema 由注解和 Pydantic args model 生成。
|
|
||||||
- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge;run 结束后 bridge 关闭。所有 LangBot 资源访问仍由 Host 按 `run_id`、caller identity 和授权快照校验。
|
|
||||||
|
|
||||||
第一批工具保持很小:当前事件快照、history page、knowledge retrieve、authorized tool call。新增工具必须先进入 SDK-owned annotated surface,再由 MCP adapter 自动投影。
|
|
||||||
|
|
||||||
## 7. Claude Code / Codex runner 当前形态
|
|
||||||
|
|
||||||
`claude-code-agent` 与 `codex-agent` 是最小可运行 MVP / dev path,用来证明外部 harness runner 可以接入同一套 AgentRunner 协议。本地 smoke 验收记录见 [PROGRESS.md](./PROGRESS.md) 与 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
|
||||||
|
|
||||||
MVP 含义:已验证 event-first context、resource projection、result stream 和
|
|
||||||
基础 resume state 可以跑通;不表示 Docker 生产部署、发布级执行隔离、
|
|
||||||
workspace lifecycle、secret projection、团队级 audit 或 runtime sidecar 已完成。
|
|
||||||
|
|
||||||
### 7.1 Claude Code runner
|
|
||||||
|
|
||||||
- Runner ID:`plugin:langbot/claude-code-agent/default`,执行方式:本地 Claude Code CLI print mode(默认 `claude -p`)。
|
|
||||||
- 默认输出 `message.completed` + `run.completed`;默认权限 `permission-mode=plan`、`max-turns=1`、`disallowedTools=AskUserQuestion`。
|
|
||||||
- 投影:写入 `agent-context.json`(schema `langbot.agent_runner.external_harness_context.v1`)和 `LANGBOT_CONTEXT.md`;LangBot skills 通过 Host / sandbox scoped tools 与 SDK-owned per-run LangBot MCP bridge 访问,不作为 harness native skill 目录直接授权;可把 scoped `mcp-config-json` 写成每次 run 的 MCP config 经 `--mcp-config` / `--strict-mcp-config` 传入;可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge。
|
|
||||||
- 状态:Claude Code 返回 `session_id` 时通过 `state.updated` 写回 `external.session_id`;工作目录优先用 config 的 `working-directory`,其次用 Host state 的 `external.working_directory`。
|
|
||||||
|
|
||||||
### 7.2 Codex runner
|
|
||||||
|
|
||||||
- Runner ID:`plugin:langbot/codex-agent/default`,执行方式:本地 Codex CLI,读取 LangBot event context。
|
|
||||||
- Codex `thread_id` 写回 host-owned state;支持 SDK-owned per-run LangBot MCP bridge;需要代理的本地环境可通过 config 的 `environment-json` 显式传递非 secret 环境变量。
|
|
||||||
|
|
||||||
### 7.3 当前限制
|
|
||||||
|
|
||||||
不是发布级安全边界实现;默认只做本地 CLI 调用,不实现完整执行隔离或 workspace 生命周期;不实现 issue-centric 队列、复杂 workflow engine 或长期任务调度;Docker 环境只能访问容器内 CLI 和凭据;Codex 仅验证协议形态,不代表 Codex 发布级能力或 Kimi runner 已完成。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
|
|
||||||
|
|
||||||
## 8. 发布和安装策略
|
|
||||||
|
|
||||||
最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用。
|
|
||||||
|
|
||||||
## 9. 验收标准
|
|
||||||
|
|
||||||
- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id;当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`。
|
|
||||||
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
|
|
||||||
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
|
|
||||||
- `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。
|
|
||||||
- `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。
|
|
||||||
- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# Agent Runner 插件化实现进度
|
|
||||||
|
|
||||||
本文档跟踪 Agent Runner 插件化的实现状态,便于快速了解当前进度。
|
|
||||||
|
|
||||||
> 本文是 agent-runner 插件化**实现状态的唯一事实源**。协议规范见 [PROTOCOL_V1.md](./PROTOCOL_V1.md),Host 架构见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。规范类文档不再各自维护"当前状态/✅"段落,状态一律以本文为准。
|
|
||||||
> 本文记录最近一次已知实现 / 验收状态,但不替代对当前 checkout 的代码和 WebUI smoke 复核;复核步骤见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
|
||||||
|
|
||||||
## 总体进度
|
|
||||||
|
|
||||||
**当前阶段**: Phase 3.6 已完成,Event-first 基础设施与外部 harness runner smoke 已完成;2026-06-04 已完成协议 / 文档漂移复核,当前未发布分支不保留 PoC 兼容 shim。EBA 完整事件网关与事件路由由外部 EBA 分支推进,目前处于联调阶段;本分支只保留其接入边界和复用点。
|
|
||||||
|
|
||||||
| Phase | 描述 | 状态 |
|
|
||||||
|-------|------|------|
|
|
||||||
| Phase 0 | PoC 验证 | ✅ 完成 |
|
|
||||||
| Phase 1 | 核心架构(Registry、Orchestrator、上下文模型) | ✅ 完成 |
|
|
||||||
| Phase 2 | 权限、能力声明、资源注入 | ✅ 完成 |
|
|
||||||
| Phase 3 | 内置 runner 迁移到插件 | ✅ 完成(7/7) |
|
|
||||||
| Phase 3.5 | Event-first 基础设施 | ✅ 完成 |
|
|
||||||
| Phase 3.6 | 外部 harness runner 协议 smoke | ✅ 完成(Claude Code MVP) |
|
|
||||||
| Phase 4 | EBA 事件支持 | ↗ 外部分支联调中(本分支已预留 event-first 入口,EventGateway / EventRouter 由 EBA 分支实现) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 详细状态
|
|
||||||
|
|
||||||
### SDK 侧 (`langbot-plugin-sdk`)
|
|
||||||
|
|
||||||
| 组件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AgentRunner` 组件 | ✅ | `api/definition/components/agent_runner/runner.py` |
|
|
||||||
| `AgentRunContext` | ✅ | `api/entities/builtin/agent_runner/context.py` |
|
|
||||||
| `AgentRunResult` | ✅ | `api/entities/builtin/agent_runner/result.py` |
|
|
||||||
| `AgentRunnerCapabilities` | ✅ | `api/entities/builtin/agent_runner/capabilities.py` |
|
|
||||||
| `AgentRunnerPermissions` | ✅ | `api/entities/builtin/agent_runner/permissions.py` |
|
|
||||||
| EBA 事件模型 (Event/Actor/Subject) | ✅ | `api/entities/builtin/agent_runner/event.py` |
|
|
||||||
| `LIST_AGENT_RUNNERS` action | ✅ | `runtime/io/handlers/control.py` |
|
|
||||||
| `RUN_AGENT` action | ✅ | `runtime/io/handlers/control.py` |
|
|
||||||
| `AgentRunAPIProxy` | ✅ | `api/proxies/agent_run_api.py` |
|
|
||||||
| Pull API handlers (State/History/Event/Artifact) | ✅ | `runtime/io/handlers/plugin.py` |
|
|
||||||
| `caller_plugin_identity` injection | ✅ | Pull API handlers inject caller identity |
|
|
||||||
|
|
||||||
### LangBot 侧
|
|
||||||
|
|
||||||
| 组件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AgentRunnerRegistry` | ✅ | `pkg/agent/runner/registry.py` |
|
|
||||||
| `AgentRunOrchestrator` | ✅ | `pkg/agent/runner/orchestrator.py` - event-first `run(event, binding)` |
|
|
||||||
| `AgentRunnerDescriptor` | ✅ | `pkg/agent/runner/descriptor.py` |
|
|
||||||
| `AgentResourceBuilder` | ✅ | `pkg/agent/runner/resource_builder.py` |
|
|
||||||
| `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context |
|
|
||||||
| `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.py` |
|
|
||||||
| `ConfigMigration` | ✅ | `pkg/agent/runner/config_migration.py` |
|
|
||||||
| `QueryEntryAdapter` | ✅ | `pkg/agent/runner/query_entry_adapter.py` - Query → Event + Binding |
|
|
||||||
| `run_from_query()` → `run(event, binding)` | ✅ | Pipeline 路径委托到 event-first path |
|
|
||||||
| `ChatMessageHandler` 集成 | ✅ | 使用 orchestrator 替代 wrapper |
|
|
||||||
| `PipelineService` 集成 | ✅ | 从 registry 获取 runner metadata |
|
|
||||||
| Plugin connector | ✅ | `list_agent_runners()` / `run_agent()` |
|
|
||||||
| `EventLogStore` | ✅ | `pkg/agent/runner/event_log_store.py` |
|
|
||||||
| `TranscriptStore` | ✅ | `pkg/agent/runner/transcript_store.py` |
|
|
||||||
| `ArtifactStore` | ✅ | `pkg/agent/runner/artifact_store.py` |
|
|
||||||
| `PersistentStateStore` | ✅ | `pkg/agent/runner/persistent_state_store.py` |
|
|
||||||
| History / Event pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| Artifact pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| State pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| `artifact.created` / `state.updated` handling | ✅ | Event-first handlers in orchestrator |
|
|
||||||
| Pipeline path host capability coverage | ✅ | EventLog/Transcript/ArtifactStore/PersistentStateStore |
|
|
||||||
| External harness state handoff | ✅ | `external.session_id` / `external.working_directory` 写入 PersistentStateStore |
|
|
||||||
|
|
||||||
### 官方插件
|
|
||||||
|
|
||||||
> 外部服务插件仓库:`langbot-agent-runner/`
|
|
||||||
> 本地 Local Agent 插件仓库:`langbot-local-agent/`
|
|
||||||
|
|
||||||
| 插件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `local-agent` | ✅ 已完成 | 核心功能:模型、工具、知识库、流式、会话 |
|
|
||||||
| `dify-agent` | ✅ 已完成 | 支持 chat/agent/workflow 三种应用类型 |
|
|
||||||
| `n8n-agent` | ✅ 已完成 | Webhook 调用,支持 basic/jwt/header 认证 |
|
|
||||||
| `coze-agent` | ✅ 已完成 | 多模态输入,思维链处理 |
|
|
||||||
| `claude-code-agent` | ✅ MVP smoke 通过 | 本地 Claude Code CLI;context / SDK-owned MCP bridge / skill-backed scoped tools;host-owned resume state |
|
|
||||||
| `dashscope-agent` | ✅ 已完成 | 阿里云百炼,支持 agent/workflow 两种模式 |
|
|
||||||
| `langflow-agent` | ✅ 已完成 | SSE 流式,tweaks 配置支持 |
|
|
||||||
| `tbox-agent` | ✅ 已完成 | 蚂蚁百宝箱,多模态输入 |
|
|
||||||
|
|
||||||
**注意**: LangBot 内置 runner(`pkg/provider/runners/`)已停用,文件顶部添加了 DEPRECATED 注释。
|
|
||||||
|
|
||||||
### 本地验收
|
|
||||||
|
|
||||||
| 日期 | 范围 | 状态 | 证据 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| 2026-05-29 | `local-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-17-59-00-462-08-00-pipeline-debug-chat.md` |
|
|
||||||
| 2026-05-29 | `claude-code-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-18-03-31-169-08-00-pipeline-debug-chat.md` |
|
|
||||||
| 2026-05-29 | Claude Code context / SDK-owned MCP bridge / skill-backed scoped tools | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` |
|
|
||||||
| 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` |
|
|
||||||
| 2026-05-29 | `codex-agent` Debug Chat + thread_id resume state | ✅ PASS | 见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) §10 / `langbot-skills/reports/` |
|
|
||||||
| 2026-06-04 | 协议 / 文档漂移复核 | ✅ PASS | SDK scaffold 与 Protocol v1 对齐;LangBot UI 旧 runner fallback 已移除;run-scoped API 身份校验已收紧。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未完成但仍属本分支收尾
|
|
||||||
|
|
||||||
以下项目属于本分支收尾工作:
|
|
||||||
|
|
||||||
- [x] Smoke / manual validation — `local-agent`、Claude Code MVP、Codex MVP 已通过本地 WebUI smoke
|
|
||||||
- [x] Docs final QA — 2026-06-04 已完成当前 Protocol v1 / scaffold / QA 指南漂移复核
|
|
||||||
- [ ] Claude Code runner 文档、安装和 marketplace 发布准备
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 非本分支范围
|
|
||||||
|
|
||||||
以下能力由其他分支负责:
|
|
||||||
|
|
||||||
| 能力 | 负责分支 | 备注 |
|
|
||||||
|------|----------|------|
|
|
||||||
| EventGateway implementation | EBA branch(联调中) | 完整事件网关、事件路由、持久化管理 |
|
|
||||||
| Event subscription / notification | EBA branch(联调中) | 事件订阅、推送通知 |
|
|
||||||
| BindingResolver persistence UI | 其他模块 | 绑定配置的持久化 UI |
|
|
||||||
| Event router integration | EBA branch(联调中) | 与 BindingResolver 集成 |
|
|
||||||
| Scheduler / background event source | 其他模块 | 定时任务、后台事件源 |
|
|
||||||
| Security release hardening | 后续 release gate | 路径隔离、权限边界、secret、MCP/skill 投影策略、资源配额、审计 |
|
|
||||||
| Codex / Kimi runner 全量接入 | 后续 runner 插件工作 | Codex MVP 已打通;Codex 发布级能力、Kimi runner 和全量 hardening 仍不扩大到当前协议闭环 |
|
|
||||||
| Issue-centric 产品模型 / 异步队列 / workflow engine | 后续产品架构 | 不属于当前 agent-runner plugin 协议闭环 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 待办事项
|
|
||||||
|
|
||||||
### 高优先级
|
|
||||||
|
|
||||||
- [x] 工具详情 API — SDK `GET_TOOL_DETAIL` action、`AgentRunAPIProxy.get_tool_detail()` 与 Host 侧授权校验已接通
|
|
||||||
- [x] Pipeline `run_from_query()` → `run(event, binding)` — 已完成
|
|
||||||
- [x] EventLog / Transcript / ArtifactStore / PersistentStateStore — 已完成
|
|
||||||
- [x] History / Event / Artifact / State pull APIs — 已完成
|
|
||||||
- [x] `caller_plugin_identity` 验证路径 — 已完成;run-scoped session 绑定插件身份时,省略或不匹配 caller identity 都会被拒绝
|
|
||||||
|
|
||||||
### 低优先级 / 未来
|
|
||||||
|
|
||||||
- [ ] EBA 完整集成 — EventGateway、EventRouter、event subscription、event notification 正在外部 EBA 分支联调,本分支不直接实现
|
|
||||||
- [ ] 平台 API 动作执行 — `action.requested` 结果类型存在但未执行
|
|
||||||
- [ ] 安全发布级 hardening — 作为生产默认启用前的 release gate,不阻塞当前协议闭环
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键决策记录
|
|
||||||
|
|
||||||
| 日期 | 决策 |
|
|
||||||
|------|------|
|
|
||||||
| 2026-05-10 | Phase 0 集成测试通过,SDK v1 协议验证成功 |
|
|
||||||
| 2026-05-13 | Phase 3 完成:所有 7 个官方 runner 插件迁移完成 |
|
|
||||||
| 2026-05-23 | Phase 3.5 完成:`run_from_query()` 委托到 event-first `run(event, binding)`,Pipeline path 获得 host capabilities |
|
|
||||||
| 2026-05-29 | 本地 `local-agent` 与 `claude-code-agent` 通过 WebUI smoke;Claude Code runner 验证 external harness context 投影和 host-owned resume state |
|
|
||||||
| 2026-06-04 | 未发布协议面收敛:移除旧 runner 字段 / 旧本地 runner 名 / PoC schema 兼容分支,SDK 文档和模板对齐当前 `AgentRunContext` |
|
|
||||||
| 2026-06-09 | EBA 状态同步:完整 EventGateway / EventRouter 已转由外部 EBA 分支联调;本分支继续作为 AgentRunner Protocol v1 / Host 底座闭环。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 相关文档
|
|
||||||
|
|
||||||
- [README.md](./README.md) — 总体设计与路由
|
|
||||||
- [PROTOCOL_V1.md](./PROTOCOL_V1.md) — 协议规范(唯一 schema 事实源)
|
|
||||||
- [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) — Agent Runner QA 指南和下一轮测试入口
|
|
||||||
- [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划
|
|
||||||
- [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛
|
|
||||||
@@ -1,671 +0,0 @@
|
|||||||
# LangBot AgentRunner Protocol v1
|
|
||||||
|
|
||||||
本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。
|
|
||||||
|
|
||||||
- 本文件描述"稳定接口应是什么",是 normative spec,不混入实现进度。实现状态见 [PROGRESS.md](./PROGRESS.md)。
|
|
||||||
- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。
|
|
||||||
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
|
||||||
|
|
||||||
## 1. 协议目标
|
|
||||||
|
|
||||||
Protocol v1 只解决四件事:
|
|
||||||
|
|
||||||
- LangBot 如何发现插件提供的 AgentRunner。
|
|
||||||
- LangBot 如何把一次事件调用封装成 `AgentRunContext`。
|
|
||||||
- AgentRunner 如何以事件流形式返回运行结果。
|
|
||||||
- AgentRunner 如何通过受限 API 访问 LangBot host 能力。
|
|
||||||
|
|
||||||
Protocol v1 **不定义**:
|
|
||||||
|
|
||||||
- LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK)。
|
|
||||||
- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md))。
|
|
||||||
- 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md))。
|
|
||||||
- Pipeline 的长期配置模型。
|
|
||||||
- 发布级安全 hardening 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
|
|
||||||
|
|
||||||
## 2. 参与方
|
|
||||||
|
|
||||||
| 名称 | 职责 |
|
|
||||||
| --- | --- |
|
|
||||||
| LangBot Host | 事件入口、绑定解析、权限、资源、存储、生命周期、结果投递。 |
|
|
||||||
| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 |
|
|
||||||
| AgentRunner | 插件提供的 agent 执行组件。 |
|
|
||||||
| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 |
|
|
||||||
| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2)。 |
|
|
||||||
|
|
||||||
产品层的 `Agent` 替代旧 Pipeline 承载 agent 配置:bot / IM channel
|
|
||||||
绑定一个 Agent,一个 Agent 可以被多个 bot / channel 复用。Host 内部的
|
|
||||||
`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的
|
|
||||||
`ctx.config`、`ctx.resources`、`ctx.context` 和 `ctx.delivery`。SDK 不需要知道
|
|
||||||
Agent / binding 的持久化形态。
|
|
||||||
|
|
||||||
外部 harness runner(Claude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact API 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。
|
|
||||||
|
|
||||||
## 3. 协议演进
|
|
||||||
|
|
||||||
当前 AgentRunner 合同不暴露显式 `protocol_version` 字段。协议演进先按字段级兼容规则处理:
|
|
||||||
|
|
||||||
- 新增可选字段保持向后兼容。
|
|
||||||
- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。
|
|
||||||
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。新增 result type 不提升大版本。
|
|
||||||
|
|
||||||
## 4. Discovery 协议
|
|
||||||
|
|
||||||
### 4.1 LIST_AGENT_RUNNERS
|
|
||||||
|
|
||||||
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ListAgentRunnersResponse(BaseModel):
|
|
||||||
runners: list[AgentRunnerManifest]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 AgentRunnerManifest
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerManifest(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
label: I18nObject
|
|
||||||
description: I18nObject | None = None
|
|
||||||
capabilities: AgentRunnerCapabilities
|
|
||||||
permissions: AgentRunnerPermissions
|
|
||||||
context: AgentRunnerContextPolicy
|
|
||||||
config_schema: list[DynamicFormItemSchema] = []
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `id` 必须稳定,格式 `plugin:author/name/runner`。
|
|
||||||
- `name` 是插件内 runner 名称,例如 `default`。
|
|
||||||
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。
|
|
||||||
- `metadata` 只放展示、诊断、非稳定扩展信息。
|
|
||||||
|
|
||||||
### 4.3 Capabilities
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerCapabilities(BaseModel):
|
|
||||||
streaming: bool = False
|
|
||||||
tool_calling: bool = False
|
|
||||||
knowledge_retrieval: bool = False
|
|
||||||
multimodal_input: bool = False
|
|
||||||
skill_authoring: bool = False
|
|
||||||
event_context: bool = True
|
|
||||||
platform_api: bool = False
|
|
||||||
interrupt: bool = False
|
|
||||||
stateful_session: bool = False
|
|
||||||
self_managed_context: bool = True
|
|
||||||
```
|
|
||||||
|
|
||||||
语义:
|
|
||||||
|
|
||||||
- `streaming`: runner 可以返回 `message.delta`。
|
|
||||||
- `tool_calling`: runner 可能调用 Host tool API。
|
|
||||||
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
|
|
||||||
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
|
|
||||||
- `skill_authoring`: runner 需要 Host 提供 skill facts 以及 skill authoring tools,例如 `activate` / `register_skill`。
|
|
||||||
- `event_context`: runner 理解 event-first 输入。
|
|
||||||
- `platform_api`: runner 可能请求平台动作。
|
|
||||||
- `interrupt`: runner 支持取消或中断。
|
|
||||||
- `stateful_session`: runner 可能维护跨 run 会话状态。
|
|
||||||
- `self_managed_context`: runner 自己管理 working context,Host 不应默认 inline 历史。
|
|
||||||
|
|
||||||
> Capabilities 字段全部是 `bool`。runner 是否寄宿 host-owned state **不在 capabilities 表达**,而通过 `permissions.storage` 声明(见 §4.4),避免出现非 bool 取值。
|
|
||||||
|
|
||||||
### 4.4 Permissions
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerPermissions(BaseModel):
|
|
||||||
models: list[Literal["invoke", "stream", "rerank"]] = []
|
|
||||||
tools: list[Literal["detail", "call"]] = []
|
|
||||||
knowledge_bases: list[Literal["list", "retrieve"]] = []
|
|
||||||
history: list[Literal["page", "search"]] = []
|
|
||||||
events: list[Literal["get", "page"]] = []
|
|
||||||
artifacts: list[Literal["metadata", "read"]] = []
|
|
||||||
storage: list[Literal["plugin", "workspace", "binding"]] = []
|
|
||||||
files: list[Literal["config", "knowledge"]] = []
|
|
||||||
platform_api: list[str] = []
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest permissions 是 runner 需要的**最大能力**。实际可用资源还要经过 Host binding policy 和当前 run scope 裁剪(三层裁剪见 HOST_SDK §4.5)。
|
|
||||||
|
|
||||||
### 4.5 Context Policy
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerContextPolicy(BaseModel):
|
|
||||||
supports_history_pull: bool = True
|
|
||||||
supports_history_search: bool = False
|
|
||||||
supports_artifact_pull: bool = True
|
|
||||||
owns_compaction: bool = True
|
|
||||||
wants_static_context_refs: bool = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Host 不使用该声明给 runner inline 历史窗口。默认原则:
|
|
||||||
|
|
||||||
- Host 不得默认 inline 全量历史。
|
|
||||||
- Host 只 inline 当前 event / input 和 context handles。
|
|
||||||
- Runner 拥有 working context assembly。
|
|
||||||
- Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。
|
|
||||||
- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。
|
|
||||||
|
|
||||||
context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
|
||||||
|
|
||||||
## 5. Run 协议
|
|
||||||
|
|
||||||
### 5.1 RUN_AGENT
|
|
||||||
|
|
||||||
Host 调用 Runtime:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunRequest(BaseModel):
|
|
||||||
runner_id: str
|
|
||||||
runner_name: str
|
|
||||||
context: AgentRunContext
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id` 和 `context` 为准。
|
|
||||||
|
|
||||||
### 5.2 AgentRunContext
|
|
||||||
|
|
||||||
这是 SDK 看到的**唯一权威 context 定义**。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunContext(BaseModel):
|
|
||||||
run_id: str
|
|
||||||
trigger: AgentTrigger
|
|
||||||
event: AgentEventContext
|
|
||||||
conversation: ConversationContext | None = None
|
|
||||||
actor: ActorContext | None = None
|
|
||||||
subject: SubjectContext | None = None
|
|
||||||
input: AgentInput
|
|
||||||
delivery: DeliveryContext
|
|
||||||
resources: AgentResources
|
|
||||||
context: ContextAccess
|
|
||||||
state: AgentRunState
|
|
||||||
runtime: AgentRuntimeContext
|
|
||||||
config: dict[str, Any] = {}
|
|
||||||
adapter: AdapterContext | None = None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
核心约束:
|
|
||||||
|
|
||||||
- `event` 是必选字段,Protocol v1 是 event-first。
|
|
||||||
- `input` 表示当前事件的主输入,不等于历史消息。
|
|
||||||
- `bootstrap` / `messages` **不是协议字段**;Host 不内联历史窗口。
|
|
||||||
- `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。
|
|
||||||
- `config` 是 Agent/runner config,不是插件实例状态。
|
|
||||||
|
|
||||||
### 5.3 AgentTrigger
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentTrigger(BaseModel):
|
|
||||||
type: str
|
|
||||||
source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"]
|
|
||||||
timestamp: int | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "message.received", "source": "host_adapter" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 AgentEventContext
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentEventContext(BaseModel):
|
|
||||||
event_id: str
|
|
||||||
event_type: str
|
|
||||||
event_time: int | None = None
|
|
||||||
source: str
|
|
||||||
source_event_type: str | None = None
|
|
||||||
raw_ref: RawEventRef | None = None
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
|
|
||||||
- 平台原始事件名放入 `source_event_type`。
|
|
||||||
- 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`。
|
|
||||||
|
|
||||||
### 5.5 Conversation / Actor / Subject
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ConversationContext(BaseModel):
|
|
||||||
conversation_id: str | None = None
|
|
||||||
thread_id: str | None = None
|
|
||||||
launcher_type: str | None = None
|
|
||||||
launcher_id: str | None = None
|
|
||||||
bot_id: str | None = None
|
|
||||||
workspace_id: str | None = None
|
|
||||||
|
|
||||||
class ActorContext(BaseModel):
|
|
||||||
actor_type: str
|
|
||||||
actor_id: str | None = None
|
|
||||||
actor_name: str | None = None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class SubjectContext(BaseModel):
|
|
||||||
subject_type: str
|
|
||||||
subject_id: str | None = None
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
- 消息事件:actor 是发消息的人,subject 是当前消息。
|
|
||||||
- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。
|
|
||||||
- 定时事件:actor 可以是 system,subject 是 schedule。
|
|
||||||
|
|
||||||
### 5.6 AgentInput
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentInput(BaseModel):
|
|
||||||
text: str | None = None
|
|
||||||
contents: list[ContentElement] = []
|
|
||||||
attachments: list[ArtifactRef] = []
|
|
||||||
message_chain: dict[str, Any] | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
- 文本、多模态、附件都属于当前 event input。
|
|
||||||
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。
|
|
||||||
- `message_chain` 是平台兼容字段,不应成为长期稳定依赖。
|
|
||||||
|
|
||||||
### 5.7 DeliveryContext
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DeliveryContext(BaseModel):
|
|
||||||
surface: str
|
|
||||||
reply_target: dict[str, Any] | None = None
|
|
||||||
supports_streaming: bool = False
|
|
||||||
supports_edit: bool = False
|
|
||||||
supports_reaction: bool = False
|
|
||||||
max_message_size: int | None = None
|
|
||||||
platform_capabilities: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。
|
|
||||||
|
|
||||||
### 5.8 ContextAccess
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ContextAccess(BaseModel):
|
|
||||||
conversation_id: str | None = None
|
|
||||||
thread_id: str | None = None
|
|
||||||
latest_cursor: str | None = None
|
|
||||||
event_seq: int | None = None
|
|
||||||
transcript_seq: int | None = None
|
|
||||||
has_history_before: bool = False
|
|
||||||
inline_policy: InlineContextPolicy
|
|
||||||
available_apis: ContextAPICapabilities
|
|
||||||
|
|
||||||
class InlineContextPolicy(BaseModel):
|
|
||||||
mode: Literal["none", "current_event", "recent_tail", "summary_tail"]
|
|
||||||
delivered_count: int = 0
|
|
||||||
source_total_count: int | None = None
|
|
||||||
messages_complete: bool = False
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
class ContextAPICapabilities(BaseModel):
|
|
||||||
history_page: bool = False
|
|
||||||
history_search: bool = False
|
|
||||||
event_get: bool = False
|
|
||||||
event_page: bool = False
|
|
||||||
artifact_metadata: bool = False
|
|
||||||
artifact_read: bool = False
|
|
||||||
state: bool = False
|
|
||||||
storage: bool = False
|
|
||||||
```
|
|
||||||
|
|
||||||
`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。
|
|
||||||
|
|
||||||
### 5.9 AgentRuntimeContext
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRuntimeContext(BaseModel):
|
|
||||||
host: str = "langbot"
|
|
||||||
langbot_version: str | None = None
|
|
||||||
trace_id: str
|
|
||||||
deadline_at: float | None = None
|
|
||||||
locale: str | None = None
|
|
||||||
timezone: str | None = None
|
|
||||||
static_refs: dict[str, StaticContextRef] = {}
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`static_refs` 用于 KV cache 友好的静态上下文引用(system policy、tool schema、resource manifest 的 hash/version)。理由见 AGENT_CONTEXT_PROTOCOL §6。
|
|
||||||
|
|
||||||
### 5.10 AgentRunState
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunState(BaseModel):
|
|
||||||
conversation: dict[str, Any] = {}
|
|
||||||
actor: dict[str, Any] = {}
|
|
||||||
subject: dict[str, Any] = {}
|
|
||||||
runner: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
|
|
||||||
|
|
||||||
## 6. Resources
|
|
||||||
|
|
||||||
```python
|
|
||||||
class 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 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
|
|
||||||
|
|
||||||
## 7. Result Stream
|
|
||||||
|
|
||||||
### 7.1 AgentRunResult envelope
|
|
||||||
|
|
||||||
```python
|
|
||||||
JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
|
|
||||||
|
|
||||||
ResultType = Literal[
|
|
||||||
"message.delta",
|
|
||||||
"message.completed",
|
|
||||||
"tool.call.started",
|
|
||||||
"tool.call.completed",
|
|
||||||
"artifact.created",
|
|
||||||
"state.updated",
|
|
||||||
"action.requested",
|
|
||||||
"run.completed",
|
|
||||||
"run.failed",
|
|
||||||
]
|
|
||||||
|
|
||||||
class AgentRunResultBase(BaseModel):
|
|
||||||
run_id: str
|
|
||||||
sequence: int | None = None
|
|
||||||
timestamp: int | None = None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`AgentRunResult` 是以下 typed result 的 discriminated union。Host 必须按 `type` 校验对应 `data` 结构;未知 `type` 按 §3 版本演进规则忽略并记录 warning。
|
|
||||||
|
|
||||||
### 7.2 稳定 result payloads
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AssistantMessageChunk(BaseModel):
|
|
||||||
role: Literal["assistant"] = "assistant"
|
|
||||||
content: str | None = None
|
|
||||||
contents: list[ContentElement] = []
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class AssistantMessage(BaseModel):
|
|
||||||
role: Literal["assistant"] = "assistant"
|
|
||||||
content: str | None = None
|
|
||||||
contents: list[ContentElement] = []
|
|
||||||
artifacts: list[ArtifactRef] = []
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class MessageDeltaData(BaseModel):
|
|
||||||
chunk: AssistantMessageChunk
|
|
||||||
|
|
||||||
class MessageCompletedData(BaseModel):
|
|
||||||
message: AssistantMessage
|
|
||||||
|
|
||||||
class ToolCallStartedData(BaseModel):
|
|
||||||
tool_call_id: str
|
|
||||||
tool_name: str
|
|
||||||
parameters: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class ToolCallCompletedData(BaseModel):
|
|
||||||
tool_call_id: str
|
|
||||||
tool_name: str
|
|
||||||
result_preview: dict[str, Any] | None = None
|
|
||||||
error_code: str | None = None
|
|
||||||
error_message: str | None = None
|
|
||||||
|
|
||||||
class ArtifactCreatedData(BaseModel):
|
|
||||||
artifact: ArtifactRef
|
|
||||||
|
|
||||||
class StateUpdatedData(BaseModel):
|
|
||||||
scope: Literal["conversation", "actor", "subject", "runner", "binding", "workspace"]
|
|
||||||
key: str
|
|
||||||
value: JSONValue
|
|
||||||
|
|
||||||
class ActionRequestedData(BaseModel):
|
|
||||||
action: str
|
|
||||||
target: dict[str, Any]
|
|
||||||
payload: dict[str, Any] = {}
|
|
||||||
idempotency_key: str | None = None
|
|
||||||
approval_hint: str | None = None
|
|
||||||
|
|
||||||
class RunCompletedData(BaseModel):
|
|
||||||
finish_reason: str = "stop"
|
|
||||||
message: AssistantMessage | None = None
|
|
||||||
usage: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class RunFailedData(BaseModel):
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
retryable: bool = False
|
|
||||||
details: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class MessageDeltaResult(AgentRunResultBase):
|
|
||||||
type: Literal["message.delta"]
|
|
||||||
data: MessageDeltaData
|
|
||||||
|
|
||||||
class MessageCompletedResult(AgentRunResultBase):
|
|
||||||
type: Literal["message.completed"]
|
|
||||||
data: MessageCompletedData
|
|
||||||
|
|
||||||
class ToolCallStartedResult(AgentRunResultBase):
|
|
||||||
type: Literal["tool.call.started"]
|
|
||||||
data: ToolCallStartedData
|
|
||||||
|
|
||||||
class ToolCallCompletedResult(AgentRunResultBase):
|
|
||||||
type: Literal["tool.call.completed"]
|
|
||||||
data: ToolCallCompletedData
|
|
||||||
|
|
||||||
class ArtifactCreatedResult(AgentRunResultBase):
|
|
||||||
type: Literal["artifact.created"]
|
|
||||||
data: ArtifactCreatedData
|
|
||||||
|
|
||||||
class StateUpdatedResult(AgentRunResultBase):
|
|
||||||
type: Literal["state.updated"]
|
|
||||||
data: StateUpdatedData
|
|
||||||
|
|
||||||
class ActionRequestedResult(AgentRunResultBase):
|
|
||||||
type: Literal["action.requested"]
|
|
||||||
data: ActionRequestedData
|
|
||||||
|
|
||||||
class RunCompletedResult(AgentRunResultBase):
|
|
||||||
type: Literal["run.completed"]
|
|
||||||
data: RunCompletedData
|
|
||||||
|
|
||||||
class RunFailedResult(AgentRunResultBase):
|
|
||||||
type: Literal["run.failed"]
|
|
||||||
data: RunFailedData
|
|
||||||
|
|
||||||
AgentRunResult = (
|
|
||||||
MessageDeltaResult
|
|
||||||
| MessageCompletedResult
|
|
||||||
| ToolCallStartedResult
|
|
||||||
| ToolCallCompletedResult
|
|
||||||
| ArtifactCreatedResult
|
|
||||||
| StateUpdatedResult
|
|
||||||
| ActionRequestedResult
|
|
||||||
| RunCompletedResult
|
|
||||||
| RunFailedResult
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 稳定 result types
|
|
||||||
|
|
||||||
| type | 说明 | 当前消费 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `message.delta` | 流式消息片段。 | ✅ |
|
|
||||||
| `message.completed` | 完整消息。 | ✅ |
|
|
||||||
| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry |
|
|
||||||
| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry |
|
|
||||||
| `artifact.created` | runner 生成 artifact。 | ✅ |
|
|
||||||
| `state.updated` | runner 请求更新 host-owned state。 | ✅ |
|
|
||||||
| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** |
|
|
||||||
| `run.completed` | run 正常结束。 | ✅ |
|
|
||||||
| `run.failed` | run 失败。 | ✅ |
|
|
||||||
|
|
||||||
`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。
|
|
||||||
|
|
||||||
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。`action.requested` 如果请求未来会产生外部副作用,runner 必须提供稳定 `idempotency_key`;本分支 Host 仍只记录 telemetry。
|
|
||||||
|
|
||||||
### 7.4 Stream delivery semantics
|
|
||||||
|
|
||||||
- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay,也不承诺 at-least-once;从 Host 视角,收到的 result 最多应用一次。
|
|
||||||
- `sequence` 是单个 `run_id` 内的结果序号。in-process / stdio 这类天然有序的在线 stream 可以省略;任何会缓冲、重放、跨进程队列或 runtime-managed task 的 transport 必须提供从 1 开始严格递增的 `sequence`。
|
|
||||||
- Host 看到已提供 `sequence` 的 result 时,应按 `(run_id, sequence)` 做重复检测,并在缺号或乱序时记录 warning;除非 transport 明确声明 replay 语义,Host 不应自行等待缺失序号重排用户可见输出。
|
|
||||||
- `run.failed.data.retryable` 只表示整次 run 理论上可由上层重试;Protocol v1 不自动重试 run,也不自动重试 proxy action。任何未来自动重试的 side-effecting action 必须依赖 `idempotency_key` 或等价 Host-owned 去重键。
|
|
||||||
- History / Event / Transcript cursor 是 opaque token。runner 不得解析 cursor,也不得假设 cursor 在不同 API、conversation、thread 或 retention window 之间可比较;当前实现即使返回数字字符串,也只是实现细节。
|
|
||||||
|
|
||||||
### 7.5 示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } }
|
|
||||||
{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } }
|
|
||||||
{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } }
|
|
||||||
{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."}, "idempotency_key": "run_1:edit:msg_1" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. AgentRunAPIProxy
|
|
||||||
|
|
||||||
所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Model
|
|
||||||
await api.invoke_llm(model_id, messages, funcs=None, extra_args=None)
|
|
||||||
async for chunk in api.invoke_llm_stream(model_id, 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.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(before_cursor=None, limit=50)
|
|
||||||
|
|
||||||
# Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围)
|
|
||||||
await api.artifact_metadata(artifact_id)
|
|
||||||
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)
|
|
||||||
await api.get_plugin_storage(key); await api.set_plugin_storage(key, value); await api.delete_plugin_storage(key)
|
|
||||||
await api.get_workspace_storage(key); await api.set_workspace_storage(key, value); await api.delete_workspace_storage(key)
|
|
||||||
```
|
|
||||||
|
|
||||||
`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / runner / binding),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。
|
|
||||||
|
|
||||||
返回数据结构(如 `HistoryPage`、artifact metadata)见 AGENT_CONTEXT_PROTOCOL §4。
|
|
||||||
|
|
||||||
## 9. 错误模型
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentAPIError(BaseModel):
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
retryable: bool = False
|
|
||||||
details: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
| code | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `unauthorized` | 未授权访问资源或 scope。 |
|
|
||||||
| `not_found` | 资源不存在或对当前 runner 不可见。 |
|
|
||||||
| `deadline_exceeded` | 超过 run deadline。 |
|
|
||||||
| `payload_too_large` | 请求或响应过大。 |
|
|
||||||
| `rate_limited` | Host 限流。 |
|
|
||||||
| `invalid_argument` | 参数错误。 |
|
|
||||||
| `runtime_error` | Host 或下游能力错误。 |
|
|
||||||
|
|
||||||
Runner 失败使用 `run.failed`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "run.failed", "data": { "code": "runner.error", "message": "failed to call external agent", "retryable": false } }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Timeout 与 Cancellation
|
|
||||||
|
|
||||||
- Host 在 `ctx.runtime.deadline_at` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。
|
|
||||||
- Host 可以取消 active run;Runtime 应尽力中断 runner。
|
|
||||||
- Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。
|
|
||||||
- Host 必须 unregister active run session。
|
|
||||||
|
|
||||||
## 11. Security 与 Guardrail(协议层)
|
|
||||||
|
|
||||||
Protocol v1 的安全边界在 Host:
|
|
||||||
|
|
||||||
- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。
|
|
||||||
- SDK 本地校验只提升开发体验,不能替代 Host 校验。
|
|
||||||
- 所有 resource id 对 runner 来说都是 opaque。
|
|
||||||
- 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。
|
|
||||||
- 大 payload 必须 artifact 化。
|
|
||||||
- Host 必须记录 run_id、runner_id、action、resource、scope、result。
|
|
||||||
|
|
||||||
Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。
|
|
||||||
|
|
||||||
对外部 harness runner,Host 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计;runner plugin 把授权后的 context/resource projection 适配为目标 harness 的形式;harness 的 native permission mode、allowed/disallowed tools 只是额外执行约束,不能替代 Host 授权。
|
|
||||||
|
|
||||||
> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
|
|
||||||
## 12. Pipeline Adapter 边界
|
|
||||||
|
|
||||||
Pipeline 是当前入口 adapter,不是协议中心。目标产品模型中 Agent 会替代
|
|
||||||
Pipeline 承载 runner config、resource policy 和 delivery policy;当前 Query
|
|
||||||
entry adapter 只是迁移桥。它负责:
|
|
||||||
|
|
||||||
- 从 `Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。
|
|
||||||
- 从当前 Agent/runner config 构造 `ctx.config`。
|
|
||||||
- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`。
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。
|
|
||||||
- `ctx.adapter.extra` 只允许承载一次性、JSON-safe、入口相关的非核心元数据,例如 `params`;不得承载 `prompt`、history window、RAG 结果、tool schema 或授权资源。
|
|
||||||
- 静态绑定 prompt 属于 `ctx.config.prompt`。preprocessing / hook 后的动态有效指令不通过 `ctx.adapter.extra` 主动推送;后续如需要保留这类能力,应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。
|
|
||||||
- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。
|
|
||||||
|
|
||||||
## 13. 已确认约束
|
|
||||||
|
|
||||||
- v1 / EBA 主线是 `one event -> one AgentBinding -> one run_id -> one runner`。
|
|
||||||
- 一个 bot / IM channel 在同一时间只绑定一个负责 agentic 处理的 Agent;一个 Agent 可以被多个 bot / channel 复用。
|
|
||||||
- 如果配置层出现多个匹配 AgentBinding,BindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。
|
|
||||||
- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。
|
|
||||||
- `AgentRunnerDescriptor.source` 只允许 `plugin`;Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。
|
|
||||||
- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshot;runtime handler 不应重新执行资源裁剪。
|
|
||||||
- v1 不要求 Agent、AgentRunner 插件实例或 runner id 全局串行。多个 bot / channel 可复用同一个 Agent;并发隔离依赖 `run_id`、binding、conversation / thread scope 和 Host authorization snapshot。
|
|
||||||
- 对 `stateful_session` runner,若外部 runtime 不支持同一 session 并发 turn,串行化粒度应是稳定的 external session key(例如 workspace / bot / binding / runner / conversation / thread / external session id),不是 Agent 或插件实例全局锁。
|
|
||||||
- 外部 harness runner 当前是 MVP / dev path,证明协议可接入,不代表发布级安全边界或 Docker 生产可用性完成。
|
|
||||||
|
|
||||||
## 14. 开放问题
|
|
||||||
|
|
||||||
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
|
|
||||||
- `TranscriptItem` 的最小字段集如何定义。
|
|
||||||
- ArtifactStore 是否复用现有 BinaryStorage backend,还是引入独立实体。
|
|
||||||
- State 与 Storage 的边界是否需要更强类型。
|
|
||||||
- `platform_api` action 的审批模型如何表达。
|
|
||||||
- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
# Agent Runner 插件化文档入口
|
|
||||||
|
|
||||||
本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 接入边界和官方 runner 迁移混在同一份 README 里。
|
|
||||||
|
|
||||||
## 背景与问题
|
|
||||||
|
|
||||||
旧 runner 路径主要围绕 Pipeline / Query 和 `pkg/provider/runners` 内置实现展开,扩展外部 agent runtime 时容易把 runner 选择、上下文裁剪、资源授权和消息投递绑在同一条聊天链路里。这个分支要把 LangBot 收敛成 Agent Host:Host 负责事件、绑定、授权、事实源和结果投递;AgentRunner 作为插件或外部 harness 消费统一协议并自主管理 prompt / history / memory。
|
|
||||||
|
|
||||||
## 文档维护原则(单一事实源)
|
|
||||||
|
|
||||||
- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。
|
|
||||||
- **实现状态唯一记录在 [PROGRESS.md](./PROGRESS.md)。** 规范类文档不维护"当前状态/✅"段落。
|
|
||||||
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。
|
|
||||||
- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。
|
|
||||||
|
|
||||||
## 本分支目标
|
|
||||||
|
|
||||||
**本分支目标:AgentRunner 外化 / 插件化基础设施**
|
|
||||||
|
|
||||||
本分支只做 LangBot 作为 Agent Host 的基础能力建设,为后续用 `Agent`
|
|
||||||
替代 Pipeline 承载 agent 配置打底:
|
|
||||||
|
|
||||||
- LangBot 与 SDK 的稳定协议合同(Protocol v1)
|
|
||||||
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型
|
|
||||||
- `run(event, binding)` event-first 入口
|
|
||||||
- `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore
|
|
||||||
- History / Event / Artifact / State pull APIs
|
|
||||||
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
|
|
||||||
|
|
||||||
## 本分支不实现
|
|
||||||
|
|
||||||
以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调:
|
|
||||||
|
|
||||||
- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理
|
|
||||||
- **Event subscription / Event notification**:事件订阅、推送通知
|
|
||||||
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
|
|
||||||
- **Scheduler / Background event source**:定时任务、后台事件源
|
|
||||||
- **Runtime control plane v2**:runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit
|
|
||||||
|
|
||||||
EventGateway / 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 可用)。
|
|
||||||
|
|
||||||
详细实现进度、已验收能力和未完成收尾见 [PROGRESS.md](./PROGRESS.md)。
|
|
||||||
|
|
||||||
## 术语表
|
|
||||||
|
|
||||||
| 术语 | 含义 |
|
|
||||||
| --- | --- |
|
|
||||||
| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同:discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 |
|
|
||||||
| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 |
|
|
||||||
| AgentConfig | Host 内部迁移期配置投影,由当前 Pipeline config 或未来持久 Agent 生成。 |
|
|
||||||
| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 |
|
|
||||||
| envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 |
|
|
||||||
| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 |
|
|
||||||
| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 |
|
|
||||||
| harness runner | Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 |
|
|
||||||
| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 |
|
|
||||||
| `static_refs` | KV cache 友好的静态上下文引用,例如 system policy、tool schema、resource manifest 的 hash/version。 |
|
|
||||||
| Runtime Control Plane | v2 Host 能力层,负责 runtime registry、heartbeat、task queue、progress/cancel 和 audit;不是 Protocol v1 主线。 |
|
|
||||||
|
|
||||||
## 设计文档
|
|
||||||
|
|
||||||
| 文档 | 关注点 |
|
|
||||||
| --- | --- |
|
|
||||||
| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | **🔒 唯一 schema 事实源**。LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:版本协商、discovery、run context、result stream、proxy actions、错误和 adapter 边界。 |
|
|
||||||
| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 |
|
|
||||||
| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 |
|
|
||||||
| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 |
|
|
||||||
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 |
|
|
||||||
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面预留:Host 新增 runtime registry、heartbeat、task queue、daemon 执行和 audit;管理插件构建在这些 Host 能力之上。**标注为 future design note**。 |
|
|
||||||
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
|
|
||||||
| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
|
|
||||||
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
|
|
||||||
| [PROGRESS.md](./PROGRESS.md) | **🔒 唯一状态事实源**。当前实现进度、已验收能力、未完成收尾和非本分支范围。 |
|
|
||||||
|
|
||||||
## 工作拆分
|
|
||||||
|
|
||||||
### 1. LangBot + SDK 基础设施
|
|
||||||
|
|
||||||
目标是把 LangBot 从内置 runner 执行器变成 agent host:
|
|
||||||
|
|
||||||
- LangBot 与 SDK 的稳定协议合同
|
|
||||||
- runner manifest / descriptor / registry
|
|
||||||
- Agent / binding 配置解析
|
|
||||||
- run orchestration 和生命周期管理
|
|
||||||
- resource authorization 与 `run_id` 级权限校验
|
|
||||||
- host-owned state / storage / event log / transcript / artifact 能力
|
|
||||||
- SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy`
|
|
||||||
|
|
||||||
协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
|
|
||||||
|
|
||||||
详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
|
||||||
|
|
||||||
### 2. Agent-owned context
|
|
||||||
|
|
||||||
LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。
|
|
||||||
|
|
||||||
Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 按需拉取历史并自行管理 working context。
|
|
||||||
|
|
||||||
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
|
||||||
|
|
||||||
### 3. Event Based Agent(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 侧新增 runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit。
|
|
||||||
|
|
||||||
在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验,runtime/task 的事实源仍由 Host 持有。
|
|
||||||
|
|
||||||
详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
|
|
||||||
|
|
||||||
## 约束事实源
|
|
||||||
|
|
||||||
本分支已确认约束不在 README 重写:
|
|
||||||
|
|
||||||
- Runner 可见协议、result stream 和调度边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
|
|
||||||
- Host 内部 `AgentConfig` / `AgentBinding` 投影见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
|
||||||
- 外部 EBA / Agent Platform / Runtime Control Plane 接入边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
# Agent Runtime Control Plane V2
|
|
||||||
|
|
||||||
本文档记录后续 Agent Platform / runtime 管控面的设计方向。它是当前讨论中的 **v2 文档**,但这里的 v2 指 Host capability layer / runtime control plane,不是 `AgentRunner Protocol v2`,也不属于当前 AgentRunner Protocol v1 插件化主线的交付范围。
|
|
||||||
|
|
||||||
> **future design note**。协议数据结构见 [PROTOCOL_V1.md](./PROTOCOL_V1.md),实现进度见 [PROGRESS.md](./PROGRESS.md)。本文只讲 v2 管控面方向,不重抄 schema。
|
|
||||||
> 与当前 runner 外化分支、EBA 和 Agent Platform 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
|
||||||
|
|
||||||
## 1. 结论
|
|
||||||
|
|
||||||
当前主线应继续收口 AgentRunner v1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
message/event -> binding -> runner.run(ctx) -> result stream
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime Control Plane v2 在 Host 侧新增 runtime control plane:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event -> task -> runtime selection -> daemon claim -> execute -> progress/audit/result
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Runtime Control Plane v2 之上,可以构建独立的 agent 管控面插件。插件负责 UI、策略和编排体验;runtime、task、heartbeat、audit 的事实源必须属于 LangBot Host,而不是插件私有 storage。
|
|
||||||
|
|
||||||
## 2. 不影响 v1 主线
|
|
||||||
|
|
||||||
v2 不应改变 AgentRunner v1 的基本契约:
|
|
||||||
|
|
||||||
- 现有 `local-agent`、Dify、n8n、Coze 等 runner 仍可按 v1 直接执行。
|
|
||||||
- 当前 Claude Code / Codex MVP runner 可以继续作为本机 subprocess 开发路径。
|
|
||||||
- Host v1 已有的 event-first context、resource authorization、history / event / artifact / state / storage pull APIs 继续保留。
|
|
||||||
- Pipeline 仍只是当前入口 adapter,不参与 v2 runtime 管控面的设计中心。
|
|
||||||
|
|
||||||
v2 只是在 Host 上新增一层可选能力。需要管控面的 runner 或管理插件可以声明使用它;不需要的 runner 不受影响。
|
|
||||||
|
|
||||||
## 3. 当前 Host 能力与缺口
|
|
||||||
|
|
||||||
当前 Host 已经具备 v2 的基础设施底座:
|
|
||||||
|
|
||||||
- `AgentEventEnvelope` / `AgentBinding`
|
|
||||||
- run-scoped resource authorization
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore
|
|
||||||
- History / Event / Artifact / State / Storage pull APIs
|
|
||||||
- AgentRunner result stream 和受控错误回流
|
|
||||||
- Agent/runner config 与 host-owned state
|
|
||||||
|
|
||||||
这些能力足够支持一次 `runner.run(ctx)` 内的安全执行,但不足以承担完整 runtime 管控面。
|
|
||||||
|
|
||||||
v2 还需要 Host 新增:
|
|
||||||
|
|
||||||
- runtime registry:runtime id、所属 workspace、所在机器、provider 能力、状态。
|
|
||||||
- capability discovery:`claude` / `codex` / 其它 CLI 是否存在、版本、登录状态、执行隔离能力。
|
|
||||||
- heartbeat / liveness:runtime 在线、忙闲、最后心跳、可用 slot。
|
|
||||||
- task queue:enqueue、claim、start、progress、complete、fail、cancel。
|
|
||||||
- workspace mapping:LangBot workspace / project 如何映射到 runtime 上的真实目录、仓库或挂载。
|
|
||||||
- secret / env projection:按授权向 runtime 投影 token、代理、MCP 配置、技能和环境变量。
|
|
||||||
- runtime audit:stdout、stderr、事件流、产物、失败原因、执行耗时、使用量。
|
|
||||||
- control API / UI:选择 runtime、测试 runtime、查看状态、下线、取消任务、重试任务。
|
|
||||||
|
|
||||||
## 4. 角色边界
|
|
||||||
|
|
||||||
### 4.1 LangBot Host
|
|
||||||
|
|
||||||
Host 是事实源和控制面内核:
|
|
||||||
|
|
||||||
- 保存 runtime / task / heartbeat / audit 状态。
|
|
||||||
- 做权限校验、资源裁剪、workspace 绑定和审计。
|
|
||||||
- 决定任务是否可被某 runtime claim。
|
|
||||||
- 将执行结果统一回写到 event / transcript / artifact / state。
|
|
||||||
|
|
||||||
Host 不应内置具体 agent CLI 的复杂业务逻辑,也不应把某个官方 runner 的特殊行为提升为通用协议。
|
|
||||||
|
|
||||||
### 4.2 Agent 管控面插件
|
|
||||||
|
|
||||||
管理插件是 v2 control plane 的产品化管理层:
|
|
||||||
|
|
||||||
- 展示 runtime、agent、task、进度、失败、审计。
|
|
||||||
- 提供策略配置,例如默认 runtime、provider 偏好、并发限制、重试策略。
|
|
||||||
- 触发 runtime 测试、任务取消、任务重试、手动分配。
|
|
||||||
|
|
||||||
管理插件不应把 runtime/task 的事实源放进自己的 plugin storage。它应该调用 Host v2 API。
|
|
||||||
|
|
||||||
### 4.3 Runtime daemon / worker
|
|
||||||
|
|
||||||
Runtime daemon 负责真实执行:
|
|
||||||
|
|
||||||
- 在所在机器上检测 CLI 和版本。
|
|
||||||
- 管理工作目录、仓库、挂载、临时文件和进程。
|
|
||||||
- 从 Host claim 任务,执行后上报 progress / complete / fail。
|
|
||||||
- 将 stdout / stderr / artifacts / session id 回流 Host。
|
|
||||||
|
|
||||||
Claude Code、Codex、OpenCode、Gemini CLI 等 provider 适配逻辑应主要落在 daemon / worker 或 provider adapter 中。
|
|
||||||
|
|
||||||
## 5. 部署形态
|
|
||||||
|
|
||||||
### 5.1 uv / local embedded
|
|
||||||
|
|
||||||
用户用 `uv` 或源码直接启动 LangBot 时,LangBot 进程所在机器就是 runtime host。
|
|
||||||
|
|
||||||
这种模式下可以直接检测用户主机上的 `claude`、`codex` 等 CLI,也可以直接 subprocess 执行。它适合个人开发和本地 smoke,但不应作为团队级管控面的唯一形态。
|
|
||||||
|
|
||||||
### 5.2 Docker embedded
|
|
||||||
|
|
||||||
用户用 Docker 启动 LangBot 时,runtime host 是容器,不是宿主机。
|
|
||||||
|
|
||||||
因此:
|
|
||||||
|
|
||||||
- 只能检测容器内的 `claude`、`codex`。
|
|
||||||
- 只能使用容器内的 HOME、PATH、凭据和挂载目录。
|
|
||||||
- 如果镜像未安装 CLI,或未挂载认证文件 / workspace,CLI runner 会不可用。
|
|
||||||
|
|
||||||
Docker embedded 可以作为高级部署选项,但需要用户显式安装 CLI、挂载工作区和凭据。Host 不应假设 Docker 容器能自动访问宿主机 CLI。
|
|
||||||
|
|
||||||
### 5.3 Sidecar daemon
|
|
||||||
|
|
||||||
推荐的 v2 形态是 sidecar daemon:
|
|
||||||
|
|
||||||
```text
|
|
||||||
LangBot Host (Docker or server)
|
|
||||||
<-> Runtime daemon on user host / worker host
|
|
||||||
-> claude / codex / other CLI
|
|
||||||
```
|
|
||||||
|
|
||||||
这种模式下,LangBot 可以跑在 Docker 内,runtime daemon 跑在宿主机或独立 worker 机器上。daemon 负责检测本机 CLI、持有本机凭据和工作区访问能力。
|
|
||||||
|
|
||||||
### 5.4 Remote runtime
|
|
||||||
|
|
||||||
团队场景可以使用远端 runtime:
|
|
||||||
|
|
||||||
- 开发机、构建机、云主机或专用 worker。
|
|
||||||
- 多个 workspace 可绑定不同 runtime。
|
|
||||||
- Host 只通过 registry / task queue / heartbeat / audit 进行管理。
|
|
||||||
|
|
||||||
### 5.5 API-only agent
|
|
||||||
|
|
||||||
Dify、n8n、Coze、DashScope 等 API 型 runner 不依赖本地 CLI。它们可以继续按 v1 直接执行,也可以在未来按需要接入 v2 task/audit。
|
|
||||||
|
|
||||||
## 6. 与 Claude Code / Codex MVP runner 的关系
|
|
||||||
|
|
||||||
当前 Claude Code / Codex runner 是 v1 runner:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runner.run(ctx) -> subprocess("claude" / "codex")
|
|
||||||
```
|
|
||||||
|
|
||||||
它们适合验证 Host context 投影、state resume、result stream 和基础 CLI 调用,但有明确限制:
|
|
||||||
|
|
||||||
- 命令只在 LangBot runtime host 上执行。
|
|
||||||
- Docker 环境只能看到容器内 CLI。
|
|
||||||
- 没有 runtime registry、heartbeat、task queue、cancel、workspace lifecycle。
|
|
||||||
- 不提供发布级执行隔离、secret projection、团队级 audit。
|
|
||||||
|
|
||||||
v2 不需要删除这些 runner。它们可以继续作为 dev / MVP 路径存在。未来若接入管控面,可以增加 runtime-managed 执行模式:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runner binding -> Host task -> runtime daemon -> provider CLI -> Host result
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 最小 v2 API 草案
|
|
||||||
|
|
||||||
以下仅记录能力边界,不代表最终 API 命名。
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- `runtime.register`
|
|
||||||
- `runtime.heartbeat`
|
|
||||||
- `runtime.list`
|
|
||||||
- `runtime.get`
|
|
||||||
- `runtime.disable`
|
|
||||||
- `runtime.capabilities.report`
|
|
||||||
- `runtime.capabilities.probe`
|
|
||||||
|
|
||||||
Task:
|
|
||||||
|
|
||||||
- `task.enqueue`
|
|
||||||
- `task.claim`
|
|
||||||
- `task.start`
|
|
||||||
- `task.progress`
|
|
||||||
- `task.complete`
|
|
||||||
- `task.fail`
|
|
||||||
- `task.cancel`
|
|
||||||
- `task.retry`
|
|
||||||
|
|
||||||
Workspace:
|
|
||||||
|
|
||||||
- `runtime.workspace.bind`
|
|
||||||
- `runtime.workspace.unbind`
|
|
||||||
- `runtime.workspace.resolve`
|
|
||||||
|
|
||||||
Audit / artifacts:
|
|
||||||
|
|
||||||
- `task.log.append`
|
|
||||||
- `task.artifact.create`
|
|
||||||
- `task.events.page`
|
|
||||||
|
|
||||||
这些 API 应由 Host 提供,并受 workspace、runtime、binding、actor 和 plugin identity 约束。
|
|
||||||
|
|
||||||
## 8. 管控面插件可以构建的能力
|
|
||||||
|
|
||||||
基于 v2 Host 能力,可以实现一个类似 Multica 的 agent 管控面插件。这里的“类似 Multica”只指产品形态:一个集中页面管理 agent profile、runtime 连接、任务队列、执行进度、失败诊断和审计视图;不是引入新的 runner 协议或把 runtime/task 事实源交给插件。
|
|
||||||
|
|
||||||
- runtime 列表、在线状态、CLI 能力、版本、认证状态。
|
|
||||||
- agent profile 与 runtime/provider 绑定。
|
|
||||||
- 任务看板、任务详情、进度流、失败原因、重试和取消。
|
|
||||||
- workspace 到 runtime 目录 / 仓库的映射管理。
|
|
||||||
- provider capability 测试,例如 Claude Code / Codex 是否可执行。
|
|
||||||
- 审计视图:输入、输出、工具、artifact、stdout/stderr、session id。
|
|
||||||
- 策略配置:并发、队列、默认 runtime、fallback runtime、权限模式。
|
|
||||||
|
|
||||||
该插件应该是 Host v2 的消费者,而不是 Host v2 的替代品。
|
|
||||||
|
|
||||||
## 9. 设计原则
|
|
||||||
|
|
||||||
- v1 先稳定,v2 可选叠加。
|
|
||||||
- Host 保存事实源,插件提供管理体验。
|
|
||||||
- Runtime daemon 执行具体 CLI 和本机资源访问。
|
|
||||||
- Docker 不假设拥有宿主机 CLI;需要 sidecar 或显式挂载。
|
|
||||||
- Pipeline 不进入 v2 控制面中心。
|
|
||||||
- 直接 subprocess runner 可保留,但只作为 local/dev/MVP 路径。
|
|
||||||
- 发布级能力必须经过 Host 权限、审计和资源边界。
|
|
||||||
|
|
||||||
## 10. 待定问题
|
|
||||||
|
|
||||||
- runtime daemon 与 Host 的认证模型:workspace token、device token、还是 scoped PAT。
|
|
||||||
- task 与 AgentRunner binding 的映射关系:由 binding 直接 enqueue,还是由独立 task policy 决定。
|
|
||||||
- runtime capability schema 的稳定字段:provider、version、login status、execution isolation、workspace access、slot。
|
|
||||||
- secret projection 的边界:Host 存储、用户本机存储、或外部 secret manager。
|
|
||||||
- Docker compose 是否提供官方 sidecar daemon 示例。
|
|
||||||
- v2 UI 是核心前端的一部分,还是完全由管理插件提供。
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Agent Runner Security Hardening
|
|
||||||
|
|
||||||
本文档记录 agent-runner 插件化进入生产发布前需要补齐的安全与稳定加固项。
|
|
||||||
|
|
||||||
## 状态
|
|
||||||
|
|
||||||
**当前结论:暂不塞进本阶段 agent-runner plugin 协议闭环。**
|
|
||||||
|
|
||||||
本阶段目标是验证 LangBot 可以通过统一的 `run(event, binding)` 协议接入 `local-agent` 与外部 harness runner(如 Claude Code runner),并能传递事件、上下文、资源句柄、状态和结果流。
|
|
||||||
|
|
||||||
安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。
|
|
||||||
|
|
||||||
> **硬规则**:能执行代码 / 访问工作目录的外部 harness runner(Claude Code、Codex、Kimi Code 等)不得在生产环境默认启用或隐式开启。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 模式或禁用高风险工具;当前 Claude Code MVP 仍包含高风险执行模式,只能作为 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。状态以 2026-06-09 当前 checkout 复核为准;“已补”只代表 self-host stdio / 容器内管理员显式 opt-in 的最小护栏,不代表 managed/default runner 已具备完整生产隔离。
|
|
||||||
|
|
||||||
| 项目 | 状态 | 当前已补 | 仍缺口 / 发布前要求 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Path isolation | Partial | 本地 Claude / Codex runner 会规范化 `working-directory`,拒绝系统根目录、用户 home 和不存在路径;context directory 必须是工作目录内相对路径,拒绝绝对路径、`..` 和 symlink 逃逸;remote daemon 对投影文件使用相对路径 + `realpath` containment,拒绝绝对路径、`..` 和 workspace 内 symlink 写出;ArtifactStore 对 file artifact 使用 `realpath` + root containment 复核。 | Host 生成 workspace / context / artifact root 还缺统一 allowlist、mount 策略、TTL cleanup 和 orphan cleanup;管理员显式 `working-directory` 仍是 operator-owned local directory,LangBot 不承诺阻止外部 CLI 访问同一 OS 用户可访问的所有路径。 |
|
|
||||||
| Permission boundary | Partial | Host 已有 runner manifest 权限、binding 级 resource policy、run-scoped authorization snapshot、proxy action `caller_plugin_identity` 校验;Claude Code `--dangerously-skip-permissions` 已改为显式配置,默认 false;Codex 默认 `sandbox=read-only`、`approval_policy=never`,并过滤用户 `mcp_servers.*` config override。 | 外部 CLI 的 native 文件 / 进程 / tool 能力仍属于 operator-owned execution;生产默认或 managed runner 需要容器/VM/OS 级隔离、tool allow/deny 和可审计审批,不能把 runner manifest 当成外部 CLI 的完整权限边界。 |
|
|
||||||
| Secret handling | Partial | 子进程不再继承完整 LangBot / daemon 环境,只保留 CLI auth、proxy、locale、CA 等 allowlisted env;Codex `environment-json` 禁止覆盖 `HOME`、`PATH`、`CODEX_HOME`、`PYTHONPATH` 和 `LANGBOT_*`;Codex per-run `CODEX_HOME` 会继承 runtime 用户的 Codex auth/session 和非 MCP provider config,但剥离全局 `mcp_servers`;LangBot managed MCP 写入 per-run `CODEX_HOME/config.toml` 且 `0600`,scoped secret 不进入 argv;remote daemon MCP config / `mcp.json` 使用 `0600`;stdout/stderr、错误和 diagnostic artifact 做 redaction + 输出截断;相关单测覆盖 secret/env 泄漏。 | 仍缺 Host 全链路统一 redaction policy、transcript / artifact metadata / admin UI 脱敏规则、secret 来源与轮换策略、跨 runner 的配置脱敏审计。 |
|
|
||||||
| MCP policy | Partial | SDK-owned per-run LangBot MCP bridge 已有;remote MCP channel 有 per-run secret;bridge 只暴露 SDK annotated tool surface;Codex managed MCP 不允许用户通过 `config-overrides` 注入/覆盖 `mcp_servers.*`,也不继承 runtime 用户全局 `mcp_servers`;remote Codex MCP secret 不进 argv。 | 缺 Host / Admin 级外部 MCP server allowlist、scoped token 生命周期、tool allow / deny 策略、危险工具审批和 MCP 调用审计。 |
|
|
||||||
| 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 subprocess timeout、timeout 后 kill、remote request size limit 已有;本地 Claude / Codex 和 remote daemon 子进程使用新进程组,timeout / cancel 路径会杀进程组;stdout/stderr 有输出上限;Codex 默认使用 `sandbox=read-only`、`approval_policy=never`;Claude Code 高风险 bypass 默认关闭。 | CPU / 内存 / 文件 / 容器 hard quota、网络策略、长期 workspace GC 和平台级 cancel/audit 仍只作为 managed/cloud/default external harness 的 full gate。self-host stdio 只能做到 runner wrapper 层的 timeout / kill / output bound。 |
|
|
||||||
| State lifecycle | Partial | PersistentStateStore 有 runner / binding / scope 隔离、JSON size limit、state get / set / list / delete;外部 runner 已写回 `external.session_id`、本地 `external.working_directory`、远端 `external.runtime_id` / `external.workspace_key`,避免把远端绝对路径当成 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、local / remote path escape、remote symlink escape、env allowlist / secret 泄漏、Claude dangerous mode 显式启用、timeout、进程组 kill、MCP bridge、remote MCP 回访、Codex MCP secret 不进 argv、Codex per-run auth/config seed、skill visibility 等单测;runner 仓库 `pytest` / `ruff` 已通过;本机真实 Claude Code CLI 与 Codex CLI 的 runner 级 E2E 已通过。 | 仍缺 Host UI smoke、生产禁用入口、MCP deny / dangerous tool 审计、workspace cleanup / audit 完整性矩阵;CPU / memory / container quota 测试属于 managed/cloud/default full gate。 |
|
|
||||||
|
|
||||||
## 非当前范围
|
|
||||||
|
|
||||||
以下内容不属于本阶段协议闭环:
|
|
||||||
|
|
||||||
- 完整异步队列与 issue-centric 产品模型。
|
|
||||||
- 复杂 workflow engine。
|
|
||||||
- Codex / Kimi runner 全量接入。
|
|
||||||
- EBA 分支的完整迁移由外部 EBA 分支联调;本阶段只复用其需要的 AgentRunner Host 底座。
|
|
||||||
- 发布级安全 hardening 的完整实现。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0-beta.2"
|
version = "4.10.0"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.0",
|
"langbot-plugin==0.4.1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -79,6 +79,7 @@ dependencies = [
|
|||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
"pgvector>=0.4.1",
|
"pgvector>=0.4.1",
|
||||||
"botocore>=1.42.39",
|
"botocore>=1.42.39",
|
||||||
|
"litellm>=1.0.0",
|
||||||
]
|
]
|
||||||
keywords = [
|
keywords = [
|
||||||
"bot",
|
"bot",
|
||||||
@@ -105,9 +106,6 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
langbot-plugin = { path = "../langbot-plugin-sdk", editable = true }
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.10.0-beta.2'
|
__version__ = '4.10.0'
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
"""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',
|
|
||||||
]
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""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',
|
|
||||||
]
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
"""Artifact store for managing Host-owned artifacts."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from ...entity.persistence.artifact import AgentArtifact
|
|
||||||
from ...entity.persistence.bstorage import BinaryStorage
|
|
||||||
|
|
||||||
_FILE_ARTIFACT_METADATA_KEY = '_langbot_file_artifact'
|
|
||||||
|
|
||||||
|
|
||||||
class ArtifactStore:
|
|
||||||
"""Store for AgentArtifact records.
|
|
||||||
|
|
||||||
Handles artifact metadata registration and content retrieval.
|
|
||||||
Actual blob storage is delegated to BinaryStorage or external storage.
|
|
||||||
|
|
||||||
All methods are async and use the provided database engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
|
|
||||||
# Hard limits
|
|
||||||
MAX_INLINE_READ_BYTES = 1024 * 1024 # 1MB max for inline base64
|
|
||||||
MAX_RANGE_READ_BYTES = 10 * 1024 * 1024 # 10MB max for range reads
|
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
|
||||||
self.engine = engine
|
|
||||||
self._session_factory = sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def register_file_artifact(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
artifact_id: str | None,
|
|
||||||
host_path: str,
|
|
||||||
host_root: str,
|
|
||||||
artifact_type: str = 'file',
|
|
||||||
source: str = 'tool',
|
|
||||||
mime_type: str | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
size_bytes: int | None = None,
|
|
||||||
sha256: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
bot_id: str | None = None,
|
|
||||||
workspace_id: str | None = None,
|
|
||||||
expires_at: datetime.datetime | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Register a Host-owned artifact backed by a bounded local file path.
|
|
||||||
|
|
||||||
The public metadata intentionally excludes the real host path. Reads go
|
|
||||||
through read_artifact(), which revalidates the path against host_root.
|
|
||||||
"""
|
|
||||||
real_path, real_root = self._validate_file_artifact_path(host_path, host_root)
|
|
||||||
if not os.path.isfile(real_path):
|
|
||||||
raise ValueError('file artifact path must point to a file')
|
|
||||||
|
|
||||||
public_metadata = dict(metadata or {})
|
|
||||||
public_metadata[_FILE_ARTIFACT_METADATA_KEY] = {
|
|
||||||
'path': real_path,
|
|
||||||
'root': real_root,
|
|
||||||
}
|
|
||||||
|
|
||||||
if size_bytes is None:
|
|
||||||
size_bytes = os.path.getsize(real_path)
|
|
||||||
|
|
||||||
return await self.register_artifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
source=source,
|
|
||||||
storage_key=f'file:{uuid.uuid4().hex}',
|
|
||||||
storage_type='file',
|
|
||||||
mime_type=mime_type,
|
|
||||||
name=name or os.path.basename(real_path),
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
sha256=sha256,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=bot_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
expires_at=expires_at,
|
|
||||||
metadata=public_metadata,
|
|
||||||
content=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def register_artifact(
|
|
||||||
self,
|
|
||||||
artifact_id: str | None,
|
|
||||||
artifact_type: str,
|
|
||||||
source: str,
|
|
||||||
storage_key: str | None = None,
|
|
||||||
storage_type: str = 'binary_storage',
|
|
||||||
mime_type: str | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
size_bytes: int | None = None,
|
|
||||||
sha256: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
bot_id: str | None = None,
|
|
||||||
workspace_id: str | None = None,
|
|
||||||
expires_at: datetime.datetime | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
content: bytes | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Register a new artifact.
|
|
||||||
|
|
||||||
If content is provided and storage_key is None, stores content
|
|
||||||
in BinaryStorage automatically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Unique artifact ID (generated if None)
|
|
||||||
artifact_type: Type of artifact (image, file, voice, tool_result, etc.)
|
|
||||||
source: Source of artifact (platform, runner, tool, system)
|
|
||||||
storage_key: Key in BinaryStorage or external reference
|
|
||||||
storage_type: Storage type (binary_storage, file, url)
|
|
||||||
mime_type: MIME type
|
|
||||||
name: Original file name
|
|
||||||
size_bytes: Size in bytes
|
|
||||||
sha256: SHA256 hash
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
run_id: Run ID that created this
|
|
||||||
runner_id: Runner ID that created this
|
|
||||||
bot_id: Bot UUID
|
|
||||||
workspace_id: Workspace ID
|
|
||||||
expires_at: Expiration time
|
|
||||||
metadata: Additional metadata
|
|
||||||
content: Optional content to store in BinaryStorage
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The artifact_id
|
|
||||||
"""
|
|
||||||
if artifact_id is None:
|
|
||||||
artifact_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# If content provided, store in BinaryStorage
|
|
||||||
if content is not None and storage_key is None:
|
|
||||||
storage_key = f"artifact:{artifact_id}"
|
|
||||||
storage_type = 'binary_storage'
|
|
||||||
if size_bytes is None:
|
|
||||||
size_bytes = len(content)
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
# Store content in BinaryStorage if provided
|
|
||||||
if content is not None:
|
|
||||||
binary_storage = BinaryStorage(
|
|
||||||
unique_key=f'artifact:{artifact_id}',
|
|
||||||
key=storage_key,
|
|
||||||
owner_type='artifact',
|
|
||||||
owner='host',
|
|
||||||
value=content,
|
|
||||||
)
|
|
||||||
session.add(binary_storage)
|
|
||||||
|
|
||||||
# Store artifact metadata
|
|
||||||
artifact = AgentArtifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
mime_type=mime_type,
|
|
||||||
name=name,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
sha256=sha256,
|
|
||||||
source=source,
|
|
||||||
storage_key=storage_key,
|
|
||||||
storage_type=storage_type,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=bot_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
expires_at=expires_at,
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
)
|
|
||||||
session.add(artifact)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return artifact_id
|
|
||||||
|
|
||||||
async def get_metadata(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Get artifact metadata (public fields only, no internal storage info).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Artifact metadata dict compatible with SDK ArtifactMetadata, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(AgentArtifact).where(
|
|
||||||
AgentArtifact.artifact_id == artifact_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return self._row_to_public_dict(row)
|
|
||||||
|
|
||||||
async def _get_internal_record(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
) -> AgentArtifact | None:
|
|
||||||
"""Get full artifact record including internal fields.
|
|
||||||
|
|
||||||
Used internally by read_artifact to access storage_key/storage_type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentArtifact ORM instance, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(AgentArtifact).where(
|
|
||||||
AgentArtifact.artifact_id == artifact_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def read_artifact(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int | None = None,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Read artifact content.
|
|
||||||
|
|
||||||
For small artifacts, returns content_base64 directly.
|
|
||||||
For large artifacts, returns file_key for chunked transfer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
offset: Byte offset to start reading from (must be >= 0)
|
|
||||||
limit: Maximum bytes to read (must be > 0 if provided)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ArtifactReadResult dict, or None if not found
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If offset < 0 or limit <= 0
|
|
||||||
"""
|
|
||||||
# Validate offset and limit
|
|
||||||
if offset < 0:
|
|
||||||
raise ValueError("offset must be >= 0")
|
|
||||||
|
|
||||||
if limit is not None and limit <= 0:
|
|
||||||
raise ValueError("limit must be > 0")
|
|
||||||
|
|
||||||
# Get internal record (includes storage_key/storage_type)
|
|
||||||
record = await self._get_internal_record(artifact_id)
|
|
||||||
if record is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
storage_type = record.storage_type or 'binary_storage'
|
|
||||||
storage_key = record.storage_key
|
|
||||||
size_bytes = record.size_bytes or 0
|
|
||||||
|
|
||||||
# Cap limit at hard limit
|
|
||||||
if limit is None:
|
|
||||||
limit = self.MAX_INLINE_READ_BYTES
|
|
||||||
limit = min(limit, self.MAX_RANGE_READ_BYTES)
|
|
||||||
|
|
||||||
# For binary_storage, read content
|
|
||||||
if storage_type == 'binary_storage' and storage_key:
|
|
||||||
content = await self._read_binary_storage(storage_key)
|
|
||||||
if content is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Apply offset and limit
|
|
||||||
if offset > 0:
|
|
||||||
content = content[offset:]
|
|
||||||
if limit and len(content) > limit:
|
|
||||||
content = content[:limit]
|
|
||||||
has_more = True
|
|
||||||
else:
|
|
||||||
has_more = False
|
|
||||||
|
|
||||||
return {
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'mime_type': record.mime_type,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
'offset': offset,
|
|
||||||
'length': len(content),
|
|
||||||
'content_base64': base64.b64encode(content).decode('utf-8'),
|
|
||||||
'file_key': None,
|
|
||||||
'has_more': has_more,
|
|
||||||
}
|
|
||||||
|
|
||||||
if storage_type == 'file':
|
|
||||||
return self._read_file_storage(record, artifact_id, offset, limit)
|
|
||||||
|
|
||||||
# For other storage types, return storage reference
|
|
||||||
# (caller can use file_key for chunked transfer)
|
|
||||||
return {
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'mime_type': record.mime_type,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
'offset': offset,
|
|
||||||
'length': None,
|
|
||||||
'content_base64': None,
|
|
||||||
'file_key': storage_key,
|
|
||||||
'has_more': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _read_binary_storage(self, key: str) -> bytes | None:
|
|
||||||
"""Read content from BinaryStorage.
|
|
||||||
|
|
||||||
Uses unique_key for isolation to prevent cross-artifact access.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique_key used when storing the artifact
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content bytes, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(BinaryStorage).where(BinaryStorage.unique_key == key)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row.value
|
|
||||||
|
|
||||||
def _read_file_storage(
|
|
||||||
self,
|
|
||||||
record: AgentArtifact,
|
|
||||||
artifact_id: str,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
metadata = self._load_metadata(record.metadata_json)
|
|
||||||
file_info = metadata.get(_FILE_ARTIFACT_METADATA_KEY)
|
|
||||||
if not isinstance(file_info, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
host_path = file_info.get('path')
|
|
||||||
host_root = file_info.get('root')
|
|
||||||
if not isinstance(host_path, str) or not isinstance(host_root, str):
|
|
||||||
return None
|
|
||||||
|
|
||||||
real_path, _ = self._validate_file_artifact_path(host_path, host_root)
|
|
||||||
if not os.path.isfile(real_path):
|
|
||||||
return None
|
|
||||||
|
|
||||||
file_size = os.path.getsize(real_path)
|
|
||||||
if offset >= file_size:
|
|
||||||
content = b''
|
|
||||||
else:
|
|
||||||
with open(real_path, 'rb') as f:
|
|
||||||
f.seek(offset)
|
|
||||||
content = f.read(limit)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'mime_type': record.mime_type,
|
|
||||||
'size_bytes': file_size,
|
|
||||||
'offset': offset,
|
|
||||||
'length': len(content),
|
|
||||||
'content_base64': base64.b64encode(content).decode('utf-8'),
|
|
||||||
'file_key': None,
|
|
||||||
'has_more': offset + len(content) < file_size,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_file_artifact_path(host_path: str, host_root: str) -> tuple[str, str]:
|
|
||||||
real_path = os.path.realpath(host_path)
|
|
||||||
real_root = os.path.realpath(host_root)
|
|
||||||
if not real_root:
|
|
||||||
raise ValueError('file artifact root is required')
|
|
||||||
if not (real_path == real_root or real_path.startswith(real_root + os.sep)):
|
|
||||||
raise ValueError('file artifact path escapes allowed root')
|
|
||||||
return real_path, real_root
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _load_metadata(metadata_json: str | None) -> dict[str, typing.Any]:
|
|
||||||
if not metadata_json:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
metadata = json.loads(metadata_json)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
return metadata if isinstance(metadata, dict) else {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _public_metadata(metadata_json: str | None) -> dict[str, typing.Any]:
|
|
||||||
metadata = ArtifactStore._load_metadata(metadata_json)
|
|
||||||
metadata.pop(_FILE_ARTIFACT_METADATA_KEY, None)
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
def _row_to_public_dict(self, row: AgentArtifact) -> dict[str, typing.Any]:
|
|
||||||
"""Convert an AgentArtifact row to public dict.
|
|
||||||
|
|
||||||
Returns only fields that match SDK ArtifactMetadata entity.
|
|
||||||
Host-only fields (bot_id, workspace_id, storage_key, storage_type) are excluded.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'artifact_id': row.artifact_id,
|
|
||||||
'artifact_type': row.artifact_type,
|
|
||||||
'mime_type': row.mime_type,
|
|
||||||
'name': row.name,
|
|
||||||
'size_bytes': row.size_bytes,
|
|
||||||
'sha256': row.sha256,
|
|
||||||
'source': row.source,
|
|
||||||
'conversation_id': row.conversation_id,
|
|
||||||
'run_id': row.run_id,
|
|
||||||
'runner_id': row.runner_id,
|
|
||||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
|
||||||
'expires_at': int(row.expires_at.timestamp()) if row.expires_at else None,
|
|
||||||
'metadata': self._public_metadata(row.metadata_json),
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Resolve host events to one effective Agent binding."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBindingResolutionError(Exception):
|
|
||||||
"""Raised when an event cannot resolve to exactly one Agent binding."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBindingResolver:
|
|
||||||
"""Resolve an event to a single AgentBinding.
|
|
||||||
|
|
||||||
The target product model is one bot / IM channel -> one Agent. Fan-out,
|
|
||||||
observer agents, or multi-runner arbitration require separate delivery and
|
|
||||||
state semantics and are intentionally not hidden in this resolver.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def resolve_one(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
agents: list[AgentConfig],
|
|
||||||
) -> AgentBinding:
|
|
||||||
"""Resolve exactly one enabled Agent for the event."""
|
|
||||||
matches = [
|
|
||||||
agent
|
|
||||||
for agent in agents
|
|
||||||
if agent.enabled and event.event_type in agent.event_types
|
|
||||||
]
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise AgentBindingResolutionError(
|
|
||||||
f'No Agent binding matches event_type={event.event_type}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(matches) > 1:
|
|
||||||
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
|
|
||||||
raise AgentBindingResolutionError(
|
|
||||||
f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}'
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._to_binding(matches[0])
|
|
||||||
|
|
||||||
def _to_binding(self, agent: AgentConfig) -> AgentBinding:
|
|
||||||
"""Project product-level Agent config into the run-time binding model."""
|
|
||||||
scope = BindingScope(
|
|
||||||
scope_type='agent',
|
|
||||||
scope_id=agent.agent_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AgentBinding(
|
|
||||||
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
|
|
||||||
scope=scope,
|
|
||||||
event_types=list(agent.event_types),
|
|
||||||
runner_id=agent.runner_id,
|
|
||||||
runner_config=agent.runner_config,
|
|
||||||
resource_policy=agent.resource_policy,
|
|
||||||
state_policy=agent.state_policy,
|
|
||||||
delivery_policy=agent.delivery_policy,
|
|
||||||
enabled=agent.enabled,
|
|
||||||
agent_id=agent.agent_id,
|
|
||||||
)
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Helpers for the current AgentRunner config shape."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMigration:
|
|
||||||
"""Configuration helper for agent runner IDs.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Resolve runner ID from ai.runner.id
|
|
||||||
- Extract current Agent/runner config from ai.runner_config
|
|
||||||
- Keep the current config container shape stable on save
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
|
|
||||||
"""Resolve runner ID from current configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Current configuration container
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner ID string, or None if not configured
|
|
||||||
"""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
runner_config = ai_config.get('runner', {})
|
|
||||||
|
|
||||||
runner_id = runner_config.get('id')
|
|
||||||
if runner_id:
|
|
||||||
return runner_id
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_runner_config(
|
|
||||||
pipeline_config: dict[str, typing.Any],
|
|
||||||
runner_id: str,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Resolve Agent/runner configuration from the current container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Current configuration container
|
|
||||||
runner_id: Resolved runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner configuration dict (empty if not found)
|
|
||||||
"""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
|
|
||||||
runner_configs = ai_config.get('runner_config', {})
|
|
||||||
if runner_id in runner_configs:
|
|
||||||
return runner_configs[runner_id]
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
|
|
||||||
"""Get conversation expire time from configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Current configuration container
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Expire time in seconds (0 means no expiry)
|
|
||||||
"""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
runner_config = ai_config.get('runner', {})
|
|
||||||
return runner_config.get('expire-time', 0)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
||||||
"""Normalize the current config container before saving.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Original configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration with explicit ai.runner and ai.runner_config containers
|
|
||||||
"""
|
|
||||||
new_config = dict(pipeline_config)
|
|
||||||
if 'ai' not in new_config:
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
ai_config = dict(new_config.get('ai', {}))
|
|
||||||
|
|
||||||
runner_config = dict(ai_config.get('runner', {}))
|
|
||||||
runner_configs = dict(ai_config.get('runner_config', {}))
|
|
||||||
|
|
||||||
ai_config['runner'] = runner_config
|
|
||||||
ai_config['runner_config'] = runner_configs
|
|
||||||
new_config['ai'] = ai_config
|
|
||||||
|
|
||||||
return new_config
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
"""Helpers for interpreting AgentRunner DynamicForm configuration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
|
|
||||||
|
|
||||||
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
|
|
||||||
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
|
||||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
|
||||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
|
||||||
|
|
||||||
|
|
||||||
def iter_schema_items(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
field_types: set[str],
|
|
||||||
) -> typing.Iterator[dict[str, typing.Any]]:
|
|
||||||
"""Yield descriptor config schema items whose type is in field_types."""
|
|
||||||
if descriptor is None:
|
|
||||||
return
|
|
||||||
for item in descriptor.config_schema or []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
if item.get('type') in field_types:
|
|
||||||
yield item
|
|
||||||
|
|
||||||
|
|
||||||
def has_permission(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
name: str,
|
|
||||||
actions: set[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Return whether a runner descriptor requests one of the given actions."""
|
|
||||||
if descriptor is None:
|
|
||||||
return False
|
|
||||||
configured_actions = descriptor.permissions.get(name, [])
|
|
||||||
return any(action in configured_actions for action in actions)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should resolve model resources for this runner."""
|
|
||||||
return (
|
|
||||||
has_permission(descriptor, 'models', {'invoke', 'stream', 'list'})
|
|
||||||
and any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should expose tool resources to this runner."""
|
|
||||||
return (
|
|
||||||
descriptor is not None
|
|
||||||
and descriptor.supports_tool_calling()
|
|
||||||
and has_permission(descriptor, 'tools', {'list', 'detail', 'call'})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should expose knowledge-base resources to this runner."""
|
|
||||||
return (
|
|
||||||
descriptor is not None
|
|
||||||
and descriptor.supports_knowledge_retrieval()
|
|
||||||
and has_permission(descriptor, 'knowledge_bases', {'list', 'retrieve'})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether the runner wants Host skill-authoring tools."""
|
|
||||||
if descriptor is None:
|
|
||||||
return False
|
|
||||||
return bool(descriptor.capabilities.get('skill_authoring', False))
|
|
||||||
|
|
||||||
|
|
||||||
def extract_prompt_config(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
default_prompt: list[dict[str, typing.Any]],
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Extract the prompt-editor value selected by the runner schema."""
|
|
||||||
for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if field_name and field_name in runner_config:
|
|
||||||
configured_prompt = runner_config[field_name]
|
|
||||||
if isinstance(configured_prompt, list):
|
|
||||||
return configured_prompt
|
|
||||||
default_value = item.get('default')
|
|
||||||
if isinstance(default_value, list):
|
|
||||||
return default_value
|
|
||||||
return default_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def extract_model_selection(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> tuple[str, list[str]]:
|
|
||||||
"""Extract primary/fallback LLM selections from schema-defined fields."""
|
|
||||||
primary_uuid = ''
|
|
||||||
fallback_uuids: list[str] = []
|
|
||||||
|
|
||||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name, item.get('default'))
|
|
||||||
if item.get('type') == 'model-fallback-selector':
|
|
||||||
if isinstance(value, str):
|
|
||||||
primary_uuid = value
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
primary_uuid = value.get('primary') or ''
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
if isinstance(fallbacks, list):
|
|
||||||
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
|
|
||||||
break
|
|
||||||
|
|
||||||
if item.get('type') == 'llm-model-selector' and isinstance(value, str):
|
|
||||||
primary_uuid = value
|
|
||||||
break
|
|
||||||
|
|
||||||
return primary_uuid, fallback_uuids
|
|
||||||
|
|
||||||
|
|
||||||
def extract_knowledge_base_uuids(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Extract configured knowledge-base UUIDs from schema-defined fields."""
|
|
||||||
if not uses_host_knowledge_bases(descriptor):
|
|
||||||
return []
|
|
||||||
|
|
||||||
kb_uuids: list[str] = []
|
|
||||||
for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
value = runner_config.get(field_name, item.get('default', []))
|
|
||||||
if isinstance(value, list):
|
|
||||||
kb_uuids.extend(
|
|
||||||
kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS
|
|
||||||
)
|
|
||||||
|
|
||||||
return list(dict.fromkeys(kb_uuids))
|
|
||||||
|
|
||||||
|
|
||||||
def iter_config_model_refs(
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> typing.Iterator[tuple[str, str]]:
|
|
||||||
"""Yield model references declared by schema-defined model selector fields."""
|
|
||||||
for item in descriptor.config_schema or []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
field_name = item.get('name')
|
|
||||||
field_type = item.get('type')
|
|
||||||
if not field_name or field_name not in runner_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name)
|
|
||||||
if field_type == 'model-fallback-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'llm', value
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
primary = value.get('primary')
|
|
||||||
if isinstance(primary, str) and primary not in NONE_SENTINELS:
|
|
||||||
yield 'llm', primary
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
if isinstance(fallbacks, list):
|
|
||||||
for fallback_uuid in fallbacks:
|
|
||||||
if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS:
|
|
||||||
yield 'llm', fallback_uuid
|
|
||||||
elif field_type == 'llm-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'llm', value
|
|
||||||
elif field_type == 'rerank-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'rerank', value
|
|
||||||
|
|
||||||
|
|
||||||
def set_empty_llm_model_selection(
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
model_uuid: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Set the first empty schema-defined LLM selector to model_uuid."""
|
|
||||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
field_type = item.get('type')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name, item.get('default'))
|
|
||||||
if field_type == 'model-fallback-selector':
|
|
||||||
if isinstance(value, dict):
|
|
||||||
primary = value.get('primary') or ''
|
|
||||||
if primary not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
runner_config[field_name] = {
|
|
||||||
'primary': model_uuid,
|
|
||||||
'fallbacks': fallbacks if isinstance(fallbacks, list) else [],
|
|
||||||
}
|
|
||||||
return True
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []}
|
|
||||||
return True
|
|
||||||
|
|
||||||
if field_type == 'llm-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
runner_config[field_name] = model_uuid
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""Agent run context builder for provisioning AgentRunContext envelopes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .persistent_state_store import get_persistent_state_store
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
# Internal models for the agent runner context protocol.
|
|
||||||
|
|
||||||
|
|
||||||
class AgentTrigger(typing.TypedDict):
|
|
||||||
"""Agent trigger information."""
|
|
||||||
|
|
||||||
type: str
|
|
||||||
source: str
|
|
||||||
timestamp: int | None
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationContext(typing.TypedDict):
|
|
||||||
"""Conversation context."""
|
|
||||||
|
|
||||||
conversation_id: str | None
|
|
||||||
thread_id: str | None
|
|
||||||
launcher_type: str | None
|
|
||||||
launcher_id: str | None
|
|
||||||
sender_id: str | None
|
|
||||||
bot_id: str | None
|
|
||||||
workspace_id: str | None
|
|
||||||
session_id: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentInput(typing.TypedDict):
|
|
||||||
"""Agent input."""
|
|
||||||
|
|
||||||
text: str | None
|
|
||||||
contents: list[dict[str, typing.Any]]
|
|
||||||
message_chain: dict[str, typing.Any] | None
|
|
||||||
attachments: list[dict[str, typing.Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunState(typing.TypedDict):
|
|
||||||
"""Agent run state with 4 scopes."""
|
|
||||||
|
|
||||||
conversation: dict[str, typing.Any]
|
|
||||||
actor: dict[str, typing.Any]
|
|
||||||
subject: dict[str, typing.Any]
|
|
||||||
runner: dict[str, typing.Any]
|
|
||||||
|
|
||||||
|
|
||||||
# Resource payload models matching langbot-plugin-sdk/resources.py.
|
|
||||||
|
|
||||||
|
|
||||||
class ModelResource(typing.TypedDict):
|
|
||||||
"""Model resource payload."""
|
|
||||||
|
|
||||||
model_id: str
|
|
||||||
model_type: str | None
|
|
||||||
provider: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ToolResource(typing.TypedDict):
|
|
||||||
"""Tool resource payload."""
|
|
||||||
|
|
||||||
tool_name: str
|
|
||||||
tool_type: str | None
|
|
||||||
description: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeBaseResource(typing.TypedDict):
|
|
||||||
"""Knowledge base resource payload."""
|
|
||||||
|
|
||||||
kb_id: str
|
|
||||||
kb_name: str | None
|
|
||||||
kb_type: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class 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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
async def build_context_from_event(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
resources: AgentResources,
|
|
||||||
) -> AgentRunContextPayload:
|
|
||||||
"""Build AgentRunContext from event-first envelope.
|
|
||||||
|
|
||||||
This is the main entry point for Protocol v1.
|
|
||||||
Does NOT inline full history by default.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
binding: Agent binding
|
|
||||||
descriptor: Runner descriptor
|
|
||||||
resources: Built resources
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunContextPayload for the runner
|
|
||||||
"""
|
|
||||||
# Generate new run_id
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Build trigger from event
|
|
||||||
trigger: AgentTrigger = {
|
|
||||||
'type': event.event_type,
|
|
||||||
'source': event.source,
|
|
||||||
'timestamp': event.event_time or int(time.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build conversation context from event
|
|
||||||
conversation: ConversationContext | None = None
|
|
||||||
if event.conversation_id:
|
|
||||||
conversation = {
|
|
||||||
'session_id': None,
|
|
||||||
'conversation_id': event.conversation_id,
|
|
||||||
'thread_id': event.thread_id,
|
|
||||||
'launcher_type': None, # Will be filled from actor/subject if needed
|
|
||||||
'launcher_id': None,
|
|
||||||
'sender_id': event.actor.actor_id if event.actor else None,
|
|
||||||
'bot_id': event.bot_id,
|
|
||||||
'workspace_id': event.workspace_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build event context (Protocol v1 event-first)
|
|
||||||
event_context = {
|
|
||||||
'event_id': event.event_id,
|
|
||||||
'event_type': event.event_type,
|
|
||||||
'event_time': event.event_time,
|
|
||||||
'source': event.source,
|
|
||||||
'source_event_type': event.source_event_type,
|
|
||||||
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
|
||||||
'data': event.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build actor context
|
|
||||||
actor_context = None
|
|
||||||
if event.actor:
|
|
||||||
actor_context = {
|
|
||||||
'actor_type': event.actor.actor_type,
|
|
||||||
'actor_id': event.actor.actor_id,
|
|
||||||
'actor_name': event.actor.actor_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build subject context
|
|
||||||
subject_context = None
|
|
||||||
if event.subject:
|
|
||||||
subject_context = {
|
|
||||||
'subject_type': event.subject.subject_type,
|
|
||||||
'subject_id': event.subject.subject_id,
|
|
||||||
'data': event.subject.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build input from event
|
|
||||||
input: AgentInput = {
|
|
||||||
'text': event.input.text,
|
|
||||||
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
|
||||||
'message_chain': event.input.message_chain,
|
|
||||||
'attachments': [
|
|
||||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build context access (no history inlined by default for Protocol v1)
|
|
||||||
# Populate with actual values from stores
|
|
||||||
context_access = await self._build_context_access(event, descriptor, binding)
|
|
||||||
|
|
||||||
# Build state snapshot from persistent state store (event-first Protocol v1)
|
|
||||||
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
|
|
||||||
|
|
||||||
# Build runtime context
|
|
||||||
runtime: AgentRuntimeContext = {
|
|
||||||
'langbot_version': self.ap.ver_mgr.get_current_version(),
|
|
||||||
'trace_id': run_id,
|
|
||||||
'deadline_at': self._build_deadline_from_binding(binding),
|
|
||||||
'metadata': {
|
|
||||||
'bot_id': event.bot_id,
|
|
||||||
'workspace_id': event.workspace_id,
|
|
||||||
'streaming_supported': event.delivery.supports_streaming,
|
|
||||||
'model_context_window_tokens': None,
|
|
||||||
# TODO(model-info): populate model_context_window_tokens after
|
|
||||||
# LiteLLM/model metadata lands. Runners fall back to their
|
|
||||||
# ctx.config until Host can provide the real window.
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build delivery context
|
|
||||||
delivery_context = {
|
|
||||||
'surface': event.delivery.surface,
|
|
||||||
'reply_target': event.delivery.reply_target,
|
|
||||||
'supports_streaming': event.delivery.supports_streaming,
|
|
||||||
'supports_edit': event.delivery.supports_edit,
|
|
||||||
'supports_reaction': event.delivery.supports_reaction,
|
|
||||||
'max_message_size': event.delivery.max_message_size,
|
|
||||||
'platform_capabilities': event.delivery.platform_capabilities,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build adapter context (empty for event-first)
|
|
||||||
adapter_context = {
|
|
||||||
'extra': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build full context - Protocol v1 structure
|
|
||||||
context: AgentRunContextPayload = {
|
|
||||||
'run_id': run_id,
|
|
||||||
'trigger': trigger,
|
|
||||||
'conversation': conversation,
|
|
||||||
'event': event_context, # REQUIRED
|
|
||||||
'actor': actor_context,
|
|
||||||
'subject': subject_context,
|
|
||||||
'input': input,
|
|
||||||
'delivery': delivery_context, # REQUIRED
|
|
||||||
'resources': resources,
|
|
||||||
'context': context_access, # ContextAccess - REQUIRED
|
|
||||||
'state': state,
|
|
||||||
'runtime': runtime,
|
|
||||||
'config': binding.runner_config,
|
|
||||||
'adapter': adapter_context,
|
|
||||||
'metadata': {}, # Additional metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def _build_deadline_from_binding(self, binding: AgentBinding) -> float | None:
|
|
||||||
"""Build deadline timestamp from binding timeout config.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
binding: Agent binding with runner_config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Deadline timestamp or None
|
|
||||||
"""
|
|
||||||
timeout = binding.runner_config.get('timeout', DEFAULT_RUNNER_TIMEOUT_SECONDS)
|
|
||||||
if timeout is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
timeout_seconds = float(timeout)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if timeout_seconds <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return time.time() + timeout_seconds
|
|
||||||
|
|
||||||
async def _build_context_access(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
binding: AgentBinding | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Build ContextAccess with actual values from stores.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
descriptor: Runner descriptor
|
|
||||||
binding: Agent binding (required for state_policy in event-first mode)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ContextAccess dict
|
|
||||||
"""
|
|
||||||
conversation_id = event.conversation_id
|
|
||||||
|
|
||||||
# Check if history APIs are available for this runner
|
|
||||||
# Based on runner permissions
|
|
||||||
permissions = descriptor.permissions or {}
|
|
||||||
history_permissions = permissions.get('history', [])
|
|
||||||
event_permissions = permissions.get('events', [])
|
|
||||||
artifact_permissions = permissions.get('artifacts', [])
|
|
||||||
|
|
||||||
history_page_enabled = 'page' in history_permissions and conversation_id is not None
|
|
||||||
history_search_enabled = 'search' in history_permissions and conversation_id is not None
|
|
||||||
event_get_enabled = 'get' in event_permissions
|
|
||||||
event_page_enabled = 'page' in event_permissions and conversation_id is not None
|
|
||||||
artifact_metadata_enabled = 'metadata' in artifact_permissions
|
|
||||||
artifact_read_enabled = 'read' in artifact_permissions
|
|
||||||
|
|
||||||
# Determine state API availability based on binding state_policy.
|
|
||||||
state_enabled = False
|
|
||||||
if binding is not None:
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
if state_policy.enable_state and state_policy.state_scopes:
|
|
||||||
state_enabled = True
|
|
||||||
|
|
||||||
# Get latest cursor and has_history_before if conversation exists
|
|
||||||
latest_cursor = None
|
|
||||||
has_history_before = False
|
|
||||||
|
|
||||||
if conversation_id:
|
|
||||||
try:
|
|
||||||
from .transcript_store import TranscriptStore
|
|
||||||
|
|
||||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
latest_cursor = await store.get_latest_cursor(conversation_id)
|
|
||||||
if latest_cursor:
|
|
||||||
has_history_before = True
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to get transcript cursor: {e}')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'conversation_id': conversation_id,
|
|
||||||
'thread_id': event.thread_id,
|
|
||||||
'latest_cursor': latest_cursor,
|
|
||||||
'event_seq': None, # Will be populated when EventLog is written
|
|
||||||
'transcript_seq': int(latest_cursor) if latest_cursor else None,
|
|
||||||
'has_history_before': has_history_before,
|
|
||||||
'inline_policy': {
|
|
||||||
'mode': 'current_event',
|
|
||||||
'delivered_count': 0,
|
|
||||||
'source_total_count': None,
|
|
||||||
'messages_complete': False,
|
|
||||||
'reason': 'self_managed_context',
|
|
||||||
},
|
|
||||||
'available_apis': {
|
|
||||||
'history_page': history_page_enabled,
|
|
||||||
'history_search': history_search_enabled,
|
|
||||||
'event_get': event_get_enabled,
|
|
||||||
'event_page': event_page_enabled,
|
|
||||||
'artifact_metadata': artifact_metadata_enabled,
|
|
||||||
'artifact_read': artifact_read_enabled,
|
|
||||||
'state': state_enabled,
|
|
||||||
'storage': True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Agent runner descriptor."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerDescriptor(pydantic.BaseModel):
|
|
||||||
"""Descriptor for an agent runner.
|
|
||||||
|
|
||||||
Represents the discovered metadata for a runner, including
|
|
||||||
its identity, capabilities, permissions, and configuration schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
"""Unique runner ID: plugin:author/plugin_name/runner_name"""
|
|
||||||
|
|
||||||
source: typing.Literal['plugin']
|
|
||||||
"""Runner source type"""
|
|
||||||
|
|
||||||
label: dict[str, str]
|
|
||||||
"""Display labels keyed by locale (e.g., en_US, zh_Hans)"""
|
|
||||||
|
|
||||||
description: dict[str, str] | None = None
|
|
||||||
"""Optional description keyed by locale"""
|
|
||||||
|
|
||||||
plugin_author: str
|
|
||||||
"""Plugin author from manifest"""
|
|
||||||
|
|
||||||
plugin_name: str
|
|
||||||
"""Plugin name from manifest"""
|
|
||||||
|
|
||||||
runner_name: str
|
|
||||||
"""AgentRunner component name from manifest"""
|
|
||||||
|
|
||||||
plugin_version: str | None = None
|
|
||||||
"""Optional plugin version"""
|
|
||||||
|
|
||||||
config_schema: list[dict[str, typing.Any]] = []
|
|
||||||
"""Configuration schema using DynamicForm format"""
|
|
||||||
|
|
||||||
capabilities: dict[str, bool] = {}
|
|
||||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
|
||||||
|
|
||||||
permissions: dict[str, list[str]] = {}
|
|
||||||
"""Requested permissions: models, tools, knowledge_bases, storage, files, platform_api"""
|
|
||||||
|
|
||||||
raw_manifest: dict[str, typing.Any] = {}
|
|
||||||
"""Original manifest for reference"""
|
|
||||||
|
|
||||||
model_config = pydantic.ConfigDict(
|
|
||||||
extra='allow',
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_plugin_id(self) -> str:
|
|
||||||
"""Return plugin identifier as author/name."""
|
|
||||||
return f'{self.plugin_author}/{self.plugin_name}'
|
|
||||||
|
|
||||||
def supports_streaming(self) -> bool:
|
|
||||||
"""Check if runner supports streaming output."""
|
|
||||||
return self.capabilities.get('streaming', False)
|
|
||||||
|
|
||||||
def supports_tool_calling(self) -> bool:
|
|
||||||
"""Check if runner supports tool calling."""
|
|
||||||
return self.capabilities.get('tool_calling', False)
|
|
||||||
|
|
||||||
def supports_knowledge_retrieval(self) -> bool:
|
|
||||||
"""Check if runner supports knowledge retrieval."""
|
|
||||||
return self.capabilities.get('knowledge_retrieval', False)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""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}')
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"""EventLog store for writing and querying event records."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from ...entity.persistence.event_log import EventLog
|
|
||||||
|
|
||||||
|
|
||||||
class EventLogStore:
|
|
||||||
"""Store for EventLog records.
|
|
||||||
|
|
||||||
Handles writing events to the event log and querying them.
|
|
||||||
All methods are async and use the provided database engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
|
|
||||||
# Hard limits
|
|
||||||
MAX_INPUT_SUMMARY_LENGTH = 1000
|
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
|
||||||
self.engine = engine
|
|
||||||
self._session_factory = sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def append_event(
|
|
||||||
self,
|
|
||||||
event_id: str | None,
|
|
||||||
event_type: str,
|
|
||||||
source: str,
|
|
||||||
bot_id: str | None = None,
|
|
||||||
workspace_id: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
thread_id: str | None = None,
|
|
||||||
actor_type: str | None = None,
|
|
||||||
actor_id: str | None = None,
|
|
||||||
actor_name: str | None = None,
|
|
||||||
subject_type: str | None = None,
|
|
||||||
subject_id: str | None = None,
|
|
||||||
input_summary: str | None = None,
|
|
||||||
input_json: dict[str, typing.Any] | None = None,
|
|
||||||
raw_ref: str | None = None,
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
event_time: datetime.datetime | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append an event to the event log.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: Unique event ID (generated if None)
|
|
||||||
event_type: Event type
|
|
||||||
source: Event source
|
|
||||||
bot_id: Bot UUID
|
|
||||||
workspace_id: Workspace ID
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
thread_id: Thread ID
|
|
||||||
actor_type: Actor type
|
|
||||||
actor_id: Actor ID
|
|
||||||
actor_name: Actor display name
|
|
||||||
subject_type: Subject type
|
|
||||||
subject_id: Subject ID
|
|
||||||
input_summary: Brief input summary
|
|
||||||
input_json: Full input JSON
|
|
||||||
raw_ref: Reference to raw event payload
|
|
||||||
run_id: Run ID processing this event
|
|
||||||
runner_id: Runner ID processing this event
|
|
||||||
event_time: When the event occurred
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The event_id
|
|
||||||
"""
|
|
||||||
if event_id is None:
|
|
||||||
event_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Truncate input summary if too long
|
|
||||||
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
|
|
||||||
input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..."
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
event = EventLog(
|
|
||||||
event_id=event_id,
|
|
||||||
event_type=event_type,
|
|
||||||
event_time=event_time,
|
|
||||||
source=source,
|
|
||||||
bot_id=bot_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
actor_type=actor_type,
|
|
||||||
actor_id=actor_id,
|
|
||||||
actor_name=actor_name,
|
|
||||||
subject_type=subject_type,
|
|
||||||
subject_id=subject_id,
|
|
||||||
input_summary=input_summary,
|
|
||||||
input_json=json.dumps(input_json) if input_json else None,
|
|
||||||
raw_ref=raw_ref,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
)
|
|
||||||
session.add(event)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return event_id
|
|
||||||
|
|
||||||
async def get_event(
|
|
||||||
self,
|
|
||||||
event_id: str,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Get a single event by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: Event ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Event record as dict, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return self._row_to_dict(row)
|
|
||||||
|
|
||||||
async def page_events(
|
|
||||||
self,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
event_types: list[str] | None = None,
|
|
||||||
before_seq: int | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
) -> tuple[list[dict[str, typing.Any]], int | None, bool]:
|
|
||||||
"""Page through event records.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Filter by conversation ID
|
|
||||||
event_types: Filter by event types
|
|
||||||
before_seq: Get events before this sequence number
|
|
||||||
limit: Maximum items to return (capped at 100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (items, next_seq, has_more)
|
|
||||||
"""
|
|
||||||
limit = min(limit, 100) # Hard cap
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(EventLog)
|
|
||||||
|
|
||||||
if conversation_id is not None:
|
|
||||||
query = query.where(EventLog.conversation_id == conversation_id)
|
|
||||||
|
|
||||||
if event_types:
|
|
||||||
query = query.where(EventLog.event_type.in_(event_types))
|
|
||||||
|
|
||||||
if before_seq is not None:
|
|
||||||
query = query.where(EventLog.id < before_seq)
|
|
||||||
|
|
||||||
query = query.order_by(EventLog.id.desc()).limit(limit + 1)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
items = [self._row_to_dict(row) for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
next_seq = items[-1]['id'] if items and has_more else None
|
|
||||||
|
|
||||||
return items, next_seq, has_more
|
|
||||||
|
|
||||||
async def get_latest_cursor(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get the latest cursor for a conversation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cursor string (seq number), or None if no events
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(EventLog.id)
|
|
||||||
.where(EventLog.conversation_id == conversation_id)
|
|
||||||
.order_by(EventLog.id.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return str(row)
|
|
||||||
|
|
||||||
async def has_events_before(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
seq: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if there are events before a sequence number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
seq: Sequence number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if there are events before
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(EventLog)
|
|
||||||
.where(
|
|
||||||
EventLog.conversation_id == conversation_id,
|
|
||||||
EventLog.id < seq,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
count = result.scalar()
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]:
|
|
||||||
"""Convert an EventLog row to dict."""
|
|
||||||
return {
|
|
||||||
'id': row.id,
|
|
||||||
'event_id': row.event_id,
|
|
||||||
'event_type': row.event_type,
|
|
||||||
'event_time': int(row.event_time.timestamp()) if row.event_time else None,
|
|
||||||
'source': row.source,
|
|
||||||
'bot_id': row.bot_id,
|
|
||||||
'workspace_id': row.workspace_id,
|
|
||||||
'conversation_id': row.conversation_id,
|
|
||||||
'thread_id': row.thread_id,
|
|
||||||
'actor_type': row.actor_type,
|
|
||||||
'actor_id': row.actor_id,
|
|
||||||
'actor_name': row.actor_name,
|
|
||||||
'subject_type': row.subject_type,
|
|
||||||
'subject_id': row.subject_id,
|
|
||||||
'input_summary': row.input_summary,
|
|
||||||
'input_json': json.loads(row.input_json) if row.input_json else None,
|
|
||||||
'raw_ref': row.raw_ref,
|
|
||||||
'run_id': row.run_id,
|
|
||||||
'runner_id': row.runner_id,
|
|
||||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
|
||||||
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"""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."""
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""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:')
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""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}')
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
"""Agent run orchestrator for coordinating runner execution."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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 'params' in adapter_context:
|
|
||||||
context['adapter']['extra']['params'] = adapter_context['params']
|
|
||||||
|
|
||||||
state_context = build_state_context(event, binding, descriptor)
|
|
||||||
run_id = context['run_id']
|
|
||||||
await self._session_registry.register(
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
query_id=session_query_id,
|
|
||||||
plugin_identity=descriptor.get_plugin_id(),
|
|
||||||
resources=resources,
|
|
||||||
permissions=descriptor.permissions or {},
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
state_policy={
|
|
||||||
'enable_state': binding.state_policy.enable_state,
|
|
||||||
'state_scopes': list(binding.state_policy.state_scopes),
|
|
||||||
},
|
|
||||||
state_context=state_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_artifact_refs: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
|
||||||
result_type = result_dict.get('type')
|
|
||||||
|
|
||||||
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)
|
|
||||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result_type == 'message.completed' and event.conversation_id:
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
|
||||||
if result is not None:
|
|
||||||
yield result
|
|
||||||
finally:
|
|
||||||
await self._session_registry.unregister(run_id)
|
|
||||||
|
|
||||||
async def run_from_query(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
|
||||||
"""Run 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 _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,
|
|
||||||
)
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
"""Persistent state store for AgentRunner protocol state.
|
|
||||||
|
|
||||||
This module provides a database-backed state store for event-first Protocol v1.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlalchemy import select, delete, update
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
from .state_scope import (
|
|
||||||
VALID_STATE_SCOPES,
|
|
||||||
build_state_scope_key,
|
|
||||||
get_binding_identity,
|
|
||||||
normalize_state_key,
|
|
||||||
)
|
|
||||||
from ...entity.persistence.agent_runner_state import AgentRunnerState
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum value_json size (256KB)
|
|
||||||
MAX_VALUE_JSON_BYTES = 256 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class PersistentStateStore:
|
|
||||||
"""Database-backed state store for AgentRunner protocol state.
|
|
||||||
|
|
||||||
IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state.
|
|
||||||
|
|
||||||
This store provides:
|
|
||||||
1. Persistent storage across runs via database
|
|
||||||
2. Scope isolation by runner_id + binding_identity + scope
|
|
||||||
3. Policy enforcement (enable_state, state_scopes)
|
|
||||||
4. JSON value validation and size limits
|
|
||||||
|
|
||||||
Used by:
|
|
||||||
- Event-first Protocol v1 (async methods)
|
|
||||||
- State API handlers (get/set/delete/list)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_engine: AsyncEngine):
|
|
||||||
self._db_engine = db_engine
|
|
||||||
|
|
||||||
def _get_scope_key(
|
|
||||||
self,
|
|
||||||
scope: str,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get scope key for given scope."""
|
|
||||||
return build_state_scope_key(scope, event, binding, descriptor)
|
|
||||||
|
|
||||||
def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool:
|
|
||||||
"""Check if scope is enabled by binding's state_policy."""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return False
|
|
||||||
return scope in state_policy.state_scopes
|
|
||||||
|
|
||||||
def _validate_json_value(
|
|
||||||
self,
|
|
||||||
value: typing.Any,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[str | None, str | None]:
|
|
||||||
"""Validate and serialize value to JSON.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (json_string, error_message). If error_message is not None,
|
|
||||||
json_string will be None.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
json_str = json.dumps(value, ensure_ascii=False)
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
return None, f'Value is not JSON-serializable: {e}'
|
|
||||||
|
|
||||||
# Check size limit
|
|
||||||
json_bytes = len(json_str.encode('utf-8'))
|
|
||||||
if json_bytes > MAX_VALUE_JSON_BYTES:
|
|
||||||
return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes'
|
|
||||||
|
|
||||||
return json_str, None
|
|
||||||
|
|
||||||
# ========== Async DB Operations ==========
|
|
||||||
|
|
||||||
async def build_snapshot_from_event(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, dict[str, typing.Any]]:
|
|
||||||
"""Build state snapshot for all scopes from event and binding.
|
|
||||||
|
|
||||||
Reads from database, respects state_policy.
|
|
||||||
"""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
|
|
||||||
# If state is disabled, return all empty scopes
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return {
|
|
||||||
'conversation': {},
|
|
||||||
'actor': {},
|
|
||||||
'subject': {},
|
|
||||||
'runner': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot: dict[str, dict[str, typing.Any]] = {
|
|
||||||
'conversation': {},
|
|
||||||
'actor': {},
|
|
||||||
'subject': {},
|
|
||||||
'runner': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
for scope in VALID_STATE_SCOPES:
|
|
||||||
if not self._check_scope_enabled(scope, binding):
|
|
||||||
continue
|
|
||||||
|
|
||||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
|
||||||
if not scope_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Query all state entries for this scope_key
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.state_key, AgentRunnerState.value_json)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
key = row.state_key
|
|
||||||
value_json = row.value_json
|
|
||||||
if value_json:
|
|
||||||
try:
|
|
||||||
snapshot[scope][key] = json.loads(value_json)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass # Skip invalid JSON
|
|
||||||
|
|
||||||
# Seed external.conversation_id from event.conversation_id if not set
|
|
||||||
if self._check_scope_enabled('conversation', binding) and event.conversation_id:
|
|
||||||
if 'external.conversation_id' not in snapshot['conversation']:
|
|
||||||
snapshot['conversation']['external.conversation_id'] = event.conversation_id
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
async def apply_update_from_event(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
scope: str,
|
|
||||||
key: str,
|
|
||||||
value: typing.Any,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Apply a state update from event context.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (success, error_message). If success is False, error_message
|
|
||||||
contains the reason.
|
|
||||||
"""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
|
|
||||||
# Check if state is disabled
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return False, 'State is disabled by binding policy'
|
|
||||||
|
|
||||||
# Validate scope
|
|
||||||
if scope not in VALID_STATE_SCOPES:
|
|
||||||
return False, f'Invalid scope: {scope}'
|
|
||||||
|
|
||||||
# Check if scope is enabled
|
|
||||||
if not self._check_scope_enabled(scope, binding):
|
|
||||||
return False, f'Scope "{scope}" not enabled by binding policy'
|
|
||||||
|
|
||||||
# Map accepted key aliases
|
|
||||||
key = normalize_state_key(key)
|
|
||||||
|
|
||||||
# Get scope key
|
|
||||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
|
||||||
if not scope_key:
|
|
||||||
return False, f'Missing identity for scope "{scope}"'
|
|
||||||
|
|
||||||
# Validate and serialize value
|
|
||||||
value_json, error = self._validate_json_value(value, logger)
|
|
||||||
if error:
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
# Build context fields
|
|
||||||
binding_identity = get_binding_identity(binding)
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
# Check if entry exists
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.id)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == key)
|
|
||||||
)
|
|
||||||
existing = result.first()
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing entry
|
|
||||||
await conn.execute(
|
|
||||||
update(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.id == existing.id)
|
|
||||||
.values(
|
|
||||||
value_json=value_json,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Insert new entry
|
|
||||||
await conn.execute(
|
|
||||||
sqlalchemy.insert(AgentRunnerState).values(
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
binding_identity=binding_identity,
|
|
||||||
scope=scope,
|
|
||||||
scope_key=scope_key,
|
|
||||||
state_key=key,
|
|
||||||
value_json=value_json,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
actor_type=event.actor.actor_type if event.actor else None,
|
|
||||||
actor_id=event.actor.actor_id if event.actor else None,
|
|
||||||
subject_type=event.subject.subject_type if event.subject else None,
|
|
||||||
subject_id=event.subject.subject_id if event.subject else None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
async def state_get(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
) -> typing.Any:
|
|
||||||
"""Get a single state value by scope_key and state_key.
|
|
||||||
|
|
||||||
Used by State API handlers.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.value_json)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
|
|
||||||
if not row or not row.value_json:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(row.value_json)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def state_set(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
value: typing.Any,
|
|
||||||
runner_id: str,
|
|
||||||
binding_identity: str,
|
|
||||||
scope: str,
|
|
||||||
context: dict[str, typing.Any] | None = None,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Set a state value.
|
|
||||||
|
|
||||||
Used by State API handlers.
|
|
||||||
Context contains optional fields like bot_id, conversation_id, etc.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
# Validate and serialize value
|
|
||||||
value_json, error = self._validate_json_value(value, logger)
|
|
||||||
if error:
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
context = context or {}
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
# Check if entry exists
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.id)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
)
|
|
||||||
existing = result.first()
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing entry
|
|
||||||
await conn.execute(
|
|
||||||
update(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.id == existing.id)
|
|
||||||
.values(
|
|
||||||
value_json=value_json,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Insert new entry
|
|
||||||
await conn.execute(
|
|
||||||
sqlalchemy.insert(AgentRunnerState).values(
|
|
||||||
runner_id=runner_id,
|
|
||||||
binding_identity=binding_identity,
|
|
||||||
scope=scope,
|
|
||||||
scope_key=scope_key,
|
|
||||||
state_key=state_key,
|
|
||||||
value_json=value_json,
|
|
||||||
bot_id=context.get('bot_id'),
|
|
||||||
workspace_id=context.get('workspace_id'),
|
|
||||||
conversation_id=context.get('conversation_id'),
|
|
||||||
thread_id=context.get('thread_id'),
|
|
||||||
actor_type=context.get('actor_type'),
|
|
||||||
actor_id=context.get('actor_id'),
|
|
||||||
subject_type=context.get('subject_type'),
|
|
||||||
subject_id=context.get('subject_id'),
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
async def state_delete(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Delete a state value.
|
|
||||||
|
|
||||||
Returns True if deleted, False if not found.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
delete(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
.returning(AgentRunnerState.id)
|
|
||||||
)
|
|
||||||
deleted = result.first()
|
|
||||||
return deleted is not None
|
|
||||||
|
|
||||||
async def state_list(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
prefix: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> tuple[list[str], bool]:
|
|
||||||
"""List state keys in a scope.
|
|
||||||
|
|
||||||
Returns tuple of (keys, has_more).
|
|
||||||
"""
|
|
||||||
# Enforce limit cap
|
|
||||||
limit = min(limit, 100)
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
query = (
|
|
||||||
select(AgentRunnerState.state_key)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.order_by(AgentRunnerState.state_key)
|
|
||||||
.limit(limit + 1) # Fetch one extra to check has_more
|
|
||||||
)
|
|
||||||
|
|
||||||
if prefix:
|
|
||||||
prefix = normalize_state_key(prefix)
|
|
||||||
query = query.where(
|
|
||||||
AgentRunnerState.state_key.like(f'{prefix}%')
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await conn.execute(query)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
keys = [row.state_key for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
|
|
||||||
return keys, has_more
|
|
||||||
|
|
||||||
async def clear_all(self) -> None:
|
|
||||||
"""Clear all state entries (for testing)."""
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
await conn.execute(delete(AgentRunnerState))
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton persistent state store
|
|
||||||
_persistent_state_store: PersistentStateStore | None = None
|
|
||||||
_persistent_state_store_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore:
|
|
||||||
"""Get the global persistent state store singleton.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_engine: Database engine (required on first call)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PersistentStateStore singleton
|
|
||||||
"""
|
|
||||||
global _persistent_state_store
|
|
||||||
with _persistent_state_store_lock:
|
|
||||||
if _persistent_state_store is None:
|
|
||||||
if db_engine is None:
|
|
||||||
raise RuntimeError("db_engine required for first call to get_persistent_state_store")
|
|
||||||
_persistent_state_store = PersistentStateStore(db_engine)
|
|
||||||
return _persistent_state_store
|
|
||||||
|
|
||||||
|
|
||||||
def reset_persistent_state_store() -> None:
|
|
||||||
"""Reset the global persistent state store (for testing)."""
|
|
||||||
global _persistent_state_store
|
|
||||||
with _persistent_state_store_lock:
|
|
||||||
_persistent_state_store = None
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
"""Query entry adapter for converting Query to event-first envelope.
|
|
||||||
|
|
||||||
This adapter bridges the current Query entry point with the event-first
|
|
||||||
Protocol v1 architecture without exposing Query internals to runners.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
|
||||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
|
||||||
AgentEventContext,
|
|
||||||
ConversationContext,
|
|
||||||
ActorContext,
|
|
||||||
SubjectContext,
|
|
||||||
RawEventRef,
|
|
||||||
)
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
|
||||||
|
|
||||||
from .host_models import (
|
|
||||||
AgentConfig,
|
|
||||||
AgentEventEnvelope,
|
|
||||||
ResourcePolicy,
|
|
||||||
StatePolicy,
|
|
||||||
DeliveryPolicy,
|
|
||||||
)
|
|
||||||
from . import events as runner_events
|
|
||||||
|
|
||||||
|
|
||||||
class QueryEntryAdapter:
|
|
||||||
"""Adapter for converting Query to event-first envelope.
|
|
||||||
|
|
||||||
This adapter is responsible for:
|
|
||||||
- Converting Query to AgentEventEnvelope
|
|
||||||
- Projecting current Pipeline config to temporary AgentConfig
|
|
||||||
- Putting Query-only fields into adapter context
|
|
||||||
"""
|
|
||||||
|
|
||||||
INTERNAL_PREFIX = '_'
|
|
||||||
SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey')
|
|
||||||
PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission')
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def query_to_event(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> AgentEventEnvelope:
|
|
||||||
"""Convert Query to AgentEventEnvelope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Current entry query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentEventEnvelope for event-first processing
|
|
||||||
"""
|
|
||||||
# Build event context
|
|
||||||
event = cls._build_event_context(query)
|
|
||||||
|
|
||||||
# Build conversation context
|
|
||||||
conversation = cls._build_conversation_context(query)
|
|
||||||
|
|
||||||
# Build actor context
|
|
||||||
actor = cls._build_actor_context(query)
|
|
||||||
|
|
||||||
# Build subject context
|
|
||||||
subject = cls._build_subject_context(query)
|
|
||||||
|
|
||||||
# Build input
|
|
||||||
input = cls._build_input(query)
|
|
||||||
|
|
||||||
# Build delivery context
|
|
||||||
delivery = cls._build_delivery_context(query)
|
|
||||||
|
|
||||||
# Build raw ref
|
|
||||||
raw_ref = cls._build_raw_ref(query)
|
|
||||||
|
|
||||||
return AgentEventEnvelope(
|
|
||||||
event_id=event.event_id or str(query.query_id),
|
|
||||||
event_type=event.event_type or runner_events.MESSAGE_RECEIVED,
|
|
||||||
event_time=event.event_time,
|
|
||||||
source="host_adapter",
|
|
||||||
source_event_type=event.source_event_type,
|
|
||||||
bot_id=query.bot_uuid,
|
|
||||||
workspace_id=None, # Not available in Query
|
|
||||||
conversation_id=conversation.conversation_id,
|
|
||||||
thread_id=conversation.thread_id,
|
|
||||||
actor=actor,
|
|
||||||
subject=subject,
|
|
||||||
input=input,
|
|
||||||
delivery=delivery,
|
|
||||||
raw_ref=raw_ref,
|
|
||||||
data=event.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def config_to_agent_config(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
runner_id: str,
|
|
||||||
) -> AgentConfig:
|
|
||||||
"""Project the current Pipeline config container into target Agent config."""
|
|
||||||
pipeline_config = query.pipeline_config or {}
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
runner_config = ai_config.get('runner_config', {}).get(runner_id, {})
|
|
||||||
agent_id = getattr(query, 'pipeline_uuid', None)
|
|
||||||
|
|
||||||
# Build resource policy from current config
|
|
||||||
resource_policy = ResourcePolicy(
|
|
||||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
|
||||||
allowed_tool_names=cls._extract_allowed_tools(query),
|
|
||||||
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
|
||||||
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:
|
|
||||||
event_data = message_event.model_dump(mode='json')
|
|
||||||
except TypeError:
|
|
||||||
event_data = message_event.model_dump()
|
|
||||||
except Exception:
|
|
||||||
event_data = {}
|
|
||||||
event_data.pop('source_platform_object', None)
|
|
||||||
|
|
||||||
source_event_type = None
|
|
||||||
if message_event:
|
|
||||||
source_event_type = getattr(message_event, 'type', None)
|
|
||||||
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
message_id = getattr(message_chain, 'message_id', None)
|
|
||||||
if message_id == -1:
|
|
||||||
message_id = None
|
|
||||||
|
|
||||||
event_time = None
|
|
||||||
if message_event:
|
|
||||||
event_time = getattr(message_event, 'time', None)
|
|
||||||
if isinstance(event_time, (int, float)):
|
|
||||||
event_time = int(event_time)
|
|
||||||
|
|
||||||
source_event_id = str(message_id or query.query_id)
|
|
||||||
return AgentEventContext(
|
|
||||||
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
|
|
||||||
event_type=runner_events.MESSAGE_RECEIVED,
|
|
||||||
event_time=event_time,
|
|
||||||
source="host_adapter",
|
|
||||||
source_event_type=source_event_type,
|
|
||||||
data=event_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_scoped_event_id(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
source_event_id: str,
|
|
||||||
event_time: int | None,
|
|
||||||
) -> str:
|
|
||||||
"""Build a globally unique host event id from pipeline-local ids."""
|
|
||||||
launcher_type = getattr(query, 'launcher_type', None)
|
|
||||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None
|
|
||||||
scope_parts = [
|
|
||||||
'host_adapter',
|
|
||||||
getattr(query, 'pipeline_uuid', None),
|
|
||||||
getattr(query, 'bot_uuid', None),
|
|
||||||
launcher_type_value,
|
|
||||||
getattr(query, 'launcher_id', None),
|
|
||||||
getattr(query, 'sender_id', None),
|
|
||||||
source_event_id,
|
|
||||||
event_time,
|
|
||||||
]
|
|
||||||
scoped = '|'.join('' if part is None else str(part) for part in scope_parts)
|
|
||||||
digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32]
|
|
||||||
return f'host:{digest}'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_conversation_context(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> ConversationContext:
|
|
||||||
"""Build ConversationContext from Query."""
|
|
||||||
# Handle launcher_type safely
|
|
||||||
launcher_type = getattr(query, 'launcher_type', None)
|
|
||||||
launcher_type_value = None
|
|
||||||
if launcher_type is not None:
|
|
||||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
|
||||||
|
|
||||||
# Handle launcher_id
|
|
||||||
launcher_id = getattr(query, 'launcher_id', None)
|
|
||||||
|
|
||||||
# Build session_id from launcher info if available
|
|
||||||
session_id = None
|
|
||||||
if launcher_type_value and launcher_id:
|
|
||||||
session_id = f'{launcher_type_value}_{launcher_id}'
|
|
||||||
|
|
||||||
# Handle session and conversation_id
|
|
||||||
conversation_id = None
|
|
||||||
session = getattr(query, 'session', None)
|
|
||||||
if session:
|
|
||||||
conversation = getattr(session, 'using_conversation', None)
|
|
||||||
if conversation:
|
|
||||||
conversation_id = getattr(conversation, 'uuid', None)
|
|
||||||
|
|
||||||
if not conversation_id:
|
|
||||||
variables = getattr(query, 'variables', None) or {}
|
|
||||||
conversation_id = variables.get('conversation_id') or None
|
|
||||||
|
|
||||||
if not conversation_id:
|
|
||||||
conversation_id = session_id
|
|
||||||
|
|
||||||
# Handle sender_id
|
|
||||||
sender_id = getattr(query, 'sender_id', None)
|
|
||||||
if sender_id is not None:
|
|
||||||
sender_id = str(sender_id)
|
|
||||||
|
|
||||||
# Handle bot_uuid
|
|
||||||
bot_uuid = getattr(query, 'bot_uuid', None)
|
|
||||||
|
|
||||||
return ConversationContext(
|
|
||||||
conversation_id=str(conversation_id) if conversation_id is not None else None,
|
|
||||||
thread_id=None,
|
|
||||||
launcher_type=launcher_type_value,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
sender_id=sender_id,
|
|
||||||
bot_id=bot_uuid,
|
|
||||||
workspace_id=None,
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_actor_context(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> ActorContext:
|
|
||||||
"""Build ActorContext from Query."""
|
|
||||||
message_event = getattr(query, 'message_event', None)
|
|
||||||
sender = getattr(message_event, 'sender', None) if message_event else None
|
|
||||||
sender_id = getattr(query, 'sender_id', None)
|
|
||||||
actor_id = getattr(sender, 'id', None) if sender else None
|
|
||||||
if actor_id is None:
|
|
||||||
actor_id = sender_id
|
|
||||||
actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None
|
|
||||||
|
|
||||||
return ActorContext(
|
|
||||||
actor_type="user",
|
|
||||||
actor_id=str(actor_id) if actor_id is not None else None,
|
|
||||||
actor_name=actor_name,
|
|
||||||
metadata={},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_subject_context(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> SubjectContext:
|
|
||||||
"""Build SubjectContext from Query."""
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
message_id = getattr(message_chain, 'message_id', None) if message_chain else None
|
|
||||||
if message_id == -1:
|
|
||||||
message_id = None
|
|
||||||
|
|
||||||
query_id = getattr(query, 'query_id', None)
|
|
||||||
|
|
||||||
# Safely get launcher_type
|
|
||||||
launcher_type = getattr(query, 'launcher_type', None)
|
|
||||||
launcher_type_value = None
|
|
||||||
if launcher_type is not None:
|
|
||||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
|
||||||
|
|
||||||
return SubjectContext(
|
|
||||||
subject_type="message",
|
|
||||||
subject_id=str(message_id or query_id or ''),
|
|
||||||
data={
|
|
||||||
"launcher_type": launcher_type_value,
|
|
||||||
"launcher_id": getattr(query, 'launcher_id', None),
|
|
||||||
"sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None,
|
|
||||||
"bot_uuid": getattr(query, 'bot_uuid', None),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_input(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> AgentInput:
|
|
||||||
"""Build AgentInput from Query."""
|
|
||||||
text = None
|
|
||||||
text_parts: list[str] = []
|
|
||||||
contents: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
user_message = getattr(query, 'user_message', None)
|
|
||||||
if user_message:
|
|
||||||
content = getattr(user_message, 'content', None)
|
|
||||||
if isinstance(content, list):
|
|
||||||
for elem in content:
|
|
||||||
elem_dict = None
|
|
||||||
if hasattr(elem, 'model_dump'):
|
|
||||||
elem_dict = elem.model_dump(mode='json')
|
|
||||||
elif isinstance(elem, dict):
|
|
||||||
elem_dict = elem
|
|
||||||
|
|
||||||
if not isinstance(elem_dict, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
contents.append(elem_dict)
|
|
||||||
if elem_dict.get('type') == 'text':
|
|
||||||
elem_text = elem_dict.get('text')
|
|
||||||
if elem_text:
|
|
||||||
text_parts.append(elem_text)
|
|
||||||
elif content is not None:
|
|
||||||
text = str(content)
|
|
||||||
contents.append({'type': 'text', 'text': text})
|
|
||||||
|
|
||||||
if text_parts:
|
|
||||||
text = ''.join(text_parts)
|
|
||||||
|
|
||||||
message_chain_dict = None
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
if message_chain:
|
|
||||||
if hasattr(message_chain, 'model_dump'):
|
|
||||||
message_chain_dict = message_chain.model_dump(mode='json')
|
|
||||||
|
|
||||||
attachments = cls._build_attachments(query, contents)
|
|
||||||
|
|
||||||
return AgentInput(
|
|
||||||
text=text,
|
|
||||||
contents=contents,
|
|
||||||
message_chain=message_chain_dict,
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_attachments(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
contents: list[dict[str, typing.Any]],
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Extract attachments from query."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
attachments: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
for elem in contents:
|
|
||||||
elem_type = elem.get('type')
|
|
||||||
artifact_id = str(uuid.uuid4()) # Generate unique ID
|
|
||||||
|
|
||||||
if elem_type == 'image_url':
|
|
||||||
image_url = elem.get('image_url') or {}
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'image',
|
|
||||||
'source': 'url',
|
|
||||||
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
|
|
||||||
})
|
|
||||||
elif elem_type == 'image_base64':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'image',
|
|
||||||
'source': 'base64',
|
|
||||||
'content': elem.get('image_base64'),
|
|
||||||
})
|
|
||||||
elif elem_type == 'file_url':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'file',
|
|
||||||
'source': 'url',
|
|
||||||
'url': elem.get('file_url'),
|
|
||||||
'name': elem.get('file_name'),
|
|
||||||
})
|
|
||||||
elif elem_type == 'file_base64':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'file',
|
|
||||||
'source': 'base64',
|
|
||||||
'content': elem.get('file_base64'),
|
|
||||||
'name': elem.get('file_name'),
|
|
||||||
})
|
|
||||||
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
if message_chain:
|
|
||||||
try:
|
|
||||||
message_components = iter(message_chain)
|
|
||||||
except TypeError:
|
|
||||||
message_components = iter(())
|
|
||||||
|
|
||||||
for component in message_components:
|
|
||||||
artifact_id = str(uuid.uuid4()) # Generate unique ID
|
|
||||||
|
|
||||||
if isinstance(component, platform_message.Image):
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'image',
|
|
||||||
'source': 'message_chain',
|
|
||||||
'id': component.image_id or None,
|
|
||||||
'url': component.url or None,
|
|
||||||
})
|
|
||||||
elif isinstance(component, platform_message.File):
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'file',
|
|
||||||
'source': 'message_chain',
|
|
||||||
'id': component.id or None,
|
|
||||||
'name': component.name or None,
|
|
||||||
})
|
|
||||||
elif isinstance(component, platform_message.Voice):
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'voice',
|
|
||||||
'source': 'message_chain',
|
|
||||||
'id': component.voice_id or None,
|
|
||||||
'url': component.url or None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return attachments
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_delivery_context(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> DeliveryContext:
|
|
||||||
"""Build DeliveryContext from Query."""
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
return DeliveryContext(
|
|
||||||
surface="platform",
|
|
||||||
reply_target={
|
|
||||||
"message_id": getattr(message_chain, 'message_id', None),
|
|
||||||
},
|
|
||||||
supports_streaming=True,
|
|
||||||
supports_edit=False,
|
|
||||||
supports_reaction=False,
|
|
||||||
platform_capabilities={},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_raw_ref(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> RawEventRef | None:
|
|
||||||
"""Build RawEventRef from Query."""
|
|
||||||
# For now, we don't store raw event payload
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_allowed_models(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Extract allowed model UUIDs from query."""
|
|
||||||
model_uuids: list[str] = []
|
|
||||||
model_uuid = getattr(query, 'use_llm_model_uuid', None)
|
|
||||||
if model_uuid:
|
|
||||||
model_uuids.append(model_uuid)
|
|
||||||
|
|
||||||
variables = getattr(query, 'variables', None) or {}
|
|
||||||
for fallback_uuid in variables.get('_fallback_model_uuids', []) or []:
|
|
||||||
if fallback_uuid and fallback_uuid not in model_uuids:
|
|
||||||
model_uuids.append(fallback_uuid)
|
|
||||||
|
|
||||||
return model_uuids or None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_allowed_tools(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Extract allowed tool names from query."""
|
|
||||||
use_funcs = getattr(query, 'use_funcs', None)
|
|
||||||
if not use_funcs:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
tool_names = []
|
|
||||||
for func in use_funcs:
|
|
||||||
if isinstance(func, dict):
|
|
||||||
name = func.get('name')
|
|
||||||
elif hasattr(func, 'name'):
|
|
||||||
name = func.name
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if name:
|
|
||||||
tool_names.append(name)
|
|
||||||
return tool_names if tool_names else None
|
|
||||||
except (TypeError, AttributeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_allowed_kbs(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Extract allowed knowledge base UUIDs from query."""
|
|
||||||
variables = getattr(query, 'variables', None)
|
|
||||||
if not variables:
|
|
||||||
return None
|
|
||||||
kb_uuids = variables.get('_knowledge_base_uuids')
|
|
||||||
if kb_uuids:
|
|
||||||
return kb_uuids
|
|
||||||
return None
|
|
||||||
|
|
||||||
@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]
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
"""Agent runner registry for discovering and caching runner descriptors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .id import parse_runner_id, format_runner_id
|
|
||||||
from .errors import RunnerNotFoundError, RunnerNotAuthorizedError
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerRegistry:
|
|
||||||
"""Registry for discovering and managing agent runners.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Discover runners from plugin runtime via LIST_AGENT_RUNNERS
|
|
||||||
- Validate runner manifests (kind, metadata, spec)
|
|
||||||
- Cache discovered runners for performance
|
|
||||||
- Filter runners by bound plugins
|
|
||||||
- Handle manifest errors gracefully (log warning, skip runner)
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
_cache: dict[str, AgentRunnerDescriptor] | None
|
|
||||||
"""Cached runner descriptors keyed by runner ID"""
|
|
||||||
|
|
||||||
_cache_lock: asyncio.Lock
|
|
||||||
"""Lock for cache refresh operations"""
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
self._cache = None
|
|
||||||
self._cache_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]:
|
|
||||||
"""Discover runners from plugin runtime.
|
|
||||||
|
|
||||||
Always discovers ALL runners (no bound_plugins filter).
|
|
||||||
The cache should contain unfiltered discovery results.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of runner descriptors keyed by runner ID
|
|
||||||
"""
|
|
||||||
if not self.ap.plugin_connector.is_enable_plugin:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
runners: dict[str, AgentRunnerDescriptor] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Always list all runners (bound_plugins=None)
|
|
||||||
plugin_runners = await self.ap.plugin_connector.list_agent_runners(None)
|
|
||||||
|
|
||||||
for runner_data in plugin_runners:
|
|
||||||
try:
|
|
||||||
descriptor = self._validate_and_build_descriptor(runner_data)
|
|
||||||
if descriptor is not None:
|
|
||||||
runners[descriptor.id] = descriptor
|
|
||||||
except Exception as e:
|
|
||||||
plugin_author = runner_data.get('plugin_author', 'unknown')
|
|
||||||
plugin_name = runner_data.get('plugin_name', 'unknown')
|
|
||||||
runner_name = runner_data.get('runner_name', 'unknown')
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}')
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return runners
|
|
||||||
|
|
||||||
def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None:
|
|
||||||
"""Validate runner manifest and build descriptor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_data: Raw runner data from plugin runtime with fields:
|
|
||||||
- plugin_author, plugin_name, runner_name
|
|
||||||
- manifest (full component manifest dict)
|
|
||||||
- capabilities, permissions, config (extracted from spec)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunnerDescriptor if valid, None if invalid
|
|
||||||
"""
|
|
||||||
plugin_author = runner_data.get('plugin_author', '')
|
|
||||||
plugin_name = runner_data.get('plugin_name', '')
|
|
||||||
runner_name = runner_data.get('runner_name', '')
|
|
||||||
|
|
||||||
if not plugin_author or not plugin_name or not runner_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
manifest = runner_data.get('manifest', {})
|
|
||||||
|
|
||||||
# Validate kind
|
|
||||||
kind = manifest.get('kind', '')
|
|
||||||
if kind != 'AgentRunner':
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate metadata
|
|
||||||
metadata = manifest.get('metadata', {})
|
|
||||||
name = metadata.get('name', '')
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# metadata.label must exist
|
|
||||||
label = metadata.get('label', {})
|
|
||||||
if not label:
|
|
||||||
label = {name: name} # fallback
|
|
||||||
|
|
||||||
spec = manifest.get('spec', {})
|
|
||||||
|
|
||||||
# SDK now provides these directly extracted from spec. Fall back to
|
|
||||||
# manifest.spec for older runtimes/tests that return the raw manifest.
|
|
||||||
config_schema = runner_data.get('config') or spec.get('config', [])
|
|
||||||
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
|
|
||||||
permissions = runner_data.get('permissions') or spec.get('permissions', {})
|
|
||||||
|
|
||||||
# Build descriptor
|
|
||||||
runner_id = format_runner_id(
|
|
||||||
source='plugin',
|
|
||||||
plugin_author=plugin_author,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
runner_name=runner_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AgentRunnerDescriptor(
|
|
||||||
id=runner_id,
|
|
||||||
source='plugin',
|
|
||||||
label=label,
|
|
||||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
|
||||||
plugin_author=plugin_author,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
runner_name=runner_name,
|
|
||||||
plugin_version=runner_data.get('plugin_version'),
|
|
||||||
config_schema=config_schema,
|
|
||||||
capabilities=capabilities,
|
|
||||||
permissions=permissions,
|
|
||||||
raw_manifest=manifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def refresh(self) -> None:
|
|
||||||
"""Refresh runner cache.
|
|
||||||
|
|
||||||
Always discovers ALL runners (no bound_plugins filter).
|
|
||||||
The cache contains unfiltered discovery results.
|
|
||||||
"""
|
|
||||||
async with self._cache_lock:
|
|
||||||
self._cache = await self._discover_runners()
|
|
||||||
|
|
||||||
async def list_runners(
|
|
||||||
self,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
use_cache: bool = True,
|
|
||||||
) -> list[AgentRunnerDescriptor]:
|
|
||||||
"""List available runners.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bound_plugins: Optional filter for bound plugins (applied locally)
|
|
||||||
use_cache: Use cached data if available
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of runner descriptors
|
|
||||||
"""
|
|
||||||
if use_cache and self._cache is not None:
|
|
||||||
# Filter from cache
|
|
||||||
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
|
|
||||||
|
|
||||||
# Discover fresh (always full list)
|
|
||||||
runners = await self._discover_runners()
|
|
||||||
|
|
||||||
# Update cache (full list, unfiltered)
|
|
||||||
async with self._cache_lock:
|
|
||||||
self._cache = runners
|
|
||||||
|
|
||||||
# Filter locally
|
|
||||||
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
|
|
||||||
|
|
||||||
def _filter_runners_by_bound_plugins(
|
|
||||||
self,
|
|
||||||
runners: dict[str, AgentRunnerDescriptor],
|
|
||||||
bound_plugins: list[str] | None,
|
|
||||||
) -> list[AgentRunnerDescriptor]:
|
|
||||||
"""Filter runners by bound plugins.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runners: Dict of runner descriptors
|
|
||||||
bound_plugins: Optional filter (None means all plugins allowed)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered list of runner descriptors
|
|
||||||
"""
|
|
||||||
if bound_plugins is None:
|
|
||||||
# All plugins allowed
|
|
||||||
return list(runners.values())
|
|
||||||
|
|
||||||
allowed_plugin_ids = set(bound_plugins)
|
|
||||||
filtered = []
|
|
||||||
for descriptor in runners.values():
|
|
||||||
plugin_id = descriptor.get_plugin_id()
|
|
||||||
if plugin_id in allowed_plugin_ids:
|
|
||||||
filtered.append(descriptor)
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
runner_id: str,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
) -> AgentRunnerDescriptor:
|
|
||||||
"""Get a specific runner descriptor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_id: Runner ID to lookup
|
|
||||||
bound_plugins: Optional bound plugins filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunnerDescriptor
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerNotFoundError: If runner not found
|
|
||||||
RunnerNotAuthorizedError: If runner not in bound plugins
|
|
||||||
"""
|
|
||||||
# Parse and validate runner ID format
|
|
||||||
try:
|
|
||||||
parse_runner_id(runner_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise RunnerNotFoundError(runner_id) from e
|
|
||||||
|
|
||||||
# Get from cache or discover (always full list)
|
|
||||||
if self._cache is None:
|
|
||||||
await self.refresh()
|
|
||||||
|
|
||||||
if self._cache is None:
|
|
||||||
raise RunnerNotFoundError(runner_id)
|
|
||||||
|
|
||||||
descriptor = self._cache.get(runner_id)
|
|
||||||
if descriptor is None:
|
|
||||||
raise RunnerNotFoundError(runner_id)
|
|
||||||
|
|
||||||
# Check authorization
|
|
||||||
if bound_plugins is not None:
|
|
||||||
plugin_id = descriptor.get_plugin_id()
|
|
||||||
if plugin_id not in bound_plugins:
|
|
||||||
raise RunnerNotAuthorizedError(runner_id, bound_plugins)
|
|
||||||
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Get runner metadata for pipeline configuration UI.
|
|
||||||
|
|
||||||
Returns runner options and their config schemas for the DynamicForm.
|
|
||||||
"""
|
|
||||||
# Get all runners (no bound plugin filter for metadata listing)
|
|
||||||
runners = await self.list_runners(bound_plugins=None)
|
|
||||||
|
|
||||||
options = []
|
|
||||||
stages = []
|
|
||||||
|
|
||||||
for descriptor in runners:
|
|
||||||
config_schema = []
|
|
||||||
for index, config_item in enumerate(descriptor.config_schema):
|
|
||||||
item = dict(config_item)
|
|
||||||
if not item.get('id'):
|
|
||||||
item_name = item.get('name') or str(index)
|
|
||||||
item['id'] = f'{descriptor.id}.{item_name}'
|
|
||||||
config_schema.append(item)
|
|
||||||
|
|
||||||
# Add runner option
|
|
||||||
options.append(
|
|
||||||
{
|
|
||||||
'name': descriptor.id,
|
|
||||||
'label': descriptor.label,
|
|
||||||
'description': descriptor.description,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add config schema as stage if not empty
|
|
||||||
if descriptor.config_schema:
|
|
||||||
stages.append(
|
|
||||||
{
|
|
||||||
'name': descriptor.id,
|
|
||||||
'label': descriptor.label,
|
|
||||||
'description': descriptor.description,
|
|
||||||
'config': config_schema,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return options, stages
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
"""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,
|
|
||||||
StorageResource,
|
|
||||||
)
|
|
||||||
from . import config_schema
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResourceBuilder:
|
|
||||||
"""Builder for constructing AgentResources with permission filtering.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Apply 3-layer permission filtering:
|
|
||||||
1. Runner manifest declared permissions
|
|
||||||
2. Pipeline extensions_preference (bound plugins/MCP servers)
|
|
||||||
3. Agent/runner config selected resources
|
|
||||||
- Build models list from authorized models
|
|
||||||
- Build tools list from bound plugins/MCP servers
|
|
||||||
- Build knowledge_bases list from config
|
|
||||||
- Build storage and files permissions summary
|
|
||||||
|
|
||||||
Note: This only builds the resource declaration. The actual proxy actions
|
|
||||||
in handler.py must still validate against ctx.resources at runtime.
|
|
||||||
|
|
||||||
Resource field names match the plugin SDK payload:
|
|
||||||
- ModelResource: model_id, model_type, provider
|
|
||||||
- ToolResource: tool_name, tool_type, description
|
|
||||||
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
|
||||||
- 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 permissions and capabilities
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentResources dict with filtered resource lists
|
|
||||||
"""
|
|
||||||
# Layer 1: Runner manifest permissions
|
|
||||||
manifest_perms = descriptor.permissions
|
|
||||||
|
|
||||||
# Layer 2: Binding resource policy
|
|
||||||
resource_policy = binding.resource_policy
|
|
||||||
|
|
||||||
# Layer 3: Agent/runner config
|
|
||||||
runner_config = binding.runner_config
|
|
||||||
|
|
||||||
# Build each resource category
|
|
||||||
models = await self._build_models_from_binding(
|
|
||||||
manifest_perms, resource_policy, descriptor, runner_config
|
|
||||||
)
|
|
||||||
tools = await self._build_tools_from_binding(
|
|
||||||
manifest_perms, resource_policy, binding
|
|
||||||
)
|
|
||||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
|
||||||
manifest_perms, resource_policy, descriptor, runner_config
|
|
||||||
)
|
|
||||||
skills = self._build_skills_from_binding(
|
|
||||||
resource_policy, descriptor
|
|
||||||
)
|
|
||||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'models': models,
|
|
||||||
'tools': tools,
|
|
||||||
'knowledge_bases': knowledge_bases,
|
|
||||||
'skills': skills,
|
|
||||||
'files': [], # Files are populated at runtime
|
|
||||||
'storage': storage,
|
|
||||||
'platform_capabilities': {}, # Reserved for EBA
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _build_models_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[ModelResource]:
|
|
||||||
"""Build models list from binding."""
|
|
||||||
models: list[ModelResource] = []
|
|
||||||
seen_model_ids: set[str] = set()
|
|
||||||
|
|
||||||
model_perms = manifest_perms.get('models', [])
|
|
||||||
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
|
|
||||||
allow_rerank = 'rerank' in model_perms
|
|
||||||
if not allow_llm and not allow_rerank:
|
|
||||||
return models
|
|
||||||
|
|
||||||
# Get additional model UUID grants from resource policy.
|
|
||||||
allowed_uuids = resource_policy.allowed_model_uuids
|
|
||||||
|
|
||||||
# Add model resources from Agent/runner config schema
|
|
||||||
await self._append_config_declared_model_resources(
|
|
||||||
models=models,
|
|
||||||
seen_model_ids=seen_model_ids,
|
|
||||||
descriptor=descriptor,
|
|
||||||
runner_config=runner_config,
|
|
||||||
include_llm=allow_llm,
|
|
||||||
include_rerank=allow_rerank,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add explicitly allowed models
|
|
||||||
if allowed_uuids and allow_llm:
|
|
||||||
for model_uuid in allowed_uuids:
|
|
||||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
|
|
||||||
return models
|
|
||||||
|
|
||||||
async def _build_tools_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
binding: AgentBinding,
|
|
||||||
) -> list[ToolResource]:
|
|
||||||
"""Build tools list from binding."""
|
|
||||||
tools: list[ToolResource] = []
|
|
||||||
|
|
||||||
# Check manifest permission
|
|
||||||
tool_perms = manifest_perms.get('tools', [])
|
|
||||||
if 'detail' not in tool_perms and 'call' not in tool_perms:
|
|
||||||
return tools
|
|
||||||
|
|
||||||
# Get tool names from resource policy
|
|
||||||
allowed_names = resource_policy.allowed_tool_names
|
|
||||||
|
|
||||||
if allowed_names:
|
|
||||||
for tool_name in allowed_names:
|
|
||||||
tools.append({
|
|
||||||
'tool_name': tool_name,
|
|
||||||
'tool_type': None,
|
|
||||||
'description': None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return tools
|
|
||||||
|
|
||||||
async def _build_knowledge_bases_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[KnowledgeBaseResource]:
|
|
||||||
"""Build knowledge bases list from binding."""
|
|
||||||
kb_resources: list[KnowledgeBaseResource] = []
|
|
||||||
|
|
||||||
# Check manifest permission
|
|
||||||
kb_perms = manifest_perms.get('knowledge_bases', [])
|
|
||||||
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
|
|
||||||
return kb_resources
|
|
||||||
|
|
||||||
# Get KB UUID grants from schema-defined config fields.
|
|
||||||
kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
|
|
||||||
|
|
||||||
# Also include resource policy grants.
|
|
||||||
allowed_uuids = resource_policy.allowed_kb_uuids
|
|
||||||
if allowed_uuids:
|
|
||||||
kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids]))
|
|
||||||
|
|
||||||
for kb_uuid in kb_uuids:
|
|
||||||
try:
|
|
||||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
|
||||||
if kb:
|
|
||||||
kb_resources.append({
|
|
||||||
'kb_id': kb_uuid,
|
|
||||||
'kb_name': kb.get_name(),
|
|
||||||
'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
|
|
||||||
|
|
||||||
return kb_resources
|
|
||||||
|
|
||||||
def _build_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_storage_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
binding: AgentBinding,
|
|
||||||
) -> StorageResource:
|
|
||||||
"""Build storage permissions from binding."""
|
|
||||||
storage_perms = manifest_perms.get('storage', [])
|
|
||||||
resource_policy = binding.resource_policy
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,
|
|
||||||
'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _append_config_declared_model_resources(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
include_llm: bool,
|
|
||||||
include_rerank: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Authorize model-like values selected through DynamicForm fields."""
|
|
||||||
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
|
|
||||||
if model_type == 'llm' and include_llm:
|
|
||||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
elif model_type == 'rerank' and include_rerank:
|
|
||||||
await self._append_rerank_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
|
|
||||||
async def _append_llm_model_resource(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
model_uuid: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Append an LLM model resource if it exists and has not been added."""
|
|
||||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
|
||||||
if model and model.model_entity:
|
|
||||||
models.append({
|
|
||||||
'model_id': model_uuid,
|
|
||||||
'model_type': getattr(model.model_entity, 'model_type', None),
|
|
||||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
|
||||||
})
|
|
||||||
seen_model_ids.add(model_uuid)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}')
|
|
||||||
|
|
||||||
async def _append_rerank_model_resource(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
model_uuid: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Append a rerank model resource if it exists and has not been added."""
|
|
||||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid)
|
|
||||||
if model and model.model_entity:
|
|
||||||
models.append({
|
|
||||||
'model_id': model_uuid,
|
|
||||||
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
|
|
||||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
|
||||||
})
|
|
||||||
seen_model_ids.add(model_uuid)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .errors import RunnerExecutionError, RunnerProtocolError
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum size for a single result payload (prevent memory exhaustion)
|
|
||||||
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResultNormalizer:
|
|
||||||
"""Normalizer for converting AgentRunResult to Pipeline messages.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Accept only supported result types (message.delta, message.completed, etc.)
|
|
||||||
- Map message.delta -> MessageChunk
|
|
||||||
- Map message.completed -> Message
|
|
||||||
- Map run.completed (with message) -> Message
|
|
||||||
- Handle run.failed as controlled error
|
|
||||||
- Ignore unknown types with warning
|
|
||||||
- Validate result size
|
|
||||||
- Validate message schema
|
|
||||||
|
|
||||||
Accepted result types:
|
|
||||||
- message.delta
|
|
||||||
- message.completed
|
|
||||||
- tool.call.started
|
|
||||||
- tool.call.completed
|
|
||||||
- state.updated
|
|
||||||
- run.completed
|
|
||||||
- run.failed
|
|
||||||
- action.requested (log only, don't execute)
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def normalize(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.Message | provider_message.MessageChunk | None:
|
|
||||||
"""Normalize AgentRunResult to Message or MessageChunk.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result_dict: Raw result dict from plugin runtime
|
|
||||||
descriptor: Runner descriptor for error context
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Message, MessageChunk, or None (for non-message events)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerExecutionError: On run.failed
|
|
||||||
RunnerProtocolError: On invalid result format
|
|
||||||
"""
|
|
||||||
# Validate result type
|
|
||||||
result_type = result_dict.get('type')
|
|
||||||
if not result_type:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'Missing result type')
|
|
||||||
|
|
||||||
# Validate result size
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
result_json = json.dumps(result_dict)
|
|
||||||
if len(result_json) > MAX_RESULT_SIZE_BYTES:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating'
|
|
||||||
)
|
|
||||||
# Truncate content if possible
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
if 'chunk' in data or 'message' in data:
|
|
||||||
content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '')
|
|
||||||
if isinstance(content, str) and len(content) > 10000:
|
|
||||||
# Keep reasonable length
|
|
||||||
data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'}
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}')
|
|
||||||
|
|
||||||
# Handle each result type
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
|
|
||||||
if result_type == 'message.delta':
|
|
||||||
return self._normalize_message_delta(data, descriptor)
|
|
||||||
|
|
||||||
elif result_type == 'message.completed':
|
|
||||||
return self._normalize_message_completed(data, descriptor)
|
|
||||||
|
|
||||||
elif result_type == 'tool.call.started':
|
|
||||||
# Log only, don't yield to pipeline
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'tool.call.completed':
|
|
||||||
# Log only, don't yield to pipeline
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'state.updated':
|
|
||||||
# Log for telemetry, don't yield to pipeline
|
|
||||||
# Orchestrator already handles the actual PersistentStateStore update.
|
|
||||||
scope = data.get('scope', 'unknown')
|
|
||||||
key = data.get('key', 'unknown')
|
|
||||||
value_repr = repr(data.get('value', '...'))[:100] # Truncate for log
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'run.completed':
|
|
||||||
# May include final message
|
|
||||||
if 'message' in data:
|
|
||||||
return self._normalize_message_completed(data, descriptor)
|
|
||||||
# If no message, it's just completion signal
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'run.failed':
|
|
||||||
error_msg = data.get('error', 'Unknown error')
|
|
||||||
error_code = data.get('code', 'unknown')
|
|
||||||
retryable = data.get('retryable', False)
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
f'{error_msg} (code: {error_code})',
|
|
||||||
retryable=retryable,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif result_type == 'action.requested':
|
|
||||||
# Reserved for EBA - log only, don't execute
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Runner {descriptor.id} requested action (not executed in current phase): '
|
|
||||||
f'{data.get("action", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'artifact.created':
|
|
||||||
# Log for telemetry, consumed by orchestrator
|
|
||||||
artifact_id = data.get('artifact_id', 'unknown')
|
|
||||||
artifact_type = data.get('artifact_type', 'unknown')
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} artifact.created logged: artifact_id={artifact_id}, type={artifact_type}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Unknown type - warn and ignore.
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Runner {descriptor.id} returned unknown result type: {result_type}. '
|
|
||||||
f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _normalize_message_delta(
|
|
||||||
self,
|
|
||||||
data: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.MessageChunk:
|
|
||||||
"""Normalize message.delta to MessageChunk."""
|
|
||||||
chunk_data = data.get('chunk', {})
|
|
||||||
if not chunk_data:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data')
|
|
||||||
|
|
||||||
try:
|
|
||||||
chunk = provider_message.MessageChunk.model_validate(chunk_data)
|
|
||||||
return chunk
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}')
|
|
||||||
|
|
||||||
def _normalize_message_completed(
|
|
||||||
self,
|
|
||||||
data: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.Message:
|
|
||||||
"""Normalize message.completed to Message."""
|
|
||||||
message_data = data.get('message', {})
|
|
||||||
if not message_data:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'message.completed missing message data')
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = provider_message.Message.model_validate(message_data)
|
|
||||||
return msg
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}')
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
async def handle_state_updated_event(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> None:
|
|
||||||
"""Handle state.updated result in event-first mode."""
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
|
|
||||||
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,
|
|
||||||
) -> 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': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
|
||||||
'attachments': [a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments],
|
|
||||||
}
|
|
||||||
|
|
||||||
return await store.append_event(
|
|
||||||
event_id=event.event_id,
|
|
||||||
event_type=event.event_type,
|
|
||||||
source=event.source,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
actor_type=event.actor.actor_type if event.actor else None,
|
|
||||||
actor_id=event.actor.actor_id if event.actor else None,
|
|
||||||
actor_name=event.actor.actor_name if event.actor else None,
|
|
||||||
subject_type=event.subject.subject_type if event.subject else None,
|
|
||||||
subject_id=event.subject.subject_id if event.subject else None,
|
|
||||||
input_summary=input_summary,
|
|
||||||
input_json=input_json,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
event_time=datetime.datetime.fromtimestamp(event.event_time) if event.event_time else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def register_input_artifacts(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Register current-event attachments referenced by AgentInput."""
|
|
||||||
if not event.input or not event.input.attachments:
|
|
||||||
return
|
|
||||||
|
|
||||||
from .artifact_store import ArtifactStore
|
|
||||||
|
|
||||||
store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
for attachment in event.input.attachments:
|
|
||||||
data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
artifact_id = data.get('artifact_id')
|
|
||||||
artifact_type = data.get('artifact_type') or 'file'
|
|
||||||
if not artifact_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
content, parsed_mime_type = self.decode_attachment_content(data.get('content'))
|
|
||||||
url = data.get('url')
|
|
||||||
platform_ref_id = data.get('id')
|
|
||||||
storage_key = None
|
|
||||||
storage_type = 'metadata_only'
|
|
||||||
if content is None:
|
|
||||||
if url:
|
|
||||||
storage_key = url
|
|
||||||
storage_type = 'url'
|
|
||||||
elif platform_ref_id:
|
|
||||||
storage_key = platform_ref_id
|
|
||||||
storage_type = 'platform_ref'
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
'input_attachment': True,
|
|
||||||
'input_source': data.get('source') or 'platform',
|
|
||||||
}
|
|
||||||
if url:
|
|
||||||
metadata['url'] = url
|
|
||||||
if platform_ref_id:
|
|
||||||
metadata['platform_ref_id'] = platform_ref_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await store.register_artifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
source='platform',
|
|
||||||
storage_key=storage_key,
|
|
||||||
storage_type=storage_type,
|
|
||||||
mime_type=data.get('mime_type') or parsed_mime_type,
|
|
||||||
name=data.get('name'),
|
|
||||||
size_bytes=data.get('size') or (len(content) if content is not None else None),
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
metadata=metadata,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Failed to register input artifact {artifact_id}: {e}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def decode_attachment_content(
|
|
||||||
self,
|
|
||||||
content: typing.Any,
|
|
||||||
) -> tuple[bytes | None, str | None]:
|
|
||||||
"""Decode base64 attachment content, including data URLs."""
|
|
||||||
if not isinstance(content, str) or not content:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
mime_type = None
|
|
||||||
payload = content
|
|
||||||
if content.startswith('data:') and ',' in content:
|
|
||||||
header, payload = content.split(',', 1)
|
|
||||||
if ';base64' in header:
|
|
||||||
mime_type = header[5:].split(';', 1)[0] or None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return base64.b64decode(payload, validate=False), mime_type
|
|
||||||
except (binascii.Error, ValueError):
|
|
||||||
return None, mime_type
|
|
||||||
|
|
||||||
async def write_user_transcript(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
event_log_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Write user message to Transcript."""
|
|
||||||
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': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in 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(a.model_dump(mode='json') if hasattr(a, 'model_dump') else a)
|
|
||||||
|
|
||||||
await store.append_transcript(
|
|
||||||
transcript_id=None,
|
|
||||||
event_id=event_log_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
role='user',
|
|
||||||
content=content,
|
|
||||||
content_json=content_json,
|
|
||||||
artifact_refs=artifact_refs if artifact_refs else None,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
item_type='message',
|
|
||||||
metadata={
|
|
||||||
'actor_type': event.actor.actor_type if event.actor else None,
|
|
||||||
'actor_id': event.actor.actor_id if event.actor else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_artifact_created(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Handle artifact.created result, register artifact, and write EventLog."""
|
|
||||||
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,
|
|
||||||
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_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
|
|
||||||
|
|
||||||
assistant_event_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
await store.append_transcript(
|
|
||||||
transcript_id=str(uuid.uuid4()),
|
|
||||||
event_id=assistant_event_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
role='assistant',
|
|
||||||
content=content,
|
|
||||||
content_json=content_json,
|
|
||||||
artifact_refs=artifact_refs,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
item_type='message',
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
metadata={
|
|
||||||
'run_id': run_id,
|
|
||||||
'runner_id': runner_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
"""Agent run session registry for proxy action permission validation."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import typing
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from .context_builder import AgentResources
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunSessionStatus(typing.TypedDict):
|
|
||||||
"""Status tracking for agent run session."""
|
|
||||||
started_at: int
|
|
||||||
last_activity_at: int
|
|
||||||
|
|
||||||
|
|
||||||
class RunAuthorizationSnapshot(typing.TypedDict):
|
|
||||||
"""Frozen authorization data for one active run.
|
|
||||||
|
|
||||||
ResourceBuilder creates the authorized resource list once before runner
|
|
||||||
execution. Runtime proxy handlers must validate against this run-scoped
|
|
||||||
snapshot instead of recomputing resource policy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
resources: AgentResources
|
|
||||||
permissions: dict[str, list[str]]
|
|
||||||
conversation_id: str | None
|
|
||||||
state_policy: dict[str, typing.Any]
|
|
||||||
state_context: dict[str, typing.Any]
|
|
||||||
authorized_ids: dict[str, set[str]]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunSession(typing.TypedDict):
|
|
||||||
"""Session for an active agent runner execution.
|
|
||||||
|
|
||||||
Stored in AgentRunSessionRegistry for proxy action permission validation.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
run_id: Unique run identifier (UUID from AgentRunContext)
|
|
||||||
runner_id: Runner descriptor ID (plugin:author/name/runner)
|
|
||||||
query_id: Host entry query ID, only present for query-based adapters
|
|
||||||
plugin_identity: Plugin identifier (author/name) of the runner
|
|
||||||
authorization: Run-scoped authorization snapshot; runtime auth truth
|
|
||||||
status: Session status tracking
|
|
||||||
"""
|
|
||||||
run_id: str
|
|
||||||
runner_id: str
|
|
||||||
query_id: int | None
|
|
||||||
plugin_identity: str # author/name
|
|
||||||
authorization: RunAuthorizationSnapshot
|
|
||||||
status: AgentRunSessionStatus
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunSessionRegistry:
|
|
||||||
"""Registry for active agent run sessions.
|
|
||||||
|
|
||||||
Host-owned registry for tracking active AgentRunner executions.
|
|
||||||
Used by proxy actions in handler.py to validate resource access.
|
|
||||||
|
|
||||||
Key: run_id (UUID from AgentRunContext)
|
|
||||||
Value: AgentRunSession with authorized resources
|
|
||||||
|
|
||||||
Thread-safe via asyncio.Lock.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_sessions: dict[str, AgentRunSession]
|
|
||||||
_lock: asyncio.Lock
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._sessions = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def register(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
query_id: int | None,
|
|
||||||
plugin_identity: str,
|
|
||||||
resources: AgentResources,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
permissions: dict[str, list[str]] | None = None,
|
|
||||||
state_policy: dict[str, typing.Any] | None = None,
|
|
||||||
state_context: dict[str, typing.Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Register a new agent run session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
runner_id: Runner descriptor ID
|
|
||||||
query_id: Host entry query ID, only present for query-based adapters
|
|
||||||
plugin_identity: Plugin identifier (author/name)
|
|
||||||
resources: Authorized resources for this run
|
|
||||||
conversation_id: Conversation ID for history/event access
|
|
||||||
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
|
|
||||||
state_policy: State policy from binding (enable_state, state_scopes)
|
|
||||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
|
|
||||||
# Normalize permissions to empty dict if None
|
|
||||||
permissions = permissions or {}
|
|
||||||
|
|
||||||
# Normalize state_policy to defaults if None
|
|
||||||
if state_policy is None:
|
|
||||||
state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
|
||||||
|
|
||||||
# Normalize state_context to empty dict if None
|
|
||||||
state_context = state_context or {}
|
|
||||||
|
|
||||||
resources_snapshot = copy.deepcopy(resources)
|
|
||||||
authorization: RunAuthorizationSnapshot = {
|
|
||||||
'resources': resources_snapshot,
|
|
||||||
'permissions': copy.deepcopy(permissions),
|
|
||||||
'conversation_id': conversation_id,
|
|
||||||
'state_policy': copy.deepcopy(state_policy),
|
|
||||||
'state_context': copy.deepcopy(state_context),
|
|
||||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
|
||||||
}
|
|
||||||
|
|
||||||
session: AgentRunSession = {
|
|
||||||
'run_id': run_id,
|
|
||||||
'runner_id': runner_id,
|
|
||||||
'query_id': query_id,
|
|
||||||
'plugin_identity': plugin_identity,
|
|
||||||
'authorization': authorization,
|
|
||||||
'status': {
|
|
||||||
'started_at': now,
|
|
||||||
'last_activity_at': now,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
self._sessions[run_id] = session
|
|
||||||
|
|
||||||
def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]:
|
|
||||||
"""Pre-compute authorized resource IDs for O(1) lookup."""
|
|
||||||
return {
|
|
||||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
|
||||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
|
||||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
|
||||||
'skill': {s.get('skill_name') for s in resources.get('skills', [])},
|
|
||||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def unregister(self, run_id: str) -> None:
|
|
||||||
"""Unregister an agent run session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if run_id in self._sessions:
|
|
||||||
del self._sessions[run_id]
|
|
||||||
|
|
||||||
async def get(self, run_id: str) -> AgentRunSession | None:
|
|
||||||
"""Get session by run_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunSession if found, None otherwise
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
return self._sessions.get(run_id)
|
|
||||||
|
|
||||||
async def update_activity(self, run_id: str) -> None:
|
|
||||||
"""Update last activity timestamp for session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if run_id in self._sessions:
|
|
||||||
self._sessions[run_id]['status']['last_activity_at'] = int(time.time())
|
|
||||||
|
|
||||||
def is_resource_allowed(
|
|
||||||
self,
|
|
||||||
session: AgentRunSession,
|
|
||||||
resource_type: str,
|
|
||||||
resource_id: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if resource access is allowed for this session.
|
|
||||||
|
|
||||||
Uses pre-computed authorized IDs for O(1) lookup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AgentRunSession to check
|
|
||||||
resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file')
|
|
||||||
resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace', file_key)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if resource is authorized, False otherwise
|
|
||||||
"""
|
|
||||||
authorization = session['authorization']
|
|
||||||
authorized_ids = authorization['authorized_ids']
|
|
||||||
resources = authorization['resources']
|
|
||||||
|
|
||||||
if resource_type in ('model', 'tool', 'knowledge_base', 'skill', 'file'):
|
|
||||||
return resource_id in authorized_ids.get(resource_type, set())
|
|
||||||
|
|
||||||
if resource_type == 'storage':
|
|
||||||
storage = resources.get('storage', {})
|
|
||||||
if resource_id == 'plugin':
|
|
||||||
return storage.get('plugin_storage', False)
|
|
||||||
elif resource_id == 'workspace':
|
|
||||||
return storage.get('workspace_storage', False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def list_active_runs(self) -> list[AgentRunSession]:
|
|
||||||
"""List all active run sessions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of active AgentRunSession dicts
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
return list(self._sessions.values())
|
|
||||||
|
|
||||||
async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int:
|
|
||||||
"""Cleanup sessions that have been inactive for too long.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_age_seconds: Maximum inactivity time in seconds (default 1 hour)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sessions cleaned up
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
cleaned = 0
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
stale_run_ids = []
|
|
||||||
for run_id, session in self._sessions.items():
|
|
||||||
last_activity = session['status'].get('last_activity_at', 0)
|
|
||||||
if now - last_activity > max_age_seconds:
|
|
||||||
stale_run_ids.append(run_id)
|
|
||||||
|
|
||||||
for run_id in stale_run_ids:
|
|
||||||
del self._sessions[run_id]
|
|
||||||
cleaned += 1
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
# Global registry instance (singleton)
|
|
||||||
_global_registry: AgentRunSessionRegistry | None = None
|
|
||||||
_global_registry_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_registry() -> AgentRunSessionRegistry:
|
|
||||||
"""Get global session registry instance (thread-safe singleton).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunSessionRegistry singleton
|
|
||||||
"""
|
|
||||||
global _global_registry
|
|
||||||
with _global_registry_lock:
|
|
||||||
if _global_registry is None:
|
|
||||||
_global_registry = AgentRunSessionRegistry()
|
|
||||||
return _global_registry
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""State scope key helpers for AgentRunner host-owned state."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .host_models import AgentBinding, AgentEventEnvelope
|
|
||||||
|
|
||||||
|
|
||||||
VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner')
|
|
||||||
|
|
||||||
STATE_KEY_ALIASES = {
|
|
||||||
'conversation_id': 'external.conversation_id',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_state_key(key: str) -> str:
|
|
||||||
"""Map accepted public aliases to protocol state keys."""
|
|
||||||
return STATE_KEY_ALIASES.get(key, key)
|
|
||||||
|
|
||||||
|
|
||||||
def get_binding_identity(binding: AgentBinding) -> str:
|
|
||||||
"""Return the stable binding identity used for state isolation."""
|
|
||||||
if binding.binding_id:
|
|
||||||
return binding.binding_id
|
|
||||||
|
|
||||||
scope = binding.scope
|
|
||||||
if scope.scope_type and scope.scope_id:
|
|
||||||
return f'{scope.scope_type}:{scope.scope_id}'
|
|
||||||
|
|
||||||
return 'unknown_binding'
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_scope_key(
|
|
||||||
scope: str,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> str | None:
|
|
||||||
"""Build the storage key for one state scope.
|
|
||||||
|
|
||||||
Returns None when the event lacks the identity required by that scope.
|
|
||||||
"""
|
|
||||||
binding_identity = get_binding_identity(binding)
|
|
||||||
|
|
||||||
if scope == 'conversation':
|
|
||||||
if not event.conversation_id:
|
|
||||||
return None
|
|
||||||
parts = [descriptor.id, binding_identity, event.conversation_id]
|
|
||||||
if event.thread_id:
|
|
||||||
parts.append(event.thread_id)
|
|
||||||
return f'conversation:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'actor':
|
|
||||||
if not event.actor or not event.actor.actor_id:
|
|
||||||
return None
|
|
||||||
parts = [
|
|
||||||
descriptor.id,
|
|
||||||
binding_identity,
|
|
||||||
event.actor.actor_type or 'user',
|
|
||||||
event.actor.actor_id,
|
|
||||||
]
|
|
||||||
return f'actor:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'subject':
|
|
||||||
if not event.subject or not event.subject.subject_id:
|
|
||||||
return None
|
|
||||||
parts = [
|
|
||||||
descriptor.id,
|
|
||||||
binding_identity,
|
|
||||||
event.subject.subject_type or 'unknown',
|
|
||||||
event.subject.subject_id,
|
|
||||||
]
|
|
||||||
return f'subject:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'runner':
|
|
||||||
return f'runner:{descriptor.id}:{binding_identity}'
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_scope_keys(
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Build all available scope keys for an event/binding pair."""
|
|
||||||
scope_keys: dict[str, str] = {}
|
|
||||||
for scope in VALID_STATE_SCOPES:
|
|
||||||
scope_key = build_state_scope_key(scope, event, binding, descriptor)
|
|
||||||
if scope_key:
|
|
||||||
scope_keys[scope] = scope_key
|
|
||||||
return scope_keys
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_context(
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Build the State API context stored in the run session."""
|
|
||||||
return {
|
|
||||||
'scope_keys': build_state_scope_keys(event, binding, descriptor),
|
|
||||||
'binding_identity': get_binding_identity(binding),
|
|
||||||
'bot_id': event.bot_id,
|
|
||||||
'workspace_id': event.workspace_id,
|
|
||||||
'conversation_id': event.conversation_id,
|
|
||||||
'thread_id': event.thread_id,
|
|
||||||
'actor_type': event.actor.actor_type if event.actor else None,
|
|
||||||
'actor_id': event.actor.actor_id if event.actor else None,
|
|
||||||
'subject_type': event.subject.subject_type if event.subject else None,
|
|
||||||
'subject_id': event.subject.subject_id if event.subject else None,
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
"""Transcript store for writing and querying conversation history."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from ...entity.persistence.transcript import Transcript
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptStore:
|
|
||||||
"""Store for Transcript records.
|
|
||||||
|
|
||||||
Handles writing transcript items and querying them for history API.
|
|
||||||
All methods are async and use the provided database engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
|
|
||||||
# Hard limits
|
|
||||||
MAX_CONTENT_LENGTH = 4000
|
|
||||||
HARD_LIMIT = 100
|
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
|
||||||
self.engine = engine
|
|
||||||
self._session_factory = sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def append_transcript(
|
|
||||||
self,
|
|
||||||
transcript_id: str | None,
|
|
||||||
event_id: str,
|
|
||||||
conversation_id: str,
|
|
||||||
role: str,
|
|
||||||
content: str | None = None,
|
|
||||||
content_json: dict[str, typing.Any] | None = None,
|
|
||||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
|
||||||
thread_id: str | None = None,
|
|
||||||
item_type: str = "message",
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append a transcript item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
transcript_id: Unique transcript ID (generated if None)
|
|
||||||
event_id: Source event ID
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
role: Message role (user, assistant, system, tool)
|
|
||||||
content: Text content
|
|
||||||
content_json: Full structured content
|
|
||||||
artifact_refs: Artifact references
|
|
||||||
thread_id: Thread ID
|
|
||||||
item_type: Item type
|
|
||||||
run_id: Run ID that generated this
|
|
||||||
runner_id: Runner ID that generated this
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The transcript_id
|
|
||||||
"""
|
|
||||||
if transcript_id is None:
|
|
||||||
transcript_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Truncate content if too long
|
|
||||||
if content and len(content) > self.MAX_CONTENT_LENGTH:
|
|
||||||
content = content[:self.MAX_CONTENT_LENGTH - 3] + "..."
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
item = Transcript(
|
|
||||||
transcript_id=transcript_id,
|
|
||||||
event_id=event_id,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
role=role,
|
|
||||||
item_type=item_type,
|
|
||||||
content=content,
|
|
||||||
content_json=json.dumps(content_json) if content_json else None,
|
|
||||||
artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None,
|
|
||||||
seq=0,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
)
|
|
||||||
session.add(item)
|
|
||||||
await session.flush()
|
|
||||||
item.seq = item.id or await self._get_next_seq(conversation_id)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return transcript_id
|
|
||||||
|
|
||||||
async def page_transcript(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
before_seq: int | None = None,
|
|
||||||
after_seq: int | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
direction: str = "backward",
|
|
||||||
include_artifacts: bool = False,
|
|
||||||
) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]:
|
|
||||||
"""Page through transcript items.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
before_seq: Get items before this sequence (backward)
|
|
||||||
after_seq: Get items after this sequence (forward)
|
|
||||||
limit: Maximum items to return (capped at 100)
|
|
||||||
direction: 'backward' (older) or 'forward' (newer)
|
|
||||||
include_artifacts: Include artifact refs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (items, next_seq, prev_seq, has_more)
|
|
||||||
"""
|
|
||||||
limit = min(limit, self.HARD_LIMIT)
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(Transcript).where(
|
|
||||||
Transcript.conversation_id == conversation_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if direction == "backward" and before_seq is not None:
|
|
||||||
query = query.where(Transcript.seq < before_seq)
|
|
||||||
query = query.order_by(Transcript.seq.desc())
|
|
||||||
elif direction == "forward" and after_seq is not None:
|
|
||||||
query = query.where(Transcript.seq > after_seq)
|
|
||||||
query = query.order_by(Transcript.seq.asc())
|
|
||||||
else:
|
|
||||||
# Default: most recent items first (backward from latest)
|
|
||||||
query = query.order_by(Transcript.seq.desc())
|
|
||||||
|
|
||||||
query = query.limit(limit + 1)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
items = [self._row_to_dict(row, include_artifacts) for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
|
|
||||||
# Calculate cursors
|
|
||||||
next_seq = None
|
|
||||||
prev_seq = None
|
|
||||||
|
|
||||||
if direction == "backward":
|
|
||||||
# Items are in descending order
|
|
||||||
if items:
|
|
||||||
next_seq = items[-1].get('seq') if has_more else None
|
|
||||||
prev_seq = items[0].get('seq')
|
|
||||||
else:
|
|
||||||
# Items are in ascending order
|
|
||||||
if items:
|
|
||||||
next_seq = items[-1].get('seq') if has_more else None
|
|
||||||
prev_seq = items[0].get('seq')
|
|
||||||
|
|
||||||
return items, next_seq, prev_seq, has_more
|
|
||||||
|
|
||||||
async def search_transcript(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
query_text: str,
|
|
||||||
filters: dict[str, typing.Any] | None = None,
|
|
||||||
top_k: int = 10,
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Search transcript items.
|
|
||||||
|
|
||||||
Basic implementation using LIKE filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
query_text: Search query
|
|
||||||
filters: Optional filters
|
|
||||||
top_k: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of matching items
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(Transcript).where(
|
|
||||||
Transcript.conversation_id == conversation_id,
|
|
||||||
Transcript.content.ilike(f"%{query_text}%"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply additional filters
|
|
||||||
if filters:
|
|
||||||
if 'roles' in filters:
|
|
||||||
query = query.where(Transcript.role.in_(filters['roles']))
|
|
||||||
if 'item_types' in filters:
|
|
||||||
query = query.where(Transcript.item_type.in_(filters['item_types']))
|
|
||||||
|
|
||||||
query = query.order_by(Transcript.seq.desc()).limit(top_k)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
return [self._row_to_dict(row, include_artifacts=True) for row in rows]
|
|
||||||
|
|
||||||
async def get_latest_cursor(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get the latest cursor for a conversation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cursor string (seq number), or None if no items
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(Transcript.seq)
|
|
||||||
.where(Transcript.conversation_id == conversation_id)
|
|
||||||
.order_by(Transcript.seq.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return str(row)
|
|
||||||
|
|
||||||
async def get_legacy_provider_messages(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
limit: int = HARD_LIMIT,
|
|
||||||
) -> list[provider_message.Message]:
|
|
||||||
"""Project Transcript rows into the legacy provider Message view.
|
|
||||||
|
|
||||||
AgentRunner history is canonical in Transcript. This view exists for
|
|
||||||
legacy Pipeline readers such as PromptPreProcessing that still expect
|
|
||||||
query.messages.
|
|
||||||
"""
|
|
||||||
items, _, _, _ = await self.page_transcript(
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
limit=limit,
|
|
||||||
direction="backward",
|
|
||||||
)
|
|
||||||
|
|
||||||
messages: list[provider_message.Message] = []
|
|
||||||
for item in reversed(items):
|
|
||||||
message = self._transcript_item_to_provider_message(item)
|
|
||||||
if message is not None:
|
|
||||||
messages.append(message)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
async def has_history_before(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
seq: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if there is history before a sequence number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
seq: Sequence number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if there are items before
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(Transcript)
|
|
||||||
.where(
|
|
||||||
Transcript.conversation_id == conversation_id,
|
|
||||||
Transcript.seq < seq,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
count = result.scalar()
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def _get_next_seq(self, conversation_id: str) -> int:
|
|
||||||
"""Fallback next sequence number for stores that cannot expose autoincrement IDs."""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.max(Transcript.seq))
|
|
||||||
.where(Transcript.conversation_id == conversation_id)
|
|
||||||
)
|
|
||||||
max_seq = result.scalar()
|
|
||||||
return (max_seq or 0) + 1
|
|
||||||
|
|
||||||
def _row_to_dict(
|
|
||||||
self,
|
|
||||||
row: Transcript,
|
|
||||||
include_artifacts: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Convert a Transcript row to dict."""
|
|
||||||
result = {
|
|
||||||
'transcript_id': row.transcript_id,
|
|
||||||
'event_id': row.event_id,
|
|
||||||
'conversation_id': row.conversation_id,
|
|
||||||
'thread_id': row.thread_id,
|
|
||||||
'role': row.role,
|
|
||||||
'item_type': row.item_type,
|
|
||||||
'content': row.content,
|
|
||||||
'content_json': json.loads(row.content_json) if row.content_json else None,
|
|
||||||
'seq': row.seq,
|
|
||||||
'cursor': str(row.seq),
|
|
||||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
|
||||||
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_artifacts and row.artifact_refs_json:
|
|
||||||
result['artifact_refs'] = json.loads(row.artifact_refs_json)
|
|
||||||
else:
|
|
||||||
result['artifact_refs'] = []
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _transcript_item_to_provider_message(
|
|
||||||
self,
|
|
||||||
item: dict[str, typing.Any],
|
|
||||||
) -> provider_message.Message | None:
|
|
||||||
"""Convert one Transcript API item into a provider Message."""
|
|
||||||
if item.get('item_type') != 'message':
|
|
||||||
return None
|
|
||||||
|
|
||||||
role = item.get('role')
|
|
||||||
if role not in {'user', 'assistant'}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
content_json = item.get('content_json')
|
|
||||||
if isinstance(content_json, dict):
|
|
||||||
message_data = dict(content_json)
|
|
||||||
message_data['role'] = role
|
|
||||||
try:
|
|
||||||
return provider_message.Message.model_validate(message_data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
content = item.get('content')
|
|
||||||
if content is None:
|
|
||||||
return None
|
|
||||||
return provider_message.Message(role=role, content=content)
|
|
||||||
@@ -46,6 +46,30 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=metrics)
|
return self.success(data=metrics)
|
||||||
|
|
||||||
|
@self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_token_statistics() -> str:
|
||||||
|
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
start_time_str = quart.request.args.get('startTime')
|
||||||
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
bucket = quart.request.args.get('bucket', 'hour')
|
||||||
|
if bucket not in ('hour', 'day'):
|
||||||
|
bucket = 'hour'
|
||||||
|
|
||||||
|
start_time = parse_iso_datetime(start_time_str)
|
||||||
|
end_time = parse_iso_datetime(end_time_str)
|
||||||
|
|
||||||
|
stats = await self.ap.monitoring_service.get_token_statistics(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
bucket=bucket,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data=stats)
|
||||||
|
|
||||||
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def get_messages() -> str:
|
async def get_messages() -> str:
|
||||||
"""Get message logs"""
|
"""Get message logs"""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""List MCP servers or create a new MCP server."""
|
"""获取MCP服务器列表"""
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
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)
|
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Get, update, or delete an MCP server configuration."""
|
"""获取、更新或删除MCP服务器配置"""
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
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)
|
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""Test an MCP server connection."""
|
"""测试MCP服务器连接"""
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class MCPService:
|
|||||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
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:
|
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||||
"""Test an MCP server connection and return the task ID."""
|
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||||
|
|
||||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
|||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....provider.modelmgr import requester as model_requester
|
from ....provider.modelmgr import requester as model_requester
|
||||||
|
|
||||||
|
|
||||||
@@ -108,9 +109,23 @@ class LLMModelsService:
|
|||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
|
|
||||||
if auto_set_to_default_pipeline:
|
if auto_set_to_default_pipeline:
|
||||||
default_config_service = getattr(self.ap, 'agent_runner_default_config_service', None)
|
# set the default pipeline model to this model
|
||||||
if default_config_service is not None:
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
await default_config_service.auto_set_default_pipeline_llm_model(model_data['uuid'])
|
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)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,179 @@ class MonitoringService:
|
|||||||
'active_sessions': active_sessions,
|
'active_sessions': active_sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def get_token_statistics(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
bucket: str = 'hour',
|
||||||
|
) -> dict:
|
||||||
|
"""Get detailed token usage statistics for production observability.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- summary: aggregate token counters and call/latency stats over the window
|
||||||
|
- by_model: per-model token + call breakdown (sorted by total tokens desc)
|
||||||
|
- timeseries: token usage bucketed by `bucket` ('hour' or 'day')
|
||||||
|
|
||||||
|
Only successful LLM calls are counted toward token totals; error calls are
|
||||||
|
reported separately so a spike in failures is visible without polluting
|
||||||
|
token accounting.
|
||||||
|
"""
|
||||||
|
LLMCall = persistence_monitoring.MonitoringLLMCall
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(LLMCall.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(LLMCall.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(LLMCall.timestamp <= end_time)
|
||||||
|
|
||||||
|
def _apply(query):
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
return query
|
||||||
|
|
||||||
|
# ---- Summary aggregates ----
|
||||||
|
summary_query = _apply(
|
||||||
|
sqlalchemy.select(
|
||||||
|
sqlalchemy.func.count(LLMCall.id),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
||||||
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)),
|
||||||
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
||||||
|
# Count of successful calls that nonetheless recorded zero tokens —
|
||||||
|
# a data-quality signal that usage reporting may be broken upstream.
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case(
|
||||||
|
(sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary_result = await self.ap.persistence_mgr.execute_async(summary_query)
|
||||||
|
row = summary_result.first()
|
||||||
|
(
|
||||||
|
total_calls,
|
||||||
|
total_input_tokens,
|
||||||
|
total_output_tokens,
|
||||||
|
total_tokens,
|
||||||
|
total_duration,
|
||||||
|
total_cost,
|
||||||
|
success_calls,
|
||||||
|
error_calls,
|
||||||
|
zero_token_success_calls,
|
||||||
|
) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0)
|
||||||
|
|
||||||
|
total_calls = total_calls or 0
|
||||||
|
success_calls = success_calls or 0
|
||||||
|
error_calls = error_calls or 0
|
||||||
|
zero_token_success_calls = zero_token_success_calls or 0
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'total_calls': total_calls,
|
||||||
|
'success_calls': success_calls,
|
||||||
|
'error_calls': error_calls,
|
||||||
|
'total_input_tokens': int(total_input_tokens or 0),
|
||||||
|
'total_output_tokens': int(total_output_tokens or 0),
|
||||||
|
'total_tokens': int(total_tokens or 0),
|
||||||
|
'total_cost': round(float(total_cost or 0.0), 6),
|
||||||
|
'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0,
|
||||||
|
'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0,
|
||||||
|
'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2)
|
||||||
|
if total_duration and total_duration > 0
|
||||||
|
else 0,
|
||||||
|
'zero_token_success_calls': zero_token_success_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Per-model breakdown ----
|
||||||
|
by_model_query = _apply(
|
||||||
|
sqlalchemy.select(
|
||||||
|
LLMCall.model_name,
|
||||||
|
sqlalchemy.func.count(LLMCall.id),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
||||||
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
||||||
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
||||||
|
).group_by(LLMCall.model_name)
|
||||||
|
)
|
||||||
|
by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query)
|
||||||
|
by_model = []
|
||||||
|
for mrow in by_model_result.all():
|
||||||
|
(
|
||||||
|
model_name,
|
||||||
|
m_calls,
|
||||||
|
m_in,
|
||||||
|
m_out,
|
||||||
|
m_total,
|
||||||
|
m_duration,
|
||||||
|
m_cost,
|
||||||
|
m_errors,
|
||||||
|
) = mrow
|
||||||
|
m_calls = m_calls or 0
|
||||||
|
by_model.append(
|
||||||
|
{
|
||||||
|
'model_name': model_name,
|
||||||
|
'calls': m_calls,
|
||||||
|
'error_calls': m_errors or 0,
|
||||||
|
'input_tokens': int(m_in or 0),
|
||||||
|
'output_tokens': int(m_out or 0),
|
||||||
|
'total_tokens': int(m_total or 0),
|
||||||
|
'cost': round(float(m_cost or 0.0), 6),
|
||||||
|
'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0,
|
||||||
|
'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
by_model.sort(key=lambda x: x['total_tokens'], reverse=True)
|
||||||
|
|
||||||
|
# ---- Time-bucketed series ----
|
||||||
|
# Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and
|
||||||
|
# aggregate in Python. The window is bounded by the time filter, so this is
|
||||||
|
# cheap for typical dashboard ranges (hours/days).
|
||||||
|
series_query = _apply(
|
||||||
|
sqlalchemy.select(
|
||||||
|
LLMCall.timestamp,
|
||||||
|
LLMCall.input_tokens,
|
||||||
|
LLMCall.output_tokens,
|
||||||
|
LLMCall.total_tokens,
|
||||||
|
).order_by(LLMCall.timestamp.asc())
|
||||||
|
)
|
||||||
|
series_result = await self.ap.persistence_mgr.execute_async(series_query)
|
||||||
|
|
||||||
|
bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
|
||||||
|
buckets: dict[str, dict] = {}
|
||||||
|
for srow in series_result.all():
|
||||||
|
ts, s_in, s_out, s_total = srow
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
key = ts.strftime(bucket_fmt)
|
||||||
|
b = buckets.setdefault(
|
||||||
|
key,
|
||||||
|
{'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0},
|
||||||
|
)
|
||||||
|
b['input_tokens'] += int(s_in or 0)
|
||||||
|
b['output_tokens'] += int(s_out or 0)
|
||||||
|
b['total_tokens'] += int(s_total or 0)
|
||||||
|
b['calls'] += 1
|
||||||
|
|
||||||
|
timeseries = [buckets[k] for k in sorted(buckets.keys())]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'summary': summary,
|
||||||
|
'by_model': by_model,
|
||||||
|
'timeseries': timeseries,
|
||||||
|
'bucket': bucket,
|
||||||
|
}
|
||||||
|
|
||||||
async def get_messages(
|
async def get_messages(
|
||||||
self,
|
self,
|
||||||
bot_ids: list[str] | None = None,
|
bot_ids: list[str] | None = None,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import typing
|
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
@@ -14,6 +13,7 @@ default_stage_order = [
|
|||||||
'BanSessionCheckStage', # 封禁会话检查
|
'BanSessionCheckStage', # 封禁会话检查
|
||||||
'PreContentFilterStage', # 内容过滤前置阶段
|
'PreContentFilterStage', # 内容过滤前置阶段
|
||||||
'PreProcessor', # 预处理器
|
'PreProcessor', # 预处理器
|
||||||
|
'ConversationMessageTruncator', # 会话消息截断器
|
||||||
'RequireRateLimitOccupancy', # 请求速率限制占用
|
'RequireRateLimitOccupancy', # 请求速率限制占用
|
||||||
'MessageProcessor', # 处理器
|
'MessageProcessor', # 处理器
|
||||||
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
||||||
@@ -30,100 +30,11 @@ class PipelineService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
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]:
|
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 [
|
return [
|
||||||
self.ap.pipeline_config_meta_trigger,
|
self.ap.pipeline_config_meta_trigger,
|
||||||
self.ap.pipeline_config_meta_safety,
|
self.ap.pipeline_config_meta_safety,
|
||||||
ai_metadata,
|
self.ap.pipeline_config_meta_ai,
|
||||||
self.ap.pipeline_config_meta_output,
|
self.ap.pipeline_config_meta_output,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -163,6 +74,8 @@ class PipelineService:
|
|||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||||
|
|
||||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||||
|
from ....utils import paths as path_utils
|
||||||
|
|
||||||
# Check limitation
|
# Check limitation
|
||||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||||
max_pipelines = limitation.get('max_pipelines', -1)
|
max_pipelines = limitation.get('max_pipelines', -1)
|
||||||
@@ -176,7 +89,9 @@ class PipelineService:
|
|||||||
pipeline_data['stages'] = default_stage_order.copy()
|
pipeline_data['stages'] = default_stage_order.copy()
|
||||||
pipeline_data['is_default'] = default
|
pipeline_data['is_default'] = default
|
||||||
|
|
||||||
pipeline_data['config'] = await self.get_default_pipeline_config()
|
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)
|
||||||
|
|
||||||
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
||||||
if 'extensions_preferences' not in pipeline_data:
|
if 'extensions_preferences' not in pipeline_data:
|
||||||
@@ -198,16 +113,10 @@ class PipelineService:
|
|||||||
return pipeline_data['uuid']
|
return pipeline_data['uuid']
|
||||||
|
|
||||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||||
from ....agent.runner.config_migration import ConfigMigration
|
|
||||||
|
|
||||||
pipeline_data = pipeline_data.copy()
|
pipeline_data = pipeline_data.copy()
|
||||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||||
pipeline_data.pop(protected_field, None)
|
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(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||||
|
|||||||
@@ -146,19 +146,13 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
|||||||
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
||||||
|
|
||||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
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 TMPDIR="$_LB_TMP_DIR"
|
||||||
export TEMP="$_LB_TMP_DIR"
|
export TEMP="$_LB_TMP_DIR"
|
||||||
export TMP="$_LB_TMP_DIR"
|
export TMP="$_LB_TMP_DIR"
|
||||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||||
|
|
||||||
_lb_python_meta() {{
|
_lb_python_meta() {{
|
||||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
python - <<'PY'
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -231,7 +225,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
|||||||
|
|
||||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||||
rm -rf "$_LB_VENV_DIR"
|
rm -rf "$_LB_VENV_DIR"
|
||||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
python -m venv "$_LB_VENV_DIR"
|
||||||
. "$_LB_VENV_DIR/bin/activate"
|
. "$_LB_VENV_DIR/bin/activate"
|
||||||
python -m pip install --upgrade pip setuptools wheel
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..platform import botmgr as im_mgr
|
from ..platform import botmgr as im_mgr
|
||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
@@ -47,9 +46,6 @@ from ..telemetry import telemetry as telemetry_module
|
|||||||
from ..survey import manager as survey_module
|
from ..survey import manager as survey_module
|
||||||
from ..skill import manager as skill_mgr
|
from ..skill import manager as skill_mgr
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService
|
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
"""Runtime application object and context"""
|
"""Runtime application object and context"""
|
||||||
@@ -169,13 +165,6 @@ class Application:
|
|||||||
|
|
||||||
maintenance_service: maintenance_service.MaintenanceService = None
|
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):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ required_deps = {
|
|||||||
'telegramify_markdown': 'telegramify-markdown',
|
'telegramify_markdown': 'telegramify-markdown',
|
||||||
'slack_sdk': 'slack_sdk',
|
'slack_sdk': 'slack_sdk',
|
||||||
'asyncpg': 'asyncpg',
|
'asyncpg': 'asyncpg',
|
||||||
|
'litellm': 'litellm',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
22
src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py
Normal file
22
src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class('msg-truncator-cfg-migration', 9)
|
||||||
|
class MsgTruncatorConfigMigration(migration.Migration):
|
||||||
|
"""迁移"""
|
||||||
|
|
||||||
|
async def need_migrate(self) -> bool:
|
||||||
|
"""判断当前环境是否需要运行此迁移"""
|
||||||
|
return 'msg-truncate' not in self.ap.pipeline_cfg.data
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""执行迁移"""
|
||||||
|
|
||||||
|
self.ap.pipeline_cfg.data['msg-truncate'] = {
|
||||||
|
'method': 'round',
|
||||||
|
'round': {'max-round': 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.pipeline_cfg.dump_config()
|
||||||
@@ -39,7 +39,6 @@ from ...vector import mgr as vectordb_mgr
|
|||||||
from .. import taskmgr
|
from .. import taskmgr
|
||||||
from ...telemetry import telemetry as telemetry_module
|
from ...telemetry import telemetry as telemetry_module
|
||||||
from ...survey import manager as survey_module
|
from ...survey import manager as survey_module
|
||||||
from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService
|
|
||||||
|
|
||||||
|
|
||||||
@stage.stage_class('BuildAppStage')
|
@stage.stage_class('BuildAppStage')
|
||||||
@@ -195,15 +194,5 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
ap.plugin_connector = plugin_connector_inst
|
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)
|
ctrl = controller.Controller(ap)
|
||||||
ap.ctrl = ctrl
|
ap.ctrl = ctrl
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Agent runner state persistence entity for host-owned state."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerState(Base):
|
|
||||||
"""AgentRunnerState stores host-owned state for AgentRunner protocol.
|
|
||||||
|
|
||||||
State is:
|
|
||||||
- Host-owned: Managed by LangBot, not by plugin instances
|
|
||||||
- Scope-isolated: Separated by runner_id + binding_identity + scope
|
|
||||||
- Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes)
|
|
||||||
|
|
||||||
Scope key design:
|
|
||||||
- conversation: runner_id + binding_id + conversation_id [+ thread_id]
|
|
||||||
- actor: runner_id + binding_id + actor_type + actor_id
|
|
||||||
- subject: runner_id + binding_id + subject_type + subject_id
|
|
||||||
- runner: runner_id + binding_id
|
|
||||||
|
|
||||||
This table is the production store for AgentRunner state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'agent_runner_state'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
# Identity
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Runner descriptor ID (plugin:author/name/runner)."""
|
|
||||||
|
|
||||||
binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Binding identity for isolation (binding_id or scope_type:scope_id)."""
|
|
||||||
|
|
||||||
scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
|
||||||
"""State scope: 'conversation', 'actor', 'subject', or 'runner'."""
|
|
||||||
|
|
||||||
scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False, index=True)
|
|
||||||
"""Full scope key for unique lookup (includes all identity parts)."""
|
|
||||||
|
|
||||||
state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
"""State key within scope (should use namespace prefix like external.*)."""
|
|
||||||
|
|
||||||
value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""State value as JSON string (size-limited by host)."""
|
|
||||||
|
|
||||||
# Context fields for querying/filtering
|
|
||||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Bot UUID if applicable."""
|
|
||||||
|
|
||||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Workspace ID for multi-tenant."""
|
|
||||||
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Conversation ID for conversation scope."""
|
|
||||||
|
|
||||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Thread ID for thread-scoped conversation state."""
|
|
||||||
|
|
||||||
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Actor type for actor scope."""
|
|
||||||
|
|
||||||
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Actor ID for actor scope."""
|
|
||||||
|
|
||||||
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Subject type for subject scope."""
|
|
||||||
|
|
||||||
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Subject ID for subject scope."""
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this state entry was created."""
|
|
||||||
|
|
||||||
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
|
||||||
"""When this state entry was last updated."""
|
|
||||||
|
|
||||||
# Unique constraint: scope_key + state_key
|
|
||||||
__table_args__ = (
|
|
||||||
sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'),
|
|
||||||
sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'),
|
|
||||||
sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'),
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""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."""
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""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."""
|
|
||||||
@@ -31,6 +31,7 @@ class LLMModel(Base):
|
|||||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||||
|
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
"""Transcript persistence entity for conversation history projection."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(Base):
|
|
||||||
"""Transcript stores conversation-oriented message projection for history API.
|
|
||||||
|
|
||||||
This is a projection of EventLog, optimized for agent history retrieval.
|
|
||||||
It includes message content and artifact refs, but not raw platform payloads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'transcript'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
|
||||||
"""Unique transcript item identifier."""
|
|
||||||
|
|
||||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Reference to the source event in EventLog."""
|
|
||||||
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Conversation this item belongs to."""
|
|
||||||
|
|
||||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Thread ID if platform supports threads."""
|
|
||||||
|
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
"""Message role: 'user', 'assistant', 'system', or 'tool'."""
|
|
||||||
|
|
||||||
item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message')
|
|
||||||
"""Item type: 'message', 'tool_call', 'tool_result', 'system'."""
|
|
||||||
|
|
||||||
# Content
|
|
||||||
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
|
|
||||||
|
|
||||||
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Full structured content as JSON string (Message model dump)."""
|
|
||||||
|
|
||||||
# Artifact references
|
|
||||||
artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Artifact references as JSON string (list of ArtifactRef)."""
|
|
||||||
|
|
||||||
# Sequence for cursor-based pagination
|
|
||||||
seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, index=True)
|
|
||||||
"""Monotonic cursor sequence for pagination."""
|
|
||||||
|
|
||||||
# Context
|
|
||||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Run ID that generated this item (for assistant messages)."""
|
|
||||||
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Runner ID that generated this item."""
|
|
||||||
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this item was created."""
|
|
||||||
|
|
||||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'),
|
|
||||||
sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'),
|
|
||||||
)
|
|
||||||
@@ -13,28 +13,6 @@ from sqlalchemy.engine import Connection
|
|||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
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
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add llm model context length
|
||||||
|
|
||||||
|
Revision ID: 0004_add_llm_model_context_length
|
||||||
|
Revises: 0003_add_rerank_models
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0004_add_llm_model_context_length'
|
||||||
|
down_revision = '0003_add_rerank_models'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||||
|
if 'context_length' not in columns:
|
||||||
|
op.add_column('llm_models', sa.Column('context_length', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||||
|
if 'context_length' in columns:
|
||||||
|
op.drop_column('llm_models', 'context_length')
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Normalize AgentRunner config containers
|
|
||||||
|
|
||||||
Revision ID: 0004_migrate_runner_config
|
|
||||||
Revises: 0003_add_rerank_models
|
|
||||||
Create Date: 2026-05-10
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0004_migrate_runner_config'
|
|
||||||
down_revision = '0003_add_rerank_models'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
def migrate_pipeline_config(config: dict) -> dict:
|
|
||||||
"""Keep current AgentRunner config containers explicit."""
|
|
||||||
new_config = dict(config)
|
|
||||||
if 'ai' not in new_config:
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
ai_config = dict(new_config.get('ai', {}))
|
|
||||||
|
|
||||||
ai_config['runner'] = dict(ai_config.get('runner', {}))
|
|
||||||
ai_config['runner_config'] = dict(ai_config.get('runner_config', {}))
|
|
||||||
new_config['ai'] = ai_config
|
|
||||||
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Normalize existing pipeline config containers."""
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
|
|
||||||
# Check if pipelines table exists (may not exist in fresh install)
|
|
||||||
if 'pipelines' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get all pipelines
|
|
||||||
result = conn.execute(sa.text('SELECT uuid, config FROM pipelines'))
|
|
||||||
pipelines = result.fetchall()
|
|
||||||
|
|
||||||
for pipeline_uuid, config_json in pipelines:
|
|
||||||
if not config_json:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = json.loads(config_json)
|
|
||||||
migrated_config = migrate_pipeline_config(config)
|
|
||||||
|
|
||||||
# Only update if config changed
|
|
||||||
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
|
|
||||||
conn.execute(
|
|
||||||
sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
|
|
||||||
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Skip invalid configs
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade is not supported for data migration."""
|
|
||||||
# No downgrade - keep configs in new format
|
|
||||||
pass
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""add_event_log_and_transcript_tables
|
|
||||||
|
|
||||||
Revision ID: 58846a8d7a81
|
|
||||||
Revises: 0004_migrate_runner_config
|
|
||||||
Create Date: 2026-05-23 15:41:47.030841
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = '58846a8d7a81'
|
|
||||||
down_revision = '0004_migrate_runner_config'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def _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 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('conversation_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('thread_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('role', sa.String(50), nullable=False),
|
|
||||||
sa.Column('item_type', sa.String(50), nullable=False, server_default='message'),
|
|
||||||
sa.Column('content', sa.Text(), nullable=True),
|
|
||||||
sa.Column('content_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('artifact_refs_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('seq', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('run_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
|
||||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for transcript
|
|
||||||
_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_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_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_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_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')
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 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', ['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')
|
|
||||||
_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 ###
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -118,6 +118,9 @@ class DBMigrateV3Config(migration.DBMigration):
|
|||||||
'runner': self.ap.provider_cfg.data['runner'],
|
'runner': self.ap.provider_cfg.data['runner'],
|
||||||
}
|
}
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_uuid
|
pipeline_config['ai']['local-agent']['model'] = model_uuid
|
||||||
|
pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][
|
||||||
|
'max-round'
|
||||||
|
]
|
||||||
|
|
||||||
pipeline_config['ai']['local-agent']['prompt'] = [
|
pipeline_config['ai']['local-agent']['prompt'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(26)
|
||||||
|
class DBMigrateLLMModelContextLength(migration.DBMigration):
|
||||||
|
"""Add context_length column to LLM models"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
columns = await self._get_columns('llm_models')
|
||||||
|
if 'context_length' not in columns:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN context_length INTEGER')
|
||||||
|
)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
columns = await self._get_columns('llm_models')
|
||||||
|
if 'context_length' not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('ALTER TABLE llm_models DROP COLUMN IF EXISTS context_length')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('ALTER TABLE llm_models DROP COLUMN context_length')
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_columns(self, table_name: str) -> set[str]:
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = :table_name
|
||||||
|
"""),
|
||||||
|
{'table_name': table_name},
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.fetchall()}
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name})'))
|
||||||
|
return {row[1] for row in result.fetchall()}
|
||||||
0
src/langbot/pkg/pipeline/msgtrun/__init__.py
Normal file
0
src/langbot/pkg/pipeline/msgtrun/__init__.py
Normal file
35
src/langbot/pkg/pipeline/msgtrun/msgtrun.py
Normal file
35
src/langbot/pkg/pipeline/msgtrun/msgtrun.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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)
|
||||||
56
src/langbot/pkg/pipeline/msgtrun/truncator.py
Normal file
56
src/langbot/pkg/pipeline/msgtrun/truncator.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
30
src/langbot/pkg/pipeline/msgtrun/truncators/round.py
Normal file
30
src/langbot/pkg/pipeline/msgtrun/truncators/round.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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,6 +28,7 @@ from . import (
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
|
msgtrun,
|
||||||
)
|
)
|
||||||
|
|
||||||
importutil.import_modules_in_pkgs(
|
importutil.import_modules_in_pkgs(
|
||||||
@@ -41,6 +42,7 @@ importutil.import_modules_in_pkgs(
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
|
msgtrun,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,9 +438,6 @@ class PipelineManager:
|
|||||||
# initialize stage containers according to pipeline_entity.stages
|
# initialize stage containers according to pipeline_entity.stages
|
||||||
stage_containers: list[StageInstContainer] = []
|
stage_containers: list[StageInstContainer] = []
|
||||||
for stage_name in pipeline_entity.stages:
|
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)))
|
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
|
||||||
|
|
||||||
for stage_container in stage_containers:
|
for stage_container in stage_containers:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import typing
|
|
||||||
|
|
||||||
from .. import stage, entities
|
from .. import stage, entities
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
@@ -10,15 +9,6 @@ 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.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
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')
|
@stage.stage_class('PreProcessor')
|
||||||
class PreProcessor(stage.PipelineStage):
|
class PreProcessor(stage.PipelineStage):
|
||||||
@@ -35,156 +25,55 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
- use_funcs
|
- use_funcs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def _get_runner_descriptor(
|
|
||||||
self,
|
|
||||||
runner_id: str | None,
|
|
||||||
bound_plugins: list[str] | None,
|
|
||||||
) -> AgentRunnerDescriptor | None:
|
|
||||||
if not runner_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
registry = getattr(self.ap, 'agent_runner_registry', None)
|
|
||||||
if registry is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await registry.get(runner_id, bound_plugins)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _resolve_llm_model(
|
|
||||||
self,
|
|
||||||
primary_uuid: str,
|
|
||||||
) -> typing.Any | None:
|
|
||||||
if primary_uuid in config_schema.NONE_SENTINELS:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
|
||||||
except ValueError:
|
|
||||||
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _resolve_fallback_models(self, fallback_uuids: list[str]) -> list[str]:
|
|
||||||
valid_fallbacks = []
|
|
||||||
for fallback_uuid in fallback_uuids:
|
|
||||||
if fallback_uuid in config_schema.NONE_SENTINELS:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await self.ap.model_mgr.get_model_by_uuid(fallback_uuid)
|
|
||||||
valid_fallbacks.append(fallback_uuid)
|
|
||||||
except ValueError:
|
|
||||||
self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping')
|
|
||||||
return valid_fallbacks
|
|
||||||
|
|
||||||
def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
if descriptor is None:
|
|
||||||
return True
|
|
||||||
return descriptor.capabilities.get('multimodal_input', False)
|
|
||||||
|
|
||||||
def _model_supports_vision(self, llm_model: typing.Any | None) -> bool:
|
|
||||||
if not llm_model:
|
|
||||||
return False
|
|
||||||
abilities = getattr(getattr(llm_model, 'model_entity', None), 'abilities', [])
|
|
||||||
return 'vision' in abilities
|
|
||||||
|
|
||||||
def _should_keep_image_inputs(
|
|
||||||
self,
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
uses_host_models: bool,
|
|
||||||
llm_model: typing.Any | None,
|
|
||||||
) -> bool:
|
|
||||||
if not self._runner_accepts_multimodal_input(descriptor):
|
|
||||||
return False
|
|
||||||
if uses_host_models:
|
|
||||||
return self._model_supports_vision(llm_model)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _strip_images_from_history(self, query: pipeline_query.Query) -> None:
|
|
||||||
for msg in query.messages:
|
|
||||||
if isinstance(msg.content, list):
|
|
||||||
msg.content = [elem for elem in msg.content if elem.type != 'image_url']
|
|
||||||
|
|
||||||
def _has_declared_db_engine(self) -> bool:
|
|
||||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
|
||||||
if persistence_mgr is None:
|
|
||||||
return False
|
|
||||||
if 'get_db_engine' in getattr(persistence_mgr, '__dict__', {}):
|
|
||||||
return True
|
|
||||||
return hasattr(type(persistence_mgr), 'get_db_engine')
|
|
||||||
|
|
||||||
async def _load_agent_runner_history_messages(
|
|
||||||
self,
|
|
||||||
runner_id: str | None,
|
|
||||||
conversation_uuid: str | None,
|
|
||||||
) -> list[provider_message.Message] | None:
|
|
||||||
if not runner_id or not conversation_uuid or not self._has_declared_db_engine():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from ...agent.runner.transcript_store import TranscriptStore
|
|
||||||
|
|
||||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
messages = await store.get_legacy_provider_messages(str(conversation_uuid))
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return messages or None
|
|
||||||
|
|
||||||
async def _resolve_history_messages(
|
|
||||||
self,
|
|
||||||
runner_id: str | None,
|
|
||||||
conversation: typing.Any,
|
|
||||||
) -> list[provider_message.Message]:
|
|
||||||
transcript_messages = await self._load_agent_runner_history_messages(
|
|
||||||
runner_id,
|
|
||||||
getattr(conversation, 'uuid', None),
|
|
||||||
)
|
|
||||||
if transcript_messages is not None:
|
|
||||||
return transcript_messages
|
|
||||||
return conversation.messages.copy()
|
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
stage_inst_name: str,
|
stage_inst_name: str,
|
||||||
) -> entities.StageProcessResult:
|
) -> entities.StageProcessResult:
|
||||||
"""Process"""
|
"""Process"""
|
||||||
# Resolve runner ID from the current ai.runner.id shape.
|
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
||||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
include_skill_authoring = (
|
||||||
|
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
||||||
# 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)
|
session = await self.ap.sess_mgr.get_session(query)
|
||||||
|
|
||||||
uses_host_models = config_schema.uses_host_models(descriptor)
|
# When not local-agent, llm_model is None
|
||||||
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
|
llm_model = None
|
||||||
if uses_host_models:
|
if selected_runner == 'local-agent':
|
||||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
llm_model = await self._resolve_llm_model(primary_uuid)
|
# but handle legacy plain string for backward compatibility
|
||||||
valid_fallbacks = await self._resolve_fallback_models(fallback_uuids)
|
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 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:
|
if valid_fallbacks:
|
||||||
query.variables['_fallback_model_uuids'] = 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(
|
conversation = await self.ap.sess_mgr.get_conversation(
|
||||||
query,
|
query,
|
||||||
session,
|
session,
|
||||||
prompt_config,
|
query.pipeline_config['ai']['local-agent']['prompt'],
|
||||||
query.pipeline_uuid,
|
query.pipeline_uuid,
|
||||||
query.bot_uuid,
|
query.bot_uuid,
|
||||||
)
|
)
|
||||||
@@ -193,7 +82,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
# been idle for longer than the configured conversation expire time.
|
# been idle for longer than the configured conversation expire time.
|
||||||
# The idle window is measured from the last preprocess/update time, not
|
# The idle window is measured from the last preprocess/update time, not
|
||||||
# from the conversation creation time.
|
# from the conversation creation time.
|
||||||
conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config)
|
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
if conversation_expire_time is not None and conversation_expire_time > 0:
|
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)
|
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||||
@@ -210,17 +99,20 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
# time instead of the first message/creation time.
|
# time instead of the first message/creation time.
|
||||||
conversation.update_time = now
|
conversation.update_time = now
|
||||||
|
|
||||||
# Attach resolved session state to the query.
|
# 设置query
|
||||||
query.session = session
|
query.session = session
|
||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
query.messages = await self._resolve_history_messages(runner_id, conversation)
|
query.messages = conversation.messages.copy()
|
||||||
|
|
||||||
if uses_host_models:
|
if selected_runner == 'local-agent':
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
if llm_model:
|
if llm_model:
|
||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if uses_host_tools and llm_model.model_entity.abilities.__contains__('func_call'):
|
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)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
@@ -233,22 +125,14 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
# If primary model doesn't support func_call but fallback models exist,
|
# If primary model doesn't support func_call but fallback models exist,
|
||||||
# load tools anyway since fallback models may support them
|
# load tools anyway since fallback models may support them
|
||||||
if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
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)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
include_skill_authoring=include_skill_authoring,
|
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 = ''
|
sender_name = ''
|
||||||
|
|
||||||
@@ -273,25 +157,32 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
}
|
}
|
||||||
query.variables.update(variables)
|
query.variables.update(variables)
|
||||||
|
|
||||||
keep_image_inputs = self._should_keep_image_inputs(descriptor, uses_host_models, llm_model)
|
# Check if this model supports vision, if not, remove all images
|
||||||
if not keep_image_inputs:
|
# TODO this checking should be performed in runner, and in this stage, the image should be reserved
|
||||||
self._strip_images_from_history(query)
|
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)
|
||||||
|
|
||||||
content_list: list[provider_message.ContentElement] = []
|
content_list: list[provider_message.ContentElement] = []
|
||||||
|
|
||||||
plain_text = ''
|
plain_text = ''
|
||||||
quote_msg = query.pipeline_config['trigger'].get('misc', {}).get('combine-quote-message', False)
|
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
||||||
|
|
||||||
for me in query.message_chain:
|
for me in query.message_chain:
|
||||||
if isinstance(me, platform_message.Plain):
|
if isinstance(me, platform_message.Plain):
|
||||||
content_list.append(provider_message.ContentElement.from_text(me.text))
|
content_list.append(provider_message.ContentElement.from_text(me.text))
|
||||||
plain_text += me.text
|
plain_text += me.text
|
||||||
elif isinstance(me, platform_message.Image):
|
elif isinstance(me, platform_message.Image):
|
||||||
if keep_image_inputs:
|
if selected_runner != 'local-agent' or (
|
||||||
|
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
|
||||||
|
):
|
||||||
if me.base64 is not None:
|
if me.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||||
elif isinstance(me, platform_message.Voice):
|
elif isinstance(me, platform_message.Voice):
|
||||||
# Convert voice input into file content for downstream model upload.
|
# 转成文件链接,让下游 runner 上传到目标模型
|
||||||
if me.base64:
|
if me.base64:
|
||||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
|
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
|
||||||
elif me.url:
|
elif me.url:
|
||||||
@@ -306,7 +197,9 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if isinstance(msg, platform_message.Plain):
|
if isinstance(msg, platform_message.Plain):
|
||||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||||
elif isinstance(msg, platform_message.Image):
|
elif isinstance(msg, platform_message.Image):
|
||||||
if keep_image_inputs:
|
if selected_runner != 'local-agent' or (
|
||||||
|
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
|
||||||
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
elif isinstance(msg, platform_message.File):
|
elif isinstance(msg, platform_message.File):
|
||||||
@@ -326,14 +219,16 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
query.user_message = provider_message.Message(role='user', content=content_list)
|
query.user_message = provider_message.Message(role='user', content=content_list)
|
||||||
|
|
||||||
# Extract configured KB UUIDs into query variables so PromptPreProcessing
|
# Extract knowledge base UUIDs into query variables so plugins can modify them
|
||||||
# plugins can still adjust the authorized retrieval set before run_agent.
|
# during PromptPreProcessing before the runner performs retrieval.
|
||||||
query.variables['_knowledge_base_uuids'] = config_schema.extract_knowledge_base_uuids(
|
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
||||||
descriptor,
|
if not kb_uuids:
|
||||||
runner_config,
|
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)
|
||||||
|
|
||||||
# Emit PromptPreProcessing before the runner receives the query.
|
# =========== 触发事件 PromptPreProcessing
|
||||||
|
|
||||||
event = events.PromptPreProcessing(
|
event = events.PromptPreProcessing(
|
||||||
session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||||
@@ -349,7 +244,19 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt.messages = event_ctx.event.default_prompt
|
query.prompt.messages = event_ctx.event.default_prompt
|
||||||
query.messages = event_ctx.event.prompt
|
query.messages = event_ctx.event.prompt
|
||||||
|
|
||||||
if include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
# =========== 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:
|
||||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||||
@@ -361,4 +268,43 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
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)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -9,35 +9,29 @@ from datetime import datetime
|
|||||||
|
|
||||||
from .. import handler
|
from .. import handler
|
||||||
from ... import entities
|
from ... import entities
|
||||||
|
from ....provider import runner as runner_module
|
||||||
|
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ....agent.runner.config_migration import ConfigMigration
|
from ....utils import importutil, constants, runner as runner_utils
|
||||||
from ....agent.runner import config_schema
|
from ....provider import runners
|
||||||
from ....utils import constants, runner as runner_utils
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PROMPT_CONFIG = [
|
importutil.import_modules_in_pkg(runners)
|
||||||
{'role': 'system', 'content': 'You are a helpful assistant.'},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ChatMessageHandler(handler.MessageHandler):
|
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(
|
async def handle(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
|
) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
|
||||||
"""Handle chat message by delegating to AgentRunOrchestrator."""
|
"""处理"""
|
||||||
# Trigger plugin event
|
# 调API
|
||||||
|
# 生成器
|
||||||
|
|
||||||
|
# 触发插件事件
|
||||||
event_class = (
|
event_class = (
|
||||||
events.PersonNormalMessageReceived
|
events.PersonNormalMessageReceived
|
||||||
if query.launcher_type == provider_session.LauncherTypes.PERSON
|
if query.launcher_type == provider_session.LauncherTypes.PERSON
|
||||||
@@ -58,7 +52,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||||
|
|
||||||
is_create_card = False # Track if streaming card was created
|
is_create_card = False # 判断下是否需要创建流式卡片
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
if event_ctx.event.reply_message_chain is not None:
|
if event_ctx.event.reply_message_chain is not None:
|
||||||
@@ -89,37 +83,35 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
is_stream = False
|
is_stream = False
|
||||||
|
|
||||||
try:
|
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
|
# Mark start time for telemetry
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
|
|
||||||
# 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 is_stream:
|
||||||
|
resp_message_id = uuid.uuid4()
|
||||||
|
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
||||||
|
|
||||||
|
async for result in runner.run(query):
|
||||||
|
result.resp_message_id = str(resp_message_id)
|
||||||
if query.resp_messages:
|
if query.resp_messages:
|
||||||
query.resp_messages.pop()
|
query.resp_messages.pop()
|
||||||
if query.resp_message_chain:
|
if query.resp_message_chain:
|
||||||
query.resp_message_chain.pop()
|
query.resp_message_chain.pop()
|
||||||
|
# 此时连接外部 AI 服务正常,创建卡片
|
||||||
# Create streaming card on first result (connection established)
|
if not is_create_card: # 只有不是第一次才创建卡片
|
||||||
if not is_create_card:
|
|
||||||
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
||||||
is_create_card = True
|
is_create_card = True
|
||||||
|
|
||||||
query.resp_messages.append(result)
|
query.resp_messages.append(result)
|
||||||
|
|
||||||
if is_stream:
|
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
# Only log every 10th chunk to reduce excessive logging during streaming.
|
# Only log every 10th chunk to reduce excessive logging during streaming
|
||||||
# First chunk uses INFO level to confirm connection establishment.
|
# This prevents memory overflow from thousands of log entries per conversation
|
||||||
|
# First chunk uses INFO level to confirm connection establishment
|
||||||
if chunk_count == 1:
|
if chunk_count == 1:
|
||||||
summary = self.format_result_log(result)
|
summary = self.format_result_log(result)
|
||||||
if summary is not None:
|
if summary is not None:
|
||||||
@@ -130,7 +122,21 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
|
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:
|
else:
|
||||||
|
async for result in runner.run(query):
|
||||||
|
query.resp_messages.append(result)
|
||||||
|
|
||||||
summary = self.format_result_log(result)
|
summary = self.format_result_log(result)
|
||||||
if summary is not None:
|
if summary is not None:
|
||||||
self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}')
|
self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}')
|
||||||
@@ -140,41 +146,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
# Log final summary after streaming completes
|
query.session.using_conversation.messages.append(query.user_message)
|
||||||
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:
|
except Exception as e:
|
||||||
# Import orchestrator errors for specific handling
|
|
||||||
from ....agent.runner.errors import (
|
|
||||||
RunnerNotFoundError,
|
|
||||||
RunnerNotAuthorizedError,
|
|
||||||
RunnerExecutionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
error_info = f'{traceback.format_exc()}'
|
error_info = f'{traceback.format_exc()}'
|
||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
# 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')
|
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||||
|
|
||||||
if exception_handling == 'show-error':
|
if exception_handling == 'show-error':
|
||||||
@@ -192,7 +171,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Telemetry reporting
|
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
|
||||||
try:
|
try:
|
||||||
end_ts = time.time()
|
end_ts = time.time()
|
||||||
duration_ms = None
|
duration_ms = None
|
||||||
@@ -200,14 +179,16 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
duration_ms = int((end_ts - start_ts) * 1000)
|
duration_ms = int((end_ts - start_ts) * 1000)
|
||||||
|
|
||||||
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
|
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
|
||||||
|
)
|
||||||
|
|
||||||
# Use orchestrator to resolve runner ID for telemetry
|
# Model name if using localagent
|
||||||
runner_name = self.ap.agent_run_orchestrator.resolve_runner_id_for_telemetry(query)
|
|
||||||
|
|
||||||
# Model name if available
|
|
||||||
model_name = None
|
model_name = None
|
||||||
try:
|
try:
|
||||||
if getattr(query, 'use_llm_model_uuid', None):
|
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
|
||||||
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
if m and getattr(m, 'model_entity', None):
|
if m and getattr(m, 'model_entity', None):
|
||||||
model_name = getattr(m.model_entity, 'name', None)
|
model_name = getattr(m.model_entity, 'name', None)
|
||||||
@@ -217,7 +198,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
|
||||||
runner_category = runner_utils.get_runner_category_from_runner(
|
runner_category = runner_utils.get_runner_category_from_runner(
|
||||||
runner_name, None, query.pipeline_config
|
runner_name, runner, query.pipeline_config
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -235,6 +216,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'timestamp': datetime.utcnow().isoformat(),
|
'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)
|
await self.ap.telemetry.start_send_task(payload)
|
||||||
|
|
||||||
# Trigger survey event on first successful non-WebSocket response
|
# Trigger survey event on first successful non-WebSocket response
|
||||||
@@ -242,70 +224,5 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
if self.ap.survey:
|
if self.ap.survey:
|
||||||
await self.ap.survey.trigger_event('first_bot_response_success')
|
await self.ap.survey.trigger_event('first_bot_response_success')
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
# Ensure telemetry issues do not affect normal flow
|
||||||
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||||
|
|
||||||
async def _ensure_conversation_for_history(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> provider_session.Conversation:
|
|
||||||
session = getattr(query, 'session', None)
|
|
||||||
conversation = getattr(session, 'using_conversation', None)
|
|
||||||
if conversation is not None:
|
|
||||||
return conversation
|
|
||||||
|
|
||||||
if session is None or getattr(self.ap, 'sess_mgr', None) is None:
|
|
||||||
raise RuntimeError('Conversation is not available for history update')
|
|
||||||
|
|
||||||
prompt_config = await self._build_history_prompt_config(query)
|
|
||||||
conversation = await self.ap.sess_mgr.get_conversation(
|
|
||||||
query,
|
|
||||||
session,
|
|
||||||
prompt_config,
|
|
||||||
query.pipeline_uuid,
|
|
||||||
query.bot_uuid,
|
|
||||||
)
|
|
||||||
if conversation is None:
|
|
||||||
raise RuntimeError('Conversation manager did not return a conversation')
|
|
||||||
|
|
||||||
if getattr(session, 'using_conversation', None) is None:
|
|
||||||
session.using_conversation = conversation
|
|
||||||
return conversation
|
|
||||||
|
|
||||||
async def _build_history_prompt_config(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
prompt_messages = getattr(getattr(query, 'prompt', None), 'messages', None)
|
|
||||||
if prompt_messages:
|
|
||||||
prompt_config = []
|
|
||||||
for message in prompt_messages:
|
|
||||||
if hasattr(message, 'model_dump'):
|
|
||||||
prompt_config.append(message.model_dump(mode='python'))
|
|
||||||
elif isinstance(message, dict):
|
|
||||||
prompt_config.append(message)
|
|
||||||
if prompt_config:
|
|
||||||
return prompt_config
|
|
||||||
|
|
||||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
|
||||||
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
|
||||||
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
|
|
||||||
return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG)
|
|
||||||
|
|
||||||
async def _get_runner_descriptor(
|
|
||||||
self,
|
|
||||||
runner_id: str | None,
|
|
||||||
bound_plugins: list[str] | None,
|
|
||||||
) -> typing.Any | None:
|
|
||||||
if not runner_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
registry = getattr(self.ap, 'agent_runner_registry', None)
|
|
||||||
if registry is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await registry.get(runner_id, bound_plugins)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}')
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -84,20 +84,6 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type, None)
|
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:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -187,15 +187,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
async def initialize_plugins(self):
|
async def initialize_plugins(self):
|
||||||
pass
|
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):
|
async def ping_plugin_runtime(self):
|
||||||
if not hasattr(self, 'handler'):
|
if not hasattr(self, 'handler'):
|
||||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||||
@@ -468,12 +459,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
file_bytes = download_resp.content
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(
|
self._inspect_plugin_package(file_bytes, task_context)
|
||||||
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}'
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
@@ -560,7 +546,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
task_context.metadata.update(metadata)
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||||
await self._refresh_agent_runner_registry()
|
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
@@ -579,8 +564,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.trace(trace)
|
task_context.trace(trace)
|
||||||
|
|
||||||
await self._refresh_agent_runner_registry()
|
|
||||||
|
|
||||||
async def delete_plugin(
|
async def delete_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -605,8 +588,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
task_context.trace('Cleaning up plugin configuration and storage...')
|
||||||
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
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]]:
|
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
|
||||||
"""List plugins, optionally filtered by component kinds.
|
"""List plugins, optionally filtered by component kinds.
|
||||||
|
|
||||||
@@ -797,53 +778,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
yield cmd_ret
|
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(
|
async def retrieve_knowledge(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -38,11 +37,41 @@ class ModelManager:
|
|||||||
self.requester_components = []
|
self.requester_components = []
|
||||||
self.requester_dict = {}
|
self.requester_dict = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_litellm_provider_from_manifest(component: engine.Component | None) -> str | None:
|
||||||
|
if component is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
spec = getattr(component, 'spec', None) or {}
|
||||||
|
litellm_provider = None
|
||||||
|
|
||||||
|
if isinstance(spec, dict):
|
||||||
|
litellm_provider = spec.get('litellm_provider')
|
||||||
|
else:
|
||||||
|
getter = getattr(spec, 'get', None)
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
litellm_provider = getter('litellm_provider')
|
||||||
|
except Exception:
|
||||||
|
litellm_provider = None
|
||||||
|
|
||||||
|
if isinstance(litellm_provider, str) and litellm_provider:
|
||||||
|
return litellm_provider
|
||||||
|
return None
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
|
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
|
||||||
|
|
||||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
||||||
for component in self.requester_components:
|
for component in self.requester_components:
|
||||||
|
# Skip components that use litellm_provider (they will use litellmchat.py instead)
|
||||||
|
litellm_provider = self._get_litellm_provider_from_manifest(component)
|
||||||
|
if litellm_provider:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'Skipping Python class loading for {component.metadata.name} '
|
||||||
|
f'(uses litellm_provider={litellm_provider})'
|
||||||
|
)
|
||||||
|
continue
|
||||||
requester_dict[component.metadata.name] = component.get_python_component_class()
|
requester_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
|
|
||||||
self.requester_dict = requester_dict
|
self.requester_dict = requester_dict
|
||||||
@@ -55,19 +84,8 @@ class ModelManager:
|
|||||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||||
return
|
return
|
||||||
|
|
||||||
sync_timeout = space_config.get('models_sync_timeout')
|
|
||||||
try:
|
try:
|
||||||
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()
|
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:
|
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('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||||
self.ap.logger.warning(f' - Error: {e}')
|
self.ap.logger.warning(f' - Error: {e}')
|
||||||
@@ -155,18 +173,24 @@ class ModelManager:
|
|||||||
# get the latest models from space
|
# get the latest models from space
|
||||||
space_models = await self.ap.space_service.get_models()
|
space_models = await self.ap.space_service.get_models()
|
||||||
|
|
||||||
exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()]
|
# Index existing models by uuid. Space reuses a model's uuid across
|
||||||
exists_embedding_models_uuids = [
|
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
|
||||||
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()
|
# may later become ``claude-opus-4-7``). So for Space-managed models we
|
||||||
]
|
# upsert: create when the uuid is new, otherwise update name/abilities/
|
||||||
|
# ranking to track Space. Models owned by other providers are never
|
||||||
|
# touched, even on an (unexpected) uuid collision.
|
||||||
|
existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
|
||||||
|
existing_embedding_models = {
|
||||||
|
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||||
|
}
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
for space_model in space_models:
|
for space_model in space_models:
|
||||||
if space_model.category == 'chat':
|
if space_model.category == 'chat':
|
||||||
uuid = space_model.uuid
|
existing = existing_llm_models.get(space_model.uuid)
|
||||||
|
if existing is None:
|
||||||
if uuid in exists_llm_models_uuids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# model will be automatically loaded
|
# model will be automatically loaded
|
||||||
await self.ap.llm_model_service.create_llm_model(
|
await self.ap.llm_model_service.create_llm_model(
|
||||||
{
|
{
|
||||||
@@ -180,13 +204,25 @@ class ModelManager:
|
|||||||
preserve_uuid=True,
|
preserve_uuid=True,
|
||||||
auto_set_to_default_pipeline=False,
|
auto_set_to_default_pipeline=False,
|
||||||
)
|
)
|
||||||
|
created += 1
|
||||||
|
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||||
|
desired = {
|
||||||
|
'name': space_model.model_id,
|
||||||
|
'provider_uuid': space_model_provider.uuid,
|
||||||
|
'abilities': space_model.llm_abilities or [],
|
||||||
|
'prefered_ranking': space_model.featured_order,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.get('name') != desired['name']
|
||||||
|
or list(existing.get('abilities') or []) != list(desired['abilities'])
|
||||||
|
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||||
|
):
|
||||||
|
await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
elif space_model.category == 'embedding':
|
elif space_model.category == 'embedding':
|
||||||
uuid = space_model.uuid
|
existing = existing_embedding_models.get(space_model.uuid)
|
||||||
|
if existing is None:
|
||||||
if uuid in exists_embedding_models_uuids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# model will be automatically loaded
|
# model will be automatically loaded
|
||||||
await self.ap.embedding_models_service.create_embedding_model(
|
await self.ap.embedding_models_service.create_embedding_model(
|
||||||
{
|
{
|
||||||
@@ -198,6 +234,22 @@ class ModelManager:
|
|||||||
},
|
},
|
||||||
preserve_uuid=True,
|
preserve_uuid=True,
|
||||||
)
|
)
|
||||||
|
created += 1
|
||||||
|
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||||
|
desired = {
|
||||||
|
'name': space_model.model_id,
|
||||||
|
'provider_uuid': space_model_provider.uuid,
|
||||||
|
'prefered_ranking': space_model.featured_order,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.get('name') != desired['name']
|
||||||
|
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||||
|
):
|
||||||
|
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if created or updated:
|
||||||
|
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
|
||||||
|
|
||||||
async def init_temporary_runtime_llm_model(
|
async def init_temporary_runtime_llm_model(
|
||||||
self,
|
self,
|
||||||
@@ -214,6 +266,7 @@ class ModelManager:
|
|||||||
name=model_info.get('name', ''),
|
name=model_info.get('name', ''),
|
||||||
provider_uuid='',
|
provider_uuid='',
|
||||||
abilities=model_info.get('abilities', []),
|
abilities=model_info.get('abilities', []),
|
||||||
|
context_length=model_info.get('context_length'),
|
||||||
extra_args=model_info.get('extra_args', {}),
|
extra_args=model_info.get('extra_args', {}),
|
||||||
),
|
),
|
||||||
provider=runtime_provider,
|
provider=runtime_provider,
|
||||||
@@ -272,13 +325,37 @@ class ModelManager:
|
|||||||
else:
|
else:
|
||||||
provider_entity = provider_info
|
provider_entity = provider_info
|
||||||
|
|
||||||
|
# Get requester manifest to check for litellm_provider
|
||||||
|
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||||
|
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
|
||||||
|
|
||||||
|
# Build config from base_url
|
||||||
|
config = {'base_url': provider_entity.base_url}
|
||||||
|
|
||||||
|
# Check if requester manifest specifies litellm_provider
|
||||||
|
if litellm_provider:
|
||||||
|
from .requesters import litellmchat
|
||||||
|
|
||||||
|
# Use unified LiteLLMRequester with provider prefix
|
||||||
|
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
|
||||||
|
config['custom_llm_provider'] = litellm_provider
|
||||||
|
requester_inst = litellmchat.LiteLLMRequester(
|
||||||
|
ap=self.ap,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'Using LiteLLMRequester for {provider_entity.requester} '
|
||||||
|
f'with custom_llm_provider={config["custom_llm_provider"]}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Use original requester class (for backward compatibility)
|
||||||
if provider_entity.requester not in self.requester_dict:
|
if provider_entity.requester not in self.requester_dict:
|
||||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||||
|
|
||||||
requester_inst = self.requester_dict[provider_entity.requester](
|
requester_inst = self.requester_dict[provider_entity.requester](
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
config={'base_url': provider_entity.base_url},
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
await requester_inst.initialize()
|
await requester_inst.initialize()
|
||||||
|
|
||||||
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
||||||
@@ -384,6 +461,7 @@ class ModelManager:
|
|||||||
name=model_info.get('name', ''),
|
name=model_info.get('name', ''),
|
||||||
provider_uuid=model_info.get('provider_uuid', ''),
|
provider_uuid=model_info.get('provider_uuid', ''),
|
||||||
abilities=model_info.get('abilities', []),
|
abilities=model_info.get('abilities', []),
|
||||||
|
context_length=model_info.get('context_length'),
|
||||||
extra_args=model_info.get('extra_args', {}),
|
extra_args=model_info.get('extra_args', {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ class RuntimeProvider:
|
|||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
msg, usage_info = result
|
msg, usage_info = result
|
||||||
if usage_info:
|
if usage_info:
|
||||||
input_tokens = usage_info.get('input_tokens', 0)
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
output_tokens = usage_info.get('output_tokens', 0)
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
return msg
|
return msg
|
||||||
else:
|
else:
|
||||||
return result
|
return result
|
||||||
@@ -128,7 +128,6 @@ class RuntimeProvider:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
status = 'success'
|
status = 'success'
|
||||||
error_message = None
|
error_message = None
|
||||||
# Note: Stream doesn't easily provide token counts, set to 0
|
|
||||||
input_tokens = 0
|
input_tokens = 0
|
||||||
output_tokens = 0
|
output_tokens = 0
|
||||||
|
|
||||||
@@ -143,6 +142,15 @@ class RuntimeProvider:
|
|||||||
remove_think=remove_think,
|
remove_think=remove_think,
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
# Extract usage from stream if available (stored by LiteLLM requester)
|
||||||
|
if query:
|
||||||
|
if query.variables is None:
|
||||||
|
query.variables = {}
|
||||||
|
if '_stream_usage' in query.variables:
|
||||||
|
usage_info = query.variables['_stream_usage']
|
||||||
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
|
del query.variables['_stream_usage']
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = 'error'
|
status = 'error'
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import openai
|
|
||||||
|
|
||||||
from . import chatcmpl
|
|
||||||
|
|
||||||
|
|
||||||
class AI302ChatCompletions(chatcmpl.OpenAIChatCompletions):
|
|
||||||
"""302.AI ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
client: openai.AsyncClient
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.302.ai/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: 302.AI
|
zh_Hans: 302.AI
|
||||||
icon: 302ai.png
|
icon: 302ai.png
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import json
|
|
||||||
import platform
|
|
||||||
import socket
|
|
||||||
import anthropic
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .. import errors, requester
|
|
||||||
|
|
||||||
from ....utils import image
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessages(requester.ProviderAPIRequester):
|
|
||||||
"""Anthropic Messages API 请求器"""
|
|
||||||
|
|
||||||
client: anthropic.AsyncAnthropic
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.anthropic.com',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
# 兼容 Windows 缺失 TCP_KEEPINTVL 和 TCP_KEEPCNT 的问题
|
|
||||||
if platform.system() == 'Windows':
|
|
||||||
if not hasattr(socket, 'TCP_KEEPINTVL'):
|
|
||||||
socket.TCP_KEEPINTVL = 0
|
|
||||||
if not hasattr(socket, 'TCP_KEEPCNT'):
|
|
||||||
socket.TCP_KEEPCNT = 0
|
|
||||||
httpx_client = anthropic._base_client.AsyncHttpxClientWrapper(
|
|
||||||
base_url=self.requester_cfg['base_url'],
|
|
||||||
# cast to a valid type because mypy doesn't understand our type narrowing
|
|
||||||
timeout=typing.cast(httpx.Timeout, self.requester_cfg['timeout']),
|
|
||||||
limits=anthropic._constants.DEFAULT_CONNECTION_LIMITS,
|
|
||||||
follow_redirects=True,
|
|
||||||
trust_env=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client = anthropic.AsyncAnthropic(
|
|
||||||
api_key='',
|
|
||||||
http_client=httpx_client,
|
|
||||||
base_url=self.requester_cfg['base_url'],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def invoke_llm(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
model: requester.RuntimeLLMModel,
|
|
||||||
messages: typing.List[provider_message.Message],
|
|
||||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.Message:
|
|
||||||
self.client.api_key = model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = extra_args.copy()
|
|
||||||
args['model'] = model.model_entity.name
|
|
||||||
|
|
||||||
# 处理消息
|
|
||||||
|
|
||||||
# system
|
|
||||||
system_role_message = None
|
|
||||||
|
|
||||||
for i, m in enumerate(messages):
|
|
||||||
if m.role == 'system':
|
|
||||||
system_role_message = m
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
if system_role_message:
|
|
||||||
messages.pop(i)
|
|
||||||
|
|
||||||
if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str):
|
|
||||||
args['system'] = system_role_message.content
|
|
||||||
|
|
||||||
req_messages = []
|
|
||||||
|
|
||||||
for m in messages:
|
|
||||||
if m.role == 'tool':
|
|
||||||
tool_call_id = m.tool_call_id
|
|
||||||
|
|
||||||
req_messages.append(
|
|
||||||
{
|
|
||||||
'role': 'user',
|
|
||||||
'content': [
|
|
||||||
{
|
|
||||||
'type': 'tool_result',
|
|
||||||
'tool_use_id': tool_call_id,
|
|
||||||
'is_error': False,
|
|
||||||
'content': [{'type': 'text', 'text': m.content}],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg_dict = m.dict(exclude_none=True)
|
|
||||||
|
|
||||||
if isinstance(m.content, str) and m.content.strip() != '':
|
|
||||||
msg_dict['content'] = [{'type': 'text', 'text': m.content}]
|
|
||||||
elif isinstance(m.content, list):
|
|
||||||
for i, ce in enumerate(m.content):
|
|
||||||
if ce.type == 'image_base64':
|
|
||||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
|
||||||
|
|
||||||
alter_image_ele = {
|
|
||||||
'type': 'image',
|
|
||||||
'source': {
|
|
||||||
'type': 'base64',
|
|
||||||
'media_type': f'image/{image_format}',
|
|
||||||
'data': image_b64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
msg_dict['content'][i] = alter_image_ele
|
|
||||||
|
|
||||||
if m.tool_calls:
|
|
||||||
for tool_call in m.tool_calls:
|
|
||||||
msg_dict['content'].append(
|
|
||||||
{
|
|
||||||
'type': 'tool_use',
|
|
||||||
'id': tool_call.id,
|
|
||||||
'name': tool_call.function.name,
|
|
||||||
'input': json.loads(tool_call.function.arguments),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
del msg_dict['tool_calls']
|
|
||||||
|
|
||||||
req_messages.append(msg_dict)
|
|
||||||
|
|
||||||
args['messages'] = req_messages
|
|
||||||
|
|
||||||
if 'thinking' in args:
|
|
||||||
args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000}
|
|
||||||
|
|
||||||
if funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs)
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await self.client.messages.create(**args)
|
|
||||||
|
|
||||||
args = {
|
|
||||||
'content': '',
|
|
||||||
'role': resp.role,
|
|
||||||
}
|
|
||||||
assert type(resp) is anthropic.types.message.Message
|
|
||||||
|
|
||||||
for block in resp.content:
|
|
||||||
if not remove_think and block.type == 'thinking':
|
|
||||||
args['content'] = '<think>\n' + block.thinking + '\n</think>\n' + args['content']
|
|
||||||
elif block.type == 'text':
|
|
||||||
args['content'] += block.text
|
|
||||||
elif block.type == 'tool_use':
|
|
||||||
assert type(block) is anthropic.types.tool_use_block.ToolUseBlock
|
|
||||||
tool_call = provider_message.ToolCall(
|
|
||||||
id=block.id,
|
|
||||||
type='function',
|
|
||||||
function=provider_message.FunctionCall(name=block.name, arguments=json.dumps(block.input)),
|
|
||||||
)
|
|
||||||
if 'tool_calls' not in args:
|
|
||||||
args['tool_calls'] = []
|
|
||||||
args['tool_calls'].append(tool_call)
|
|
||||||
|
|
||||||
return provider_message.Message(**args)
|
|
||||||
except anthropic.AuthenticationError as e:
|
|
||||||
raise errors.RequesterError(f'api-key 无效: {e.message}')
|
|
||||||
except anthropic.BadRequestError as e:
|
|
||||||
raise errors.RequesterError(str(e.message))
|
|
||||||
except anthropic.NotFoundError as e:
|
|
||||||
if 'model: ' in str(e):
|
|
||||||
raise errors.RequesterError(f'模型无效: {e.message}')
|
|
||||||
else:
|
|
||||||
raise errors.RequesterError(f'请求地址无效: {e.message}')
|
|
||||||
|
|
||||||
async def invoke_llm_stream(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
model: requester.RuntimeLLMModel,
|
|
||||||
messages: typing.List[provider_message.Message],
|
|
||||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.Message:
|
|
||||||
self.client.api_key = model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = extra_args.copy()
|
|
||||||
args['model'] = model.model_entity.name
|
|
||||||
args['stream'] = True
|
|
||||||
|
|
||||||
# 处理消息
|
|
||||||
|
|
||||||
# system
|
|
||||||
system_role_message = None
|
|
||||||
|
|
||||||
for i, m in enumerate(messages):
|
|
||||||
if m.role == 'system':
|
|
||||||
system_role_message = m
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
if system_role_message:
|
|
||||||
messages.pop(i)
|
|
||||||
|
|
||||||
if isinstance(system_role_message, provider_message.Message) and isinstance(system_role_message.content, str):
|
|
||||||
args['system'] = system_role_message.content
|
|
||||||
|
|
||||||
req_messages = []
|
|
||||||
|
|
||||||
for m in messages:
|
|
||||||
if m.role == 'tool':
|
|
||||||
tool_call_id = m.tool_call_id
|
|
||||||
|
|
||||||
req_messages.append(
|
|
||||||
{
|
|
||||||
'role': 'user',
|
|
||||||
'content': [
|
|
||||||
{
|
|
||||||
'type': 'tool_result',
|
|
||||||
'tool_use_id': tool_call_id,
|
|
||||||
'is_error': False, # 暂时直接写false
|
|
||||||
'content': [
|
|
||||||
{'type': 'text', 'text': m.content}
|
|
||||||
], # 这里要是list包裹,应该是多个返回的情况?type类型好像也可以填其他的,暂时只写text
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg_dict = m.dict(exclude_none=True)
|
|
||||||
|
|
||||||
if isinstance(m.content, str) and m.content.strip() != '':
|
|
||||||
msg_dict['content'] = [{'type': 'text', 'text': m.content}]
|
|
||||||
elif isinstance(m.content, list):
|
|
||||||
for i, ce in enumerate(m.content):
|
|
||||||
if ce.type == 'image_base64':
|
|
||||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
|
||||||
|
|
||||||
alter_image_ele = {
|
|
||||||
'type': 'image',
|
|
||||||
'source': {
|
|
||||||
'type': 'base64',
|
|
||||||
'media_type': f'image/{image_format}',
|
|
||||||
'data': image_b64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
msg_dict['content'][i] = alter_image_ele
|
|
||||||
if isinstance(msg_dict['content'], str) and msg_dict['content'] == '':
|
|
||||||
msg_dict['content'] = [] # 这里不知道为什么会莫名有个空导致content为字符
|
|
||||||
if m.tool_calls:
|
|
||||||
for tool_call in m.tool_calls:
|
|
||||||
msg_dict['content'].append(
|
|
||||||
{
|
|
||||||
'type': 'tool_use',
|
|
||||||
'id': tool_call.id,
|
|
||||||
'name': tool_call.function.name,
|
|
||||||
'input': json.loads(tool_call.function.arguments),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
del msg_dict['tool_calls']
|
|
||||||
|
|
||||||
req_messages.append(msg_dict)
|
|
||||||
if 'thinking' in args:
|
|
||||||
args['thinking'] = {'type': 'enabled', 'budget_tokens': 10000}
|
|
||||||
|
|
||||||
args['messages'] = req_messages
|
|
||||||
|
|
||||||
if funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_anthropic(funcs)
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
try:
|
|
||||||
role = 'assistant' # 默认角色
|
|
||||||
# chunk_idx = 0
|
|
||||||
think_started = False
|
|
||||||
think_ended = False
|
|
||||||
finish_reason = False
|
|
||||||
tool_name = ''
|
|
||||||
tool_id = ''
|
|
||||||
async for chunk in await self.client.messages.create(**args):
|
|
||||||
content = ''
|
|
||||||
tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'}
|
|
||||||
if isinstance(
|
|
||||||
chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent
|
|
||||||
): # 记录开始
|
|
||||||
if chunk.content_block.type == 'tool_use':
|
|
||||||
if chunk.content_block.name is not None:
|
|
||||||
tool_name = chunk.content_block.name
|
|
||||||
if chunk.content_block.id is not None:
|
|
||||||
tool_id = chunk.content_block.id
|
|
||||||
|
|
||||||
tool_call['function']['name'] = tool_name
|
|
||||||
tool_call['function']['arguments'] = ''
|
|
||||||
tool_call['id'] = tool_id
|
|
||||||
|
|
||||||
if not remove_think:
|
|
||||||
if chunk.content_block.type == 'thinking' and not remove_think:
|
|
||||||
think_started = True
|
|
||||||
elif chunk.content_block.type == 'text' and chunk.index != 0 and not remove_think:
|
|
||||||
think_ended = True
|
|
||||||
continue
|
|
||||||
elif isinstance(chunk, anthropic.types.raw_content_block_delta_event.RawContentBlockDeltaEvent):
|
|
||||||
if chunk.delta.type == 'thinking_delta':
|
|
||||||
if think_started:
|
|
||||||
think_started = False
|
|
||||||
content = '<think>\n' + chunk.delta.thinking
|
|
||||||
elif remove_think:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
content = chunk.delta.thinking
|
|
||||||
elif chunk.delta.type == 'text_delta':
|
|
||||||
if think_ended:
|
|
||||||
think_ended = False
|
|
||||||
content = '\n</think>\n' + chunk.delta.text
|
|
||||||
else:
|
|
||||||
content = chunk.delta.text
|
|
||||||
elif chunk.delta.type == 'input_json_delta':
|
|
||||||
tool_call['function']['arguments'] = chunk.delta.partial_json
|
|
||||||
tool_call['function']['name'] = tool_name
|
|
||||||
tool_call['id'] = tool_id
|
|
||||||
elif isinstance(chunk, anthropic.types.raw_content_block_stop_event.RawContentBlockStopEvent):
|
|
||||||
continue # 记录raw_content_block结束的
|
|
||||||
|
|
||||||
elif isinstance(chunk, anthropic.types.raw_message_delta_event.RawMessageDeltaEvent):
|
|
||||||
if chunk.delta.stop_reason == 'end_turn':
|
|
||||||
finish_reason = True
|
|
||||||
elif isinstance(chunk, anthropic.types.raw_message_stop_event.RawMessageStopEvent):
|
|
||||||
continue # 这个好像是完全结束
|
|
||||||
else:
|
|
||||||
# print(chunk)
|
|
||||||
self.ap.logger.debug(f'anthropic chunk: {chunk}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
args = {
|
|
||||||
'content': content,
|
|
||||||
'role': role,
|
|
||||||
'is_final': finish_reason,
|
|
||||||
'tool_calls': None if tool_call['id'] is None else [tool_call],
|
|
||||||
}
|
|
||||||
# if chunk_idx == 0:
|
|
||||||
# chunk_idx += 1
|
|
||||||
# continue
|
|
||||||
|
|
||||||
# assert type(chunk) is anthropic.types.message.Chunk
|
|
||||||
|
|
||||||
yield provider_message.MessageChunk(**args)
|
|
||||||
|
|
||||||
# return llm_entities.Message(**args)
|
|
||||||
except anthropic.AuthenticationError as e:
|
|
||||||
raise errors.RequesterError(f'api-key 无效: {e.message}')
|
|
||||||
except anthropic.BadRequestError as e:
|
|
||||||
raise errors.RequesterError(str(e.message))
|
|
||||||
except anthropic.NotFoundError as e:
|
|
||||||
if 'model: ' in str(e):
|
|
||||||
raise errors.RequesterError(f'模型无效: {e.message}')
|
|
||||||
else:
|
|
||||||
raise errors.RequesterError(f'请求地址无效: {e.message}')
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: Anthropic
|
zh_Hans: Anthropic
|
||||||
icon: anthropic.svg
|
icon: anthropic.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: anthropic
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
@@ -24,6 +25,8 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
provider_category: manufacturer
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
5
src/langbot/pkg/provider/modelmgr/requesters/baidu.svg
Normal file
5
src/langbot/pkg/provider/modelmgr/requesters/baidu.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="60" height="50" viewBox="0 0 60 50" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="60" height="50" rx="8" fill="#2932E1"/>
|
||||||
|
<text x="30" y="28" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white" text-anchor="middle">Baidu</text>
|
||||||
|
<text x="30" y="40" font-family="Arial, sans-serif" font-size="8" fill="white" text-anchor="middle">ERNIE</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: LLMAPIRequester
|
||||||
|
metadata:
|
||||||
|
name: baidu-chat-completions
|
||||||
|
label:
|
||||||
|
en_US: Baidu ERNIE
|
||||||
|
zh_Hans: 百度文心一言
|
||||||
|
icon: baidu.svg
|
||||||
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
|
config:
|
||||||
|
- name: base_url
|
||||||
|
label:
|
||||||
|
en_US: Base URL
|
||||||
|
zh_Hans: 基础 URL
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop
|
||||||
|
- name: timeout
|
||||||
|
label:
|
||||||
|
en_US: Timeout
|
||||||
|
zh_Hans: 超时时间
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
default: 120
|
||||||
|
support_type:
|
||||||
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
|
provider_category: manufacturer
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import dashscope
|
|
||||||
import openai
|
|
||||||
|
|
||||||
from . import modelscopechatcmpl
|
|
||||||
from .. import requester
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
|
|
||||||
"""阿里云百炼大模型平台 ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
client: openai.AsyncClient
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _closure_stream(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
req_messages: list[dict],
|
|
||||||
use_model: requester.RuntimeLLMModel,
|
|
||||||
use_funcs: list[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
|
||||||
self.client.api_key = use_model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {}
|
|
||||||
args['model'] = use_model.model_entity.name
|
|
||||||
|
|
||||||
if use_funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
# 设置此次请求中的messages
|
|
||||||
messages = req_messages.copy()
|
|
||||||
|
|
||||||
is_use_dashscope_call = False # 是否使用阿里原生库调用
|
|
||||||
is_enable_multi_model = True # 是否支持多轮对话
|
|
||||||
use_time_num = 0 # 模型已调用次数,防止存在多文件时重复调用
|
|
||||||
use_time_ids = [] # 已调用的ID列表
|
|
||||||
message_id = 0 # 记录消息序号
|
|
||||||
|
|
||||||
for msg in messages:
|
|
||||||
# print(msg)
|
|
||||||
if 'content' in msg and isinstance(msg['content'], list):
|
|
||||||
for me in msg['content']:
|
|
||||||
if me['type'] == 'image_base64':
|
|
||||||
me['image_url'] = {'url': me['image_base64']}
|
|
||||||
me['type'] = 'image_url'
|
|
||||||
del me['image_base64']
|
|
||||||
elif me['type'] == 'file_url' and '.' in me.get('file_name', ''):
|
|
||||||
# 1. 视频文件推理
|
|
||||||
# https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2845871
|
|
||||||
file_type = me.get('file_name').lower().split('.')[-1]
|
|
||||||
if file_type in ['mp4', 'avi', 'mkv', 'mov', 'flv', 'wmv']:
|
|
||||||
me['type'] = 'video_url'
|
|
||||||
me['video_url'] = {'url': me['file_url']}
|
|
||||||
del me['file_url']
|
|
||||||
del me['file_name']
|
|
||||||
use_time_num += 1
|
|
||||||
use_time_ids.append(message_id)
|
|
||||||
is_enable_multi_model = False
|
|
||||||
# 2. 语音文件识别, 无法通过openai的audio字段传递,暂时不支持
|
|
||||||
# https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2979031
|
|
||||||
elif file_type in [
|
|
||||||
'aac',
|
|
||||||
'amr',
|
|
||||||
'aiff',
|
|
||||||
'flac',
|
|
||||||
'm4a',
|
|
||||||
'mp3',
|
|
||||||
'mpeg',
|
|
||||||
'ogg',
|
|
||||||
'opus',
|
|
||||||
'wav',
|
|
||||||
'webm',
|
|
||||||
'wma',
|
|
||||||
]:
|
|
||||||
me['audio'] = me['file_url']
|
|
||||||
me['type'] = 'audio'
|
|
||||||
del me['file_url']
|
|
||||||
del me['type']
|
|
||||||
del me['file_name']
|
|
||||||
is_use_dashscope_call = True
|
|
||||||
use_time_num += 1
|
|
||||||
use_time_ids.append(message_id)
|
|
||||||
is_enable_multi_model = False
|
|
||||||
message_id += 1
|
|
||||||
|
|
||||||
# 使用列表推导式,保留不在 use_time_ids[:-1] 中的元素,仅保留最后一个多媒体消息
|
|
||||||
if not is_enable_multi_model and use_time_num > 1:
|
|
||||||
messages = [msg for idx, msg in enumerate(messages) if idx not in use_time_ids[:-1]]
|
|
||||||
|
|
||||||
if not is_enable_multi_model:
|
|
||||||
messages = [msg for msg in messages if 'resp_message_id' not in msg]
|
|
||||||
|
|
||||||
args['messages'] = messages
|
|
||||||
args['stream'] = True
|
|
||||||
|
|
||||||
# 流式处理状态
|
|
||||||
# tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
|
||||||
chunk_idx = 0
|
|
||||||
thinking_started = False
|
|
||||||
thinking_ended = False
|
|
||||||
role = 'assistant' # 默认角色
|
|
||||||
|
|
||||||
if is_use_dashscope_call:
|
|
||||||
response = dashscope.MultiModalConversation.call(
|
|
||||||
# 若没有配置环境变量,请用百炼API Key将下行替换为:api_key = "sk-xxx"
|
|
||||||
api_key=use_model.provider.token_mgr.get_token(),
|
|
||||||
model=use_model.model_entity.name,
|
|
||||||
messages=messages,
|
|
||||||
result_format='message',
|
|
||||||
asr_options={
|
|
||||||
# "language": "zh", # 可选,若已知音频的语种,可通过该参数指定待识别语种,以提升识别准确率
|
|
||||||
'enable_lid': True,
|
|
||||||
'enable_itn': False,
|
|
||||||
},
|
|
||||||
stream=True,
|
|
||||||
)
|
|
||||||
content_length_list = []
|
|
||||||
previous_length = 0 # 记录上一次的内容长度
|
|
||||||
for res in response:
|
|
||||||
chunk = res['output']
|
|
||||||
# 解析 chunk 数据
|
|
||||||
if hasattr(chunk, 'choices') and chunk.choices:
|
|
||||||
choice = chunk.choices[0]
|
|
||||||
delta_content = choice['message'].content[0]['text']
|
|
||||||
finish_reason = choice['finish_reason']
|
|
||||||
content_length_list.append(len(delta_content))
|
|
||||||
else:
|
|
||||||
delta_content = ''
|
|
||||||
finish_reason = None
|
|
||||||
|
|
||||||
# 跳过空的第一个 chunk(只有 role 没有内容)
|
|
||||||
if chunk_idx == 0 and not delta_content:
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 检查 content_length_list 是否有足够的数据
|
|
||||||
if len(content_length_list) >= 2:
|
|
||||||
now_content = delta_content[previous_length : content_length_list[-1]]
|
|
||||||
previous_length = content_length_list[-1] # 更新上一次的长度
|
|
||||||
else:
|
|
||||||
now_content = delta_content # 第一次循环时直接使用 delta_content
|
|
||||||
previous_length = len(delta_content) # 更新上一次的长度
|
|
||||||
|
|
||||||
# 构建 MessageChunk - 只包含增量内容
|
|
||||||
chunk_data = {
|
|
||||||
'role': role,
|
|
||||||
'content': now_content if now_content else None,
|
|
||||||
'is_final': bool(finish_reason) and finish_reason != 'null',
|
|
||||||
}
|
|
||||||
|
|
||||||
# 移除 None 值
|
|
||||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
|
||||||
yield provider_message.MessageChunk(**chunk_data)
|
|
||||||
chunk_idx += 1
|
|
||||||
else:
|
|
||||||
async for chunk in self._req_stream(args, extra_body=extra_args):
|
|
||||||
# 解析 chunk 数据
|
|
||||||
if hasattr(chunk, 'choices') and chunk.choices:
|
|
||||||
choice = chunk.choices[0]
|
|
||||||
delta_obj = getattr(choice, 'delta', None)
|
|
||||||
delta = delta_obj.model_dump() if delta_obj is not None else {}
|
|
||||||
finish_reason = getattr(choice, 'finish_reason', None)
|
|
||||||
else:
|
|
||||||
delta = {}
|
|
||||||
finish_reason = None
|
|
||||||
|
|
||||||
# 从第一个 chunk 获取 role,后续使用这个 role
|
|
||||||
if 'role' in delta and delta['role']:
|
|
||||||
role = delta['role']
|
|
||||||
|
|
||||||
# 获取增量内容
|
|
||||||
delta_content = delta.get('content', '')
|
|
||||||
reasoning_content = delta.get('reasoning_content', '')
|
|
||||||
|
|
||||||
# 处理 reasoning_content
|
|
||||||
if reasoning_content:
|
|
||||||
# accumulated_reasoning += reasoning_content
|
|
||||||
# 如果设置了 remove_think,跳过 reasoning_content
|
|
||||||
if remove_think:
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 第一次出现 reasoning_content,添加 <think> 开始标签
|
|
||||||
if not thinking_started:
|
|
||||||
thinking_started = True
|
|
||||||
delta_content = '<think>\n' + reasoning_content
|
|
||||||
else:
|
|
||||||
# 继续输出 reasoning_content
|
|
||||||
delta_content = reasoning_content
|
|
||||||
elif thinking_started and not thinking_ended and delta_content:
|
|
||||||
# reasoning_content 结束,normal content 开始,添加 </think> 结束标签
|
|
||||||
thinking_ended = True
|
|
||||||
delta_content = '\n</think>\n' + delta_content
|
|
||||||
|
|
||||||
# 处理工具调用增量
|
|
||||||
if delta.get('tool_calls'):
|
|
||||||
for tool_call in delta['tool_calls']:
|
|
||||||
if tool_call['id'] != '':
|
|
||||||
tool_id = tool_call['id']
|
|
||||||
if tool_call['function']['name'] is not None:
|
|
||||||
tool_name = tool_call['function']['name']
|
|
||||||
|
|
||||||
if tool_call['type'] is None:
|
|
||||||
tool_call['type'] = 'function'
|
|
||||||
tool_call['id'] = tool_id
|
|
||||||
tool_call['function']['name'] = tool_name
|
|
||||||
tool_call['function']['arguments'] = (
|
|
||||||
'' if tool_call['function']['arguments'] is None else tool_call['function']['arguments']
|
|
||||||
)
|
|
||||||
|
|
||||||
# 跳过空的第一个 chunk(只有 role 没有内容)
|
|
||||||
if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 构建 MessageChunk - 只包含增量内容
|
|
||||||
chunk_data = {
|
|
||||||
'role': role,
|
|
||||||
'content': delta_content if delta_content else None,
|
|
||||||
'tool_calls': delta.get('tool_calls'),
|
|
||||||
'is_final': bool(finish_reason),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 移除 None 值
|
|
||||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
|
||||||
|
|
||||||
yield provider_message.MessageChunk(**chunk_data)
|
|
||||||
chunk_idx += 1
|
|
||||||
# return
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: 阿里云百炼
|
zh_Hans: 阿里云百炼
|
||||||
icon: bailian.png
|
icon: bailian.png
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
@@ -24,6 +25,7 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
- text-embedding
|
||||||
- rerank
|
- rerank
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
|
|||||||
@@ -1,703 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import openai
|
|
||||||
import openai.types.chat.chat_completion as chat_completion_module
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .. import errors, requester
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
|
||||||
"""OpenAI ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
client: openai.AsyncClient
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.openai.com/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
self.client = openai.AsyncClient(
|
|
||||||
api_key=self.init_api_key,
|
|
||||||
base_url=self.requester_cfg['base_url'].replace(' ', ''),
|
|
||||||
timeout=self.requester_cfg['timeout'],
|
|
||||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _mask_api_key(self, api_key: str | None) -> str:
|
|
||||||
if not api_key:
|
|
||||||
return ''
|
|
||||||
if len(api_key) <= 8:
|
|
||||||
return '****'
|
|
||||||
return f'{api_key[:4]}...{api_key[-4:]}'
|
|
||||||
|
|
||||||
def _infer_model_type(self, model_id: str) -> str:
|
|
||||||
normalized_model_id = (model_id or '').lower()
|
|
||||||
embedding_keywords = (
|
|
||||||
'embedding',
|
|
||||||
'embed',
|
|
||||||
'bge-',
|
|
||||||
'e5-',
|
|
||||||
'm3e',
|
|
||||||
'gte-',
|
|
||||||
'multilingual-e5',
|
|
||||||
'text-embedding',
|
|
||||||
)
|
|
||||||
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
|
|
||||||
|
|
||||||
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
|
|
||||||
normalized_model_id = (model_id or '').lower()
|
|
||||||
abilities: set[str] = set()
|
|
||||||
|
|
||||||
def _flatten(value: typing.Any) -> list[str]:
|
|
||||||
if value is None:
|
|
||||||
return []
|
|
||||||
if isinstance(value, str):
|
|
||||||
return [value.lower()]
|
|
||||||
if isinstance(value, dict):
|
|
||||||
flattened: list[str] = []
|
|
||||||
for nested_value in value.values():
|
|
||||||
flattened.extend(_flatten(nested_value))
|
|
||||||
return flattened
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
flattened: list[str] = []
|
|
||||||
for nested_value in value:
|
|
||||||
flattened.extend(_flatten(nested_value))
|
|
||||||
return flattened
|
|
||||||
return [str(value).lower()]
|
|
||||||
|
|
||||||
capability_tokens = _flatten(item.get('capabilities'))
|
|
||||||
capability_tokens.extend(_flatten(item.get('modalities')))
|
|
||||||
capability_tokens.extend(_flatten(item.get('input_modalities')))
|
|
||||||
capability_tokens.extend(_flatten(item.get('output_modalities')))
|
|
||||||
capability_tokens.extend(_flatten(item.get('supported_generation_methods')))
|
|
||||||
capability_tokens.extend(_flatten(item.get('supported_parameters')))
|
|
||||||
capability_tokens.extend(_flatten(item.get('architecture')))
|
|
||||||
|
|
||||||
combined_tokens = capability_tokens + [normalized_model_id]
|
|
||||||
|
|
||||||
vision_keywords = (
|
|
||||||
'vision',
|
|
||||||
'image',
|
|
||||||
'file',
|
|
||||||
'video',
|
|
||||||
'multimodal',
|
|
||||||
'vl',
|
|
||||||
'ocr',
|
|
||||||
'omni',
|
|
||||||
)
|
|
||||||
function_call_keywords = (
|
|
||||||
'function',
|
|
||||||
'tool',
|
|
||||||
'tools',
|
|
||||||
'tool_choice',
|
|
||||||
'tool_call',
|
|
||||||
'tool-use',
|
|
||||||
'tool_use',
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens):
|
|
||||||
abilities.add('vision')
|
|
||||||
|
|
||||||
if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens):
|
|
||||||
abilities.add('func_call')
|
|
||||||
|
|
||||||
return sorted(abilities)
|
|
||||||
|
|
||||||
def _normalize_modalities(self, value: typing.Any) -> list[str]:
|
|
||||||
normalized: list[str] = []
|
|
||||||
|
|
||||||
def _collect(item: typing.Any):
|
|
||||||
if item is None:
|
|
||||||
return
|
|
||||||
if isinstance(item, str):
|
|
||||||
for part in item.replace('->', ',').replace('+', ',').split(','):
|
|
||||||
token = part.strip().lower()
|
|
||||||
if token and token not in normalized:
|
|
||||||
normalized.append(token)
|
|
||||||
return
|
|
||||||
if isinstance(item, dict):
|
|
||||||
for nested in item.values():
|
|
||||||
_collect(nested)
|
|
||||||
return
|
|
||||||
if isinstance(item, (list, tuple, set)):
|
|
||||||
for nested in item:
|
|
||||||
_collect(nested)
|
|
||||||
return
|
|
||||||
|
|
||||||
_collect(value)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]:
|
|
||||||
display_name = item.get('name')
|
|
||||||
if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id:
|
|
||||||
display_name = ''
|
|
||||||
|
|
||||||
description = item.get('description')
|
|
||||||
if not isinstance(description, str) or not description.strip():
|
|
||||||
description = ''
|
|
||||||
|
|
||||||
context_length = item.get('context_length')
|
|
||||||
if context_length is None and isinstance(item.get('top_provider'), dict):
|
|
||||||
context_length = item['top_provider'].get('context_length')
|
|
||||||
|
|
||||||
if not isinstance(context_length, int):
|
|
||||||
try:
|
|
||||||
context_length = int(context_length) if context_length is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
context_length = None
|
|
||||||
|
|
||||||
input_modalities = self._normalize_modalities(item.get('input_modalities'))
|
|
||||||
output_modalities = self._normalize_modalities(item.get('output_modalities'))
|
|
||||||
|
|
||||||
if isinstance(item.get('architecture'), dict):
|
|
||||||
if not input_modalities:
|
|
||||||
input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities'))
|
|
||||||
if not output_modalities:
|
|
||||||
output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities'))
|
|
||||||
|
|
||||||
owned_by = item.get('owned_by')
|
|
||||||
if not isinstance(owned_by, str) or not owned_by.strip():
|
|
||||||
owned_by = ''
|
|
||||||
|
|
||||||
return {
|
|
||||||
'display_name': display_name or None,
|
|
||||||
'description': description or None,
|
|
||||||
'context_length': context_length,
|
|
||||||
'owned_by': owned_by or None,
|
|
||||||
'input_modalities': input_modalities,
|
|
||||||
'output_modalities': output_modalities,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
|
||||||
headers = {}
|
|
||||||
if api_key:
|
|
||||||
headers['Authorization'] = f'Bearer {api_key}'
|
|
||||||
|
|
||||||
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models'
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
|
||||||
response = await client.get(models_url, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
|
|
||||||
models = []
|
|
||||||
for item in payload.get('data', []):
|
|
||||||
model_id = item.get('id')
|
|
||||||
if not model_id:
|
|
||||||
continue
|
|
||||||
models.append(
|
|
||||||
{
|
|
||||||
'id': model_id,
|
|
||||||
'name': model_id,
|
|
||||||
'type': self._infer_model_type(model_id),
|
|
||||||
'abilities': self._infer_model_abilities(item, model_id),
|
|
||||||
**self._extract_scan_metadata(item, model_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
|
||||||
return {
|
|
||||||
'models': models,
|
|
||||||
'debug': {
|
|
||||||
'request': {
|
|
||||||
'method': 'GET',
|
|
||||||
'url': models_url,
|
|
||||||
'headers': {
|
|
||||||
'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'response': payload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _req(
|
|
||||||
self,
|
|
||||||
args: dict,
|
|
||||||
extra_body: dict = {},
|
|
||||||
) -> chat_completion_module.ChatCompletion:
|
|
||||||
return await self.client.chat.completions.create(**args, extra_body=extra_body)
|
|
||||||
|
|
||||||
async def _req_stream(
|
|
||||||
self,
|
|
||||||
args: dict,
|
|
||||||
extra_body: dict = {},
|
|
||||||
):
|
|
||||||
async for chunk in await self.client.chat.completions.create(**args, extra_body=extra_body):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
async def _make_msg(
|
|
||||||
self,
|
|
||||||
chat_completion: chat_completion_module.ChatCompletion,
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.Message:
|
|
||||||
if not isinstance(chat_completion, chat_completion_module.ChatCompletion):
|
|
||||||
raise TypeError(f'Expected ChatCompletion, got {type(chat_completion).__name__}: {chat_completion[:16]}')
|
|
||||||
|
|
||||||
chatcmpl_message = chat_completion.choices[0].message.model_dump()
|
|
||||||
|
|
||||||
# 确保 role 字段存在且不为 None
|
|
||||||
if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:
|
|
||||||
chatcmpl_message['role'] = 'assistant'
|
|
||||||
|
|
||||||
# 处理思维链
|
|
||||||
content = chatcmpl_message.get('content', '')
|
|
||||||
reasoning_content = chatcmpl_message.get('reasoning_content', None)
|
|
||||||
|
|
||||||
processed_content, _ = await self._process_thinking_content(
|
|
||||||
content=content, reasoning_content=reasoning_content, remove_think=remove_think
|
|
||||||
)
|
|
||||||
|
|
||||||
chatcmpl_message['content'] = processed_content
|
|
||||||
|
|
||||||
# 移除 reasoning_content 字段,避免传递给 Message
|
|
||||||
if 'reasoning_content' in chatcmpl_message:
|
|
||||||
del chatcmpl_message['reasoning_content']
|
|
||||||
|
|
||||||
message = provider_message.Message(**chatcmpl_message)
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def _process_thinking_content(
|
|
||||||
self,
|
|
||||||
content: str,
|
|
||||||
reasoning_content: str = None,
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""处理思维链内容
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: 原始内容
|
|
||||||
reasoning_content: reasoning_content 字段内容
|
|
||||||
remove_think: 是否移除思维链
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(处理后的内容, 提取的思维链内容)
|
|
||||||
"""
|
|
||||||
thinking_content = ''
|
|
||||||
|
|
||||||
# 1. 从 reasoning_content 提取思维链
|
|
||||||
if reasoning_content:
|
|
||||||
thinking_content = reasoning_content
|
|
||||||
|
|
||||||
# 2. 从 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:
|
|
||||||
# 如果已有 reasoning_content,则追加
|
|
||||||
if thinking_content:
|
|
||||||
thinking_content += '\n' + '\n'.join(think_matches)
|
|
||||||
else:
|
|
||||||
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
|
|
||||||
|
|
||||||
async def _closure_stream(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
req_messages: list[dict],
|
|
||||||
use_model: requester.RuntimeLLMModel,
|
|
||||||
use_funcs: list[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.MessageChunk:
|
|
||||||
self.client.api_key = use_model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {}
|
|
||||||
args['model'] = use_model.model_entity.name
|
|
||||||
|
|
||||||
if use_funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
# 设置此次请求中的messages
|
|
||||||
messages = req_messages.copy()
|
|
||||||
|
|
||||||
# 检查vision
|
|
||||||
for msg in messages:
|
|
||||||
if 'content' in msg and isinstance(msg['content'], list):
|
|
||||||
for me in msg['content']:
|
|
||||||
if me['type'] == 'image_base64':
|
|
||||||
me['image_url'] = {'url': me['image_base64']}
|
|
||||||
me['type'] = 'image_url'
|
|
||||||
del me['image_base64']
|
|
||||||
|
|
||||||
args['messages'] = messages
|
|
||||||
args['stream'] = True
|
|
||||||
|
|
||||||
# 流式处理状态
|
|
||||||
# tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
|
||||||
chunk_idx = 0
|
|
||||||
thinking_started = False
|
|
||||||
thinking_ended = False
|
|
||||||
role = 'assistant' # 默认角色
|
|
||||||
tool_id = ''
|
|
||||||
tool_name = ''
|
|
||||||
# accumulated_reasoning = '' # 仅用于判断何时结束思维链
|
|
||||||
|
|
||||||
async for chunk in self._req_stream(args, extra_body=extra_args):
|
|
||||||
# 解析 chunk 数据
|
|
||||||
|
|
||||||
if hasattr(chunk, 'choices') and chunk.choices:
|
|
||||||
choice = chunk.choices[0]
|
|
||||||
delta_obj = getattr(choice, 'delta', None)
|
|
||||||
delta = delta_obj.model_dump() if delta_obj is not None else {}
|
|
||||||
|
|
||||||
finish_reason = getattr(choice, 'finish_reason', None)
|
|
||||||
else:
|
|
||||||
delta = {}
|
|
||||||
finish_reason = None
|
|
||||||
# 从第一个 chunk 获取 role,后续使用这个 role
|
|
||||||
if 'role' in delta and delta['role']:
|
|
||||||
role = delta['role']
|
|
||||||
|
|
||||||
# 获取增量内容
|
|
||||||
delta_content = delta.get('content', '')
|
|
||||||
reasoning_content = delta.get('reasoning_content', '')
|
|
||||||
|
|
||||||
# 处理 reasoning_content
|
|
||||||
if reasoning_content:
|
|
||||||
# accumulated_reasoning += reasoning_content
|
|
||||||
# 如果设置了 remove_think,跳过 reasoning_content
|
|
||||||
if remove_think:
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 第一次出现 reasoning_content,添加 <think> 开始标签
|
|
||||||
if not thinking_started:
|
|
||||||
thinking_started = True
|
|
||||||
delta_content = '<think>\n' + reasoning_content
|
|
||||||
else:
|
|
||||||
# 继续输出 reasoning_content
|
|
||||||
delta_content = reasoning_content
|
|
||||||
elif thinking_started and not thinking_ended and delta_content:
|
|
||||||
# reasoning_content 结束,normal content 开始,添加 </think> 结束标签
|
|
||||||
thinking_ended = True
|
|
||||||
delta_content = '\n</think>\n' + delta_content
|
|
||||||
|
|
||||||
# 处理 content 中已有的 <think> 标签(如果需要移除)
|
|
||||||
# if delta_content and remove_think and '<think>' in delta_content:
|
|
||||||
# import re
|
|
||||||
#
|
|
||||||
# # 移除 <think> 标签及其内容
|
|
||||||
# delta_content = re.sub(r'<think>.*?</think>', '', delta_content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# 处理工具调用增量
|
|
||||||
# delta_tool_calls = None
|
|
||||||
if delta.get('tool_calls'):
|
|
||||||
for tool_call in delta['tool_calls']:
|
|
||||||
if tool_call['id'] and tool_call['function']['name']:
|
|
||||||
tool_id = tool_call['id']
|
|
||||||
tool_name = tool_call['function']['name']
|
|
||||||
else:
|
|
||||||
tool_call['id'] = tool_id
|
|
||||||
tool_call['function']['name'] = tool_name
|
|
||||||
if tool_call['type'] is None:
|
|
||||||
tool_call['type'] = 'function'
|
|
||||||
|
|
||||||
# 跳过空的第一个 chunk(只有 role 没有内容)
|
|
||||||
if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
# 构建 MessageChunk - 只包含增量内容
|
|
||||||
chunk_data = {
|
|
||||||
'role': role,
|
|
||||||
'content': delta_content if delta_content else None,
|
|
||||||
'tool_calls': delta.get('tool_calls'),
|
|
||||||
'is_final': bool(finish_reason),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 移除 None 值
|
|
||||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
|
||||||
|
|
||||||
yield provider_message.MessageChunk(**chunk_data)
|
|
||||||
chunk_idx += 1
|
|
||||||
|
|
||||||
async def _closure(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
req_messages: list[dict],
|
|
||||||
use_model: requester.RuntimeLLMModel,
|
|
||||||
use_funcs: list[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> tuple[provider_message.Message, dict]:
|
|
||||||
self.client.api_key = use_model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {}
|
|
||||||
args['model'] = use_model.model_entity.name
|
|
||||||
|
|
||||||
if use_funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
# 设置此次请求中的messages
|
|
||||||
messages = req_messages.copy()
|
|
||||||
|
|
||||||
# 检查vision
|
|
||||||
for msg in messages:
|
|
||||||
if 'content' in msg and isinstance(msg['content'], list):
|
|
||||||
for me in msg['content']:
|
|
||||||
if me['type'] == 'image_base64':
|
|
||||||
me['image_url'] = {'url': me['image_base64']}
|
|
||||||
me['type'] = 'image_url'
|
|
||||||
del me['image_base64']
|
|
||||||
|
|
||||||
args['messages'] = messages
|
|
||||||
|
|
||||||
# 发送请求
|
|
||||||
|
|
||||||
resp = await self._req(args, extra_body=extra_args)
|
|
||||||
# 处理请求结果
|
|
||||||
message = await self._make_msg(resp, remove_think)
|
|
||||||
|
|
||||||
# Extract token usage from response
|
|
||||||
usage_info = {}
|
|
||||||
if hasattr(resp, 'usage') and resp.usage:
|
|
||||||
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
|
|
||||||
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
|
|
||||||
usage_info['total_tokens'] = resp.usage.total_tokens or 0
|
|
||||||
|
|
||||||
return message, usage_info
|
|
||||||
|
|
||||||
async def invoke_llm(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
model: requester.RuntimeLLMModel,
|
|
||||||
messages: typing.List[provider_message.Message],
|
|
||||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> tuple[provider_message.Message, dict]:
|
|
||||||
"""Invoke LLM and return message with usage info"""
|
|
||||||
req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行
|
|
||||||
for m in messages:
|
|
||||||
msg_dict = m.dict(exclude_none=True)
|
|
||||||
content = msg_dict.get('content')
|
|
||||||
if isinstance(content, list):
|
|
||||||
# 检查 content 列表中是否每个部分都是文本
|
|
||||||
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
|
|
||||||
# 将所有文本部分合并为一个字符串
|
|
||||||
msg_dict['content'] = '\n'.join(part['text'] for part in content)
|
|
||||||
req_messages.append(msg_dict)
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg, usage_info = await self._closure(
|
|
||||||
query=query,
|
|
||||||
req_messages=req_messages,
|
|
||||||
use_model=model,
|
|
||||||
use_funcs=funcs,
|
|
||||||
extra_args=extra_args,
|
|
||||||
remove_think=remove_think,
|
|
||||||
)
|
|
||||||
return msg, usage_info
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise errors.RequesterError('请求超时')
|
|
||||||
except openai.BadRequestError as e:
|
|
||||||
error_message = str(e.message) if hasattr(e, 'message') else str(e)
|
|
||||||
if 'context_length_exceeded' in str(e):
|
|
||||||
raise errors.RequesterError(f'上文过长,请重置会话: {error_message}')
|
|
||||||
else:
|
|
||||||
raise errors.RequesterError(f'请求参数错误: {error_message}')
|
|
||||||
except openai.AuthenticationError as e:
|
|
||||||
error_message = str(e.message) if hasattr(e, 'message') else str(e)
|
|
||||||
raise errors.RequesterError(f'无效的 api-key: {error_message}')
|
|
||||||
except openai.NotFoundError as e:
|
|
||||||
error_message = str(e.message) if hasattr(e, 'message') else str(e)
|
|
||||||
raise errors.RequesterError(f'请求路径错误: {error_message}')
|
|
||||||
except openai.RateLimitError as e:
|
|
||||||
error_message = str(e.message) if hasattr(e, 'message') else str(e)
|
|
||||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {error_message}')
|
|
||||||
except openai.APIConnectionError as e:
|
|
||||||
error_message = f'连接错误: {str(e)}'
|
|
||||||
raise errors.RequesterError(error_message)
|
|
||||||
except openai.APIError as e:
|
|
||||||
error_message = str(e.message) if hasattr(e, 'message') else str(e)
|
|
||||||
raise errors.RequesterError(f'请求错误: {error_message}')
|
|
||||||
|
|
||||||
async def invoke_embedding(
|
|
||||||
self,
|
|
||||||
model: requester.RuntimeEmbeddingModel,
|
|
||||||
input_text: list[str],
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
) -> tuple[list[list[float]], dict]:
|
|
||||||
"""调用 Embedding API, returns (embeddings, usage_info)"""
|
|
||||||
self.client.api_key = model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {
|
|
||||||
'model': model.model_entity.name,
|
|
||||||
'input': input_text,
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.model_entity.extra_args:
|
|
||||||
args.update(model.model_entity.extra_args)
|
|
||||||
|
|
||||||
args.update(extra_args)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await self.client.embeddings.create(**args)
|
|
||||||
|
|
||||||
# Extract usage info
|
|
||||||
usage_info = {}
|
|
||||||
if hasattr(resp, 'usage') and resp.usage:
|
|
||||||
usage_info['prompt_tokens'] = resp.usage.prompt_tokens or 0
|
|
||||||
usage_info['total_tokens'] = resp.usage.total_tokens or 0
|
|
||||||
|
|
||||||
return [d.embedding for d in resp.data], usage_info
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise errors.RequesterError('请求超时')
|
|
||||||
except openai.BadRequestError as e:
|
|
||||||
raise errors.RequesterError(f'请求参数错误: {e.message}')
|
|
||||||
|
|
||||||
async def invoke_llm_stream(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
model: requester.RuntimeLLMModel,
|
|
||||||
messages: typing.List[provider_message.Message],
|
|
||||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.MessageChunk:
|
|
||||||
req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行
|
|
||||||
for m in messages:
|
|
||||||
msg_dict = m.dict(exclude_none=True)
|
|
||||||
content = msg_dict.get('content')
|
|
||||||
if isinstance(content, list):
|
|
||||||
# 检查 content 列表中是否每个部分都是文本
|
|
||||||
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
|
|
||||||
# 将所有文本部分合并为一个字符串
|
|
||||||
msg_dict['content'] = '\n'.join(part['text'] for part in content)
|
|
||||||
req_messages.append(msg_dict)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for item in self._closure_stream(
|
|
||||||
query=query,
|
|
||||||
req_messages=req_messages,
|
|
||||||
use_model=model,
|
|
||||||
use_funcs=funcs,
|
|
||||||
extra_args=extra_args,
|
|
||||||
remove_think=remove_think,
|
|
||||||
):
|
|
||||||
yield item
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise errors.RequesterError('请求超时')
|
|
||||||
except openai.BadRequestError as e:
|
|
||||||
if 'context_length_exceeded' in e.message:
|
|
||||||
raise errors.RequesterError(f'上文过长,请重置会话: {e.message}')
|
|
||||||
else:
|
|
||||||
raise errors.RequesterError(f'请求参数错误: {e.message}')
|
|
||||||
except openai.AuthenticationError as e:
|
|
||||||
raise errors.RequesterError(f'无效的 api-key: {e.message}')
|
|
||||||
except openai.NotFoundError as e:
|
|
||||||
raise errors.RequesterError(f'请求路径错误: {e.message}')
|
|
||||||
except openai.RateLimitError as e:
|
|
||||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
|
|
||||||
except openai.APIError as e:
|
|
||||||
raise errors.RequesterError(f'请求错误: {e.message}')
|
|
||||||
|
|
||||||
async def invoke_rerank(
|
|
||||||
self,
|
|
||||||
model: requester.RuntimeRerankModel,
|
|
||||||
query: str,
|
|
||||||
documents: typing.List[str],
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
) -> typing.List[dict]:
|
|
||||||
"""Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible)
|
|
||||||
|
|
||||||
Supports extra_args from model.extra_args:
|
|
||||||
- rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
|
|
||||||
- rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank")
|
|
||||||
- Any other fields are merged into the request payload.
|
|
||||||
"""
|
|
||||||
api_key = model.provider.token_mgr.get_token()
|
|
||||||
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
|
|
||||||
timeout = self.requester_cfg.get('timeout', 120)
|
|
||||||
|
|
||||||
merged_args = {}
|
|
||||||
if model.model_entity.extra_args:
|
|
||||||
merged_args.update(model.model_entity.extra_args)
|
|
||||||
if extra_args:
|
|
||||||
merged_args.update(extra_args)
|
|
||||||
|
|
||||||
rerank_url = merged_args.pop('rerank_url', None)
|
|
||||||
rerank_path = merged_args.pop('rerank_path', 'rerank')
|
|
||||||
if not rerank_url:
|
|
||||||
rerank_url = f'{base_url}/{rerank_path}'
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'model': model.model_entity.name,
|
|
||||||
'query': query,
|
|
||||||
'documents': documents[:64],
|
|
||||||
'top_n': min(len(documents), 64),
|
|
||||||
}
|
|
||||||
|
|
||||||
if merged_args:
|
|
||||||
payload.update(merged_args)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
|
|
||||||
resp = await client.post(rerank_url, headers=headers, json=payload)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
results = self._parse_rerank_response(data)
|
|
||||||
|
|
||||||
if results:
|
|
||||||
scores = [r.get('relevance_score', 0.0) for r in results]
|
|
||||||
min_score = min(scores)
|
|
||||||
max_score = max(scores)
|
|
||||||
if max_score - min_score > 1e-6:
|
|
||||||
for r in results:
|
|
||||||
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
|
|
||||||
|
|
||||||
return results
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}')
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise errors.RequesterError('Rerank request timed out')
|
|
||||||
except Exception as e:
|
|
||||||
raise errors.RequesterError(f'Rerank request error: {str(e)}')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_rerank_response(data: dict) -> typing.List[dict]:
|
|
||||||
"""Parse rerank response from various providers.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]}
|
|
||||||
- Voyage AI: {"data": [{"index", "relevance_score"}]}
|
|
||||||
- DashScope: {"output": {"results": [{"index", "relevance_score"}]}}
|
|
||||||
"""
|
|
||||||
if 'results' in data:
|
|
||||||
return data['results']
|
|
||||||
if 'data' in data:
|
|
||||||
return data['data']
|
|
||||||
if 'output' in data and isinstance(data['output'], dict):
|
|
||||||
return data['output'].get('results', [])
|
|
||||||
return []
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: OpenAI
|
zh_Hans: OpenAI
|
||||||
icon: openai.svg
|
icon: openai.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: Cohere
|
zh_Hans: Cohere
|
||||||
icon: cohere.svg
|
icon: cohere.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: cohere
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import openai
|
|
||||||
|
|
||||||
from . import chatcmpl
|
|
||||||
|
|
||||||
|
|
||||||
class CompShareChatCompletions(chatcmpl.OpenAIChatCompletions):
|
|
||||||
"""CompShare ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
client: openai.AsyncClient
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.modelverse.cn/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: 优云智算
|
zh_Hans: 优云智算
|
||||||
icon: compshare.png
|
icon: compshare.png
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
@@ -24,6 +25,8 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from . import chatcmpl
|
|
||||||
from .. import errors, requester
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
|
|
||||||
"""Deepseek ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.deepseek.com',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _closure(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
req_messages: list[dict],
|
|
||||||
use_model: requester.RuntimeLLMModel,
|
|
||||||
use_funcs: list[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> tuple[provider_message.Message, dict]:
|
|
||||||
self.client.api_key = use_model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {}
|
|
||||||
args['model'] = use_model.model_entity.name
|
|
||||||
|
|
||||||
if use_funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
# 设置此次请求中的messages
|
|
||||||
messages = req_messages
|
|
||||||
|
|
||||||
# deepseek 不支持多模态,把content都转换成纯文字
|
|
||||||
for m in messages:
|
|
||||||
if 'content' in m and isinstance(m['content'], list):
|
|
||||||
m['content'] = ' '.join([c['text'] for c in m['content'] if 'text' in c])
|
|
||||||
|
|
||||||
args['messages'] = messages
|
|
||||||
|
|
||||||
# 发送请求
|
|
||||||
resp = await self._req(args, extra_body=extra_args)
|
|
||||||
|
|
||||||
# print(resp)
|
|
||||||
|
|
||||||
if resp is None:
|
|
||||||
raise errors.RequesterError('接口返回为空,请确定模型提供商服务是否正常')
|
|
||||||
# 处理请求结果
|
|
||||||
message = await self._make_msg(resp, remove_think)
|
|
||||||
|
|
||||||
# Extract token usage from response
|
|
||||||
usage_info = {}
|
|
||||||
if hasattr(resp, 'usage') and resp.usage:
|
|
||||||
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
|
|
||||||
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
|
|
||||||
usage_info['total_tokens'] = resp.usage.total_tokens or 0
|
|
||||||
|
|
||||||
return message, usage_info
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: DeepSeek
|
zh_Hans: DeepSeek
|
||||||
icon: deepseek.svg
|
icon: deepseek.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: deepseek
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
@@ -24,6 +25,8 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
provider_category: manufacturer
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
4
src/langbot/pkg/provider/modelmgr/requesters/doubao.svg
Normal file
4
src/langbot/pkg/provider/modelmgr/requesters/doubao.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="60" height="50" viewBox="0 0 60 50" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="60" height="50" rx="8" fill="#3B82F6"/>
|
||||||
|
<text x="30" y="32" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="white" text-anchor="middle">豆包</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: LLMAPIRequester
|
||||||
|
metadata:
|
||||||
|
name: doubao-chat-completions
|
||||||
|
label:
|
||||||
|
en_US: ByteDance Doubao
|
||||||
|
zh_Hans: 字节豆包
|
||||||
|
icon: doubao.svg
|
||||||
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
|
config:
|
||||||
|
- name: base_url
|
||||||
|
label:
|
||||||
|
en_US: Base URL
|
||||||
|
zh_Hans: 基础 URL
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
- name: timeout
|
||||||
|
label:
|
||||||
|
en_US: Timeout
|
||||||
|
zh_Hans: 超时时间
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
default: 120
|
||||||
|
support_type:
|
||||||
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
|
provider_category: manufacturer
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from . import chatcmpl
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from .. import requester
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
|
|
||||||
"""Google Gemini API 请求器"""
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
|
||||||
models_url = 'https://generativelanguage.googleapis.com/v1beta/models'
|
|
||||||
params = {'key': api_key} if api_key else {}
|
|
||||||
|
|
||||||
all_models: list[dict[str, typing.Any]] = []
|
|
||||||
next_page_token = ''
|
|
||||||
last_payload: dict[str, typing.Any] = {}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
|
||||||
while True:
|
|
||||||
request_params = dict(params)
|
|
||||||
if next_page_token:
|
|
||||||
request_params['pageToken'] = next_page_token
|
|
||||||
|
|
||||||
response = await client.get(models_url, params=request_params)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
last_payload = payload
|
|
||||||
|
|
||||||
for item in payload.get('models', []):
|
|
||||||
model_name = item.get('name', '')
|
|
||||||
model_id = model_name.replace('models/', '', 1)
|
|
||||||
if not model_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
supported_methods = item.get('supportedGenerationMethods', []) or []
|
|
||||||
if 'embedContent' in supported_methods and 'generateContent' not in supported_methods:
|
|
||||||
model_type = 'embedding'
|
|
||||||
else:
|
|
||||||
model_type = 'llm'
|
|
||||||
|
|
||||||
all_models.append(
|
|
||||||
{
|
|
||||||
'id': model_id,
|
|
||||||
'name': model_id,
|
|
||||||
'type': model_type,
|
|
||||||
'abilities': self._infer_model_abilities(item, model_id),
|
|
||||||
'display_name': item.get('displayName') or None,
|
|
||||||
'description': item.get('description') or None,
|
|
||||||
'context_length': item.get('inputTokenLimit'),
|
|
||||||
'input_modalities': self._normalize_modalities(item.get('inputModalities')),
|
|
||||||
'output_modalities': self._normalize_modalities(item.get('outputModalities')),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
next_page_token = payload.get('nextPageToken', '')
|
|
||||||
if not next_page_token:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
|
||||||
return {
|
|
||||||
'models': all_models,
|
|
||||||
'debug': {
|
|
||||||
'request': {
|
|
||||||
'method': 'GET',
|
|
||||||
'url': models_url,
|
|
||||||
'query': {'key': self._mask_api_key(api_key)} if api_key else {},
|
|
||||||
},
|
|
||||||
'response': last_payload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _closure_stream(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
req_messages: list[dict],
|
|
||||||
use_model: requester.RuntimeLLMModel,
|
|
||||||
use_funcs: list[resource_tool.LLMTool] = None,
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
remove_think: bool = False,
|
|
||||||
) -> provider_message.MessageChunk:
|
|
||||||
self.client.api_key = use_model.provider.token_mgr.get_token()
|
|
||||||
|
|
||||||
args = {}
|
|
||||||
args['model'] = use_model.model_entity.name
|
|
||||||
|
|
||||||
if use_funcs:
|
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
||||||
if tools:
|
|
||||||
args['tools'] = tools
|
|
||||||
|
|
||||||
# 设置此次请求中的messages
|
|
||||||
messages = req_messages.copy()
|
|
||||||
|
|
||||||
# 检查vision
|
|
||||||
for msg in messages:
|
|
||||||
if 'content' in msg and isinstance(msg['content'], list):
|
|
||||||
for me in msg['content']:
|
|
||||||
if me['type'] == 'image_base64':
|
|
||||||
me['image_url'] = {'url': me['image_base64']}
|
|
||||||
me['type'] = 'image_url'
|
|
||||||
del me['image_base64']
|
|
||||||
|
|
||||||
args['messages'] = messages
|
|
||||||
args['stream'] = True
|
|
||||||
|
|
||||||
# 流式处理状态
|
|
||||||
# tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
|
||||||
chunk_idx = 0
|
|
||||||
thinking_started = False
|
|
||||||
thinking_ended = False
|
|
||||||
role = 'assistant' # 默认角色
|
|
||||||
tool_id = ''
|
|
||||||
tool_name = ''
|
|
||||||
# accumulated_reasoning = '' # 仅用于判断何时结束思维链
|
|
||||||
|
|
||||||
async for chunk in self._req_stream(args, extra_body=extra_args):
|
|
||||||
# 解析 chunk 数据
|
|
||||||
|
|
||||||
if hasattr(chunk, 'choices') and chunk.choices:
|
|
||||||
choice = chunk.choices[0]
|
|
||||||
delta_obj = getattr(choice, 'delta', None)
|
|
||||||
delta = delta_obj.model_dump() if delta_obj is not None else {}
|
|
||||||
|
|
||||||
finish_reason = getattr(choice, 'finish_reason', None)
|
|
||||||
else:
|
|
||||||
delta = {}
|
|
||||||
finish_reason = None
|
|
||||||
# 从第一个 chunk 获取 role,后续使用这个 role
|
|
||||||
if 'role' in delta and delta['role']:
|
|
||||||
role = delta['role']
|
|
||||||
|
|
||||||
# 获取增量内容
|
|
||||||
delta_content = delta.get('content', '')
|
|
||||||
reasoning_content = delta.get('reasoning_content', '')
|
|
||||||
|
|
||||||
# 处理 reasoning_content
|
|
||||||
if reasoning_content:
|
|
||||||
# accumulated_reasoning += reasoning_content
|
|
||||||
# 如果设置了 remove_think,跳过 reasoning_content
|
|
||||||
if remove_think:
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 第一次出现 reasoning_content,添加 <think> 开始标签
|
|
||||||
if not thinking_started:
|
|
||||||
thinking_started = True
|
|
||||||
delta_content = '<think>\n' + reasoning_content
|
|
||||||
else:
|
|
||||||
# 继续输出 reasoning_content
|
|
||||||
delta_content = reasoning_content
|
|
||||||
elif thinking_started and not thinking_ended and delta_content:
|
|
||||||
# reasoning_content 结束,normal content 开始,添加 </think> 结束标签
|
|
||||||
thinking_ended = True
|
|
||||||
delta_content = '\n</think>\n' + delta_content
|
|
||||||
|
|
||||||
# 处理 content 中已有的 <think> 标签(如果需要移除)
|
|
||||||
# if delta_content and remove_think and '<think>' in delta_content:
|
|
||||||
# import re
|
|
||||||
#
|
|
||||||
# # 移除 <think> 标签及其内容
|
|
||||||
# delta_content = re.sub(r'<think>.*?</think>', '', delta_content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# 处理工具调用增量
|
|
||||||
# delta_tool_calls = None
|
|
||||||
if delta.get('tool_calls'):
|
|
||||||
for tool_call in delta['tool_calls']:
|
|
||||||
if tool_call['id'] == '' and tool_id == '':
|
|
||||||
tool_id = str(uuid.uuid4())
|
|
||||||
if tool_call['function']['name']:
|
|
||||||
tool_name = tool_call['function']['name']
|
|
||||||
tool_call['id'] = tool_id
|
|
||||||
tool_call['function']['name'] = tool_name
|
|
||||||
if tool_call['type'] is None:
|
|
||||||
tool_call['type'] = 'function'
|
|
||||||
|
|
||||||
# 跳过空的第一个 chunk(只有 role 没有内容)
|
|
||||||
if chunk_idx == 0 and not delta_content and not reasoning_content and not delta.get('tool_calls'):
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
# 构建 MessageChunk - 只包含增量内容
|
|
||||||
chunk_data = {
|
|
||||||
'role': role,
|
|
||||||
'content': delta_content if delta_content else None,
|
|
||||||
'tool_calls': delta.get('tool_calls'),
|
|
||||||
'is_final': bool(finish_reason),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 移除 None 值
|
|
||||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
|
||||||
|
|
||||||
yield provider_message.MessageChunk(**chunk_data)
|
|
||||||
chunk_idx += 1
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: Google Gemini
|
zh_Hans: Google Gemini
|
||||||
icon: gemini.svg
|
icon: gemini.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: gemini
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
@@ -24,6 +25,8 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
provider_category: manufacturer
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from . import ppiochatcmpl
|
|
||||||
|
|
||||||
|
|
||||||
class GiteeAIChatCompletions(ppiochatcmpl.PPIOChatCompletions):
|
|
||||||
"""Gitee AI ChatCompletions API 请求器"""
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://ai.gitee.com/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ metadata:
|
|||||||
zh_Hans: Gitee AI
|
zh_Hans: Gitee AI
|
||||||
icon: giteeai.svg
|
icon: giteeai.svg
|
||||||
spec:
|
spec:
|
||||||
|
litellm_provider: openai
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
4
src/langbot/pkg/provider/modelmgr/requesters/groq.svg
Normal file
4
src/langbot/pkg/provider/modelmgr/requesters/groq.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="60" height="50" viewBox="0 0 60 50" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="60" height="50" rx="8" fill="#F97316"/>
|
||||||
|
<text x="30" y="32" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="white" text-anchor="middle">Groq</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 280 B |
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: LLMAPIRequester
|
||||||
|
metadata:
|
||||||
|
name: groq-chat-completions
|
||||||
|
label:
|
||||||
|
en_US: Groq
|
||||||
|
zh_Hans: Groq
|
||||||
|
icon: groq.svg
|
||||||
|
spec:
|
||||||
|
litellm_provider: groq
|
||||||
|
config:
|
||||||
|
- name: base_url
|
||||||
|
label:
|
||||||
|
en_US: Base URL
|
||||||
|
zh_Hans: 基础 URL
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: https://api.groq.com/openai/v1
|
||||||
|
- name: timeout
|
||||||
|
label:
|
||||||
|
en_US: Timeout
|
||||||
|
zh_Hans: 超时时间
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
default: 120
|
||||||
|
support_type:
|
||||||
|
- llm
|
||||||
|
- text-embedding
|
||||||
|
- rerank
|
||||||
|
provider_category: manufacturer
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user