mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 23:36:02 +00:00
Compare commits
5 Commits
feat/agent
...
feat/card_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f65b17ec | ||
|
|
2b533c4a00 | ||
|
|
f663d87a60 | ||
|
|
60e5b873ee | ||
|
|
b96f209b98 |
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,15 +10,6 @@ body:
|
|||||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 部署版本
|
|
||||||
description: 请选择您使用的 LangBot 部署版本。
|
|
||||||
options:
|
|
||||||
- 社区版
|
|
||||||
- 云服务
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -10,15 +10,6 @@ body:
|
|||||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Deployment version
|
|
||||||
description: Please select the LangBot deployment version you are using.
|
|
||||||
options:
|
|
||||||
- Community Edition
|
|
||||||
- Cloud Service
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Exception
|
label: Exception
|
||||||
|
|||||||
12
.github/workflows/run-tests.yml
vendored
12
.github/workflows/run-tests.yml
vendored
@@ -15,10 +15,14 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
- 'feat/**'
|
paths:
|
||||||
# No path filter on push: every push to the branches above runs the
|
- 'src/langbot/**'
|
||||||
# full unit-test suite. feat/** branches in particular must be tested
|
- 'tests/**'
|
||||||
# on every push (they accumulate large changes before a PR exists).
|
- '.github/workflows/run-tests.yml'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
- 'run_tests.sh'
|
||||||
|
- 'scripts/test-*.sh'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">扩展市场</a> |
|
<a href="https://space.langbot.app">插件市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,40 +18,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- langbot_network
|
- langbot_network
|
||||||
|
|
||||||
# The Box sandbox runtime is optional. It is only started when you run
|
|
||||||
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
|
||||||
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
|
||||||
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
|
||||||
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
|
||||||
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
|
||||||
langbot_box:
|
|
||||||
image: rockchin/langbot:latest
|
|
||||||
container_name: langbot_box
|
|
||||||
profiles: ["box", "all"]
|
|
||||||
volumes:
|
|
||||||
# Keep the source and target path identical because langbot_box uses the
|
|
||||||
# host Docker socket to create sandbox containers. Override
|
|
||||||
# LANGBOT_BOX_ROOT with an absolute path if you do not want the default.
|
|
||||||
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
|
||||||
# Mount container runtime socket for Box sandbox backend.
|
|
||||||
# Uncomment the one that matches your container runtime:
|
|
||||||
# - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock # Docker
|
|
||||||
restart: on-failure
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
# The Box runtime does NOT read box.local.* from config.yaml or env; it
|
|
||||||
# receives its configuration from LangBot via the INIT RPC action.
|
|
||||||
# Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored.
|
|
||||||
# Launched through the same CLI entry point as the plugin runtime
|
|
||||||
# (`langbot_plugin.cli.__init__ <subcommand>`). WebSocket is the default
|
|
||||||
# control transport — mirrors `rt`, which also runs with no flag. Pass
|
|
||||||
# `-s` / `--stdio-control` only for the stdio mode LangBot uses outside
|
|
||||||
# containers.
|
|
||||||
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
|
||||||
networks:
|
|
||||||
- langbot_network
|
|
||||||
|
|
||||||
langbot:
|
langbot:
|
||||||
image: rockchin/langbot:latest
|
image: rockchin/langbot:latest
|
||||||
container_name: langbot
|
container_name: langbot
|
||||||
@@ -60,13 +26,6 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
|
||||||
# matching config.yaml field (see LoadConfigStage). These map onto
|
|
||||||
# box.local.* and are forwarded to the Box runtime via INIT RPC.
|
|
||||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
|
||||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
|
||||||
- BOX__LOCAL__SKILLS_ROOT=skills
|
|
||||||
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
|
||||||
ports:
|
ports:
|
||||||
- 5300:5300 # For web ui and webhook callback
|
- 5300:5300 # For web ui and webhook callback
|
||||||
- 2280-2285:2280-2285 # For platform reverse connection
|
- 2280-2285:2280-2285 # For platform reverse connection
|
||||||
|
|||||||
@@ -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,595 +0,0 @@
|
|||||||
# Box 系统架构深度分析
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 全局架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ LangBot 主进程 │
|
|
||||||
│ │
|
|
||||||
│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ exec / read / write / edit │
|
|
||||||
│ │ │ glob / grep │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ├──> MCPLoader ──> BoxStdioSession │
|
|
||||||
│ │ │ (shared 容器, 多 process) │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ├──> SkillToolLoader (activate 工具) │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ├──> SkillAuthoringToolLoader │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ └──> PluginToolLoader │
|
|
||||||
│ │ │
|
|
||||||
│ BoxService (门面) │
|
|
||||||
│ ├─ Profile 管理 (locked 字段) │
|
|
||||||
│ ├─ Host mount 校验 (allowed_mount_roots) │
|
|
||||||
│ ├─ Workspace quota 检查 │
|
|
||||||
│ ├─ 输出截断 (head+tail) │
|
|
||||||
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
|
||||||
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
|
||||||
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
|
||||||
│ └─ BoxRuntimeConnector │
|
|
||||||
│ ├─ 心跳 loop (20s ping) │
|
|
||||||
│ └─ ActionRPCBoxClient │
|
|
||||||
│ │ Action RPC (stdio 或 WebSocket) │
|
|
||||||
│ │
|
|
||||||
│ SkillManager (skill_mgr) │
|
|
||||||
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Box Runtime 进程 (SDK 侧) │
|
|
||||||
│ │
|
|
||||||
│ BoxServerHandler (Action RPC 处理, INIT 配置注入) │
|
|
||||||
│ │ │
|
|
||||||
│ BoxRuntime (session 管理 / 进程生命周期 / TTL reaper) │
|
|
||||||
│ │ └─ session.managed_processes: dict[pid, _ManagedProcess]
|
|
||||||
│ │ │
|
|
||||||
│ Backend (启动时根据 box.backend 配置选择): │
|
|
||||||
│ DockerBackend ──┐ │
|
|
||||||
│ PodmanBackend ──┤── CLISandboxBackend │
|
|
||||||
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
|
||||||
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
|
||||||
│ │
|
|
||||||
│ BoxSkillStore │
|
|
||||||
│ ├─ list / get / create / update / delete │
|
|
||||||
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
|
||||||
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
|
||||||
│ │
|
|
||||||
│ aiohttp 单端口服务 (默认 :5410): │
|
|
||||||
│ /rpc/ws — Action RPC │
|
|
||||||
│ /v1/sessions/{id}/managed-process/ws — 默认 process │
|
|
||||||
│ /v1/sessions/{id}/managed-process/{pid}/ws — 指定 process │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 容器 / 沙箱 (Docker/Podman 容器, nsjail sandbox, 或 E2B 远程沙箱) │
|
|
||||||
│ - 隔离文件系统 / 网络 / PID 命名空间 │
|
|
||||||
│ - 资源限制 (CPU, 内存, PID 数, 可选 workspace 配额) │
|
|
||||||
│ - 主挂载 (host_path → mount_path) + 任意条 extra_mounts │
|
|
||||||
│ └─ Skills 通过 extra_mounts 挂在 /workspace/.skills/<name> │
|
|
||||||
│ - exec: 用户命令在此执行 │
|
|
||||||
│ - managed process: 多个长驻进程并存 (MCP Server / 自定义服务) │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**核心设计原则**:
|
|
||||||
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
|
||||||
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
|
||||||
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. LangBot 侧模块
|
|
||||||
|
|
||||||
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
|
||||||
|
|
||||||
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
|
||||||
|
|
||||||
主要公开方法(按定义顺序):
|
|
||||||
|
|
||||||
```
|
|
||||||
BoxService
|
|
||||||
├─ initialize() 连接 Box Runtime + 默认 workspace 准备
|
|
||||||
├─ _on_runtime_disconnect(connector) 触发重连
|
|
||||||
├─ _reconnect_loop(connector) 指数退避重连
|
|
||||||
├─ available (property) 连接状态
|
|
||||||
│
|
|
||||||
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
|
||||||
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
|
||||||
│
|
|
||||||
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
|
||||||
│ ├─ _apply_profile / build_spec
|
|
||||||
│ ├─ _validate_host_mount
|
|
||||||
│ ├─ _enforce_workspace_quota (phase=pre)
|
|
||||||
│ ├─ client.execute(spec)
|
|
||||||
│ ├─ _enforce_workspace_quota (phase=post)
|
|
||||||
│ └─ _truncate (stdout/stderr)
|
|
||||||
│
|
|
||||||
├─ execute_spec_payload(spec_payload, ...) 内部入口(其他 loader 调用)
|
|
||||||
├─ create_session(spec_payload, ...) 显式创建 session
|
|
||||||
├─ start_managed_process(session_id, ...) 启动 managed process
|
|
||||||
├─ get_managed_process(session_id, pid) 查询进程状态(pid 默认 'default')
|
|
||||||
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
|
||||||
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
|
||||||
│
|
|
||||||
├─ list_skills() / get_skill(name) Skill 元数据
|
|
||||||
├─ create_skill / update_skill / delete_skill Skill CRUD
|
|
||||||
├─ scan_skill_directory(path) 扫描目录
|
|
||||||
├─ list_skill_files / read_skill_file / write_skill_file
|
|
||||||
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
|
|
||||||
│
|
|
||||||
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
|
||||||
├─ get_status() / get_sessions() / get_recent_errors()
|
|
||||||
└─ get_system_guidance() LLM 系统提示
|
|
||||||
```
|
|
||||||
|
|
||||||
**Profile 系统**: 4 个内置 Profile(`default` / `offline_readonly` / `network_basic` / `network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。
|
|
||||||
|
|
||||||
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
|
||||||
|
|
||||||
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
|
||||||
|
|
||||||
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
|
||||||
|
|
||||||
管理与 Box Runtime 的通信连接:
|
|
||||||
|
|
||||||
- **本地 stdio**: Unix/macOS 默认路径,fork `python -m langbot_plugin.cli.__init__ box -s --ws-control-port {port}` 子进程(与 plugin runtime 统一走 `lbp` CLI 入口)
|
|
||||||
- **本地 subprocess + WS**: Windows 本地(asyncio ProactorEventLoop 不支持 stdio pipe)
|
|
||||||
- **远程 WebSocket**: Docker 部署 / `box.runtime.endpoint` 显式配置时,连接 `ws://{host}:{port}/rpc/ws`
|
|
||||||
- **同步等待**: `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接
|
|
||||||
- **心跳**: `_heartbeat_loop()` 每 20s 调用 `ping()`,失败仅 DEBUG 日志(断开检测靠 connection close)
|
|
||||||
- **重连**: `runtime_disconnect_callback` 由 BoxService 提供,触发 `_reconnect_loop`
|
|
||||||
- **INIT 注入**: 连接建立后立即下发当前 `box.*` 配置子树(剔除 `runtime` 私有字段),Runtime 据此初始化 backend
|
|
||||||
|
|
||||||
> **历史改进**: 2026-04-16 版本本文档曾列 P0 「Box 无心跳 / 无重连」,已修复(commit `2dfd9d5d`、`c6882cf`、`5029d9c` 等)。
|
|
||||||
|
|
||||||
### 2.3 BoxWorkspaceSession 工具 (`pkg/box/workspace.py`, 413 行)
|
|
||||||
|
|
||||||
此文件目前提供两类能力:
|
|
||||||
|
|
||||||
1. **路径与命令重写工具函数** — `normalize_host_path` / `rewrite_mounted_path` / `unwrap_venv_path` / `rewrite_venv_command` / `infer_workspace_host_path`,被 MCP loader 与 Skill 路径解析共用。
|
|
||||||
2. **`BoxWorkspaceSession`** — 围绕 BoxService 的轻量包装,专供 MCP-in-Box 场景使用(管理一个共享 session 的 session_id、构建挂载 payload、stage host 文件到共享 workspace)。
|
|
||||||
|
|
||||||
**变化点**: 早期 Skill exec 会为每个 skill 创建独立 BoxWorkspaceSession(独占 session);当前实现已转为 `extra_mounts` 模式,Skill 不再独占容器,只追加挂载。这部分 wrapping 逻辑已从 native loader 移除。
|
|
||||||
|
|
||||||
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
|
||||||
|
|
||||||
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
|
|
||||||
|
|
||||||
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
|
||||||
|
|
||||||
```
|
|
||||||
SkillManager
|
|
||||||
├─ initialize() 调用 reload_skills()
|
|
||||||
├─ reload_skills() 先从 Box runtime list_skills(),
|
|
||||||
│ 不可用则回落 data/skills/ 扫描
|
|
||||||
├─ refresh_skill_from_disk() 单 skill 重新加载
|
|
||||||
├─ get_skill_by_name(name)
|
|
||||||
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
|
|
||||||
```
|
|
||||||
|
|
||||||
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
|
||||||
|
|
||||||
### 2.6 Skill activation (`pkg/skill/activation.py`, 33 行) + Skill loader 辅助
|
|
||||||
|
|
||||||
历史上 skill 通过 LLM 在文本中输出 `[ACTIVATE_SKILL:name]` 标记激活;当前已改为 **Tool Call 机制**:
|
|
||||||
|
|
||||||
- `SkillToolLoader` (`pkg/provider/tools/loaders/skill.py`, 157 行) 暴露 `activate` 工具,参数为 skill 名
|
|
||||||
- 工具实现调用 `register_activated_skill(query, skill_data)`,将激活态写入 `query.variables['_activated_skills']`
|
|
||||||
- 这种 KV-cache-friendly 模式对齐 Claude Code 设计;详见 [box-session-scope.md §4.3](./box-session-scope.md) 的 Tool Call 描述
|
|
||||||
|
|
||||||
`activation.py` 现仅保留对外辅助函数(pipeline 层调用 loader 的 `register_activated_skill`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. SDK 侧模块
|
|
||||||
|
|
||||||
### 3.1 BoxRuntime (`box/runtime.py`, 599 行)
|
|
||||||
|
|
||||||
核心编排器,管理 session 生命周期与 backend 调度:
|
|
||||||
|
|
||||||
```
|
|
||||||
Session 生命周期:
|
|
||||||
|
|
||||||
Client EXEC / CREATE_SESSION
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
_get_or_create_session(spec)
|
|
||||||
├─ _reap_expired_sessions_locked() 清理 TTL 过期 session
|
|
||||||
├─ 已存在? → _assert_session_compatible() → 复用
|
|
||||||
├─ Backend session 失踪? → 重建 (commit c6882cf)
|
|
||||||
└─ 新建? → backend.start_session(spec) → 创建容器
|
|
||||||
│ └─ 应用 spec.extra_mounts (多挂载)
|
|
||||||
▼
|
|
||||||
execute(spec)
|
|
||||||
├─ 获取 session lock (每 session 独立)
|
|
||||||
├─ backend.exec(session, spec) 在容器中执行命令
|
|
||||||
├─ 更新 last_used_at
|
|
||||||
└─ 超时? → 销毁 session
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Session 保持存活直到:
|
|
||||||
├─ TTL 过期 (默认 300s,下次操作时清理)
|
|
||||||
├─ 执行超时 (自动销毁)
|
|
||||||
├─ 客户端 DELETE_SESSION
|
|
||||||
└─ SHUTDOWN
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键设计**:
|
|
||||||
- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行
|
|
||||||
- 每 session 维护 `managed_processes: dict[process_id, _ManagedProcess]`,支持多个长驻进程并存(MCP / 自定义)
|
|
||||||
- 全局 `_lock` 保护 `_sessions` dict 的读写
|
|
||||||
- 兼容性检查:比较核心 spec 字段,`image` 字段对不支持自定义镜像的 backend(nsjail/E2B)会跳过
|
|
||||||
|
|
||||||
**Backend 选择 (`_select_backend`)**: 优先级
|
|
||||||
1. 显式 `box.backend` 配置(`docker` / `nsjail` / `e2b`)
|
|
||||||
2. `local` (默认) → Docker / Podman / nsjail CLI 顺序探测
|
|
||||||
3. `get_status` 调用时若当前 backend 不可用,会尝试重新选择 (commit `e5617c7`)
|
|
||||||
|
|
||||||
### 3.2 Backend 系统
|
|
||||||
|
|
||||||
#### CLISandboxBackend (`box/backend.py`, 411 行)
|
|
||||||
|
|
||||||
Docker / Podman 公共基类:
|
|
||||||
|
|
||||||
```
|
|
||||||
start_session(spec):
|
|
||||||
1. validate_sandbox_security(spec)
|
|
||||||
2. docker/podman run -d --rm --name <name>
|
|
||||||
--network none (可选)
|
|
||||||
--cpus/--memory/--pids-limit
|
|
||||||
--read-only + --tmpfs /tmp
|
|
||||||
-v <host>:<mount>:<mode> 主挂载
|
|
||||||
-v <extra.host>:<extra.mount>:.. 额外挂载 (extra_mounts)
|
|
||||||
<image> sh -lc 'while true; do sleep 3600; done'
|
|
||||||
3. 返回 BoxSessionInfo
|
|
||||||
|
|
||||||
exec(session, spec):
|
|
||||||
docker/podman exec -e KEY=VAL <container>
|
|
||||||
sh -lc 'mkdir -p <workdir> && cd <workdir> && <cmd>'
|
|
||||||
|
|
||||||
start_managed_process(session, spec):
|
|
||||||
docker/podman exec -i <container>
|
|
||||||
sh -lc 'mkdir -p <cwd> && cd <cwd> && exec <command> <args>'
|
|
||||||
返回 asyncio.subprocess.Process (stdin/stdout PIPE)
|
|
||||||
```
|
|
||||||
|
|
||||||
容器以 idle 进程启动,实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。
|
|
||||||
|
|
||||||
**Windows 支持**: backend 内对 Windows 路径处理与 subprocess 调用做了适配(commit `120817a`)。
|
|
||||||
|
|
||||||
**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。
|
|
||||||
|
|
||||||
#### NsjailBackend (`box/nsjail_backend.py`, 552 行)
|
|
||||||
|
|
||||||
轻量级 Linux 沙箱(无容器引擎依赖):
|
|
||||||
|
|
||||||
- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net)
|
|
||||||
- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目
|
|
||||||
- 每 session 创建独立目录(workspace/tmp/home)
|
|
||||||
- 资源限制: cgroup v2 优先,fallback 到 rlimit
|
|
||||||
- **CLI 兼容**: 通过 `shutil.which(self._nsjail_bin)` 检测系统安装版 nsjail;不存在时再尝试容器内 nsjail(commit `686fcc0`、`feed530`)
|
|
||||||
- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'`,兼容性检查跳过 image
|
|
||||||
|
|
||||||
#### E2BBackend (`box/e2b_backend.py`, 429 行)
|
|
||||||
|
|
||||||
云沙箱后端(commit `75b547f` 引入):
|
|
||||||
|
|
||||||
- 通过 `e2b` SDK 与 E2B 平台通信
|
|
||||||
- 配置:`box.e2b.api_key` / `api_url` / `template`
|
|
||||||
- 支持 `extra_mounts`(commit `0fea9b1` 同步上传文件)
|
|
||||||
- 无本地容器引擎依赖,适合无 Docker 的部署或 SaaS 多租户场景
|
|
||||||
- 不支持自定义 image 字段,由 template 控制
|
|
||||||
|
|
||||||
### 3.3 Server (`box/server.py`, 508 行)
|
|
||||||
|
|
||||||
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
|
||||||
|
|
||||||
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
|
|
||||||
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
|
|
||||||
|
|
||||||
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
|
||||||
|
|
||||||
### 3.4 Client (`box/client.py`, 377 行)
|
|
||||||
|
|
||||||
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
|
||||||
|
|
||||||
- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown)
|
|
||||||
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
|
||||||
- `execute()` timeout = 300s,其他默认 15s
|
|
||||||
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
|
||||||
|
|
||||||
包级别 `__init__.py` 显式导出:`BoxRuntimeClient`、`ActionRPCBoxClient`(commit `df9c722`)。
|
|
||||||
|
|
||||||
### 3.5 Actions (`box/actions.py`, 34 行)
|
|
||||||
|
|
||||||
`LangBotToBoxAction` 枚举共定义 **25 个** action:
|
|
||||||
|
|
||||||
| 类别 | Actions |
|
|
||||||
|------|---------|
|
|
||||||
| 控制 | `INIT`、`HEALTH`、`STATUS`、`GET_BACKEND_INFO`、`SHUTDOWN` |
|
|
||||||
| 执行 | `EXEC` |
|
|
||||||
| Session | `CREATE_SESSION` / `GET_SESSION` / `GET_SESSIONS` / `DELETE_SESSION` |
|
|
||||||
| Managed Process | `START_MANAGED_PROCESS` / `GET_MANAGED_PROCESS` / `STOP_MANAGED_PROCESS` |
|
|
||||||
| Skill | `LIST_SKILLS` / `GET_SKILL` / `CREATE_SKILL` / `UPDATE_SKILL` / `DELETE_SKILL` / `SCAN_SKILL_DIRECTORY` / `LIST_SKILL_FILES` / `READ_SKILL_FILE` / `WRITE_SKILL_FILE` / `PREVIEW_SKILL_ZIP` / `INSTALL_SKILL_ZIP` |
|
|
||||||
|
|
||||||
### 3.6 Models (`box/models.py`, 331 行)
|
|
||||||
|
|
||||||
核心数据模型:
|
|
||||||
|
|
||||||
| 模型 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `BoxNetworkMode` | `OFF` / `ON` |
|
|
||||||
| `BoxExecutionStatus` | `COMPLETED` / `TIMED_OUT` |
|
|
||||||
| `BoxHostMountMode` | `NONE` / `READ_ONLY` / `READ_WRITE` |
|
|
||||||
| `BoxManagedProcessStatus` | `RUNNING` / `EXITED` |
|
|
||||||
| `BoxMountSpec` | 单条挂载(host_path/mount_path/mode)— **新增** |
|
|
||||||
| `BoxSpec` | 执行请求;新增 `extra_mounts: list[BoxMountSpec]`、`persistent`、`workspace_quota_mb` |
|
|
||||||
| `BoxProfile` | 4 个内置 Profile + `locked` frozenset |
|
|
||||||
| `BoxSessionInfo` | Session 状态(含 backend_name/created_at/last_used_at) |
|
|
||||||
| `BoxManagedProcessSpec` | 长驻进程参数(process_id/command/args/env/cwd) |
|
|
||||||
| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) |
|
|
||||||
| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) |
|
|
||||||
|
|
||||||
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
|
||||||
|
|
||||||
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
|
|
||||||
|
|
||||||
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
|
|
||||||
|
|
||||||
```
|
|
||||||
BoxSkillStore
|
|
||||||
├─ list_skills() / get_skill(name)
|
|
||||||
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
|
||||||
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
|
||||||
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
|
||||||
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
|
||||||
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
|
||||||
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
|
||||||
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
|
||||||
```
|
|
||||||
|
|
||||||
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。
|
|
||||||
|
|
||||||
### 3.8 Security (`box/security.py`, 52 行)
|
|
||||||
|
|
||||||
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
|
||||||
|
|
||||||
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
|
|
||||||
|
|
||||||
### 3.9 Errors (`box/errors.py`, 33 行)
|
|
||||||
|
|
||||||
| 异常类型 | 含义 |
|
|
||||||
|----------|------|
|
|
||||||
| `BoxError` | 基类 |
|
|
||||||
| `BoxValidationError` | spec/参数校验失败 |
|
|
||||||
| `BoxBackendUnavailableError` | 无可用 backend |
|
|
||||||
| `BoxRuntimeUnavailableError` | Runtime 服务不可用 |
|
|
||||||
| `BoxSessionConflictError` | session 已存在但 spec 不兼容 |
|
|
||||||
| `BoxSessionNotFoundError` | session 不存在 |
|
|
||||||
| `BoxManagedProcessConflictError` | session 已有同名 process |
|
|
||||||
| `BoxManagedProcessNotFoundError` | process 不存在 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 工具系统集成
|
|
||||||
|
|
||||||
### 4.1 ToolManager 编排 (`toolmgr.py`)
|
|
||||||
|
|
||||||
```
|
|
||||||
ToolManager.initialize()
|
|
||||||
├─ NativeToolLoader (exec / read / write / edit / glob / grep)
|
|
||||||
├─ PluginToolLoader (插件工具)
|
|
||||||
├─ MCPLoader (MCP Server 工具)
|
|
||||||
├─ SkillToolLoader (activate 工具 — Tool Call 激活)
|
|
||||||
└─ SkillAuthoringToolLoader (Skill CRUD)
|
|
||||||
|
|
||||||
工具调用优先级: native → plugin → mcp → skill → skill_authoring
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Native Tools (`native.py`, 846 行)
|
|
||||||
|
|
||||||
| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 |
|
|
||||||
|------|:---:|:---:|
|
|
||||||
| `exec` | 是 | 否 |
|
|
||||||
| `read` | **否** | **是** — 直接 `open()` 宿主文件 |
|
|
||||||
| `write` | **否** | **是** — 直接 `open()` 宿主文件 |
|
|
||||||
| `edit` | **否** | **是** — 直接 `open()` 宿主文件 |
|
|
||||||
| `glob` | **否** | **是** — 直接遍历宿主目录 |
|
|
||||||
| `grep` | **否** | **是** — 直接读宿主文件 |
|
|
||||||
|
|
||||||
**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit`/`glob`/`grep` 绕过沙箱以获得性能(避免容器 I/O 开销与跨进程拷贝),但意味着 LLM 可以直接读写 `allowed_mount_roots` 下任何文件。Skill 路径经 `_resolve_host_path()` 重写,禁止穿越 `package_root`。
|
|
||||||
|
|
||||||
**exec 的 Skill 分支**: 命令中引用 `/workspace/.skills/<name>` 的 skill 时:
|
|
||||||
1. 验证 skill 已激活
|
|
||||||
2. 单次 exec 只能引用一个 skill 包
|
|
||||||
3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`)
|
|
||||||
4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session**
|
|
||||||
|
|
||||||
### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行)
|
|
||||||
|
|
||||||
`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行,**共享 session、多 process**模式(commit `529088e`):
|
|
||||||
|
|
||||||
```
|
|
||||||
initialize()
|
|
||||||
1. 复用/创建共享 session (session_id = _build_box_session_id())
|
|
||||||
- persistent=True,长期保持
|
|
||||||
2. workspace.execute_raw(install_cmd) 安装依赖 (可选)
|
|
||||||
3. 将每个 MCP server 文件 stage 到 /workspace/.mcp/<process_id>/
|
|
||||||
4. workspace.start_managed_process(process_id=<server>)
|
|
||||||
5. websocket_client(ws_url) 通过 WS relay 连接
|
|
||||||
6. ClientSession.initialize() MCP 协议握手
|
|
||||||
```
|
|
||||||
|
|
||||||
配置 (`MCPServerBoxConfig`): `network='on'` (MCP 服务器通常需要网络),`host_path_mode='ro'` (默认只读),`startup_timeout_sec=120` (留时间给 pip install)。
|
|
||||||
|
|
||||||
每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 启动与生命周期
|
|
||||||
|
|
||||||
### 5.1 启动顺序 (`build_app.py`)
|
|
||||||
|
|
||||||
```
|
|
||||||
BuildAppStage.run(ap)
|
|
||||||
├─ ... (persistence, models, sessions) ...
|
|
||||||
│
|
|
||||||
├─ BoxService(ap)
|
|
||||||
├─ box_service.initialize()
|
|
||||||
│ └─ connector.initialize()
|
|
||||||
│ ├─ [stdio] fork box subprocess
|
|
||||||
│ ├─ [subprocess+WS] Windows 本地
|
|
||||||
│ └─ [remote WS] connect URL
|
|
||||||
│ └─ 启动心跳 _heartbeat_task
|
|
||||||
├─ ap.box_service = box_service
|
|
||||||
│
|
|
||||||
├─ ToolManager(ap)
|
|
||||||
├─ tool_mgr.initialize()
|
|
||||||
│ ├─ NativeToolLoader (检查 box_service.available)
|
|
||||||
│ ├─ PluginToolLoader
|
|
||||||
│ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱)
|
|
||||||
│ └─ SkillAuthoringToolLoader
|
|
||||||
├─ ap.tool_mgr = tool_mgr
|
|
||||||
│
|
|
||||||
├─ ... (platform, pipeline) ...
|
|
||||||
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
|
||||||
└─ ... (RAG, HTTP, plugins) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。
|
|
||||||
|
|
||||||
### 5.2 初始化失败处理
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
await self._runtime_connector.initialize()
|
|
||||||
self._available = True
|
|
||||||
except Exception as e:
|
|
||||||
self._available = False
|
|
||||||
logger.warning(f"Box runtime unavailable: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
|
||||||
|
|
||||||
### 5.3 销毁流程
|
|
||||||
|
|
||||||
```
|
|
||||||
app.dispose()
|
|
||||||
└─ box_service.dispose()
|
|
||||||
├─ connector.dispose()
|
|
||||||
│ ├─ cancel _heartbeat_task
|
|
||||||
│ ├─ cancel _handler_task / _ctrl_task
|
|
||||||
│ └─ terminate subprocess (SIGTERM)
|
|
||||||
└─ loop.create_task(client.shutdown())
|
|
||||||
└─ RPC SHUTDOWN → Box Runtime 清理所有容器
|
|
||||||
```
|
|
||||||
|
|
||||||
Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 配置
|
|
||||||
|
|
||||||
### config.yaml (重构后)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
box:
|
|
||||||
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
|
||||||
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
|
||||||
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
|
||||||
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
|
||||||
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
|
||||||
# - skill 列表/读取保持只读可用
|
|
||||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
|
||||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
|
||||||
# 由 box.backend / BOX__BACKEND 选择后端
|
|
||||||
runtime:
|
|
||||||
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
|
||||||
# 留空 = 本地自管 Runtime
|
|
||||||
local:
|
|
||||||
profile: 'default'
|
|
||||||
image: '' # 覆盖 profile 默认 image
|
|
||||||
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
|
||||||
default_workspace: '' # 默认 '<host_root>/default'
|
|
||||||
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
|
||||||
allowed_mount_roots: # 默认 ['<host_root>']
|
|
||||||
- './data/box'
|
|
||||||
- '/tmp'
|
|
||||||
workspace_quota_mb: null # 配额覆盖,null = 走 profile
|
|
||||||
e2b:
|
|
||||||
api_key: '' # 也可走 E2B_API_KEY 环境变量
|
|
||||||
api_url: '' # 自托管 E2B 时填写
|
|
||||||
template: '' # 默认 template ID
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重大变更**: 较 2026-04-16 文档,配置结构完全重组(commit `eefdea4`)。原字段 `box.profile` / `box.runtime_url` / `box.shared_host_root` / `box.allowed_host_mount_roots` 全部迁入 `box.local.*` 子表,新增 `box.backend` 与 `box.e2b.*` 配置组。
|
|
||||||
|
|
||||||
### docker-compose.yaml
|
|
||||||
|
|
||||||
`langbot_box` 服务受 compose profile 控制,默认 `docker compose up` **不会**启动它。需要 sandbox 时:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --profile box up # 启动 langbot + langbot_box + plugin runtime
|
|
||||||
docker compose --profile all up # 同上
|
|
||||||
docker compose up # 只起 langbot + plugin runtime (box 关闭)
|
|
||||||
```
|
|
||||||
|
|
||||||
若不起 `langbot_box`,需要同步在 `data/config.yaml` 中设 `box.enabled: false`(或 langbot 容器 env 加 `BOX__ENABLED=false`),否则 LangBot 会一直尝试连接不存在的 Box runtime 并报错。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# langbot_box 的关键 volume
|
|
||||||
volumes:
|
|
||||||
- ${LANGBOT_BOX_ROOT}:${LANGBOT_BOX_ROOT} # 工作区挂载(源/目标同路径)
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock # Docker backend 复用宿主 docker
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关闭/连接失败时的行为矩阵
|
|
||||||
|
|
||||||
`box.enabled = false` 与"启用但连接失败"在用户可观察行为上**完全一致**——都通过 `BoxService.available = False` 表达,只是 `get_status` 多返回 `enabled` 字段供前端区分文案。
|
|
||||||
|
|
||||||
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
|
||||||
|---|---|---|
|
|
||||||
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
|
||||||
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
|
|
||||||
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
|
||||||
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
|
||||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
|
||||||
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
|
||||||
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
|
||||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
|
||||||
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
|
||||||
|
|
||||||
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
|
|
||||||
|
|
||||||
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
|
||||||
|
|
||||||
`local-agent.config.box-session-id-template` 控制 session 作用域,预设:
|
|
||||||
|
|
||||||
- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认)
|
|
||||||
- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户
|
|
||||||
- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文
|
|
||||||
- `{query_id}` — 每条消息(完全隔离)
|
|
||||||
|
|
||||||
详见 [box-session-scope.md](./box-session-scope.md)。
|
|
||||||
|
|
||||||
### REST API
|
|
||||||
|
|
||||||
| 端点 | 方法 | 说明 | 前端 |
|
|
||||||
|------|------|------|:---:|
|
|
||||||
| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | ✅ 监控页 |
|
|
||||||
| `/api/v1/box/sessions` | GET | 活跃 session 列表 | ❌ |
|
|
||||||
| `/api/v1/box/errors` | GET | 最近 50 条错误 | ❌ |
|
|
||||||
| `/api/v1/skills` 等 | GET/POST/PUT/DELETE | Skill CRUD、文件浏览、zip/GitHub 安装、preview | ✅ Skill 管理页 |
|
|
||||||
|
|
||||||
前端 `web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx` 已接入 `/api/v1/box/status`,展示 backend 名称、profile 与活跃 session 数。Sessions 与 errors API 仍未接入。
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Box 系统 — SaaS 发布前阻塞项
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
|
||||||
|
|
||||||
## 范围说明
|
|
||||||
|
|
||||||
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。
|
|
||||||
|
|
||||||
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
|
|
||||||
|
|
||||||
## 已解决(社区版发布前)
|
|
||||||
|
|
||||||
| 项 | 处理 |
|
|
||||||
|----|------|
|
|
||||||
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) |
|
|
||||||
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) |
|
|
||||||
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
|
|
||||||
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
|
|
||||||
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SaaS 阻塞项
|
|
||||||
|
|
||||||
### S1. Box 控制面无认证 — Critical
|
|
||||||
|
|
||||||
- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|
|
||||||
- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
|
|
||||||
- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。
|
|
||||||
- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。
|
|
||||||
|
|
||||||
### S2. 无 exec 授权模型(policy.py 死代码) — High
|
|
||||||
|
|
||||||
- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py`
|
|
||||||
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
|
|
||||||
- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
|
|
||||||
|
|
||||||
### S3. 会话资源无界(DoS) — High
|
|
||||||
|
|
||||||
- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
|
|
||||||
- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
|
|
||||||
- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。
|
|
||||||
|
|
||||||
### S4. 工作区配额无内核级限制(TOCTOU) — Med-High
|
|
||||||
|
|
||||||
- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
|
|
||||||
- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
|
|
||||||
- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
|
|
||||||
|
|
||||||
### S5. 挂载校验缺口 — Med-High
|
|
||||||
|
|
||||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
|
||||||
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
|
||||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
|
||||||
|
|
||||||
### S6. 容器加固缺失 — Med
|
|
||||||
|
|
||||||
- **位置**: SDK `box/backend.py` 的 `docker run` 组装
|
|
||||||
- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。
|
|
||||||
- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。
|
|
||||||
|
|
||||||
### S7. 全局锁内执行慢操作(扩展性) — Med
|
|
||||||
|
|
||||||
- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`)
|
|
||||||
- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
|
|
||||||
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
|
|
||||||
|
|
||||||
### S8. 其他硬化 / 跟进 — Low
|
|
||||||
|
|
||||||
- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
|
|
||||||
- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
|
|
||||||
- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
|
|
||||||
- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。
|
|
||||||
- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
# Box Session Scope Design
|
|
||||||
|
|
||||||
> Date: 2026-04-18 (last reviewed 2026-06-02)
|
|
||||||
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
|
|
||||||
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Implementation Status (2026-05-19)
|
|
||||||
|
|
||||||
This document was authored as a design proposal. The current `feat/sandbox` branch
|
|
||||||
has shipped the design largely as written:
|
|
||||||
|
|
||||||
| Item | Status | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` |
|
|
||||||
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
|
||||||
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
|
||||||
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
|
||||||
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
|
||||||
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
|
||||||
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
|
||||||
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
|
||||||
| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) |
|
|
||||||
|
|
||||||
The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on
|
|
||||||
the same line. Pipeline-scoped (not user-scoped) MCP container is the realized
|
|
||||||
behavior: each pipeline's MCP servers share one `mcp-<pipeline>` session, and
|
|
||||||
user exec sessions use the template-derived id.
|
|
||||||
|
|
||||||
The remaining open work is multi-tenant overlays (tenant_id in session_id,
|
|
||||||
quota counters keyed by tenant), tracked in the toB analysis doc rather than here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problems
|
|
||||||
|
|
||||||
### 1.1 Default exec: per-message containers
|
|
||||||
|
|
||||||
Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an
|
|
||||||
auto-incrementing integer per incoming message. Every user message creates a new sandbox
|
|
||||||
container. Dependencies installed and in-container state are lost between messages.
|
|
||||||
|
|
||||||
### 1.2 Three isolated container pools
|
|
||||||
|
|
||||||
Default exec, skills, and MCP servers each manage their own containers with
|
|
||||||
independent session IDs:
|
|
||||||
|
|
||||||
| Path | Session ID | Container |
|
|
||||||
|--------------|-----------------------------------------------|-------------|
|
|
||||||
| Default exec | `str(query_id)` (per message) | Ephemeral |
|
|
||||||
| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill |
|
|
||||||
| MCP stdio | `mcp-{server_uuid}` | Per server |
|
|
||||||
|
|
||||||
This means a single logical user interaction can spawn 3+ containers that cannot
|
|
||||||
share state, see each other's files, or reuse installed dependencies.
|
|
||||||
|
|
||||||
### 1.3 Single bind mount limitation
|
|
||||||
|
|
||||||
`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount.
|
|
||||||
This prevents mounting both a default workspace and skill directories into the
|
|
||||||
same container.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Concept Model
|
|
||||||
|
|
||||||
```
|
|
||||||
Platform Message
|
|
||||||
→ Query (query_id: int, auto-increment, per message)
|
|
||||||
→ Session (launcher_type + launcher_id, per chat window)
|
|
||||||
→ Conversation (uuid, per dialogue context within a Session)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Concept | Key | Example | Scope |
|
|
||||||
|---------------|-------------------------------------|----------------------------|------------------------------|
|
|
||||||
| Query | `query_id` | `42` | Single message |
|
|
||||||
| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) |
|
|
||||||
| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session |
|
|
||||||
| Sender | `sender_id` | `789` | Individual user |
|
|
||||||
|
|
||||||
Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The
|
|
||||||
individual sender is tracked as `sender_id` but does not affect Session/Conversation routing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Target Scenarios
|
|
||||||
|
|
||||||
| # | Scenario | Box Granularity | Desired `session_id` |
|
|
||||||
|----|--------------------------------|------------------------------------------|---------------------------------------------------------|
|
|
||||||
| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` |
|
|
||||||
| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` |
|
|
||||||
| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` |
|
|
||||||
| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` |
|
|
||||||
| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` |
|
|
||||||
| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` |
|
|
||||||
| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` |
|
|
||||||
| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` |
|
|
||||||
|
|
||||||
No single fixed granularity covers all scenarios. A template-based approach is needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Design Overview
|
|
||||||
|
|
||||||
Two key changes:
|
|
||||||
|
|
||||||
1. **Unified container**: exec, skills, and MCP all share the same container per
|
|
||||||
session scope. No more separate container pools.
|
|
||||||
2. **Configurable session scope**: `session_id` is generated from a template with
|
|
||||||
pipeline variables, configurable per pipeline.
|
|
||||||
|
|
||||||
### 4.1 Unified Container with Multiple Mounts
|
|
||||||
|
|
||||||
A single container per session scope is created on first use. It has:
|
|
||||||
|
|
||||||
- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`)
|
|
||||||
- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at
|
|
||||||
`/workspace/.skills/{skill_name}/`
|
|
||||||
- **MCP servers**: run as managed processes inside the same container
|
|
||||||
|
|
||||||
```
|
|
||||||
Container (session_id = "group_123456")
|
|
||||||
/workspace/ ← default workspace (bind mount, rw)
|
|
||||||
/workspace/.skills/web-search/ ← skill package (bind mount, rw)
|
|
||||||
/workspace/.skills/data-analysis/ ← skill package (bind mount, rw)
|
|
||||||
[managed process: mcp-server-a] ← MCP server running inside
|
|
||||||
[managed process: mcp-server-b] ← MCP server running inside
|
|
||||||
```
|
|
||||||
|
|
||||||
This requires extending `BoxSpec` to support multiple mounts (see §5).
|
|
||||||
|
|
||||||
### 4.2 Session ID Template
|
|
||||||
|
|
||||||
A new field `box-session-id-template` in the `local-agent` pipeline runner config
|
|
||||||
controls the session scope:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# templates/metadata/pipeline/ai.yaml (under local-agent.config)
|
|
||||||
- name: box-session-id-template
|
|
||||||
label:
|
|
||||||
en_US: Sandbox Scope
|
|
||||||
zh_Hans: 沙箱作用域
|
|
||||||
description:
|
|
||||||
en_US: >-
|
|
||||||
Determines how sandbox environments are shared. Use variables to
|
|
||||||
control isolation granularity.
|
|
||||||
zh_Hans: >-
|
|
||||||
决定沙箱环境的共享方式。使用变量控制隔离粒度。
|
|
||||||
type: select
|
|
||||||
required: false
|
|
||||||
default: "{launcher_type}_{launcher_id}"
|
|
||||||
options:
|
|
||||||
- value: "{launcher_type}_{launcher_id}"
|
|
||||||
label:
|
|
||||||
en_US: Per chat (Recommended)
|
|
||||||
zh_Hans: 每个会话(推荐)
|
|
||||||
- value: "{launcher_type}_{launcher_id}_{sender_id}"
|
|
||||||
label:
|
|
||||||
en_US: Per user in chat
|
|
||||||
zh_Hans: 会话中每个用户
|
|
||||||
- value: "{launcher_type}_{launcher_id}_{conversation_id}"
|
|
||||||
label:
|
|
||||||
en_US: Per conversation context
|
|
||||||
zh_Hans: 每个对话上下文
|
|
||||||
- value: "{query_id}"
|
|
||||||
label:
|
|
||||||
en_US: Per message (isolated)
|
|
||||||
zh_Hans: 每条消息(完全隔离)
|
|
||||||
```
|
|
||||||
|
|
||||||
Available template variables (populated by PreProcessor in `query.variables`):
|
|
||||||
|
|
||||||
| Variable | Source | Example |
|
|
||||||
|---------------------|---------------------------------|----------------------|
|
|
||||||
| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` |
|
|
||||||
| `{launcher_id}` | `query.session.launcher_id` | `123456` |
|
|
||||||
| `{sender_id}` | `query.sender_id` | `789` |
|
|
||||||
| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` |
|
|
||||||
| `{query_id}` | `query.query_id` | `42` |
|
|
||||||
|
|
||||||
Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. SDK Changes: Multi-Mount BoxSpec
|
|
||||||
|
|
||||||
### 5.1 Model Extension
|
|
||||||
|
|
||||||
```python
|
|
||||||
# box/models.py
|
|
||||||
|
|
||||||
class BoxMountSpec(pydantic.BaseModel):
|
|
||||||
"""A single bind mount specification."""
|
|
||||||
host_path: str
|
|
||||||
mount_path: str
|
|
||||||
mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
|
||||||
|
|
||||||
class BoxSpec(pydantic.BaseModel):
|
|
||||||
# ... existing fields ...
|
|
||||||
host_path: str | None = None # Primary mount (backward compat)
|
|
||||||
host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
|
||||||
mount_path: str = DEFAULT_BOX_MOUNT_PATH
|
|
||||||
extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts
|
|
||||||
```
|
|
||||||
|
|
||||||
`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains
|
|
||||||
the primary mount for backward compatibility.
|
|
||||||
|
|
||||||
### 5.2 Backend: Apply Extra Mounts
|
|
||||||
|
|
||||||
```python
|
|
||||||
# box/backend.py — CLISandboxBackend.start_session()
|
|
||||||
|
|
||||||
# Primary mount (unchanged)
|
|
||||||
if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE:
|
|
||||||
args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}'])
|
|
||||||
|
|
||||||
# Extra mounts (NEW)
|
|
||||||
for mount in spec.extra_mounts:
|
|
||||||
if mount.mode != BoxHostMountMode.NONE:
|
|
||||||
args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}'])
|
|
||||||
```
|
|
||||||
|
|
||||||
Same pattern for nsjail backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. LangBot Changes
|
|
||||||
|
|
||||||
### 6.1 Session ID Resolution
|
|
||||||
|
|
||||||
In `BoxService.execute_tool()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Before:
|
|
||||||
spec_payload.setdefault('session_id', str(query.query_id))
|
|
||||||
|
|
||||||
# After:
|
|
||||||
template = (query.pipeline_config or {}).get('ai', {}) \
|
|
||||||
.get('local-agent', {}).get('box-session-id-template',
|
|
||||||
'{launcher_type}_{launcher_id}')
|
|
||||||
variables = query.variables or {}
|
|
||||||
session_id = template.format_map(collections.defaultdict(
|
|
||||||
lambda: 'unknown', variables
|
|
||||||
))
|
|
||||||
spec_payload.setdefault('session_id', session_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Skill Exec: Use Same Container
|
|
||||||
|
|
||||||
Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per
|
|
||||||
skill with `host_path=package_root`. Instead:
|
|
||||||
|
|
||||||
1. Use the **same session_id** as default exec (from the template).
|
|
||||||
2. Pass the skill's `package_root` as an **extra mount** at
|
|
||||||
`/workspace/.skills/{skill_name}/` instead of replacing `/workspace`.
|
|
||||||
3. The container already has the default workspace at `/workspace`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# native.py — _invoke_exec, skill branch (REVISED)
|
|
||||||
|
|
||||||
# Same session_id as default exec
|
|
||||||
session_id = resolve_box_session_id(query)
|
|
||||||
|
|
||||||
spec_payload = {
|
|
||||||
'cmd': rewritten_command,
|
|
||||||
'workdir': rewritten_workdir,
|
|
||||||
'session_id': session_id,
|
|
||||||
'extra_mounts': [{
|
|
||||||
'host_path': package_root,
|
|
||||||
'mount_path': f'/workspace/.skills/{selected_skill_name}',
|
|
||||||
'mode': 'rw',
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
result = await self.ap.box_service.execute_spec_payload(spec_payload, query)
|
|
||||||
```
|
|
||||||
|
|
||||||
The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the
|
|
||||||
command level — it maps directly to the bind mount path inside the container.
|
|
||||||
|
|
||||||
### 6.3 MCP: Use Same Container
|
|
||||||
|
|
||||||
MCP servers should run inside the same container as exec and skills. Changes:
|
|
||||||
|
|
||||||
1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of
|
|
||||||
`mcp-{server_uuid}`.
|
|
||||||
2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`).
|
|
||||||
3. MCP server's dependencies are mounted or installed into that subdirectory.
|
|
||||||
4. The MCP server runs as a managed process inside the shared container.
|
|
||||||
|
|
||||||
Since MCP servers start at LangBot boot (not per-query), the session must be
|
|
||||||
created eagerly. The container will be kept alive by the managed process
|
|
||||||
exemption in TTL reaping (`runtime.py:259`).
|
|
||||||
|
|
||||||
**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id
|
|
||||||
should be a **fixed identifier per pipeline** rather than the user-facing template.
|
|
||||||
This means one shared MCP container per pipeline, with user exec sessions separate.
|
|
||||||
|
|
||||||
Alternatively, in a future iteration, MCP managed processes could be launched
|
|
||||||
lazily into the user's container on first MCP tool call. This is more complex
|
|
||||||
but maximizes sharing. For V1, keeping MCP containers at pipeline scope is
|
|
||||||
simpler and more predictable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Mount Layout Summary
|
|
||||||
|
|
||||||
### Default exec (no skills activated)
|
|
||||||
|
|
||||||
```
|
|
||||||
Container (session_id from template)
|
|
||||||
/workspace/ ← default_host_workspace (rw)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Exec with activated skills
|
|
||||||
|
|
||||||
```
|
|
||||||
Container (same session_id)
|
|
||||||
/workspace/ ← default_host_workspace (rw)
|
|
||||||
/workspace/.skills/web-search/ ← skill package_root (rw)
|
|
||||||
/workspace/.skills/data-analysis/ ← skill package_root (rw)
|
|
||||||
```
|
|
||||||
|
|
||||||
Extra mounts are **additive** — they are added when the container is first
|
|
||||||
created (or on the first exec that references a skill). Since Docker bind
|
|
||||||
mounts are specified at container creation time, skills must be known at
|
|
||||||
creation time.
|
|
||||||
|
|
||||||
**Resolution**: When creating a container, inject `extra_mounts` for **all
|
|
||||||
pipeline-bound skills** (from `extensions_preferences`), not just the
|
|
||||||
currently activated one. This way any skill can be activated later without
|
|
||||||
recreating the container.
|
|
||||||
|
|
||||||
### MCP servers (V1: pipeline-scoped)
|
|
||||||
|
|
||||||
```
|
|
||||||
Container (session_id = "mcp-pipeline-{pipeline_uuid}")
|
|
||||||
/workspace/ ← MCP shared workspace
|
|
||||||
/workspace/.mcp/server-a/ ← MCP server A files
|
|
||||||
/workspace/.mcp/server-b/ ← MCP server B files
|
|
||||||
[managed process: server-a]
|
|
||||||
[managed process: server-b]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Data Migration
|
|
||||||
|
|
||||||
Existing pipelines do not have `box-session-id-template`. The backend uses
|
|
||||||
`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`.
|
|
||||||
This changes behavior from per-message to per-launcher for existing pipelines.
|
|
||||||
|
|
||||||
Recommendation: **accept the behavior change** — per-launcher is the more
|
|
||||||
intuitive default, and the old per-message behavior was rarely desired.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Cloud Quota Implications
|
|
||||||
|
|
||||||
| Scope | Typical concurrent containers |
|
|
||||||
|-----------------------------------------------|-------------------------------|
|
|
||||||
| `{query_id}` (per message) | Many, short-lived |
|
|
||||||
| `{launcher_type}_{launcher_id}` (per chat) | = active chat count |
|
|
||||||
| `{sender_id}` (per user) | = active user count |
|
|
||||||
| `{conversation_id}` (per conversation) | Between per-chat and per-msg |
|
|
||||||
|
|
||||||
With the unified container model, each scope value maps to exactly **one**
|
|
||||||
container (instead of potentially 3+ per-message). This significantly reduces
|
|
||||||
resource usage.
|
|
||||||
|
|
||||||
Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Session scope + skill unification (this PR)
|
|
||||||
|
|
||||||
1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`.
|
|
||||||
2. **SDK**: Update Docker/nsjail backends to apply extra mounts.
|
|
||||||
3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata
|
|
||||||
and default pipeline config JSON.
|
|
||||||
4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation.
|
|
||||||
5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same
|
|
||||||
session_id + extra mounts instead of separate `BoxWorkspaceSession`.
|
|
||||||
6. **LangBot**: On container creation, inject extra mounts for all
|
|
||||||
pipeline-bound skills.
|
|
||||||
7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields.
|
|
||||||
8. **Tests**: Unit tests for template interpolation and multi-mount specs.
|
|
||||||
|
|
||||||
### Phase 2: MCP unification (future)
|
|
||||||
|
|
||||||
1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container.
|
|
||||||
2. MCP servers become managed processes in the shared container.
|
|
||||||
3. Support multiple concurrent managed processes per container.
|
|
||||||
|
|
||||||
MCP unification is deferred because it requires changes to the managed process
|
|
||||||
model (currently 1 managed process per session) and has startup ordering
|
|
||||||
concerns (MCP servers start at boot, before any user query determines
|
|
||||||
a session_id).
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Box 系统测试覆盖分析
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 测试文件清单
|
|
||||||
|
|
||||||
### LangBot 仓库
|
|
||||||
|
|
||||||
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
|
||||||
|------|------|---------|---------|
|
|
||||||
| `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 |
|
|
||||||
| `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) |
|
|
||||||
| `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 |
|
|
||||||
| `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info |
|
|
||||||
| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) |
|
|
||||||
| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 |
|
|
||||||
| `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD |
|
|
||||||
| `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 |
|
|
||||||
| `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 |
|
|
||||||
| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 |
|
|
||||||
| `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 |
|
|
||||||
| `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 |
|
|
||||||
| `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 |
|
|
||||||
|
|
||||||
### SDK 仓库
|
|
||||||
|
|
||||||
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
|
||||||
|------|------|---------|---------|
|
|
||||||
| `tests/box/test_backend_selection.py` | 255 | 是 | 显式 backend / local 模式探测顺序 / 配置变更触发 reselect |
|
|
||||||
| `tests/box/test_nsjail_backend.py` | 452 | 是 | nsjail 可用性、安装版 CLI vs 容器内 CLI、session、arg 构建、资源限制 |
|
|
||||||
| `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 |
|
|
||||||
| `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD |
|
|
||||||
|
|
||||||
**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。
|
|
||||||
|
|
||||||
> 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 覆盖良好的区域
|
|
||||||
|
|
||||||
| 区域 | 质量 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置、消失 session 重建 |
|
|
||||||
| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp |
|
|
||||||
| BoxService host mount 安全 | 优秀 | allowed_mount_roots、disallowed_roots、shared host root |
|
|
||||||
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
|
||||||
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
|
||||||
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
|
||||||
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
|
||||||
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
|
||||||
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
|
||||||
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
|
||||||
| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 |
|
|
||||||
| NsjailBackend | 良好 | 可用性、安装版 vs 容器内、session 生命周期、arg 构建、资源限制 |
|
|
||||||
| E2BBackend | 良好 | mock SDK、session/extra_mounts 同步 |
|
|
||||||
| Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect |
|
|
||||||
| MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process |
|
|
||||||
| Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 |
|
|
||||||
| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 |
|
|
||||||
| Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 覆盖缺失的区域
|
|
||||||
|
|
||||||
### 3.1 零测试 / 严重不足
|
|
||||||
|
|
||||||
| 区域 | 源文件 | 影响 |
|
|
||||||
|------|--------|------|
|
|
||||||
| **`security.py`** | SDK `box/security.py` (52 行) | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 |
|
|
||||||
| **`policy.py`** | `pkg/box/policy.py` (98 行) | 三层安全策略无测试(也是死代码) |
|
|
||||||
| **`skill_store.py` 边缘场景** | SDK `box/skill_store.py` (647 行) vs 测试 88 行 | GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip、文件冲突等场景未覆盖 |
|
|
||||||
|
|
||||||
### 3.2 未测试的关键路径
|
|
||||||
|
|
||||||
| 区域 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 |
|
|
||||||
| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 |
|
|
||||||
| **Container backend (Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend |
|
|
||||||
| **E2B 真实 sandbox** | 单测全是 mock,未对接真实 E2B API |
|
|
||||||
| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 |
|
|
||||||
| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 |
|
|
||||||
| **WS relay** | 仅在集成测试中覆盖(CI 不运行) |
|
|
||||||
| **NsjailBackend managed process** | 完全未测试 |
|
|
||||||
| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 多 process 并发 → 重试 |
|
|
||||||
| **BoxService start/stop_managed_process** | 单 process 流转有单测,多 process 互不阻塞主要靠集成测试 |
|
|
||||||
| **重连指数退避** | connector 单测覆盖回调接线,未实际跑完整重连周期 |
|
|
||||||
|
|
||||||
### 3.3 边缘情况缺失
|
|
||||||
|
|
||||||
| 区域 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 |
|
|
||||||
| BoxSpec.extra_mounts | 重复 mount_path、与 host_path 冲突、绝对 vs 相对路径 |
|
|
||||||
| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 |
|
|
||||||
| 多后端 fallback | local 模式探测顺序仅靠 mock,无真实 Docker 不可用 → nsjail 真机 fallback 测试 |
|
|
||||||
| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 |
|
|
||||||
| INIT 配置变更触发 backend 重建 | 单测仅在初始化场景验证 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 集成测试 vs CI 的差距
|
|
||||||
|
|
||||||
CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**:
|
|
||||||
|
|
||||||
- 真实容器的创建/执行/销毁
|
|
||||||
- 容器网络隔离(`--network none`)
|
|
||||||
- 容器资源限制生效(cpus/memory/pids_limit)
|
|
||||||
- Managed process 的 WS 双向 I/O
|
|
||||||
- 多 process 同 session 并发 I/O
|
|
||||||
- 孤儿容器清理
|
|
||||||
- Session 删除清理容器
|
|
||||||
- 进程退出检测
|
|
||||||
- E2B 真实 sandbox 行为
|
|
||||||
|
|
||||||
**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径(exec / MCP attach / session 销毁)。
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# Box 系统 toB 商业化分析
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 现有优势
|
|
||||||
|
|
||||||
| 能力 | toB 价值 | 代码位置 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` |
|
|
||||||
| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail/E2B) | SDK `box/runtime.py` `_select_backend()` |
|
|
||||||
| **E2B 云沙箱** | SaaS / 无 Docker 部署的兜底执行环境 | SDK `box/e2b_backend.py` |
|
|
||||||
| **连接自愈** | 心跳 + 自动重连,单点 Box runtime 故障可恢复 | `pkg/box/connector.py` `_heartbeat_loop`, `pkg/box/service.py` `_reconnect_loop` |
|
|
||||||
| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` |
|
|
||||||
| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` |
|
|
||||||
| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` |
|
|
||||||
| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` |
|
|
||||||
| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` |
|
|
||||||
| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session |
|
|
||||||
| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session |
|
|
||||||
| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. toB 差距分析
|
|
||||||
|
|
||||||
### 2.1 安全与合规
|
|
||||||
|
|
||||||
| 维度 | 现状 | toB 要求 | 优先级 |
|
|
||||||
|------|------|---------|--------|
|
|
||||||
| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** |
|
|
||||||
| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** |
|
|
||||||
| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** |
|
|
||||||
| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** |
|
|
||||||
| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** |
|
|
||||||
|
|
||||||
### 2.2 多租户
|
|
||||||
|
|
||||||
| 维度 | 现状 | toB 要求 | 优先级 |
|
|
||||||
|------|------|---------|--------|
|
|
||||||
| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** |
|
|
||||||
| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** |
|
|
||||||
| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** |
|
|
||||||
| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** |
|
|
||||||
|
|
||||||
### 2.3 可靠性
|
|
||||||
|
|
||||||
| 维度 | 现状 | toB 要求 | 优先级 |
|
|
||||||
|------|------|---------|--------|
|
|
||||||
| **连接恢复** | 已实现:20s 心跳 + `_reconnect_loop` 指数退避 | 已满足基本要求 | 已有 |
|
|
||||||
| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** |
|
|
||||||
| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡(按 tenant 路由) | **P1** |
|
|
||||||
| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 |
|
|
||||||
| **Backend 自愈** | 已实现:`get_status` 时若 backend 不可用会重新选择 | 已满足基本要求 | 已有 |
|
|
||||||
|
|
||||||
### 2.4 可观测性
|
|
||||||
|
|
||||||
| 维度 | 现状 | toB 要求 | 优先级 |
|
|
||||||
|------|------|---------|--------|
|
|
||||||
| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** |
|
|
||||||
| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** |
|
|
||||||
| **前端面板** | 监控页接入 `/api/v1/box/status`(backend 名 + 活跃 session 数);`sessions` / `errors` 仍未接入 | 完整状态面板 + 历史错误/审计列表 | **P2** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. SaaS 部署架构建议
|
|
||||||
|
|
||||||
### 3.1 方案 A: 共享 Box Runtime Pool (快速上线)
|
|
||||||
|
|
||||||
```
|
|
||||||
LangBot Instance ──> Box Runtime (共享)
|
|
||||||
├─ tenant_id 标签隔离
|
|
||||||
├─ Redis 配额计数器
|
|
||||||
└─ Container labels: langbot.tenant_id=xxx
|
|
||||||
```
|
|
||||||
|
|
||||||
- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可
|
|
||||||
- **缺点**: 容器引擎共享,安全隔离弱
|
|
||||||
|
|
||||||
### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期)
|
|
||||||
|
|
||||||
```
|
|
||||||
LangBot ──> K8s API
|
|
||||||
├─ namespace: tenant-xxx
|
|
||||||
│ ├─ RuntimeClass: gVisor (runsc)
|
|
||||||
│ ├─ ResourceQuota
|
|
||||||
│ └─ NetworkPolicy
|
|
||||||
└─ namespace: tenant-yyy
|
|
||||||
└─ ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额
|
|
||||||
- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高
|
|
||||||
|
|
||||||
### 3.3 方案 C: K8s Job 直接编排 (长期)
|
|
||||||
|
|
||||||
```
|
|
||||||
LangBot ──> K8s Job per execution
|
|
||||||
├─ 每次执行创建 Job
|
|
||||||
├─ Pod Security Standards
|
|
||||||
├─ 自动调度和资源分配
|
|
||||||
└─ Job TTL Controller 自动清理
|
|
||||||
```
|
|
||||||
|
|
||||||
- **优点**: 最强隔离,天然水平扩展
|
|
||||||
- **缺点**: 冷启动延迟,架构重写
|
|
||||||
|
|
||||||
**推荐演进路径**: A → B → C
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 配额体系建议
|
|
||||||
|
|
||||||
### 三层配额
|
|
||||||
|
|
||||||
| 层 | 实现 | 作用 |
|
|
||||||
|----|------|------|
|
|
||||||
| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 |
|
|
||||||
| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 |
|
|
||||||
| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) |
|
|
||||||
|
|
||||||
### Profile 与套餐映射
|
|
||||||
|
|
||||||
| 套餐 | Profile | locked 字段 | 配额 |
|
|
||||||
|------|---------|------------|------|
|
|
||||||
| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB |
|
|
||||||
| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB |
|
|
||||||
| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 |
|
|
||||||
|
|
||||||
### TOCTOU 配额修复
|
|
||||||
|
|
||||||
当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决:
|
|
||||||
|
|
||||||
1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚
|
|
||||||
2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 优先实施路线
|
|
||||||
|
|
||||||
### Phase 1 (2-4 周): 安全基线
|
|
||||||
|
|
||||||
- [ ] WS relay 加 token 认证
|
|
||||||
- [ ] 接入或删除 policy.py
|
|
||||||
- [x] ~~Box 加重连和心跳~~(已完成,见 [box-issues.md 已解决](./box-issues.md))
|
|
||||||
- [ ] 审计日志持久化(至少写文件/数据库)
|
|
||||||
- [ ] `security.py` 加 `/` 拦截,考虑白名单
|
|
||||||
- [ ] INIT 与 backend 初始化顺序整理(避免 backend 在配置到达前实例化)
|
|
||||||
|
|
||||||
### Phase 2 (4-8 周): 多租户基础
|
|
||||||
|
|
||||||
- [ ] BoxSpec 加 `tenant_id` 字段
|
|
||||||
- [ ] 容器 labels 加 tenant 标识
|
|
||||||
- [ ] Redis 配额计数器(并发/执行次数/时间)
|
|
||||||
- [ ] RBAC 基础框架
|
|
||||||
- [ ] 定时 session reaper
|
|
||||||
|
|
||||||
### Phase 3 (8-16 周): 生产就绪
|
|
||||||
|
|
||||||
- [ ] Prometheus metrics exporter
|
|
||||||
- [ ] 前端 Box 状态面板
|
|
||||||
- [ ] K8s backend 支持 (方案 B)
|
|
||||||
- [ ] 结构化日志 (JSON, trace_id)
|
|
||||||
- [ ] 水平扩展支持
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# Box Runtime vs Plugin Runtime: 连接架构对比
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-02
|
|
||||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
|
||||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 总体差异
|
|
||||||
|
|
||||||
| 维度 | Plugin Runtime | Box Runtime |
|
|
||||||
|------|---------------|-------------|
|
|
||||||
| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) |
|
|
||||||
| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 3 条 (本地 stdio, Win32/subprocess+WS, 远程 WS) |
|
|
||||||
| **心跳** | 20s ping loop | 20s ping loop(`_heartbeat_loop`) |
|
|
||||||
| **重连** | WS 模式: sleep 3s → re-initialize | 由 BoxService `_reconnect_loop` 处理,指数退避 |
|
|
||||||
| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` + `BoxServerHandler`(SDK 端 25 action) |
|
|
||||||
| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler |
|
|
||||||
| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) |
|
|
||||||
| **初始化失败** | 异常上抛 | 静默降级 `_available=False` |
|
|
||||||
| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 传输决策
|
|
||||||
|
|
||||||
### Plugin: 3-路决策
|
|
||||||
|
|
||||||
```python
|
|
||||||
# pkg/plugin/connector.py:106-165
|
|
||||||
if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime():
|
|
||||||
# Docker/WS → ws://langbot_plugin_runtime:5400/control/ws
|
|
||||||
elif get_platform() == 'win32':
|
|
||||||
# Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws
|
|
||||||
else:
|
|
||||||
# Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Box: 3-路决策
|
|
||||||
|
|
||||||
```python
|
|
||||||
# pkg/box/connector.py
|
|
||||||
if self._uses_websocket():
|
|
||||||
if platform.get_platform() == 'win32' and not self.configured_runtime_url:
|
|
||||||
await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws
|
|
||||||
else:
|
|
||||||
await self._connect_remote_ws() # ws://{host}:5410/rpc/ws
|
|
||||||
else:
|
|
||||||
await self._start_local_stdio() # StdioClientController
|
|
||||||
```
|
|
||||||
|
|
||||||
> 历史:2026-04-16 版本本文档曾把 Box 描述为 2 路决策(缺 Windows 分支)。现已对齐 Plugin 的 3 路设计。
|
|
||||||
|
|
||||||
### 决策矩阵
|
|
||||||
|
|
||||||
| 环境 | Plugin | Box |
|
|
||||||
|------|--------|-----|
|
|
||||||
| Docker | WS → `:5400` | WS → `:5410/rpc/ws` |
|
|
||||||
| `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` |
|
|
||||||
| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) |
|
|
||||||
| Unix/Mac 非 Docker | stdio | stdio |
|
|
||||||
| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 连接建立
|
|
||||||
|
|
||||||
### 同步模式差异
|
|
||||||
|
|
||||||
**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。
|
|
||||||
|
|
||||||
**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。
|
|
||||||
|
|
||||||
### Box stdio 路径
|
|
||||||
|
|
||||||
```
|
|
||||||
connector._start_local_stdio()
|
|
||||||
├─ connected = asyncio.Event()
|
|
||||||
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', N])
|
|
||||||
├─ _ctrl_task = create_task(ctrl.run(callback))
|
|
||||||
│ callback:
|
|
||||||
│ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback
|
|
||||||
│ client.set_handler(handler)
|
|
||||||
│ _handler_task = create_task(handler.run())
|
|
||||||
│ call_action(PING, {}) ← 握手, timeout=15s
|
|
||||||
│ connected.set() ← 通知外层
|
|
||||||
│ await _handler_task ← 阻塞直到断开
|
|
||||||
└─ await wait_for(connected.wait(), 30s) ← 同步等待
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin stdio 路径
|
|
||||||
|
|
||||||
```
|
|
||||||
connector.initialize()
|
|
||||||
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s'])
|
|
||||||
├─ task = ctrl.run(callback)
|
|
||||||
│ callback:
|
|
||||||
│ disconnect_callback:
|
|
||||||
│ [WS] → runtime_disconnect_callback → 重连
|
|
||||||
│ [stdio] → 仅日志, 不重连
|
|
||||||
│ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap)
|
|
||||||
│ create_task(handler.run())
|
|
||||||
│ handler.ping() ← 握手, timeout=10s
|
|
||||||
│ await handler_task ← 阻塞直到断开
|
|
||||||
├─ create_task(heartbeat_loop()) ← 20s ping loop
|
|
||||||
└─ create_task(task) ← 不等待连接
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 心跳与重连
|
|
||||||
|
|
||||||
### 心跳
|
|
||||||
|
|
||||||
| 维度 | Plugin | Box |
|
|
||||||
|------|--------|-----|
|
|
||||||
| 有心跳? | 是 | 是(`connector.py` `_heartbeat_loop`) |
|
|
||||||
| 间隔 | 20s | 20s |
|
|
||||||
| 失败处理 | 仅 DEBUG 日志,不触发重连 | 仅 DEBUG 日志,依赖 connection close 触发重连 |
|
|
||||||
| 生命周期 | 整个应用生命周期 | 连接建立后启动;`dispose()` 时 cancel |
|
|
||||||
|
|
||||||
### 重连
|
|
||||||
|
|
||||||
| 维度 | Plugin | Box |
|
|
||||||
|------|--------|-----|
|
|
||||||
| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | `runtime_disconnect_callback` → `BoxService._reconnect_loop()`(指数退避) |
|
|
||||||
| WS 连接失败 | 同上 | 同上;初次失败时 `_available=False`,重连成功后恢复 |
|
|
||||||
| stdio 断开 | 仅日志,不重连 | 接同样回调;stdio 重连需重新 fork 子进程 |
|
|
||||||
| 重连退避 | 固定 3s,无 backoff | 指数退避 |
|
|
||||||
|
|
||||||
> 历史:2026-04-16 版本本文档曾把心跳与重连标记为 Box 缺失。这两项已在 commit `2dfd9d5d` / `c6882cf` / `5029d9c` 等修复(详见 [box-issues.md 已解决](./box-issues.md))。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 共享 IO 层
|
|
||||||
|
|
||||||
两者复用同一套 SDK IO 基础设施:
|
|
||||||
|
|
||||||
```
|
|
||||||
Handler ← ABC (runtime/io/handler.py)
|
|
||||||
├── RuntimeConnectionHandler (Plugin 用, LangBot 侧)
|
|
||||||
├── ControlConnectionHandler (Plugin 用, SDK 侧)
|
|
||||||
├── BoxServerHandler (Box 用, SDK 侧)
|
|
||||||
└── 匿名 Handler 实例 (Box 用, LangBot 侧)
|
|
||||||
|
|
||||||
Connection ← ABC
|
|
||||||
├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议)
|
|
||||||
└── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧)
|
|
||||||
|
|
||||||
Controller ← ABC
|
|
||||||
├── StdioClientController (fork 子进程, pipe stdin/stdout)
|
|
||||||
├── StdioServerController (接管当前进程 stdin/stdout)
|
|
||||||
├── WebSocketClientController (连接 WS 服务端)
|
|
||||||
└── WebSocketServerController (监听 WS 端口)
|
|
||||||
```
|
|
||||||
|
|
||||||
共享的核心机制:
|
|
||||||
- `call_action()` / `call_action_generator()` — RPC 调用/流式调用
|
|
||||||
- `ActionRequest` / `ActionResponse` — 请求/响应协议
|
|
||||||
- `seq_id` 关联 — 并发请求复用单连接
|
|
||||||
- `CommonAction.PING` — 两者都用于初始握手
|
|
||||||
- 文件传输 (`send_file`) — Plugin 用,Box 不用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 端口方案
|
|
||||||
|
|
||||||
| 服务 | Plugin | Box |
|
|
||||||
|------|--------|-----|
|
|
||||||
| Action RPC (stdio) | stdin/stdout | stdin/stdout |
|
|
||||||
| Action RPC (WS) | `:5400` | `:5410/rpc/ws` |
|
|
||||||
| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` |
|
|
||||||
|
|
||||||
**Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 销毁对比
|
|
||||||
|
|
||||||
### Plugin
|
|
||||||
|
|
||||||
```python
|
|
||||||
dispose():
|
|
||||||
if stdio: ctrl.process.terminate()
|
|
||||||
_dispose_subprocess() # Windows 子进程
|
|
||||||
heartbeat_task.cancel()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Box
|
|
||||||
|
|
||||||
```python
|
|
||||||
connector.dispose():
|
|
||||||
_handler_task.cancel()
|
|
||||||
_ctrl_task.cancel()
|
|
||||||
_subprocess.terminate()
|
|
||||||
|
|
||||||
service.dispose():
|
|
||||||
connector.dispose()
|
|
||||||
loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器
|
|
||||||
```
|
|
||||||
|
|
||||||
Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 改进建议
|
|
||||||
|
|
||||||
### P0
|
|
||||||
|
|
||||||
1. **两者都加 WS 认证**: 至少 token 认证(INIT 时下发,连接时校验)
|
|
||||||
|
|
||||||
### P1
|
|
||||||
|
|
||||||
2. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess` / `_wait_until_ready` / `_dispose_subprocess`,减少重复代码
|
|
||||||
3. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议向 Box 的指数退避看齐
|
|
||||||
4. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种
|
|
||||||
|
|
||||||
### 已完成(自上一轮)
|
|
||||||
|
|
||||||
- ~~Box 加重连~~(commit `2dfd9d5d`)
|
|
||||||
- ~~Box 加心跳~~(20s loop 与 Plugin 一致)
|
|
||||||
- ~~Box 加 Windows 支持~~(commit `120817a` / `fafb7a4`)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0-beta.2"
|
version = "4.9.7"
|
||||||
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.3.11",
|
||||||
"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",
|
||||||
@@ -105,9 +105,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"
|
||||||
@@ -226,3 +223,4 @@ skip-magic-trailing-comma = false
|
|||||||
|
|
||||||
# Like Black, automatically detect the appropriate line ending.
|
# Like Black, automatically detect the appropriate line ending.
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
|
|||||||
@@ -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.9.7'
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from langbot.pkg.utils import paths
|
|
||||||
|
|
||||||
# ASCII art banner
|
# ASCII art banner
|
||||||
asciiart = r"""
|
asciiart = r"""
|
||||||
_ ___ _
|
_ ___ _
|
||||||
@@ -29,12 +27,6 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
help='Use standalone plugin runtime / 使用独立插件运行时',
|
help='Use standalone plugin runtime / 使用独立插件运行时',
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
'--standalone-box',
|
|
||||||
action='store_true',
|
|
||||||
help='Use standalone box runtime / 使用独立 Box 运行时',
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -43,11 +35,6 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
|
|
||||||
platform.standalone_runtime = True
|
platform.standalone_runtime = True
|
||||||
|
|
||||||
if args.standalone_box:
|
|
||||||
from langbot.pkg.utils import platform
|
|
||||||
|
|
||||||
platform.standalone_box = True
|
|
||||||
|
|
||||||
if args.debug:
|
if args.debug:
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -100,7 +87,7 @@ def main():
|
|||||||
# Set up the working directory
|
# Set up the working directory
|
||||||
# When installed as a package, we need to handle the working directory differently
|
# When installed as a package, we need to handle the working directory differently
|
||||||
# We'll create data directory in current working directory if not exists
|
# We'll create data directory in current working directory if not exists
|
||||||
os.makedirs(paths.get_data_root(), exist_ok=True)
|
os.makedirs('data', exist_ok=True)
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,61 @@ class AsyncDifyServiceClient:
|
|||||||
if chunk.startswith('data:'):
|
if chunk.startswith('data:'):
|
||||||
yield json.loads(chunk[5:])
|
yield json.loads(chunk[5:])
|
||||||
|
|
||||||
|
async def workflow_submit(
|
||||||
|
self,
|
||||||
|
form_token: str,
|
||||||
|
workflow_run_id: str,
|
||||||
|
inputs: dict[str, typing.Any],
|
||||||
|
user: str,
|
||||||
|
action: str = '',
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""Submit human input to resume a paused workflow, then stream events.
|
||||||
|
|
||||||
|
1. POST /form/human_input/{form_token} to submit the form
|
||||||
|
2. GET /workflow/{task_id}/events to stream the resumed workflow events
|
||||||
|
"""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
# Step 1: Submit the form
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'inputs': inputs if isinstance(inputs, dict) else {},
|
||||||
|
'user': user,
|
||||||
|
'action': action,
|
||||||
|
}
|
||||||
|
|
||||||
|
submit_resp = await client.post(
|
||||||
|
f'/form/human_input/{form_token}',
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if submit_resp.status_code != 200:
|
||||||
|
raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
|
||||||
|
|
||||||
|
# Step 2: Stream resumed workflow events
|
||||||
|
async with client.stream(
|
||||||
|
'GET',
|
||||||
|
f'/workflow/{workflow_run_id}/events',
|
||||||
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
|
params={'user': user},
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise DifyAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
yield json.loads(chunk[5:])
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self,
|
self,
|
||||||
file: httpx._types.FileTypes,
|
file: httpx._types.FileTypes,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import group
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('box', '/api/v1/box')
|
|
||||||
class BoxRouterGroup(group.RouterGroup):
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
status = await self.ap.box_service.get_status()
|
|
||||||
return self.success(data=status)
|
|
||||||
|
|
||||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
sessions = await self.ap.box_service.get_sessions()
|
|
||||||
return self.success(data=sessions)
|
|
||||||
|
|
||||||
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
errors = self.ap.box_service.get_recent_errors()
|
|
||||||
return self.success(data=errors)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from .. import group
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('extensions', '/api/v1/extensions')
|
|
||||||
class ExtensionsRouterGroup(group.RouterGroup):
|
|
||||||
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> quart.Response:
|
|
||||||
plugins, mcp_servers, skills = await asyncio.gather(
|
|
||||||
self.ap.plugin_connector.list_plugins(),
|
|
||||||
self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
|
|
||||||
self.ap.skill_service.list_skills(),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _sort_key(item: dict) -> str:
|
|
||||||
if item['type'] == 'plugin':
|
|
||||||
return (
|
|
||||||
item['plugin']
|
|
||||||
.get('manifest', {})
|
|
||||||
.get('manifest', {})
|
|
||||||
.get('metadata', {})
|
|
||||||
.get('name', '')
|
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
if item['type'] == 'mcp':
|
|
||||||
return (item['server'].get('name') or '').lower()
|
|
||||||
if item['type'] == 'skill':
|
|
||||||
return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower()
|
|
||||||
return ''
|
|
||||||
|
|
||||||
extensions: list[dict] = []
|
|
||||||
if isinstance(plugins, list):
|
|
||||||
for plugin in plugins:
|
|
||||||
extensions.append({'type': 'plugin', 'plugin': plugin})
|
|
||||||
if isinstance(mcp_servers, list):
|
|
||||||
for server in mcp_servers:
|
|
||||||
extensions.append({'type': 'mcp', 'server': server})
|
|
||||||
if isinstance(skills, list):
|
|
||||||
for skill in skills:
|
|
||||||
extensions.append({'type': 'skill', 'skill': skill})
|
|
||||||
|
|
||||||
extensions.sort(key=_sort_key)
|
|
||||||
|
|
||||||
return self.success(data={'extensions': extensions})
|
|
||||||
@@ -73,21 +73,15 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||||
|
|
||||||
# Get available skills
|
|
||||||
available_skills = await self.ap.skill_service.list_skills()
|
|
||||||
|
|
||||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||||
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
|
||||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||||
'available_plugins': plugins,
|
'available_plugins': plugins,
|
||||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||||
'available_mcp_servers': mcp_servers,
|
'available_mcp_servers': mcp_servers,
|
||||||
'bound_skills': extensions_prefs.get('skills', []),
|
|
||||||
'available_skills': available_skills,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
@@ -95,19 +89,11 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||||
enable_all_skills = json_data.get('enable_all_skills', True)
|
|
||||||
bound_plugins = json_data.get('bound_plugins', [])
|
bound_plugins = json_data.get('bound_plugins', [])
|
||||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||||
bound_skills = json_data.get('bound_skills', [])
|
|
||||||
|
|
||||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||||
pipeline_uuid,
|
pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
||||||
bound_plugins,
|
|
||||||
bound_mcp_servers,
|
|
||||||
enable_all_plugins,
|
|
||||||
enable_all_mcp_servers,
|
|
||||||
bound_skills=bound_skills,
|
|
||||||
enable_all_skills=enable_all_skills,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|||||||
@@ -43,12 +43,8 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Dashboard pipeline-debug sessions must always run under the
|
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
||||||
# built-in websocket_proxy_bot identity. We deliberately do NOT
|
owner_bot = self._find_owner_bot(pipeline_uuid)
|
||||||
# resolve a web_page_bot owner here — even if one is bound to
|
|
||||||
# the same pipeline, debug requests must not be attributed to
|
|
||||||
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
|
|
||||||
# is the one that carries the page-bot identity.
|
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
@@ -77,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -185,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
def _find_owner_bot(self, pipeline_uuid: str):
|
||||||
|
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
||||||
|
return bot
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -210,10 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
# owner_bot is intentionally NOT passed: the dashboard
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
# debug WebSocket must always run under the proxy bot,
|
|
||||||
# never under a coincidentally-bound web_page_bot.
|
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
|
||||||
import quart
|
import quart
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import zipfile
|
|
||||||
import yaml
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
import posixpath
|
import posixpath
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -57,97 +53,6 @@ def _get_request_origin() -> str:
|
|||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
@staticmethod
|
|
||||||
def _normalize_archive_path(path: str) -> str:
|
|
||||||
normalized = str(path or '').replace('\\', '/').strip('/')
|
|
||||||
return posixpath.normpath(normalized) if normalized else ''
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _component_source_path(cls, entry) -> str:
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
return cls._normalize_archive_path(entry.get('path') or '')
|
|
||||||
return cls._normalize_archive_path(str(entry or ''))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _count_component_configs(cls, component_config, archive_names: list[str]) -> int:
|
|
||||||
normalized_names = [cls._normalize_archive_path(name) for name in archive_names]
|
|
||||||
component_files: set[str] = set()
|
|
||||||
|
|
||||||
if isinstance(component_config, list):
|
|
||||||
return len(component_config)
|
|
||||||
if not isinstance(component_config, dict):
|
|
||||||
return 1 if component_config else 0
|
|
||||||
|
|
||||||
for entry in component_config.get('fromFiles') or []:
|
|
||||||
source_path = cls._component_source_path(entry)
|
|
||||||
if source_path and source_path in normalized_names:
|
|
||||||
component_files.add(source_path)
|
|
||||||
|
|
||||||
for entry in component_config.get('fromDirs') or []:
|
|
||||||
source_dir = cls._component_source_path(entry).rstrip('/')
|
|
||||||
if not source_dir:
|
|
||||||
continue
|
|
||||||
prefix = f'{source_dir}/'
|
|
||||||
for archive_name in normalized_names:
|
|
||||||
if not archive_name.startswith(prefix):
|
|
||||||
continue
|
|
||||||
if archive_name.lower().endswith(('.yaml', '.yml')):
|
|
||||||
component_files.add(archive_name)
|
|
||||||
|
|
||||||
if component_files:
|
|
||||||
return len(component_files)
|
|
||||||
|
|
||||||
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]:
|
|
||||||
if not isinstance(components, dict):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
component_counts: dict[str, int] = {}
|
|
||||||
for kind, component_config in components.items():
|
|
||||||
count = cls._count_component_configs(component_config, archive_names)
|
|
||||||
if count > 0:
|
|
||||||
component_counts[str(kind)] = count
|
|
||||||
return component_counts
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_github_repo_url(repo_url: str) -> dict | None:
|
|
||||||
raw_url = str(repo_url or '').strip()
|
|
||||||
if not raw_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url):
|
|
||||||
raw_url = f'https://{raw_url}'
|
|
||||||
|
|
||||||
parsed = urlparse(raw_url)
|
|
||||||
if parsed.netloc.lower() not in ('github.com', 'www.github.com'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = [part for part in parsed.path.strip('/').split('/') if part]
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
owner = parts[0]
|
|
||||||
repo = parts[1]
|
|
||||||
if repo.endswith('.git'):
|
|
||||||
repo = repo[:-4]
|
|
||||||
if not owner or not repo:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ref = ''
|
|
||||||
subdir = ''
|
|
||||||
if len(parts) >= 4 and parts[2] in ('tree', 'blob'):
|
|
||||||
ref = parts[3]
|
|
||||||
subdir = '/'.join(parts[4:]).strip('/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'owner': owner,
|
|
||||||
'repo': repo,
|
|
||||||
'ref': ref,
|
|
||||||
'subdir': subdir,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _check_extensions_limit(self) -> str | None:
|
async def _check_extensions_limit(self) -> str | None:
|
||||||
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
||||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||||
@@ -349,37 +254,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
repo_url = data.get('repo_url', '')
|
repo_url = data.get('repo_url', '')
|
||||||
|
|
||||||
parsed_repo = self._parse_github_repo_url(repo_url)
|
# Parse GitHub repository URL to extract owner and repo
|
||||||
if not parsed_repo:
|
# Supports: https://github.com/owner/repo or github.com/owner/repo
|
||||||
|
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
|
||||||
|
match = re.search(pattern, repo_url)
|
||||||
|
|
||||||
|
if not match:
|
||||||
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
||||||
|
|
||||||
owner = parsed_repo['owner']
|
owner, repo = match.groups()
|
||||||
repo = parsed_repo['repo']
|
|
||||||
requested_ref = parsed_repo['ref']
|
|
||||||
requested_subdir = parsed_repo['subdir']
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if requested_ref:
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'releases': [
|
|
||||||
{
|
|
||||||
'id': 0,
|
|
||||||
'tag_name': requested_ref,
|
|
||||||
'name': requested_ref,
|
|
||||||
'published_at': '',
|
|
||||||
'prerelease': False,
|
|
||||||
'draft': False,
|
|
||||||
'source_type': 'branch',
|
|
||||||
'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'owner': owner,
|
|
||||||
'repo': repo,
|
|
||||||
'source_subdir': requested_subdir,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch releases from GitHub API
|
# Fetch releases from GitHub API
|
||||||
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -405,14 +290,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success(
|
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
|
||||||
data={
|
|
||||||
'releases': formatted_releases,
|
|
||||||
'owner': owner,
|
|
||||||
'repo': repo,
|
|
||||||
'source_subdir': requested_subdir,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
||||||
|
|
||||||
@@ -567,62 +445,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={'task_id': wrapper.id})
|
return self.success(data={'task_id': wrapper.id})
|
||||||
|
|
||||||
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
file = (await quart.request.files).get('file')
|
|
||||||
if file is None:
|
|
||||||
return self.http_status(400, -1, 'file is required')
|
|
||||||
|
|
||||||
file_bytes = file.read()
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
|
||||||
names = [name for name in zf.namelist() if not name.endswith('/')]
|
|
||||||
manifest_name = next(
|
|
||||||
(
|
|
||||||
name
|
|
||||||
for name in names
|
|
||||||
if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml')
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if manifest_name is None:
|
|
||||||
return self.http_status(400, -1, 'manifest.yaml is required')
|
|
||||||
|
|
||||||
manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {}
|
|
||||||
requirements: list[str] = []
|
|
||||||
requirements_name = next(
|
|
||||||
(name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if requirements_name is not None:
|
|
||||||
requirements = [
|
|
||||||
line.strip()
|
|
||||||
for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines()
|
|
||||||
if line.strip() and not line.strip().startswith('#')
|
|
||||||
]
|
|
||||||
|
|
||||||
spec = manifest.get('spec') or {}
|
|
||||||
components = spec.get('components') or {}
|
|
||||||
component_counts = self._count_plugin_components(components, names)
|
|
||||||
component_types = list(component_counts.keys())
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'filename': file.filename or 'local plugin',
|
|
||||||
'size': len(file_bytes),
|
|
||||||
'manifest': manifest,
|
|
||||||
'metadata': manifest.get('metadata') or {},
|
|
||||||
'component_types': component_types,
|
|
||||||
'component_counts': component_counts,
|
|
||||||
'requirements': requirements,
|
|
||||||
'file_count': len(names),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except zipfile.BadZipFile:
|
|
||||||
return self.http_status(400, -1, 'invalid .lbpkg file')
|
|
||||||
except Exception as exc:
|
|
||||||
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
|
|
||||||
|
|
||||||
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""Upload a file for plugin configuration"""
|
"""Upload a file for plugin configuration"""
|
||||||
|
|||||||
@@ -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,10 +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
|
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
|
|
||||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
||||||
if server_data is None:
|
if server_data is None:
|
||||||
@@ -59,10 +56,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
|
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
server_data = await quart.request.json
|
server_data = await quart.request.json
|
||||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||||
return self.success(data={'task_id': task_id})
|
return self.success(data={'task_id': task_id})
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from langbot_plugin.box.errors import BoxError
|
|
||||||
|
|
||||||
from .. import group
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('skills', '/api/v1/skills')
|
|
||||||
class SkillsRouterGroup(group.RouterGroup):
|
|
||||||
"""Skills management API endpoints."""
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def list_or_create_skills() -> quart.Response:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
try:
|
|
||||||
skills = await self.ap.skill_service.list_skills()
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
return self.success(data={'skills': skills})
|
|
||||||
|
|
||||||
data = await quart.request.json
|
|
||||||
if 'name' not in data or not data['name']:
|
|
||||||
return self.http_status(400, -1, 'Missing required field: name')
|
|
||||||
|
|
||||||
try:
|
|
||||||
skill = await self.ap.skill_service.create_skill(data)
|
|
||||||
return self.success(data={'skill': skill})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def get_update_delete_skill(skill_name: str) -> quart.Response:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
try:
|
|
||||||
skill = await self.ap.skill_service.get_skill(skill_name)
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
if not skill:
|
|
||||||
return self.http_status(404, -1, 'Skill not found')
|
|
||||||
return self.success(data={'skill': skill})
|
|
||||||
|
|
||||||
if quart.request.method == 'PUT':
|
|
||||||
data = await quart.request.json
|
|
||||||
try:
|
|
||||||
skill = await self.ap.skill_service.update_skill(skill_name, data)
|
|
||||||
return self.success(data={'skill': skill})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ap.skill_service.delete_skill(skill_name)
|
|
||||||
return self.success()
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
@self.route('/<skill_name>/files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def list_skill_files(skill_name: str) -> quart.Response:
|
|
||||||
"""List files in skill package directory."""
|
|
||||||
path = quart.request.args.get('path', '.').strip()
|
|
||||||
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.ap.skill_service.list_skill_files(
|
|
||||||
skill_name,
|
|
||||||
path=path,
|
|
||||||
include_hidden=include_hidden,
|
|
||||||
)
|
|
||||||
return self.success(data=result)
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
||||||
)
|
|
||||||
async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
|
|
||||||
"""Read or write a file in skill package."""
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
try:
|
|
||||||
result = await self.ap.skill_service.read_skill_file(skill_name, path)
|
|
||||||
return self.success(data=result)
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
# PUT - write file
|
|
||||||
data = await quart.request.json
|
|
||||||
content = data.get('content', '')
|
|
||||||
if content is None:
|
|
||||||
return self.http_status(400, -1, 'Missing required field: content')
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
|
|
||||||
return self.success(data=result)
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
|
|
||||||
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def preview_skill(skill_name: str) -> quart.Response:
|
|
||||||
skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
|
|
||||||
if not skill:
|
|
||||||
return self.http_status(404, -1, 'Skill not found')
|
|
||||||
return self.success(data={'instructions': skill.get('instructions', '')})
|
|
||||||
|
|
||||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def install_skill_from_github() -> quart.Response:
|
|
||||||
data = await quart.request.json
|
|
||||||
required_fields = ['asset_url', 'owner', 'repo']
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in data or not data[field]:
|
|
||||||
return self.http_status(400, -1, f'Missing required field: {field}')
|
|
||||||
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
|
||||||
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
|
||||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
|
||||||
|
|
||||||
try:
|
|
||||||
skill = await self.ap.skill_service.install_from_github(data)
|
|
||||||
return self.success(data={'skills': skill})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
except Exception as exc:
|
|
||||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
|
||||||
|
|
||||||
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def preview_skill_from_github() -> quart.Response:
|
|
||||||
data = await quart.request.json
|
|
||||||
required_fields = ['asset_url', 'owner', 'repo']
|
|
||||||
for field in required_fields:
|
|
||||||
if field not in data or not data[field]:
|
|
||||||
return self.http_status(400, -1, f'Missing required field: {field}')
|
|
||||||
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
|
||||||
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
|
||||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
|
||||||
|
|
||||||
try:
|
|
||||||
preview = await self.ap.skill_service.preview_install_from_github(data)
|
|
||||||
return self.success(data={'skills': preview})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
except Exception as exc:
|
|
||||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
|
||||||
|
|
||||||
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def install_skill_from_upload() -> quart.Response:
|
|
||||||
file = (await quart.request.files).get('file')
|
|
||||||
if file is None:
|
|
||||||
return self.http_status(400, -1, 'file is required')
|
|
||||||
form = await quart.request.form
|
|
||||||
|
|
||||||
try:
|
|
||||||
skill = await self.ap.skill_service.install_from_zip_upload(
|
|
||||||
file_bytes=file.read(),
|
|
||||||
filename=file.filename or '',
|
|
||||||
source_paths=form.getlist('source_paths'),
|
|
||||||
)
|
|
||||||
return self.success(data={'skills': skill})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
except Exception as exc:
|
|
||||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
|
||||||
|
|
||||||
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def preview_skill_from_upload() -> quart.Response:
|
|
||||||
file = (await quart.request.files).get('file')
|
|
||||||
if file is None:
|
|
||||||
return self.http_status(400, -1, 'file is required')
|
|
||||||
|
|
||||||
try:
|
|
||||||
preview = await self.ap.skill_service.preview_install_from_zip_upload(
|
|
||||||
file_bytes=file.read(),
|
|
||||||
filename=file.filename or '',
|
|
||||||
)
|
|
||||||
return self.success(data={'skills': preview})
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
except Exception as exc:
|
|
||||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
|
||||||
|
|
||||||
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def scan_skill_directory() -> quart.Response:
|
|
||||||
path = quart.request.args.get('path', '').strip()
|
|
||||||
if not path:
|
|
||||||
return self.http_status(400, -1, 'Missing required parameter: path')
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.ap.skill_service.scan_directory_async(path)
|
|
||||||
return self.success(data=result)
|
|
||||||
except (ValueError, BoxError) as exc:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
|
||||||
@@ -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']
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -306,8 +215,6 @@ class PipelineService:
|
|||||||
bound_mcp_servers: list[str] = None,
|
bound_mcp_servers: list[str] = None,
|
||||||
enable_all_plugins: bool = True,
|
enable_all_plugins: bool = True,
|
||||||
enable_all_mcp_servers: bool = True,
|
enable_all_mcp_servers: bool = True,
|
||||||
bound_skills: list[str] = None,
|
|
||||||
enable_all_skills: bool = True,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||||
# Get current pipeline
|
# Get current pipeline
|
||||||
@@ -325,12 +232,9 @@ class PipelineService:
|
|||||||
extensions_preferences = pipeline.extensions_preferences or {}
|
extensions_preferences = pipeline.extensions_preferences or {}
|
||||||
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
||||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
|
||||||
extensions_preferences['plugins'] = bound_plugins
|
extensions_preferences['plugins'] = bound_plugins
|
||||||
if bound_mcp_servers is not None:
|
if bound_mcp_servers is not None:
|
||||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||||
if bound_skills is not None:
|
|
||||||
extensions_preferences['skills'] = bound_skills
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import inspect
|
|
||||||
import os
|
|
||||||
import posixpath
|
|
||||||
import zipfile
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import quote, unquote, urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from ....core import app
|
|
||||||
from ....skill.utils import parse_frontmatter
|
|
||||||
|
|
||||||
|
|
||||||
_PUBLIC_SKILL_FIELDS = (
|
|
||||||
'name',
|
|
||||||
'display_name',
|
|
||||||
'description',
|
|
||||||
'instructions',
|
|
||||||
'package_root',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
)
|
|
||||||
|
|
||||||
_GITHUB_ASSET_HOSTS = {
|
|
||||||
'github.com',
|
|
||||||
'api.github.com',
|
|
||||||
'objects.githubusercontent.com',
|
|
||||||
'githubusercontent.com',
|
|
||||||
'raw.githubusercontent.com',
|
|
||||||
'codeload.github.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SkillService:
|
|
||||||
"""Filesystem-backed skill management service."""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
def _box_service(self):
|
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
|
||||||
if box_service is not None and getattr(box_service, 'available', False):
|
|
||||||
return box_service
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _require_box(self, action: str):
|
|
||||||
"""Return the Box service or raise if it is not available.
|
|
||||||
|
|
||||||
Box is the only source of truth for skills. Every read and write
|
|
||||||
operation goes through it — there is no local-filesystem fallback.
|
|
||||||
"""
|
|
||||||
box_service = self._box_service()
|
|
||||||
if box_service is not None:
|
|
||||||
return box_service
|
|
||||||
ap_box = getattr(self.ap, 'box_service', None)
|
|
||||||
if ap_box is None:
|
|
||||||
reason = 'not initialised'
|
|
||||||
elif not getattr(ap_box, 'enabled', True):
|
|
||||||
reason = 'disabled in config (box.enabled = false)'
|
|
||||||
else:
|
|
||||||
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
|
||||||
reason = f'unavailable: {connector_error}'
|
|
||||||
raise ValueError(
|
|
||||||
f'{action} requires the Box runtime, which is {reason}. '
|
|
||||||
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
|
||||||
f'runtime is reachable before retrying.'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _require_box_for_write(self, action: str) -> None:
|
|
||||||
"""Backwards-compatible alias preserved for clarity at call sites."""
|
|
||||||
self._require_box(action)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _serialize_skill(skill: dict) -> dict:
|
|
||||||
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
|
|
||||||
|
|
||||||
async def list_skills(self) -> list[dict]:
|
|
||||||
# When Box is unavailable, surface an empty list rather than raising —
|
|
||||||
# the skills page should render cleanly, and the UI separately renders
|
|
||||||
# a "Box disabled / unavailable" banner via useBoxStatus.
|
|
||||||
box_service = self._box_service()
|
|
||||||
if box_service is None:
|
|
||||||
return []
|
|
||||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills()]
|
|
||||||
|
|
||||||
async def get_skill(self, skill_name: str) -> Optional[dict]:
|
|
||||||
box_service = self._box_service()
|
|
||||||
if box_service is None:
|
|
||||||
return None
|
|
||||||
skill = await box_service.get_skill(skill_name)
|
|
||||||
return self._serialize_skill(skill) if skill else None
|
|
||||||
|
|
||||||
async def get_skill_by_name(self, name: str) -> Optional[dict]:
|
|
||||||
return await self.get_skill(name)
|
|
||||||
|
|
||||||
async def create_skill(self, data: dict) -> dict:
|
|
||||||
box_service = self._require_box('Creating a skill')
|
|
||||||
created = await box_service.create_skill(data)
|
|
||||||
await self._reload_skills()
|
|
||||||
return self._serialize_skill(created)
|
|
||||||
|
|
||||||
async def update_skill(self, skill_name: str, data: dict) -> dict:
|
|
||||||
box_service = self._require_box('Editing a skill')
|
|
||||||
updated = await box_service.update_skill(skill_name, data)
|
|
||||||
await self._reload_skills()
|
|
||||||
return self._serialize_skill(updated)
|
|
||||||
|
|
||||||
async def delete_skill(self, skill_name: str) -> bool:
|
|
||||||
box_service = self._require_box('Deleting a skill')
|
|
||||||
await box_service.delete_skill(skill_name)
|
|
||||||
await self._reload_skills()
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def list_skill_files(
|
|
||||||
self,
|
|
||||||
skill_name: str,
|
|
||||||
path: str = '.',
|
|
||||||
include_hidden: bool = False,
|
|
||||||
max_entries: int = 200,
|
|
||||||
) -> dict:
|
|
||||||
box_service = self._require_box('Browsing skill files')
|
|
||||||
return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries)
|
|
||||||
|
|
||||||
async def read_skill_file(self, skill_name: str, path: str) -> dict:
|
|
||||||
box_service = self._require_box('Reading a skill file')
|
|
||||||
return await box_service.read_skill_file(skill_name, path)
|
|
||||||
|
|
||||||
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
|
|
||||||
box_service = self._require_box('Editing skill files')
|
|
||||||
result = await box_service.write_skill_file(skill_name, path, content)
|
|
||||||
await self._reload_skills()
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def install_from_github(self, data: dict) -> list[dict]:
|
|
||||||
box_service = self._require_box('Installing a skill from GitHub')
|
|
||||||
owner = str(data['owner']).strip()
|
|
||||||
repo = str(data['repo']).strip()
|
|
||||||
release_tag = str(data.get('release_tag', '')).strip()
|
|
||||||
raw_asset_url = str(data['asset_url']).strip()
|
|
||||||
if self._is_github_skill_md_url(raw_asset_url):
|
|
||||||
return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data)
|
|
||||||
|
|
||||||
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
|
||||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
|
||||||
|
|
||||||
zip_bytes = await self._download_github_asset(asset_url)
|
|
||||||
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
|
||||||
installed = await box_service.install_skill_zip(
|
|
||||||
zip_bytes,
|
|
||||||
filename,
|
|
||||||
source_paths=data.get('source_paths') or [],
|
|
||||||
source_path=str(data.get('source_path', '') or ''),
|
|
||||||
source_subdir=source_subdir,
|
|
||||||
)
|
|
||||||
await self._reload_skills()
|
|
||||||
return [self._serialize_skill(skill) for skill in installed]
|
|
||||||
|
|
||||||
async def preview_install_from_github(self, data: dict) -> list[dict]:
|
|
||||||
box_service = self._require_box('Previewing a skill from GitHub')
|
|
||||||
owner = str(data['owner']).strip()
|
|
||||||
repo = str(data['repo']).strip()
|
|
||||||
release_tag = str(data.get('release_tag', '')).strip()
|
|
||||||
raw_asset_url = str(data['asset_url']).strip()
|
|
||||||
if self._is_github_skill_md_url(raw_asset_url):
|
|
||||||
return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo)
|
|
||||||
|
|
||||||
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
|
||||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
|
||||||
|
|
||||||
zip_bytes = await self._download_github_asset(asset_url)
|
|
||||||
return await box_service.preview_skill_zip(
|
|
||||||
zip_bytes,
|
|
||||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
|
||||||
source_subdir=source_subdir,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def install_from_zip_upload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
file_bytes: bytes,
|
|
||||||
filename: str,
|
|
||||||
source_paths: list[str] | None = None,
|
|
||||||
source_path: str = '',
|
|
||||||
) -> list[dict]:
|
|
||||||
box_service = self._require_box('Installing a skill from upload')
|
|
||||||
installed = await box_service.install_skill_zip(
|
|
||||||
file_bytes,
|
|
||||||
filename,
|
|
||||||
source_paths=source_paths or [],
|
|
||||||
source_path=source_path,
|
|
||||||
)
|
|
||||||
await self._reload_skills()
|
|
||||||
return [self._serialize_skill(skill) for skill in installed]
|
|
||||||
|
|
||||||
async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
|
|
||||||
box_service = self._require_box('Previewing a skill upload')
|
|
||||||
return await box_service.preview_skill_zip(file_bytes, filename)
|
|
||||||
|
|
||||||
async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
|
|
||||||
box_service = self._require_box('Installing a skill from GitHub')
|
|
||||||
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
|
||||||
asset_url,
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
)
|
|
||||||
|
|
||||||
installed = await box_service.install_skill_zip(
|
|
||||||
zip_bytes,
|
|
||||||
filename,
|
|
||||||
source_paths=data.get('source_paths') or [],
|
|
||||||
source_path=str(data.get('source_path', '') or ''),
|
|
||||||
target_suffix='',
|
|
||||||
)
|
|
||||||
await self._reload_skills()
|
|
||||||
return [self._serialize_skill(skill) for skill in installed]
|
|
||||||
|
|
||||||
async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
|
|
||||||
box_service = self._require_box('Previewing a skill from GitHub')
|
|
||||||
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
|
||||||
asset_url,
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
)
|
|
||||||
return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='')
|
|
||||||
|
|
||||||
async def reload_skills(self) -> list[dict]:
|
|
||||||
await self._reload_skills()
|
|
||||||
return await self.list_skills()
|
|
||||||
|
|
||||||
async def scan_directory_async(self, path: str) -> dict:
|
|
||||||
box_service = self._require_box('Scanning a skill directory')
|
|
||||||
return await box_service.scan_skill_directory(path)
|
|
||||||
|
|
||||||
async def _reload_skills(self) -> None:
|
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
|
||||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
|
||||||
if not callable(reload_skills):
|
|
||||||
return
|
|
||||||
result = reload_skills()
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
|
|
||||||
async def _download_github_asset(self, asset_url: str) -> bytes:
|
|
||||||
async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client:
|
|
||||||
resp = await client.get(asset_url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.content
|
|
||||||
|
|
||||||
async def _download_github_skill_directory_as_zip(
|
|
||||||
self, asset_url: str, *, owner: str, repo: str
|
|
||||||
) -> tuple[bytes, str, str]:
|
|
||||||
info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo)
|
|
||||||
archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}'
|
|
||||||
archive_bytes = await self._download_github_asset(archive_url)
|
|
||||||
|
|
||||||
try:
|
|
||||||
source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r')
|
|
||||||
except zipfile.BadZipFile as exc:
|
|
||||||
raise ValueError('GitHub repository archive must be a valid .zip archive') from exc
|
|
||||||
|
|
||||||
with source_archive as source_zip:
|
|
||||||
skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path'])
|
|
||||||
try:
|
|
||||||
skill_md_content = source_zip.read(skill_entry).decode('utf-8')
|
|
||||||
except UnicodeDecodeError as exc:
|
|
||||||
raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc
|
|
||||||
|
|
||||||
package_name = self._resolve_github_skill_md_package_name(skill_md_content, info['package_name'])
|
|
||||||
source_skill_dir = posixpath.dirname(posixpath.normpath(skill_entry.filename))
|
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip:
|
|
||||||
self._copy_github_skill_directory_to_zip(source_zip, target_zip, source_skill_dir, package_name)
|
|
||||||
return buffer.getvalue(), f'{package_name}.zip', package_name
|
|
||||||
|
|
||||||
def _find_github_skill_archive_entry(self, archive: zipfile.ZipFile, file_path: str) -> zipfile.ZipInfo:
|
|
||||||
normalized_file_path = posixpath.normpath(file_path).lower()
|
|
||||||
for member in archive.infolist():
|
|
||||||
if member.is_dir():
|
|
||||||
continue
|
|
||||||
normalized_member = posixpath.normpath(member.filename)
|
|
||||||
path_parts = normalized_member.split('/', 1)
|
|
||||||
if len(path_parts) != 2:
|
|
||||||
continue
|
|
||||||
archive_relative_path = path_parts[1].lower()
|
|
||||||
if archive_relative_path == normalized_file_path:
|
|
||||||
return member
|
|
||||||
raise ValueError(f'GitHub archive does not contain requested SKILL.md: {file_path}')
|
|
||||||
|
|
||||||
def _copy_github_skill_directory_to_zip(
|
|
||||||
self,
|
|
||||||
source_zip: zipfile.ZipFile,
|
|
||||||
target_zip: zipfile.ZipFile,
|
|
||||||
source_skill_dir: str,
|
|
||||||
package_name: str,
|
|
||||||
) -> None:
|
|
||||||
normalized_source_dir = posixpath.normpath(source_skill_dir)
|
|
||||||
source_prefix = f'{normalized_source_dir}/'
|
|
||||||
copied_files = 0
|
|
||||||
|
|
||||||
for member in source_zip.infolist():
|
|
||||||
normalized_member = posixpath.normpath(member.filename)
|
|
||||||
if normalized_member != normalized_source_dir and not normalized_member.startswith(source_prefix):
|
|
||||||
continue
|
|
||||||
|
|
||||||
relative_path = posixpath.relpath(normalized_member, normalized_source_dir)
|
|
||||||
if relative_path in ('', '.'):
|
|
||||||
continue
|
|
||||||
if relative_path.startswith('../') or relative_path == '..' or posixpath.isabs(relative_path):
|
|
||||||
raise ValueError(f'GitHub archive contains an unsafe skill path: {member.filename}')
|
|
||||||
|
|
||||||
target_name = f'{package_name}/{relative_path}'
|
|
||||||
if member.is_dir() and not target_name.endswith('/'):
|
|
||||||
target_name = f'{target_name}/'
|
|
||||||
target_info = zipfile.ZipInfo(target_name, date_time=member.date_time)
|
|
||||||
target_info.external_attr = member.external_attr
|
|
||||||
target_info.compress_type = zipfile.ZIP_DEFLATED
|
|
||||||
|
|
||||||
if member.is_dir():
|
|
||||||
target_zip.writestr(target_info, b'')
|
|
||||||
continue
|
|
||||||
|
|
||||||
target_zip.writestr(target_info, source_zip.read(member))
|
|
||||||
copied_files += 1
|
|
||||||
|
|
||||||
if copied_files == 0:
|
|
||||||
raise ValueError('GitHub skill directory is empty')
|
|
||||||
|
|
||||||
def _uploaded_skill_target_stem(self, filename: str) -> str:
|
|
||||||
stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0]
|
|
||||||
safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_')
|
|
||||||
if not safe_stem:
|
|
||||||
safe_stem = 'uploaded-skill'
|
|
||||||
return safe_stem
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_github_skill_md_url(asset_url: str) -> bool:
|
|
||||||
parsed = urlparse(str(asset_url or '').strip())
|
|
||||||
normalized_path = posixpath.normpath(parsed.path or '/')
|
|
||||||
return normalized_path.lower().endswith('/skill.md')
|
|
||||||
|
|
||||||
def _parse_github_skill_md_url(self, asset_url: str, *, owner: str, repo: str) -> dict:
|
|
||||||
parsed = urlparse(str(asset_url or '').strip())
|
|
||||||
if parsed.scheme != 'https' or not parsed.netloc:
|
|
||||||
raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL')
|
|
||||||
|
|
||||||
host = parsed.netloc.lower()
|
|
||||||
path_parts = [unquote(part) for part in (parsed.path or '').split('/') if part]
|
|
||||||
if host == 'github.com':
|
|
||||||
if (
|
|
||||||
len(path_parts) < 5
|
|
||||||
or path_parts[0] != owner
|
|
||||||
or path_parts[1] != repo
|
|
||||||
or path_parts[2]
|
|
||||||
not in (
|
|
||||||
'blob',
|
|
||||||
'raw',
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path')
|
|
||||||
ref = path_parts[3]
|
|
||||||
file_path = '/'.join(path_parts[4:])
|
|
||||||
elif host == 'raw.githubusercontent.com':
|
|
||||||
if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo:
|
|
||||||
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path')
|
|
||||||
ref = path_parts[2]
|
|
||||||
file_path = '/'.join(path_parts[3:])
|
|
||||||
else:
|
|
||||||
raise ValueError('asset_url must point to a GitHub SKILL.md file')
|
|
||||||
|
|
||||||
normalized_file_path = posixpath.normpath(file_path)
|
|
||||||
normalized_file_path_lower = normalized_file_path.lower()
|
|
||||||
if normalized_file_path_lower != 'skill.md' and not normalized_file_path_lower.endswith('/skill.md'):
|
|
||||||
raise ValueError('GitHub skill import requires a URL ending with SKILL.md')
|
|
||||||
|
|
||||||
parent_dir = posixpath.basename(posixpath.dirname(normalized_file_path)) or repo
|
|
||||||
return {
|
|
||||||
'ref': ref,
|
|
||||||
'file_path': normalized_file_path,
|
|
||||||
'package_name': self._uploaded_skill_target_stem(parent_dir),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _resolve_github_skill_md_package_name(self, content: str, fallback: str) -> str:
|
|
||||||
metadata, _instructions = parse_frontmatter(content)
|
|
||||||
candidate = str(metadata.get('name') or fallback or '').strip()
|
|
||||||
try:
|
|
||||||
return self._validate_skill_name(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return self._validate_skill_name(fallback)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str:
|
|
||||||
parsed = urlparse(str(asset_url).strip())
|
|
||||||
if parsed.scheme != 'https' or not parsed.netloc:
|
|
||||||
raise ValueError('asset_url must be a valid HTTPS GitHub asset URL')
|
|
||||||
|
|
||||||
host = parsed.netloc.lower()
|
|
||||||
if host not in _GITHUB_ASSET_HOSTS:
|
|
||||||
raise ValueError('asset_url must point to a GitHub-hosted release asset or archive')
|
|
||||||
|
|
||||||
normalized_path = posixpath.normpath(parsed.path or '/')
|
|
||||||
allowed_prefixes = [
|
|
||||||
f'/repos/{owner}/{repo}/',
|
|
||||||
f'/{owner}/{repo}/',
|
|
||||||
]
|
|
||||||
if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes):
|
|
||||||
raise ValueError('asset_url does not match the requested owner/repo')
|
|
||||||
|
|
||||||
if release_tag and release_tag not in parsed.path and release_tag not in parsed.query:
|
|
||||||
raise ValueError('asset_url does not match the requested release_tag')
|
|
||||||
|
|
||||||
return parsed.geturl()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_skill_name(name: str) -> str:
|
|
||||||
name = str(name or '').strip()
|
|
||||||
if not name:
|
|
||||||
raise ValueError('Skill name is required')
|
|
||||||
if not name.replace('-', '').replace('_', '').isalnum():
|
|
||||||
raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores')
|
|
||||||
if len(name) > 64:
|
|
||||||
raise ValueError('Skill name cannot exceed 64 characters')
|
|
||||||
return name
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""LangBot Box runtime package."""
|
|
||||||
|
|
||||||
from .workspace import BoxWorkspaceSession
|
|
||||||
|
|
||||||
__all__ = ['BoxWorkspaceSession']
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import typing
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from langbot_plugin.entities.io.actions.enums import CommonAction
|
|
||||||
from langbot_plugin.runtime.io.handler import Handler
|
|
||||||
from langbot_plugin.runtime.io.connection import Connection
|
|
||||||
|
|
||||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
|
||||||
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
|
|
||||||
from langbot_plugin.box.actions import LangBotToBoxAction
|
|
||||||
|
|
||||||
from ..utils import platform
|
|
||||||
from ..utils.managed_runtime import ManagedRuntimeConnector
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..core import app as core_app
|
|
||||||
|
|
||||||
|
|
||||||
# Default Docker Compose service name for the standalone Box container.
|
|
||||||
_DOCKER_BOX_HOST = 'langbot_box'
|
|
||||||
_DEFAULT_PORT = 5410
|
|
||||||
|
|
||||||
_HEARTBEAT_INTERVAL_SEC = 20
|
|
||||||
|
|
||||||
# Top-level keys under ``box`` that are LangBot-internal and should not be
|
|
||||||
# forwarded to the Box runtime.
|
|
||||||
_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_box_config(ap) -> dict:
|
|
||||||
"""Return the 'box' section from instance config.
|
|
||||||
|
|
||||||
Environment-variable overrides are handled uniformly by
|
|
||||||
``LoadConfigStage._apply_env_overrides_to_config`` using the
|
|
||||||
``SECTION__SUBSECTION__KEY`` convention (e.g. ``BOX__LOCAL__HOST_ROOT``,
|
|
||||||
``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"``) before this is read, so no
|
|
||||||
box-specific env parsing is needed here.
|
|
||||||
"""
|
|
||||||
instance_config = getattr(ap, 'instance_config', None)
|
|
||||||
config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {}
|
|
||||||
return dict(config_data.get('box', {}) or {})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_runtime_endpoint(box_cfg: dict) -> str:
|
|
||||||
runtime_cfg = box_cfg.get('runtime') or {}
|
|
||||||
return str(runtime_cfg.get('endpoint', '')).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_config_for_runtime(box_cfg: dict) -> dict:
|
|
||||||
return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
|
|
||||||
"""Derive the WS relay base URL used for managed-process attach.
|
|
||||||
|
|
||||||
The WS relay serves the ``/v1/sessions/{id}/managed-process/ws`` endpoint
|
|
||||||
on the *relay* port (default 5410).
|
|
||||||
"""
|
|
||||||
box_cfg = _get_box_config(ap)
|
|
||||||
|
|
||||||
# Explicit runtime endpoint takes precedence. The config value is a base
|
|
||||||
# URL; endpoint-specific paths are appended by the SDK client.
|
|
||||||
endpoint = _get_runtime_endpoint(box_cfg)
|
|
||||||
if endpoint:
|
|
||||||
parsed = urlparse(endpoint)
|
|
||||||
scheme = parsed.scheme or 'ws'
|
|
||||||
if scheme == 'ws':
|
|
||||||
scheme = 'http'
|
|
||||||
elif scheme == 'wss':
|
|
||||||
scheme = 'https'
|
|
||||||
host = parsed.hostname or '127.0.0.1'
|
|
||||||
port = parsed.port or _DEFAULT_PORT
|
|
||||||
return f'{scheme}://{host}:{port}'
|
|
||||||
|
|
||||||
# In Docker, relay lives on the box runtime container.
|
|
||||||
if platform.get_platform() == 'docker':
|
|
||||||
return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}'
|
|
||||||
|
|
||||||
return f'http://127.0.0.1:{_DEFAULT_PORT}'
|
|
||||||
|
|
||||||
|
|
||||||
class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|
||||||
"""Connect to the Box runtime via action RPC.
|
|
||||||
|
|
||||||
Transport decision (mirrors Plugin runtime logic):
|
|
||||||
1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process
|
|
||||||
2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe)
|
|
||||||
3. Unix / macOS -> subprocess + stdio pipe
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: core_app.Application,
|
|
||||||
runtime_disconnect_callback: typing.Callable[
|
|
||||||
['BoxRuntimeConnector'], typing.Coroutine[typing.Any, typing.Any, None]
|
|
||||||
]
|
|
||||||
| None = None,
|
|
||||||
):
|
|
||||||
super().__init__(ap)
|
|
||||||
self.runtime_disconnect_callback = runtime_disconnect_callback
|
|
||||||
self.configured_runtime_endpoint = self._load_configured_runtime_endpoint()
|
|
||||||
self.ws_relay_base_url = resolve_box_ws_relay_url(ap)
|
|
||||||
self.client = ActionRPCBoxClient(logger=ap.logger)
|
|
||||||
|
|
||||||
self._handler: Handler | None = None
|
|
||||||
self._handler_task: asyncio.Task | None = None
|
|
||||||
self._ctrl_task: asyncio.Task | None = None
|
|
||||||
self._heartbeat_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
# Parse the relay URL once for reuse.
|
|
||||||
parsed = urlparse(self.ws_relay_base_url)
|
|
||||||
self._relay_host = parsed.hostname or '127.0.0.1'
|
|
||||||
self._relay_port = parsed.port or _DEFAULT_PORT
|
|
||||||
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
|
|
||||||
|
|
||||||
def _uses_websocket(self) -> bool:
|
|
||||||
"""Whether the connector should use WebSocket to reach the Box runtime.
|
|
||||||
|
|
||||||
True when:
|
|
||||||
- Running inside Docker (Box runtime is a separate container)
|
|
||||||
- The ``--standalone-box`` CLI flag was passed
|
|
||||||
- An explicit ``runtime.endpoint`` was configured
|
|
||||||
"""
|
|
||||||
return bool(
|
|
||||||
self.configured_runtime_endpoint
|
|
||||||
or platform.get_platform() == 'docker'
|
|
||||||
or platform.use_websocket_to_connect_box_runtime()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
if self._uses_websocket():
|
|
||||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
|
||||||
await self._start_subprocess_then_ws()
|
|
||||||
else:
|
|
||||||
await self._connect_remote_ws()
|
|
||||||
else:
|
|
||||||
await self._start_local_stdio()
|
|
||||||
|
|
||||||
# Start heartbeat after successful connection
|
|
||||||
if self._heartbeat_task is None:
|
|
||||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
||||||
|
|
||||||
# -- heartbeat -----------------------------------------------------------
|
|
||||||
|
|
||||||
async def _heartbeat_loop(self) -> None:
|
|
||||||
"""Periodically ping the Box runtime to detect silent disconnections."""
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
|
||||||
try:
|
|
||||||
await self.ping()
|
|
||||||
self.ap.logger.debug('Heartbeat to Box runtime success.')
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}')
|
|
||||||
|
|
||||||
async def ping(self) -> None:
|
|
||||||
if self._handler is None:
|
|
||||||
raise BoxRuntimeUnavailableError('Box runtime is not connected')
|
|
||||||
await self._handler.call_action(CommonAction.PING, {})
|
|
||||||
|
|
||||||
# -- transport paths -----------------------------------------------------
|
|
||||||
|
|
||||||
async def _start_local_stdio(self) -> None:
|
|
||||||
"""Launch box server as subprocess and connect via stdio (Unix/macOS)."""
|
|
||||||
from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController
|
|
||||||
|
|
||||||
self.ap.logger.info('Use stdio to connect to box runtime')
|
|
||||||
python_path = sys.executable
|
|
||||||
env = os.environ.copy()
|
|
||||||
if self._filtered_box_config:
|
|
||||||
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
|
||||||
|
|
||||||
connected = asyncio.Event()
|
|
||||||
connect_error: list[Exception] = []
|
|
||||||
|
|
||||||
ctrl = StdioClientController(
|
|
||||||
command=python_path,
|
|
||||||
# Launched through the same CLI entry point as the plugin runtime
|
|
||||||
# (cli.__init__ <subcommand>); `-s` selects the stdio transport,
|
|
||||||
# mirroring `rt -s`.
|
|
||||||
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
self._ctrl_task = asyncio.create_task(
|
|
||||||
ctrl.run(self._make_connection_callback('stdio', connected, connect_error))
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(connected.wait(), timeout=30.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise BoxRuntimeUnavailableError('box runtime subprocess did not connect in time')
|
|
||||||
|
|
||||||
if connect_error:
|
|
||||||
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
|
|
||||||
|
|
||||||
self._subprocess = ctrl.process
|
|
||||||
|
|
||||||
async def _start_subprocess_then_ws(self) -> None:
|
|
||||||
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
|
|
||||||
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
if self._filtered_box_config:
|
|
||||||
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
|
||||||
|
|
||||||
python_path = sys.executable
|
|
||||||
# Launched through the same CLI entry point as the plugin runtime
|
|
||||||
# (cli.__init__ <subcommand>); no flag => WebSocket transport.
|
|
||||||
self.runtime_subprocess = await asyncio.create_subprocess_exec(
|
|
||||||
python_path,
|
|
||||||
'-m',
|
|
||||||
'langbot_plugin.cli.__init__',
|
|
||||||
'box',
|
|
||||||
'--ws-control-port',
|
|
||||||
str(self._relay_port),
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait())
|
|
||||||
|
|
||||||
ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
|
|
||||||
await self._connect_ws(ws_url, '(windows) WebSocket')
|
|
||||||
|
|
||||||
async def _connect_remote_ws(self) -> None:
|
|
||||||
"""Connect to a remote (or Docker) box server via WebSocket."""
|
|
||||||
ws_url = self._resolve_rpc_ws_url()
|
|
||||||
self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})')
|
|
||||||
await self._connect_ws(ws_url, 'WebSocket')
|
|
||||||
|
|
||||||
# -- helpers -------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_rpc_ws_url(self) -> str:
|
|
||||||
"""Determine the action-RPC WebSocket URL.
|
|
||||||
|
|
||||||
All endpoints share a single port; action RPC is at ``/rpc/ws``.
|
|
||||||
"""
|
|
||||||
if self.configured_runtime_endpoint:
|
|
||||||
base = self.configured_runtime_endpoint.rstrip('/')
|
|
||||||
parsed = urlparse(base)
|
|
||||||
scheme = parsed.scheme or 'ws'
|
|
||||||
if scheme in ('http', 'https'):
|
|
||||||
scheme = 'wss' if scheme == 'https' else 'ws'
|
|
||||||
host = parsed.hostname or '127.0.0.1'
|
|
||||||
port = parsed.port or _DEFAULT_PORT
|
|
||||||
return f'{scheme}://{host}:{port}/rpc/ws'
|
|
||||||
|
|
||||||
if platform.get_platform() == 'docker':
|
|
||||||
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws'
|
|
||||||
|
|
||||||
return f'ws://localhost:{self._relay_port}/rpc/ws'
|
|
||||||
|
|
||||||
async def _connect_ws(self, ws_url: str, transport_name: str) -> None:
|
|
||||||
"""Shared WebSocket connection procedure."""
|
|
||||||
from langbot_plugin.runtime.io.controllers.ws.client import WebSocketClientController
|
|
||||||
|
|
||||||
connected = asyncio.Event()
|
|
||||||
connect_error: list[Exception] = []
|
|
||||||
|
|
||||||
async def on_connect_failed(ctrl, exc):
|
|
||||||
if exc is not None:
|
|
||||||
self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}): {exc}')
|
|
||||||
else:
|
|
||||||
self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}), trying to reconnect...')
|
|
||||||
connect_error.append(exc or BoxRuntimeUnavailableError('ws connection failed'))
|
|
||||||
connected.set()
|
|
||||||
if self.runtime_disconnect_callback is not None:
|
|
||||||
await self.runtime_disconnect_callback(self)
|
|
||||||
|
|
||||||
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
|
||||||
self._ctrl_task = asyncio.create_task(
|
|
||||||
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error))
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(connected.wait(), timeout=30.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise BoxRuntimeUnavailableError(f'box runtime ws connection timed out ({ws_url})')
|
|
||||||
|
|
||||||
if connect_error:
|
|
||||||
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
|
|
||||||
|
|
||||||
def _make_connection_callback(
|
|
||||||
self,
|
|
||||||
transport_name: str,
|
|
||||||
connected: asyncio.Event,
|
|
||||||
connect_error: list[Exception],
|
|
||||||
):
|
|
||||||
async def new_connection_callback(connection: Connection) -> None:
|
|
||||||
handler = Handler(connection)
|
|
||||||
self._handler = handler
|
|
||||||
self.client.set_handler(handler)
|
|
||||||
self._handler_task = asyncio.create_task(handler.run())
|
|
||||||
try:
|
|
||||||
await handler.call_action(CommonAction.PING, {})
|
|
||||||
if self._filtered_box_config:
|
|
||||||
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
|
|
||||||
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
|
|
||||||
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
|
|
||||||
connected.set()
|
|
||||||
await self._handler_task
|
|
||||||
except Exception as exc:
|
|
||||||
if not connected.is_set():
|
|
||||||
connect_error.append(exc)
|
|
||||||
connected.set()
|
|
||||||
return
|
|
||||||
|
|
||||||
# If we reach here, handler.run() returned normally (connection
|
|
||||||
# closed) or raised after the initial handshake succeeded.
|
|
||||||
# Either way, treat it as a disconnect.
|
|
||||||
if connected.is_set():
|
|
||||||
if self._uses_websocket():
|
|
||||||
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
|
|
||||||
if self.runtime_disconnect_callback is not None:
|
|
||||||
await self.runtime_disconnect_callback(self)
|
|
||||||
else:
|
|
||||||
self.ap.logger.error(
|
|
||||||
'Disconnected from Box runtime via stdio. '
|
|
||||||
'Cannot automatically reconnect — please restart LangBot.'
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_connection_callback
|
|
||||||
|
|
||||||
# -- lifecycle -----------------------------------------------------------
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
if self._heartbeat_task is not None:
|
|
||||||
self._heartbeat_task.cancel()
|
|
||||||
self._heartbeat_task = None
|
|
||||||
|
|
||||||
if self._handler_task is not None:
|
|
||||||
self._handler_task.cancel()
|
|
||||||
self._handler_task = None
|
|
||||||
|
|
||||||
if self._ctrl_task is not None:
|
|
||||||
self._ctrl_task.cancel()
|
|
||||||
self._ctrl_task = None
|
|
||||||
|
|
||||||
# stdio-managed subprocess (stored as self._subprocess by _start_local_stdio)
|
|
||||||
if hasattr(self, '_subprocess') and self._subprocess is not None and self._subprocess.returncode is None:
|
|
||||||
self.ap.logger.info('Terminating managed box runtime process...')
|
|
||||||
self._subprocess.terminate()
|
|
||||||
|
|
||||||
# Subprocess launched by ManagedRuntimeConnector._start_runtime_subprocess (Windows path)
|
|
||||||
self._dispose_subprocess()
|
|
||||||
|
|
||||||
# -- config helpers ------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_configured_runtime_endpoint(self) -> str:
|
|
||||||
return _get_runtime_endpoint(_get_box_config(self.ap))
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""Three-layer security policy for LangBot Box.
|
|
||||||
|
|
||||||
The design separates concerns into three independent layers, aligned with
|
|
||||||
OpenCode / OpenClaw patterns:
|
|
||||||
|
|
||||||
1. **SandboxPolicy** – *where* tools run (host vs sandbox).
|
|
||||||
2. **ToolPolicy** – *which* tools are allowed (allow/deny lists).
|
|
||||||
3. **ElevatedPolicy** – *whether* a single exec call may temporarily
|
|
||||||
escape the default sandbox boundary.
|
|
||||||
|
|
||||||
These three layers are orthogonal:
|
|
||||||
- ToolPolicy is a hard boundary; ``elevated`` cannot bypass a denied tool.
|
|
||||||
- SandboxPolicy decides the default execution location.
|
|
||||||
- ElevatedPolicy only affects ``exec`` and only when the framework allows it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
|
|
||||||
# ── Layer 1: Sandbox Policy ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxMode(str, enum.Enum):
|
|
||||||
"""Determines when agent execution is routed through the sandbox."""
|
|
||||||
|
|
||||||
OFF = 'off'
|
|
||||||
"""Sandbox disabled; all exec runs on the host."""
|
|
||||||
|
|
||||||
NON_DEFAULT = 'non_default'
|
|
||||||
"""Only non-default sessions are sandboxed (e.g. sub-agents, MCP)."""
|
|
||||||
|
|
||||||
ALL = 'all'
|
|
||||||
"""Every agent exec call is routed through the sandbox."""
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxPolicy:
|
|
||||||
"""Decides whether a given execution context should use the sandbox."""
|
|
||||||
|
|
||||||
def __init__(self, mode: SandboxMode = SandboxMode.ALL):
|
|
||||||
self.mode = mode
|
|
||||||
|
|
||||||
def should_sandbox(self, *, is_default_session: bool = True) -> bool:
|
|
||||||
if self.mode == SandboxMode.OFF:
|
|
||||||
return False
|
|
||||||
if self.mode == SandboxMode.ALL:
|
|
||||||
return True
|
|
||||||
# NON_DEFAULT: sandbox everything except the default session
|
|
||||||
return not is_default_session
|
|
||||||
|
|
||||||
|
|
||||||
# ── Layer 2: Tool Policy ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ToolPolicy:
|
|
||||||
"""Controls which tools are available to the current agent/session.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- ``deny`` always takes precedence over ``allow``.
|
|
||||||
- An empty ``allow`` list means "all tools allowed" (no allowlist filter).
|
|
||||||
- ``elevated`` cannot bypass a denied tool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
allow: Sequence[str] = (),
|
|
||||||
deny: Sequence[str] = (),
|
|
||||||
):
|
|
||||||
self._allow: frozenset[str] = frozenset(allow)
|
|
||||||
self._deny: frozenset[str] = frozenset(deny)
|
|
||||||
|
|
||||||
def is_tool_allowed(self, tool_name: str) -> bool:
|
|
||||||
if tool_name in self._deny:
|
|
||||||
return False
|
|
||||||
if self._allow and tool_name not in self._allow:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ── Layer 3: Elevated Policy ─────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ElevatedPolicy:
|
|
||||||
"""Controls whether ``exec`` may request temporary privilege escalation.
|
|
||||||
|
|
||||||
``elevated`` only applies to the ``exec`` tool. It means "run this
|
|
||||||
command outside the default sandbox boundary" (e.g. with network, or
|
|
||||||
on the host). The framework decides whether to honor the request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, allow_elevated: bool = False, require_approval: bool = True):
|
|
||||||
self.allow_elevated = allow_elevated
|
|
||||||
self.require_approval = require_approval
|
|
||||||
|
|
||||||
def is_elevation_permitted(self) -> bool:
|
|
||||||
return self.allow_elevated
|
|
||||||
@@ -1,797 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
import datetime as _dt
|
|
||||||
import enum
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
from langbot_plugin.box.client import BoxRuntimeClient
|
|
||||||
from .connector import BoxRuntimeConnector, _get_box_config
|
|
||||||
from langbot_plugin.box.errors import BoxError, BoxValidationError
|
|
||||||
from langbot_plugin.box.models import (
|
|
||||||
BUILTIN_PROFILES,
|
|
||||||
BoxExecutionResult,
|
|
||||||
BoxManagedProcessInfo,
|
|
||||||
BoxManagedProcessSpec,
|
|
||||||
BoxProfile,
|
|
||||||
BoxSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
_INT_ADAPTER = pydantic.TypeAdapter(int)
|
|
||||||
_UTC = _dt.timezone.utc
|
|
||||||
_MAX_RECENT_ERRORS = 50
|
|
||||||
_MIB = 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _is_path_under(path: str, root: str) -> bool:
|
|
||||||
"""Check whether *path* equals *root* or is a child of *root*."""
|
|
||||||
return path == root or path.startswith(f'{root}{os.sep}')
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..core import app as core_app
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
|
|
||||||
|
|
||||||
class BoxService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: core_app.Application,
|
|
||||||
client: BoxRuntimeClient | None = None,
|
|
||||||
output_limit_chars: int = 4000,
|
|
||||||
):
|
|
||||||
self.ap = ap
|
|
||||||
self._enabled = self._load_enabled()
|
|
||||||
self._runtime_connector: BoxRuntimeConnector | None = None
|
|
||||||
if client is None:
|
|
||||||
# Always construct a connector — its __init__ is side-effect free
|
|
||||||
# (no I/O, no subprocess). When ``box.enabled = false`` we simply
|
|
||||||
# skip ``connector.initialize()`` so no connection is attempted.
|
|
||||||
self._runtime_connector = BoxRuntimeConnector(ap, runtime_disconnect_callback=self._on_runtime_disconnect)
|
|
||||||
client = self._runtime_connector.client
|
|
||||||
self.client = client
|
|
||||||
self.output_limit_chars = output_limit_chars
|
|
||||||
self.host_root = self._load_host_root()
|
|
||||||
self.allowed_mount_roots = self._load_allowed_mount_roots()
|
|
||||||
self.default_workspace = self._load_default_workspace()
|
|
||||||
self.profile = self._load_profile()
|
|
||||||
self.custom_image = self._load_custom_image()
|
|
||||||
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
|
||||||
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
|
||||||
self._shutdown_task = None
|
|
||||||
self._available = False
|
|
||||||
self._connector_error: str = ''
|
|
||||||
self._reconnecting = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
"""Whether Box is enabled in config. False means the operator has
|
|
||||||
deliberately turned the sandbox off via ``box.enabled = false``.
|
|
||||||
Disabled and "enabled but unavailable" are reported as the same
|
|
||||||
``available = False`` to consumers, but distinguished in get_status."""
|
|
||||||
return self._enabled
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
self._ensure_default_workspace()
|
|
||||||
if not self._enabled:
|
|
||||||
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
|
||||||
# fork a stdio subprocess. Every consumer of box_service should
|
|
||||||
# gate on ``available`` and degrade gracefully.
|
|
||||||
self._available = False
|
|
||||||
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
|
||||||
self.ap.logger.info(
|
|
||||||
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
|
||||||
'skill add/edit, stdio MCP) will be unavailable.'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if self._runtime_connector is not None:
|
|
||||||
await self._runtime_connector.initialize()
|
|
||||||
else:
|
|
||||||
await self.client.initialize()
|
|
||||||
self._available = True
|
|
||||||
self._connector_error = ''
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'LangBot Box runtime initialized: profile={self.profile.name} '
|
|
||||||
f'default_workspace={self.default_workspace or "(none)"}'
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
|
||||||
self._available = False
|
|
||||||
self._connector_error = str(exc)
|
|
||||||
|
|
||||||
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
|
|
||||||
"""Called by the connector when the Box runtime connection drops.
|
|
||||||
|
|
||||||
Spawns a background reconnection loop so the caller is not blocked.
|
|
||||||
Skipped entirely when Box is disabled by config — that path should
|
|
||||||
never have connected in the first place.
|
|
||||||
"""
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
if self._reconnecting:
|
|
||||||
return # Another reconnect loop is already running
|
|
||||||
self._reconnecting = True
|
|
||||||
self._available = False
|
|
||||||
self._connector_error = 'Disconnected from Box runtime'
|
|
||||||
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
|
|
||||||
asyncio.create_task(self._reconnect_loop(connector))
|
|
||||||
|
|
||||||
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
|
|
||||||
"""Retry reconnection with exponential backoff (3s → 60s max)."""
|
|
||||||
delay = 3
|
|
||||||
max_delay = 60
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
try:
|
|
||||||
connector.dispose()
|
|
||||||
await connector.initialize()
|
|
||||||
self._available = True
|
|
||||||
self._connector_error = ''
|
|
||||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
self._connector_error = str(exc)
|
|
||||||
self.ap.logger.warning(f'Box runtime reconnection failed: {exc}')
|
|
||||||
delay = min(delay * 2, max_delay)
|
|
||||||
finally:
|
|
||||||
self._reconnecting = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
async def execute_spec_payload(
|
|
||||||
self,
|
|
||||||
spec_payload: dict,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
*,
|
|
||||||
skip_host_mount_validation: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
if not self._available:
|
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
|
||||||
try:
|
|
||||||
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
|
||||||
except BoxError as exc:
|
|
||||||
self._record_error(exc, query)
|
|
||||||
raise
|
|
||||||
self.ap.logger.info(
|
|
||||||
'LangBot Box request: '
|
|
||||||
f'query_id={query.query_id} '
|
|
||||||
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self._enforce_workspace_quota(spec, phase='before execution')
|
|
||||||
except BoxError as exc:
|
|
||||||
self._record_error(exc, query)
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
result = await self.client.execute(spec)
|
|
||||||
except BoxError as exc:
|
|
||||||
self._record_error(exc, query)
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
await self._enforce_workspace_quota(spec, phase='after execution')
|
|
||||||
except BoxError as exc:
|
|
||||||
await self._cleanup_exceeded_session(spec)
|
|
||||||
self._record_error(exc, query)
|
|
||||||
raise
|
|
||||||
self.ap.logger.info(
|
|
||||||
'LangBot Box result: '
|
|
||||||
f'query_id={query.query_id} '
|
|
||||||
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
|
|
||||||
)
|
|
||||||
return self._serialize_result(result)
|
|
||||||
|
|
||||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
|
||||||
"""Resolve the Box session_id from the pipeline's template and query variables."""
|
|
||||||
template = (
|
|
||||||
(query.pipeline_config or {})
|
|
||||||
.get('ai', {})
|
|
||||||
.get('local-agent', {})
|
|
||||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
|
||||||
)
|
|
||||||
variables = dict(query.variables or {})
|
|
||||||
launcher_type = getattr(query, 'launcher_type', None)
|
|
||||||
if hasattr(launcher_type, 'value'):
|
|
||||||
launcher_type = launcher_type.value
|
|
||||||
launcher_id = getattr(query, 'launcher_id', None)
|
|
||||||
sender_id = getattr(query, 'sender_id', None)
|
|
||||||
query_id = getattr(query, 'query_id', None)
|
|
||||||
|
|
||||||
variables.setdefault('query_id', str(query_id or 'unknown'))
|
|
||||||
variables.setdefault('launcher_type', str(launcher_type or 'query'))
|
|
||||||
variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown'))
|
|
||||||
variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown'))
|
|
||||||
variables.setdefault('global', 'global')
|
|
||||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
|
||||||
|
|
||||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
|
||||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
|
||||||
|
|
||||||
This ensures that when a container is first created it already has
|
|
||||||
all skill packages mounted, regardless of which skill is currently
|
|
||||||
activated.
|
|
||||||
|
|
||||||
Skills whose ``package_root`` is missing or no longer a directory on
|
|
||||||
the LangBot-visible filesystem are skipped with a warning instead of
|
|
||||||
being passed through to the backend. Without this guard the three
|
|
||||||
backends behave inconsistently on a stale mount: nsjail refuses to
|
|
||||||
start the sandbox (failing every exec in the session), Docker
|
|
||||||
silently auto-creates a root-owned empty directory on the host, and
|
|
||||||
E2B silently skips the upload — none of which surfaces an
|
|
||||||
actionable error to the agent or operator.
|
|
||||||
"""
|
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
|
||||||
if skill_mgr is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
from ..provider.tools.loaders import skill as skill_loader
|
|
||||||
|
|
||||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
|
||||||
mounts: list[dict] = []
|
|
||||||
for skill_name, skill_data in visible_skills.items():
|
|
||||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
|
||||||
if not package_root:
|
|
||||||
continue
|
|
||||||
if not os.path.isdir(package_root):
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
|
||||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
|
||||||
f'The skill cache may be stale — consider reloading skills.'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
mounts.append(
|
|
||||||
{
|
|
||||||
'host_path': package_root,
|
|
||||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
|
||||||
'mode': 'rw',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return mounts
|
|
||||||
|
|
||||||
async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
|
||||||
"""Execute an agent-facing ``exec`` tool call.
|
|
||||||
|
|
||||||
Translates the agent-facing ``command`` field to the internal
|
|
||||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
|
||||||
"""
|
|
||||||
spec_payload: dict = {'cmd': parameters['command']}
|
|
||||||
|
|
||||||
# Pass through allowed agent-facing fields
|
|
||||||
for key in ('workdir', 'timeout_sec', 'env'):
|
|
||||||
if key in parameters:
|
|
||||||
spec_payload[key] = parameters[key]
|
|
||||||
|
|
||||||
# Inject context the agent must not control
|
|
||||||
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
|
||||||
|
|
||||||
# Mount all pipeline-bound skills so they are available in the container
|
|
||||||
if 'extra_mounts' not in spec_payload:
|
|
||||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
|
||||||
|
|
||||||
return await self.execute_spec_payload(spec_payload, query)
|
|
||||||
|
|
||||||
async def shutdown(self):
|
|
||||||
await self.client.shutdown()
|
|
||||||
|
|
||||||
def dispose(self):
|
|
||||||
if self._runtime_connector is not None:
|
|
||||||
self._runtime_connector.dispose()
|
|
||||||
loop = getattr(self.ap, 'event_loop', None)
|
|
||||||
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
|
|
||||||
self._shutdown_task = loop.create_task(self.shutdown())
|
|
||||||
|
|
||||||
async def get_sessions(self) -> list[dict]:
|
|
||||||
if not self._available:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return await self.client.get_sessions()
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec:
|
|
||||||
spec_payload = dict(spec_payload)
|
|
||||||
spec_payload.setdefault('env', {})
|
|
||||||
if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None:
|
|
||||||
spec_payload['host_path'] = self.default_workspace
|
|
||||||
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None:
|
|
||||||
spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
|
|
||||||
|
|
||||||
# Global custom image overrides profile default (but not caller-specified image)
|
|
||||||
if self.custom_image and 'image' not in spec_payload:
|
|
||||||
spec_payload['image'] = self.custom_image
|
|
||||||
|
|
||||||
self._apply_profile(spec_payload)
|
|
||||||
|
|
||||||
try:
|
|
||||||
spec = BoxSpec.model_validate(spec_payload)
|
|
||||||
except pydantic.ValidationError as exc:
|
|
||||||
first_error = exc.errors()[0]
|
|
||||||
raise BoxValidationError(first_error.get('msg', 'invalid box arguments')) from exc
|
|
||||||
|
|
||||||
if not skip_host_mount_validation:
|
|
||||||
self._validate_host_mount(spec)
|
|
||||||
return spec
|
|
||||||
|
|
||||||
async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
|
|
||||||
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
|
||||||
return await self.client.create_session(spec)
|
|
||||||
|
|
||||||
async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
|
|
||||||
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
|
|
||||||
return await self.client.start_managed_process(session_id, process_spec)
|
|
||||||
|
|
||||||
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
|
|
||||||
return await self.client.get_managed_process(session_id, process_id)
|
|
||||||
|
|
||||||
async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None:
|
|
||||||
return await self.client.stop_managed_process(session_id, process_id)
|
|
||||||
|
|
||||||
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
|
|
||||||
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
|
|
||||||
if getter is None:
|
|
||||||
raise BoxValidationError('box runtime client does not support managed process websocket attach')
|
|
||||||
ws_relay_base_url = (
|
|
||||||
self._runtime_connector.ws_relay_base_url
|
|
||||||
if self._runtime_connector is not None
|
|
||||||
else 'http://127.0.0.1:5410'
|
|
||||||
)
|
|
||||||
return getter(session_id, ws_relay_base_url, process_id)
|
|
||||||
|
|
||||||
async def list_skills(self) -> list[dict]:
|
|
||||||
return await self.client.list_skills()
|
|
||||||
|
|
||||||
async def get_skill(self, name: str) -> dict | None:
|
|
||||||
return await self.client.get_skill(name)
|
|
||||||
|
|
||||||
async def create_skill(self, skill: dict) -> dict:
|
|
||||||
return await self.client.create_skill(skill)
|
|
||||||
|
|
||||||
async def update_skill(self, name: str, skill: dict) -> dict:
|
|
||||||
return await self.client.update_skill(name, skill)
|
|
||||||
|
|
||||||
async def delete_skill(self, name: str) -> None:
|
|
||||||
await self.client.delete_skill(name)
|
|
||||||
|
|
||||||
async def scan_skill_directory(self, path: str) -> dict:
|
|
||||||
return await self.client.scan_skill_directory(path)
|
|
||||||
|
|
||||||
async def list_skill_files(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
path: str = '.',
|
|
||||||
include_hidden: bool = False,
|
|
||||||
max_entries: int = 200,
|
|
||||||
) -> dict:
|
|
||||||
return await self.client.list_skill_files(name, path, include_hidden, max_entries)
|
|
||||||
|
|
||||||
async def read_skill_file(self, name: str, path: str) -> dict:
|
|
||||||
return await self.client.read_skill_file(name, path)
|
|
||||||
|
|
||||||
async def write_skill_file(self, name: str, path: str, content: str) -> dict:
|
|
||||||
return await self.client.write_skill_file(name, path, content)
|
|
||||||
|
|
||||||
async def preview_skill_zip(
|
|
||||||
self,
|
|
||||||
file_bytes: bytes,
|
|
||||||
filename: str,
|
|
||||||
source_subdir: str = '',
|
|
||||||
target_suffix: str = 'upload',
|
|
||||||
) -> list[dict]:
|
|
||||||
return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix)
|
|
||||||
|
|
||||||
async def install_skill_zip(
|
|
||||||
self,
|
|
||||||
file_bytes: bytes,
|
|
||||||
filename: str,
|
|
||||||
source_paths: list[str] | None = None,
|
|
||||||
source_path: str = '',
|
|
||||||
source_subdir: str = '',
|
|
||||||
target_suffix: str = 'upload',
|
|
||||||
) -> list[dict]:
|
|
||||||
return await self.client.install_skill_zip(
|
|
||||||
file_bytes,
|
|
||||||
filename,
|
|
||||||
source_paths,
|
|
||||||
source_path,
|
|
||||||
source_subdir,
|
|
||||||
target_suffix,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
|
||||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
|
||||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'session_id': result.session_id,
|
|
||||||
'backend': result.backend_name,
|
|
||||||
'status': result.status.value,
|
|
||||||
'ok': result.ok,
|
|
||||||
'exit_code': result.exit_code,
|
|
||||||
'stdout': stdout,
|
|
||||||
'stderr': stderr,
|
|
||||||
'stdout_truncated': stdout_truncated,
|
|
||||||
'stderr_truncated': stderr_truncated,
|
|
||||||
'duration_ms': result.duration_ms,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _truncate(self, text: str) -> tuple[str, bool]:
|
|
||||||
if len(text) <= self.output_limit_chars:
|
|
||||||
return text, False
|
|
||||||
if self.output_limit_chars <= 0:
|
|
||||||
return '', True
|
|
||||||
|
|
||||||
head_size = 0
|
|
||||||
tail_size = 0
|
|
||||||
notice = ''
|
|
||||||
# Recompute once the omitted count is known so the final payload
|
|
||||||
# stays within output_limit_chars even after adding the notice.
|
|
||||||
for _ in range(4):
|
|
||||||
omitted = max(len(text) - head_size - tail_size, 0)
|
|
||||||
notice = f'\n\n... [{omitted} characters truncated] ...\n\n'
|
|
||||||
available = self.output_limit_chars - len(notice)
|
|
||||||
if available <= 0:
|
|
||||||
return notice[: self.output_limit_chars], True
|
|
||||||
|
|
||||||
new_head_size = int(available * 0.6)
|
|
||||||
new_tail_size = available - new_head_size
|
|
||||||
if new_head_size == head_size and new_tail_size == tail_size:
|
|
||||||
break
|
|
||||||
head_size = new_head_size
|
|
||||||
tail_size = new_tail_size
|
|
||||||
|
|
||||||
head = text[:head_size]
|
|
||||||
tail = text[-tail_size:] if tail_size else ''
|
|
||||||
truncated = f'{head}{notice}{tail}'
|
|
||||||
return truncated[: self.output_limit_chars], True
|
|
||||||
|
|
||||||
def _summarize_spec(self, spec: BoxSpec) -> dict:
|
|
||||||
cmd = spec.cmd.strip()
|
|
||||||
if len(cmd) > 400:
|
|
||||||
cmd = f'{cmd[:397]}...'
|
|
||||||
|
|
||||||
return {
|
|
||||||
'session_id': spec.session_id,
|
|
||||||
'workdir': spec.workdir,
|
|
||||||
'mount_path': spec.mount_path,
|
|
||||||
'timeout_sec': spec.timeout_sec,
|
|
||||||
'network': spec.network.value,
|
|
||||||
'image': spec.image,
|
|
||||||
'host_path': spec.host_path,
|
|
||||||
'host_path_mode': spec.host_path_mode.value,
|
|
||||||
'cpus': spec.cpus,
|
|
||||||
'memory_mb': spec.memory_mb,
|
|
||||||
'pids_limit': spec.pids_limit,
|
|
||||||
'read_only_rootfs': spec.read_only_rootfs,
|
|
||||||
'workspace_quota_mb': spec.workspace_quota_mb,
|
|
||||||
'env_keys': sorted(spec.env.keys()),
|
|
||||||
'cmd': cmd,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _summarize_result(self, result: BoxExecutionResult) -> dict:
|
|
||||||
stdout_preview = result.stdout[:200]
|
|
||||||
stderr_preview = result.stderr[:200]
|
|
||||||
if len(result.stdout) > 200:
|
|
||||||
stdout_preview = f'{stdout_preview}...'
|
|
||||||
if len(result.stderr) > 200:
|
|
||||||
stderr_preview = f'{stderr_preview}...'
|
|
||||||
|
|
||||||
return {
|
|
||||||
'session_id': result.session_id,
|
|
||||||
'backend': result.backend_name,
|
|
||||||
'status': result.status.value,
|
|
||||||
'exit_code': result.exit_code,
|
|
||||||
'duration_ms': result.duration_ms,
|
|
||||||
'stdout_preview': stdout_preview,
|
|
||||||
'stderr_preview': stderr_preview,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _local_config(self) -> dict:
|
|
||||||
"""Return ``box.local`` from instance config.
|
|
||||||
|
|
||||||
Environment overrides are applied uniformly by
|
|
||||||
``LoadConfigStage._apply_env_overrides_to_config`` (e.g.
|
|
||||||
``BOX__LOCAL__HOST_ROOT``) before this is read, so no box-specific
|
|
||||||
env parsing happens here.
|
|
||||||
"""
|
|
||||||
return dict(_get_box_config(self.ap).get('local') or {})
|
|
||||||
|
|
||||||
def _load_allowed_mount_roots(self) -> list[str]:
|
|
||||||
configured_roots = self._local_config().get('allowed_mount_roots', [])
|
|
||||||
# The unified env-override mechanism stores a brand-new key as a raw
|
|
||||||
# string when the key is absent from config.yaml. Accept a
|
|
||||||
# comma-separated string as well as a list so that
|
|
||||||
# ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"`` keeps working even when
|
|
||||||
# the config file has no ``box.local.allowed_mount_roots`` entry.
|
|
||||||
if isinstance(configured_roots, str):
|
|
||||||
configured_roots = [item.strip() for item in configured_roots.split(',') if item.strip()]
|
|
||||||
|
|
||||||
normalized_roots: list[str] = []
|
|
||||||
for root in configured_roots:
|
|
||||||
root_value = str(root).strip()
|
|
||||||
if not root_value:
|
|
||||||
continue
|
|
||||||
normalized_roots.append(os.path.realpath(os.path.abspath(root_value)))
|
|
||||||
|
|
||||||
if not normalized_roots and self.host_root is not None:
|
|
||||||
normalized_roots.append(self.host_root)
|
|
||||||
|
|
||||||
return normalized_roots
|
|
||||||
|
|
||||||
def _load_host_root(self) -> str | None:
|
|
||||||
host_root = str(self._local_config().get('host_root', '')).strip()
|
|
||||||
if not host_root:
|
|
||||||
return None
|
|
||||||
return os.path.realpath(os.path.abspath(host_root))
|
|
||||||
|
|
||||||
def _load_default_workspace(self) -> str | None:
|
|
||||||
default_workspace = str(self._local_config().get('default_workspace', '')).strip()
|
|
||||||
if not default_workspace:
|
|
||||||
if self.host_root is None:
|
|
||||||
return None
|
|
||||||
default_workspace = os.path.join(self.host_root, 'default')
|
|
||||||
elif not os.path.isabs(default_workspace) and self.host_root is not None:
|
|
||||||
default_workspace = os.path.join(self.host_root, default_workspace)
|
|
||||||
return os.path.realpath(os.path.abspath(default_workspace))
|
|
||||||
|
|
||||||
def get_skills_root(self) -> str | None:
|
|
||||||
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
|
|
||||||
if not skills_root:
|
|
||||||
skills_root = 'skills'
|
|
||||||
if not os.path.isabs(skills_root) and self.host_root is not None:
|
|
||||||
skills_root = os.path.join(self.host_root, skills_root)
|
|
||||||
return os.path.realpath(os.path.abspath(skills_root))
|
|
||||||
|
|
||||||
def _load_enabled(self) -> bool:
|
|
||||||
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
|
||||||
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
|
||||||
and the standard env-overridden truthy values that
|
|
||||||
``LoadConfigStage._apply_env_overrides_to_config`` produces."""
|
|
||||||
raw = _get_box_config(self.ap).get('enabled', True)
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
return raw
|
|
||||||
return str(raw).strip().lower() not in ('false', '0', 'no', 'off', '')
|
|
||||||
|
|
||||||
def _load_custom_image(self) -> str | None:
|
|
||||||
raw = str(self._local_config().get('image', '') or '').strip()
|
|
||||||
return raw or None
|
|
||||||
|
|
||||||
def _load_workspace_quota_mb(self) -> int | None:
|
|
||||||
raw_value = self._local_config().get('workspace_quota_mb')
|
|
||||||
if raw_value in (None, ''):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
value = _INT_ADAPTER.validate_python(raw_value)
|
|
||||||
except pydantic.ValidationError as exc:
|
|
||||||
raise BoxValidationError('workspace_quota_mb must be an integer greater than or equal to 0') from exc
|
|
||||||
if value < 0:
|
|
||||||
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _ensure_default_workspace(self):
|
|
||||||
if self.default_workspace is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.isdir(self.default_workspace):
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.exists(self.default_workspace):
|
|
||||||
raise BoxValidationError('box.local.default_workspace must point to a directory on the host')
|
|
||||||
|
|
||||||
if not self.allowed_mount_roots:
|
|
||||||
raise BoxValidationError(
|
|
||||||
'box.local.default_workspace cannot be created because no allowed_mount_roots are configured'
|
|
||||||
)
|
|
||||||
|
|
||||||
for allowed_root in self.allowed_mount_roots:
|
|
||||||
if _is_path_under(self.default_workspace, allowed_root):
|
|
||||||
os.makedirs(self.default_workspace, exist_ok=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
allowed_roots = ', '.join(self.allowed_mount_roots)
|
|
||||||
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
|
|
||||||
|
|
||||||
def _validate_host_mount(self, spec: BoxSpec):
|
|
||||||
if spec.host_path is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
host_path = os.path.realpath(spec.host_path)
|
|
||||||
if not os.path.isdir(host_path):
|
|
||||||
raise BoxValidationError('host_path must point to an existing directory on the host')
|
|
||||||
|
|
||||||
if not self.allowed_mount_roots:
|
|
||||||
raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured')
|
|
||||||
|
|
||||||
for allowed_root in self.allowed_mount_roots:
|
|
||||||
if _is_path_under(host_path, allowed_root):
|
|
||||||
return
|
|
||||||
|
|
||||||
allowed_roots = ', '.join(self.allowed_mount_roots)
|
|
||||||
raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}')
|
|
||||||
|
|
||||||
def _load_profile(self) -> BoxProfile:
|
|
||||||
profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default'
|
|
||||||
|
|
||||||
profile = BUILTIN_PROFILES.get(profile_name)
|
|
||||||
if profile is None:
|
|
||||||
available = ', '.join(sorted(BUILTIN_PROFILES))
|
|
||||||
raise BoxValidationError(f"unknown box profile '{profile_name}', available profiles: {available}")
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def _apply_profile(self, params: dict):
|
|
||||||
"""Merge profile defaults into *params* in-place, enforce locked fields and clamp timeout."""
|
|
||||||
profile = self.profile
|
|
||||||
_PROFILE_FIELDS = (
|
|
||||||
'image',
|
|
||||||
'network',
|
|
||||||
'timeout_sec',
|
|
||||||
'host_path_mode',
|
|
||||||
'cpus',
|
|
||||||
'memory_mb',
|
|
||||||
'pids_limit',
|
|
||||||
'read_only_rootfs',
|
|
||||||
'workspace_quota_mb',
|
|
||||||
)
|
|
||||||
|
|
||||||
for field in _PROFILE_FIELDS:
|
|
||||||
profile_value = getattr(profile, field)
|
|
||||||
raw_value = profile_value.value if isinstance(profile_value, enum.Enum) else profile_value
|
|
||||||
|
|
||||||
if field in profile.locked:
|
|
||||||
params[field] = raw_value
|
|
||||||
elif field not in params:
|
|
||||||
params[field] = raw_value
|
|
||||||
|
|
||||||
timeout = params.get('timeout_sec')
|
|
||||||
try:
|
|
||||||
normalized_timeout = _INT_ADAPTER.validate_python(timeout)
|
|
||||||
except pydantic.ValidationError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if normalized_timeout > profile.max_timeout_sec:
|
|
||||||
params['timeout_sec'] = profile.max_timeout_sec
|
|
||||||
|
|
||||||
def _get_workspace_size_bytes(self, root: str) -> int:
|
|
||||||
total = 0
|
|
||||||
|
|
||||||
def _walk(path: str):
|
|
||||||
nonlocal total
|
|
||||||
try:
|
|
||||||
with os.scandir(path) as entries:
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
if entry.is_symlink():
|
|
||||||
total += entry.stat(follow_symlinks=False).st_size
|
|
||||||
continue
|
|
||||||
if entry.is_dir(follow_symlinks=False):
|
|
||||||
_walk(entry.path)
|
|
||||||
continue
|
|
||||||
total += entry.stat(follow_symlinks=False).st_size
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
|
|
||||||
_walk(root)
|
|
||||||
return total
|
|
||||||
|
|
||||||
async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
|
||||||
if spec.host_path is None or spec.workspace_quota_mb <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
host_path = os.path.realpath(spec.host_path)
|
|
||||||
if not os.path.isdir(host_path):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Walk the workspace off the event loop — this runs on every
|
|
||||||
# quota-enforced exec, and a large tree would otherwise block the whole
|
|
||||||
# asyncio runtime (all bots/pipelines) for the duration of the scan.
|
|
||||||
used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
|
|
||||||
limit_bytes = spec.workspace_quota_mb * _MIB
|
|
||||||
if used_bytes <= limit_bytes:
|
|
||||||
return
|
|
||||||
|
|
||||||
raise BoxValidationError(
|
|
||||||
f'workspace quota exceeded {phase}: '
|
|
||||||
f'used={used_bytes} bytes limit={limit_bytes} bytes '
|
|
||||||
f'host_path={host_path} session_id={spec.session_id}'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
|
|
||||||
try:
|
|
||||||
await self.client.delete_session(spec.session_id)
|
|
||||||
except Exception as exc:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
'Failed to clean up Box session after workspace quota was exceeded: '
|
|
||||||
f'session_id={spec.session_id} error={exc}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Observability ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _record_error(self, exc: Exception, query: pipeline_query.Query):
|
|
||||||
self._recent_errors.append(
|
|
||||||
{
|
|
||||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
|
||||||
'type': type(exc).__name__,
|
|
||||||
'message': str(exc),
|
|
||||||
'query_id': str(query.query_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_recent_errors(self) -> list[dict]:
|
|
||||||
return list(self._recent_errors)
|
|
||||||
|
|
||||||
def get_system_guidance(self) -> str:
|
|
||||||
"""Return LLM system-prompt guidance for the exec tool.
|
|
||||||
|
|
||||||
All execution-specific prompt text is kept here so that callers
|
|
||||||
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
|
||||||
"""
|
|
||||||
guidance = (
|
|
||||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
|
||||||
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
|
||||||
'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec '
|
|
||||||
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
|
|
||||||
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
|
|
||||||
)
|
|
||||||
if self.default_workspace:
|
|
||||||
guidance += (
|
|
||||||
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
|
|
||||||
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
|
||||||
'user for directory parameters unless they explicitly need a different directory.'
|
|
||||||
)
|
|
||||||
return guidance
|
|
||||||
|
|
||||||
async def get_status(self) -> dict:
|
|
||||||
if not self._available:
|
|
||||||
return {
|
|
||||||
'available': False,
|
|
||||||
'enabled': self._enabled,
|
|
||||||
'profile': self.profile.name,
|
|
||||||
'recent_error_count': len(self._recent_errors),
|
|
||||||
'connector_error': self._connector_error,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
runtime_status = await self.client.get_status()
|
|
||||||
except Exception as exc:
|
|
||||||
# RPC failed — the runtime likely just disconnected and the
|
|
||||||
# heartbeat hasn't flipped _available yet.
|
|
||||||
return {
|
|
||||||
'available': False,
|
|
||||||
'enabled': self._enabled,
|
|
||||||
'profile': self.profile.name,
|
|
||||||
'recent_error_count': len(self._recent_errors),
|
|
||||||
'connector_error': str(exc),
|
|
||||||
}
|
|
||||||
# Backend state can be unavailable even when the connector is healthy
|
|
||||||
# (operator selected nsjail but the binary is missing, Docker daemon
|
|
||||||
# went down after the runtime started, E2B credentials wrong, ...).
|
|
||||||
# Report the combined state in the top-level ``available`` so the
|
|
||||||
# frontend banner / ``useBoxStatus`` hook / native-tool gate all
|
|
||||||
# agree on "actually usable" rather than "connector alive". The
|
|
||||||
# detailed ``backend`` object stays in the payload so the dialog
|
|
||||||
# can still show which backend was tried.
|
|
||||||
backend_info = runtime_status.get('backend') if isinstance(runtime_status, dict) else None
|
|
||||||
backend_ok = bool(backend_info and backend_info.get('available', False))
|
|
||||||
payload = {
|
|
||||||
**runtime_status,
|
|
||||||
'available': backend_ok,
|
|
||||||
'enabled': self._enabled,
|
|
||||||
'profile': self.profile.name,
|
|
||||||
'recent_error_count': len(self._recent_errors),
|
|
||||||
}
|
|
||||||
if not backend_ok and 'connector_error' not in payload:
|
|
||||||
backend_name = backend_info.get('name') if backend_info else None
|
|
||||||
if backend_name:
|
|
||||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
|
||||||
else:
|
|
||||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
|
||||||
return payload
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
"""Reusable workspace/session helpers built on top of Box.
|
|
||||||
|
|
||||||
This module is the middle layer between the raw Box runtime primitives and
|
|
||||||
application-specific flows such as skills or MCP stdio.
|
|
||||||
|
|
||||||
It intentionally stays generic:
|
|
||||||
- path and virtualenv rewriting are workspace concerns
|
|
||||||
- Python project detection/bootstrap are workspace concerns
|
|
||||||
- session exec / managed-process helpers are workspace concerns
|
|
||||||
|
|
||||||
Higher layers add their own semantics on top, for example:
|
|
||||||
- skills choose a stable per-skill session id and use repeated exec
|
|
||||||
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import textwrap
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
PYTHON_MANIFEST_FILES = (
|
|
||||||
'requirements.txt',
|
|
||||||
'pyproject.toml',
|
|
||||||
'setup.py',
|
|
||||||
'setup.cfg',
|
|
||||||
)
|
|
||||||
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
|
||||||
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_host_path(path: str | None) -> str:
|
|
||||||
if path is None:
|
|
||||||
return ''
|
|
||||||
stripped = str(path).strip()
|
|
||||||
if not stripped:
|
|
||||||
return ''
|
|
||||||
return os.path.realpath(os.path.abspath(stripped))
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
|
||||||
"""Translate a host path into the path visible inside the sandbox mount."""
|
|
||||||
if not host_path or not path:
|
|
||||||
return path
|
|
||||||
normalized_host = os.path.realpath(host_path)
|
|
||||||
normalized_path = os.path.realpath(path)
|
|
||||||
if normalized_path.startswith(normalized_host + '/'):
|
|
||||||
return mount_path + normalized_path[len(normalized_host) :]
|
|
||||||
if normalized_path == normalized_host:
|
|
||||||
return mount_path
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap_venv_path(directory: str) -> str:
|
|
||||||
"""Collapse ``.../.venv/bin`` style paths back to the project root."""
|
|
||||||
parts = directory.replace('\\', '/').split('/')
|
|
||||||
for i in range(len(parts) - 1, 0, -1):
|
|
||||||
if parts[i] in _VENV_BIN_DIRS and i >= 1:
|
|
||||||
venv_dir = parts[i - 1]
|
|
||||||
if venv_dir in _VENV_DIRS:
|
|
||||||
project_root = '/'.join(parts[: i - 1])
|
|
||||||
return project_root if project_root else '/'
|
|
||||||
return directory
|
|
||||||
|
|
||||||
|
|
||||||
def infer_workspace_host_path(command: str, args: list[str] | None = None) -> str | None:
|
|
||||||
"""Infer the project/workspace root from absolute command/arg paths."""
|
|
||||||
candidates: list[str] = []
|
|
||||||
for part in [command, *(args or [])]:
|
|
||||||
if not os.path.isabs(part):
|
|
||||||
continue
|
|
||||||
if os.path.exists(part):
|
|
||||||
directory = os.path.dirname(part)
|
|
||||||
candidates.append(os.path.realpath(unwrap_venv_path(directory)))
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
common = os.path.commonpath(candidates)
|
|
||||||
return common if common != '/' else None
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
|
||||||
"""Rewrite host venv interpreters to plain ``python`` inside the sandbox.
|
|
||||||
|
|
||||||
Once a project is mounted into the sandbox, host virtualenv paths are no
|
|
||||||
longer valid. For those paths we intentionally drop down to ``python`` and
|
|
||||||
let the sandbox-side environment/bootstrap decide what interpreter to use.
|
|
||||||
"""
|
|
||||||
if not host_path or not command:
|
|
||||||
return command
|
|
||||||
normalized_host = os.path.realpath(host_path)
|
|
||||||
normalized_command = os.path.realpath(command)
|
|
||||||
if not normalized_command.startswith(normalized_host + '/'):
|
|
||||||
return command
|
|
||||||
rel = normalized_command[len(normalized_host) + 1 :]
|
|
||||||
parts = rel.replace('\\', '/').split('/')
|
|
||||||
if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'):
|
|
||||||
return 'python'
|
|
||||||
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
|
|
||||||
|
|
||||||
|
|
||||||
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
|
||||||
normalized_root = normalize_host_path(host_path)
|
|
||||||
if not normalized_root:
|
|
||||||
return []
|
|
||||||
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
|
||||||
|
|
||||||
|
|
||||||
def classify_python_workspace(host_path: str | None) -> str | None:
|
|
||||||
"""Return the generic Python workspace shape, without app-specific policy."""
|
|
||||||
manifest_files = set(list_python_manifest_files(host_path))
|
|
||||||
if not manifest_files:
|
|
||||||
return None
|
|
||||||
if {'pyproject.toml', 'setup.py', 'setup.cfg'} & manifest_files:
|
|
||||||
return 'package'
|
|
||||||
if 'requirements.txt' in manifest_files:
|
|
||||||
return 'requirements'
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def should_prepare_python_env(host_path: str | None) -> bool:
|
|
||||||
normalized_root = normalize_host_path(host_path)
|
|
||||||
if not normalized_root:
|
|
||||||
return False
|
|
||||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
|
||||||
return True
|
|
||||||
return bool(list_python_manifest_files(normalized_root))
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace') -> str:
|
|
||||||
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
|
|
||||||
|
|
||||||
This is the generic "workspace is a Python project" path used by mutable
|
|
||||||
workspaces such as skills. Read-only installation strategies stay in the
|
|
||||||
higher-level caller because they are application policy, not workspace
|
|
||||||
semantics.
|
|
||||||
"""
|
|
||||||
bootstrap = textwrap.dedent(
|
|
||||||
f"""
|
|
||||||
set -e
|
|
||||||
|
|
||||||
_LB_VENV_DIR="{mount_path}/.venv"
|
|
||||||
_LB_META_DIR="{mount_path}/.langbot"
|
|
||||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
|
||||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
|
||||||
_LB_TMP_DIR="{mount_path}/.tmp"
|
|
||||||
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
|
||||||
|
|
||||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
|
||||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
|
||||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
|
||||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
|
|
||||||
export TMPDIR="$_LB_TMP_DIR"
|
|
||||||
export TEMP="$_LB_TMP_DIR"
|
|
||||||
export TMP="$_LB_TMP_DIR"
|
|
||||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
|
||||||
|
|
||||||
_lb_python_meta() {{
|
|
||||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
root = "{mount_path}"
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
manifest_files = []
|
|
||||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
|
||||||
path = os.path.join(root, rel)
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
continue
|
|
||||||
manifest_files.append(rel)
|
|
||||||
with open(path, "rb") as handle:
|
|
||||||
digest.update(rel.encode("utf-8"))
|
|
||||||
digest.update(b"\\0")
|
|
||||||
digest.update(handle.read())
|
|
||||||
digest.update(b"\\0")
|
|
||||||
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{{
|
|
||||||
"python_executable": sys.executable,
|
|
||||||
"python_version": list(sys.version_info[:3]),
|
|
||||||
"manifest_files": manifest_files,
|
|
||||||
"manifest_sha256": digest.hexdigest(),
|
|
||||||
}},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
PY
|
|
||||||
}}
|
|
||||||
|
|
||||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
|
||||||
_LB_NEEDS_BOOTSTRAP=0
|
|
||||||
|
|
||||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
|
||||||
_LB_LOCK_WAIT=0
|
|
||||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
|
||||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
|
||||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
_lb_cleanup_lock() {{
|
|
||||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
|
||||||
}}
|
|
||||||
trap _lb_cleanup_lock EXIT INT TERM
|
|
||||||
|
|
||||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
|
||||||
_LB_NEEDS_BOOTSTRAP=0
|
|
||||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
|
||||||
_LB_NEEDS_BOOTSTRAP=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
|
||||||
rm -rf "$_LB_VENV_DIR"
|
|
||||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
|
||||||
. "$_LB_VENV_DIR/bin/activate"
|
|
||||||
python -m pip install --upgrade pip setuptools wheel
|
|
||||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
|
||||||
python -m pip install -r "{mount_path}/requirements.txt"
|
|
||||||
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
|
||||||
python -m pip install "{mount_path}"
|
|
||||||
fi
|
|
||||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
|
||||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
|
||||||
{command}
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
return bootstrap + '\n'
|
|
||||||
|
|
||||||
|
|
||||||
class BoxWorkspaceSession:
|
|
||||||
"""High-level handle for one reusable workspace-backed Box session.
|
|
||||||
|
|
||||||
The Box runtime already understands sessions and managed processes. This
|
|
||||||
wrapper adds LangBot's workspace-centric view on top: a mounted host path,
|
|
||||||
a stable ``session_id``, optional environment defaults, and convenience
|
|
||||||
helpers for exec or long-running processes inside that workspace.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
box_service,
|
|
||||||
session_id: str,
|
|
||||||
*,
|
|
||||||
host_path: str | None = None,
|
|
||||||
host_path_mode: str = 'rw',
|
|
||||||
workdir: str = '/workspace',
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
mount_path: str = '/workspace',
|
|
||||||
network: str | None = None,
|
|
||||||
read_only_rootfs: bool | None = None,
|
|
||||||
image: str | None = None,
|
|
||||||
cpus: float | None = None,
|
|
||||||
memory_mb: int | None = None,
|
|
||||||
pids_limit: int | None = None,
|
|
||||||
persistent: bool = False,
|
|
||||||
):
|
|
||||||
self.box_service = box_service
|
|
||||||
self.session_id = session_id
|
|
||||||
self.host_path = host_path
|
|
||||||
self.host_path_mode = host_path_mode
|
|
||||||
self.workdir = workdir
|
|
||||||
self.env = dict(env or {})
|
|
||||||
self.mount_path = mount_path
|
|
||||||
self.network = network
|
|
||||||
self.read_only_rootfs = read_only_rootfs
|
|
||||||
self.image = image
|
|
||||||
self.cpus = cpus
|
|
||||||
self.memory_mb = memory_mb
|
|
||||||
self.pids_limit = pids_limit
|
|
||||||
self.persistent = persistent
|
|
||||||
|
|
||||||
def rewrite_path(self, path: str) -> str:
|
|
||||||
return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path)
|
|
||||||
|
|
||||||
def rewrite_venv_command(self, command: str) -> str:
|
|
||||||
return rewrite_venv_command(command, self.host_path, mount_path=self.mount_path)
|
|
||||||
|
|
||||||
def build_session_payload(self) -> dict[str, Any]:
|
|
||||||
# Keep this payload generic so callers can reuse the same workspace
|
|
||||||
# handle for plain exec, file-producing tasks, or managed processes.
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
'session_id': self.session_id,
|
|
||||||
'workdir': self.workdir,
|
|
||||||
'env': self.env,
|
|
||||||
'persistent': self.persistent,
|
|
||||||
}
|
|
||||||
if self.network is not None:
|
|
||||||
payload['network'] = self.network
|
|
||||||
if self.read_only_rootfs is not None:
|
|
||||||
payload['read_only_rootfs'] = self.read_only_rootfs
|
|
||||||
if self.host_path:
|
|
||||||
payload['host_path'] = self.host_path
|
|
||||||
payload['host_path_mode'] = self.host_path_mode
|
|
||||||
for key in ('image', 'cpus', 'memory_mb', 'pids_limit'):
|
|
||||||
value = getattr(self, key)
|
|
||||||
if value is not None:
|
|
||||||
payload[key] = value
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def build_exec_payload(
|
|
||||||
self,
|
|
||||||
cmd: str,
|
|
||||||
*,
|
|
||||||
workdir: str | None = None,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
timeout_sec: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Exec payloads inherit the session-level workspace config, then layer
|
|
||||||
# per-call command/workdir/env overrides on top.
|
|
||||||
payload = self.build_session_payload()
|
|
||||||
payload['cmd'] = cmd
|
|
||||||
payload['workdir'] = workdir or self.workdir
|
|
||||||
if timeout_sec is not None:
|
|
||||||
payload['timeout_sec'] = timeout_sec
|
|
||||||
resolved_env = self.env if env is None else env
|
|
||||||
if resolved_env:
|
|
||||||
payload['env'] = resolved_env
|
|
||||||
elif 'env' in payload and not payload['env']:
|
|
||||||
payload.pop('env')
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def execute_raw(
|
|
||||||
self,
|
|
||||||
cmd: str,
|
|
||||||
*,
|
|
||||||
workdir: str | None = None,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
timeout_sec: int | None = None,
|
|
||||||
):
|
|
||||||
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
|
|
||||||
return await self.box_service.client.execute(self.box_service.build_spec(payload))
|
|
||||||
|
|
||||||
async def execute_for_query(
|
|
||||||
self,
|
|
||||||
query,
|
|
||||||
cmd: str,
|
|
||||||
*,
|
|
||||||
workdir: str | None = None,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
timeout_sec: int | None = None,
|
|
||||||
) -> dict:
|
|
||||||
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
|
|
||||||
return await self.box_service.execute_spec_payload(payload, query)
|
|
||||||
|
|
||||||
async def create_session(self):
|
|
||||||
return await self.box_service.create_session(self.build_session_payload())
|
|
||||||
|
|
||||||
def build_process_payload(
|
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
args: list[str] | None = None,
|
|
||||||
*,
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
cwd: str = '/workspace',
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Managed processes run inside the same workspace model as one-shot
|
|
||||||
# execs, so path/venv rewriting is shared here.
|
|
||||||
normalized_command = command
|
|
||||||
normalized_args = list(args or [])
|
|
||||||
normalized_cwd = cwd
|
|
||||||
if self.host_path:
|
|
||||||
normalized_command = self.rewrite_venv_command(command)
|
|
||||||
normalized_args = [self.rewrite_path(arg) for arg in normalized_args]
|
|
||||||
normalized_cwd = self.rewrite_path(cwd)
|
|
||||||
return {
|
|
||||||
'command': normalized_command,
|
|
||||||
'args': normalized_args,
|
|
||||||
'env': dict(env or {}),
|
|
||||||
'cwd': normalized_cwd,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def start_managed_process(
|
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
args: list[str] | None = None,
|
|
||||||
*,
|
|
||||||
process_id: str = 'default',
|
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
cwd: str = '/workspace',
|
|
||||||
):
|
|
||||||
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
|
|
||||||
payload['process_id'] = process_id
|
|
||||||
return await self.box_service.start_managed_process(self.session_id, payload)
|
|
||||||
|
|
||||||
async def get_managed_process(self, process_id: str = 'default'):
|
|
||||||
return await self.box_service.get_managed_process(self.session_id, process_id)
|
|
||||||
|
|
||||||
async def stop_managed_process(self, process_id: str = 'default') -> None:
|
|
||||||
await self.box_service.stop_managed_process(self.session_id, process_id)
|
|
||||||
|
|
||||||
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
|
|
||||||
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
|
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
|
||||||
await self.box_service.client.delete_session(self.session_id)
|
|
||||||
@@ -4,13 +4,11 @@ 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
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
from ..box import service as box_service_module
|
|
||||||
|
|
||||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ..config import manager as config_mgr
|
from ..config import manager as config_mgr
|
||||||
@@ -33,8 +31,8 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
from ..api.http.service import skill as skill_service
|
|
||||||
from ..api.http.service import maintenance as maintenance_service
|
from ..api.http.service import maintenance as maintenance_service
|
||||||
|
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
from ..utils import logcache
|
from ..utils import logcache
|
||||||
@@ -45,10 +43,6 @@ from ..rag.service import RAGRuntimeService
|
|||||||
from ..vector import mgr as vectordb_mgr
|
from ..vector import mgr as vectordb_mgr
|
||||||
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 ..skill import manager as skill_mgr
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService
|
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
@@ -76,7 +70,6 @@ class Application:
|
|||||||
|
|
||||||
# TODO move to pipeline
|
# TODO move to pipeline
|
||||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||||
box_service: box_service_module.BoxService = None
|
|
||||||
|
|
||||||
# ======= Config manager =======
|
# ======= Config manager =======
|
||||||
|
|
||||||
@@ -163,19 +156,8 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
skill_service: skill_service.SkillService = None
|
|
||||||
|
|
||||||
skill_mgr: skill_mgr.SkillManager = None
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -319,10 +301,7 @@ class Application:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
def dispose(self):
|
def dispose(self):
|
||||||
if self.plugin_connector is not None:
|
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
if self.box_service is not None:
|
|
||||||
self.box_service.dispose()
|
|
||||||
|
|
||||||
async def print_web_access_info(self):
|
async def print_web_access_info(self):
|
||||||
"""Print access webui tips"""
|
"""Print access webui tips"""
|
||||||
|
|||||||
@@ -62,6 +62,4 @@ async def main(loop: asyncio.AbstractEventLoop):
|
|||||||
app_inst = await make_app(loop)
|
app_inst = await make_app(loop)
|
||||||
await app_inst.run()
|
await app_inst.run()
|
||||||
except Exception:
|
except Exception:
|
||||||
if app_inst is not None:
|
|
||||||
app_inst.dispose()
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
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()
|
||||||
@@ -6,7 +6,6 @@ from .. import stage, app
|
|||||||
from ...utils import version, proxy
|
from ...utils import version, proxy
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
from ...box import service as box_service
|
|
||||||
from ...plugin import connector as plugin_connector
|
from ...plugin import connector as plugin_connector
|
||||||
from ...command import cmdmgr
|
from ...command import cmdmgr
|
||||||
from ...provider.session import sessionmgr as llm_session_mgr
|
from ...provider.session import sessionmgr as llm_session_mgr
|
||||||
@@ -29,8 +28,6 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_service
|
from ...api.http.service import monitoring as monitoring_service
|
||||||
from ...api.http.service import skill as skill_service
|
|
||||||
from ...skill import manager as skill_mgr
|
|
||||||
from ...api.http.service import maintenance as maintenance_service
|
from ...api.http.service import maintenance as maintenance_service
|
||||||
from ...discover import engine as discover_engine
|
from ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
@@ -39,7 +36,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')
|
||||||
@@ -90,9 +86,6 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||||
ap.webhook_service = webhook_service_inst
|
ap.webhook_service = webhook_service_inst
|
||||||
|
|
||||||
skill_service_inst = skill_service.SkillService(ap)
|
|
||||||
ap.skill_service = skill_service_inst
|
|
||||||
|
|
||||||
proxy_mgr = proxy.ProxyManager(ap)
|
proxy_mgr = proxy.ProxyManager(ap)
|
||||||
await proxy_mgr.initialize()
|
await proxy_mgr.initialize()
|
||||||
ap.proxy_mgr = proxy_mgr
|
ap.proxy_mgr = proxy_mgr
|
||||||
@@ -136,10 +129,6 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await llm_session_mgr_inst.initialize()
|
await llm_session_mgr_inst.initialize()
|
||||||
ap.sess_mgr = llm_session_mgr_inst
|
ap.sess_mgr = llm_session_mgr_inst
|
||||||
|
|
||||||
box_service_inst = box_service.BoxService(ap)
|
|
||||||
await box_service_inst.initialize()
|
|
||||||
ap.box_service = box_service_inst
|
|
||||||
|
|
||||||
llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)
|
llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)
|
||||||
await llm_tool_mgr_inst.initialize()
|
await llm_tool_mgr_inst.initialize()
|
||||||
ap.tool_mgr = llm_tool_mgr_inst
|
ap.tool_mgr = llm_tool_mgr_inst
|
||||||
@@ -160,11 +149,6 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
msg_aggregator_inst = message_aggregator.MessageAggregator(ap)
|
msg_aggregator_inst = message_aggregator.MessageAggregator(ap)
|
||||||
ap.msg_aggregator = msg_aggregator_inst
|
ap.msg_aggregator = msg_aggregator_inst
|
||||||
|
|
||||||
# Initialize skill manager
|
|
||||||
skill_mgr_inst = skill_mgr.SkillManager(ap)
|
|
||||||
await skill_mgr_inst.initialize()
|
|
||||||
ap.skill_mgr = skill_mgr_inst
|
|
||||||
|
|
||||||
rag_mgr_inst = rag_mgr.RAGManager(ap)
|
rag_mgr_inst = rag_mgr.RAGManager(ap)
|
||||||
await rag_mgr_inst.initialize()
|
await rag_mgr_inst.initialize()
|
||||||
ap.rag_mgr = rag_mgr_inst
|
ap.rag_mgr = rag_mgr_inst
|
||||||
@@ -195,15 +179,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."""
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ class RuntimePipeline:
|
|||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
message=result.user_notice,
|
message=result.user_notice,
|
||||||
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
||||||
is_final=[msg.is_final for msg in query.resp_messages][0],
|
is_final=[msg.is_final for msg in query.resp_messages][-1],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await query.adapter.reply_message(
|
await query.adapter.reply_message(
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ class QueryPool:
|
|||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
routed_by_rule: bool = False,
|
routed_by_rule: bool = False,
|
||||||
|
variables: typing.Optional[dict[str, typing.Any]] = None,
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
|
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
|
||||||
|
if variables:
|
||||||
|
initial_variables.update(variables)
|
||||||
query = pipeline_query.Query(
|
query = pipeline_query.Query(
|
||||||
bot_uuid=bot_uuid,
|
bot_uuid=bot_uuid,
|
||||||
query_id=query_id,
|
query_id=query_id,
|
||||||
@@ -53,7 +57,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={'_routed_by_rule': routed_by_rule},
|
variables=initial_variables,
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -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,52 @@ 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)
|
|
||||||
|
|
||||||
# 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 +79,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,22 +96,21 @@ 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 llm_model.model_entity.abilities.__contains__('func_call'):
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
bound_plugins,
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers,
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
include_skill_authoring=include_skill_authoring,
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
)
|
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
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'Bound MCP servers: {bound_mcp_servers}')
|
||||||
@@ -233,22 +118,10 @@ 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'):
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_plugins,
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
bound_mcp_servers,
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
include_skill_authoring=include_skill_authoring,
|
|
||||||
)
|
|
||||||
elif uses_host_tools:
|
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
|
||||||
bound_plugins,
|
|
||||||
bound_mcp_servers,
|
|
||||||
include_skill_authoring=include_skill_authoring,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
|
||||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
@@ -273,25 +146,36 @@ 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 not llm_model.model_entity.abilities.__contains__('vision')
|
||||||
|
):
|
||||||
|
for msg in query.messages:
|
||||||
|
if isinstance(msg.content, list):
|
||||||
|
for me in msg.content:
|
||||||
|
if me.type == 'image_url':
|
||||||
|
msg.content.remove(me)
|
||||||
|
|
||||||
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 llm_model.model_entity.abilities.__contains__('vision')
|
||||||
|
):
|
||||||
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 +190,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 llm_model.model_entity.abilities.__contains__('vision')
|
||||||
|
):
|
||||||
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 +212,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,16 +237,4 @@ 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:
|
|
||||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
|
||||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
|
||||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
|
||||||
|
|
||||||
if enable_all_skills:
|
|
||||||
bound_skills = None # None = all loaded skills are visible
|
|
||||||
else:
|
|
||||||
bound_skills = extensions_prefs.get('skills', [])
|
|
||||||
|
|
||||||
query.variables['_pipeline_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)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import abc
|
|||||||
from ...core import app
|
from ...core import app
|
||||||
from .. import entities
|
from .. import entities
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MessageHandler(metaclass=abc.ABCMeta):
|
class MessageHandler(metaclass=abc.ABCMeta):
|
||||||
@@ -32,29 +31,3 @@ class MessageHandler(metaclass=abc.ABCMeta):
|
|||||||
if len(s0) > 20 or '\n' in s:
|
if len(s0) > 20 or '\n' in s:
|
||||||
s0 = s0[:20] + '...'
|
s0 = s0[:20] + '...'
|
||||||
return s0
|
return s0
|
||||||
|
|
||||||
def format_result_log(
|
|
||||||
self,
|
|
||||||
result: provider_message.Message | provider_message.MessageChunk,
|
|
||||||
) -> str | None:
|
|
||||||
if result.tool_calls:
|
|
||||||
tool_names = [tc.function.name for tc in result.tool_calls if tc.function and tc.function.name]
|
|
||||||
if tool_names:
|
|
||||||
return f'{result.role}: requested tools: {", ".join(tool_names)}'
|
|
||||||
return f'{result.role}: requested tool calls'
|
|
||||||
|
|
||||||
content = result.content
|
|
||||||
if isinstance(content, str):
|
|
||||||
if not content.strip():
|
|
||||||
return None
|
|
||||||
|
|
||||||
if result.role == 'tool':
|
|
||||||
if content.startswith('err:'):
|
|
||||||
return f'tool error: {self.cut_str(content)}'
|
|
||||||
|
|
||||||
return self.cut_str(result.readable_str())
|
|
||||||
|
|
||||||
if isinstance(content, list) and len(content) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.cut_str(result.readable_str())
|
|
||||||
|
|||||||
@@ -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,51 +83,43 @@ 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)
|
self.ap.logger.info(
|
||||||
if summary is not None:
|
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
|
||||||
self.ap.logger.info(f'Conversation({query.query_id}) Streaming started: {summary}')
|
)
|
||||||
else:
|
|
||||||
self.ap.logger.info(f'Conversation({query.query_id}) Streaming started')
|
|
||||||
elif chunk_count % 10 == 0:
|
elif chunk_count % 10 == 0:
|
||||||
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())}'
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
summary = self.format_result_log(result)
|
|
||||||
if summary is not None:
|
|
||||||
self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}')
|
|
||||||
|
|
||||||
if result.content is not None:
|
if result.content is not None:
|
||||||
text_length += len(result.content)
|
text_length += len(result.content)
|
||||||
@@ -141,40 +127,31 @@ 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
|
# Log final summary after streaming completes
|
||||||
if is_stream:
|
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep a conversation object available for downstream legacy
|
else:
|
||||||
# readers, but do not mirror AgentRunner history into
|
async for result in runner.run(query):
|
||||||
# conversation.messages. TranscriptStore is the canonical
|
query.resp_messages.append(result)
|
||||||
# history source for this path.
|
|
||||||
await self._ensure_conversation_for_history(query)
|
|
||||||
|
|
||||||
except Exception as e:
|
self.ap.logger.info(
|
||||||
# Import orchestrator errors for specific handling
|
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
|
||||||
from ....agent.runner.errors import (
|
|
||||||
RunnerNotFoundError,
|
|
||||||
RunnerNotAuthorizedError,
|
|
||||||
RunnerExecutionError,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if result.content is not None:
|
||||||
|
text_length += len(result.content)
|
||||||
|
|
||||||
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
query.session.using_conversation.messages.append(query.user_message)
|
||||||
|
|
||||||
|
query.session.using_conversation.messages.extend(query.resp_messages)
|
||||||
|
except Exception as e:
|
||||||
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 +169,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 +177,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 +196,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 +214,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 +222,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
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
|
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
|
||||||
# TODO 命令与流式的兼容性问题
|
# TODO 命令与流式的兼容性问题
|
||||||
if await query.adapter.is_stream_output_supported() and has_chunks:
|
if await query.adapter.is_stream_output_supported() and has_chunks:
|
||||||
is_final = [msg.is_final for msg in query.resp_messages][0]
|
is_final = [msg.is_final for msg in query.resp_messages][-1]
|
||||||
await query.adapter.reply_message_chunk(
|
await query.adapter.reply_message_chunk(
|
||||||
message_source=query.message_event,
|
message_source=query.message_event,
|
||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
|
|||||||
@@ -501,6 +501,8 @@ class PlatformManager:
|
|||||||
bot_entity.adapter_config,
|
bot_entity.adapter_config,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
if hasattr(adapter_inst, 'ap'):
|
||||||
|
adapter_inst.ap = self.ap
|
||||||
|
|
||||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
|
||||||
class AESCipher(object):
|
class AESCipher(object):
|
||||||
@@ -770,6 +771,7 @@ CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟
|
|||||||
class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)
|
bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)
|
||||||
api_client: lark_oapi.Client = pydantic.Field(exclude=True)
|
api_client: lark_oapi.Client = pydantic.Field(exclude=True)
|
||||||
|
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
||||||
lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key
|
lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key
|
||||||
@@ -792,6 +794,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
pending_monitoring_msg: dict[str, str]
|
pending_monitoring_msg: dict[str, str]
|
||||||
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
|
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
|
||||||
reply_to_monitoring_msg: dict[str, tuple[str, float]]
|
reply_to_monitoring_msg: dict[str, tuple[str, float]]
|
||||||
|
reply_message_card_ids: dict[str, str]
|
||||||
|
card_sequence_dict: dict[str, int]
|
||||||
|
# card_id → set of source message ids registered against it (for cleanup)
|
||||||
|
card_id_to_source_ids: dict[str, set[str]]
|
||||||
|
# card_id → current streaming_txt content cache (needed for full aupdate during resume transition)
|
||||||
|
card_streaming_text: dict[str, str]
|
||||||
|
# card_id → pre-pause streaming_txt text (captured when resume first chunk arrives)
|
||||||
|
card_pre_pause_text: dict[str, str]
|
||||||
|
# set of card_ids that have already transitioned from "buttons visible" to "resume layout"
|
||||||
|
card_resume_transitioned: set[str]
|
||||||
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||||
@@ -812,11 +824,134 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
asyncio.create_task(on_message(event))
|
asyncio.create_task(on_message(event))
|
||||||
|
|
||||||
|
def schedule_on_app_loop(coro):
|
||||||
|
"""Run a coroutine on the application event loop from sync callbacks."""
|
||||||
|
return asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop)
|
||||||
|
|
||||||
def sync_on_card_action(event):
|
def sync_on_card_action(event):
|
||||||
try:
|
try:
|
||||||
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
|
# Parse JSON string values (from form action buttons)
|
||||||
|
if isinstance(action_value_raw, str):
|
||||||
|
try:
|
||||||
|
action_value_obj = json.loads(action_value_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
action_value_obj = {}
|
||||||
|
else:
|
||||||
|
action_value_obj = action_value_raw if isinstance(action_value_raw, dict) else {}
|
||||||
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
# Handle Dify form action button clicks
|
||||||
|
if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'):
|
||||||
|
form_token = action_value_obj.get('form_token', '')
|
||||||
|
workflow_run_id = action_value_obj.get('workflow_run_id', '')
|
||||||
|
action_id = action_value_obj.get('action_id', '')
|
||||||
|
session_key = action_value_obj.get('session_key', '')
|
||||||
|
|
||||||
|
if session_key.startswith('group_') or session_key.startswith('g:'):
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('g:')
|
||||||
|
else session_key[len('group_') :]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('p:')
|
||||||
|
else session_key[len('person_') :]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the bot entity to get bot_uuid and pipeline_uuid
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.adapter is self:
|
||||||
|
bot_uuid = bot.bot_entity.uuid
|
||||||
|
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'form_token': form_token,
|
||||||
|
'workflow_run_id': workflow_run_id,
|
||||||
|
'action_id': action_id,
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
context = getattr(event.event, 'context', None)
|
||||||
|
open_message_id = getattr(context, 'open_message_id', None)
|
||||||
|
source_time = datetime.datetime.now()
|
||||||
|
event_time = source_time.timestamp()
|
||||||
|
action_text = action_value_obj.get('action_id', 'confirm')
|
||||||
|
message_chain = platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=f'[Form Action: {action_text}]')]
|
||||||
|
)
|
||||||
|
if open_message_id:
|
||||||
|
message_chain.insert(
|
||||||
|
0,
|
||||||
|
platform_message.Source(
|
||||||
|
id=open_message_id,
|
||||||
|
time=source_time,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
operator = getattr(event.event, 'operator', None)
|
||||||
|
user_id = (
|
||||||
|
getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) or str(launcher_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=user_id,
|
||||||
|
member_name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=event_time,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=user_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=event_time,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_form_action_query():
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=user_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_on_app_loop(add_form_action_query())
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||||
|
|
||||||
if action_value == '有帮助':
|
if action_value == '有帮助':
|
||||||
feedback_type = 1
|
feedback_type = 1
|
||||||
elif action_value == '无帮助':
|
elif action_value == '无帮助':
|
||||||
@@ -857,17 +992,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if platform_events.FeedbackEvent in self.listeners:
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
loop = asyncio.get_event_loop()
|
schedule_on_app_loop(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
if loop.is_running():
|
|
||||||
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
|
||||||
else:
|
|
||||||
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
|
||||||
|
|
||||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||||
except Exception:
|
except Exception:
|
||||||
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
traceback.print_exc()
|
||||||
|
schedule_on_app_loop(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||||
@@ -881,8 +1013,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
|
|
||||||
domain = self._resolve_domain(config)
|
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
|
|
||||||
api_client = self.build_api_client(config)
|
api_client = self.build_api_client(config)
|
||||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||||
self.request_app_ticket(api_client, config)
|
self.request_app_ticket(api_client, config)
|
||||||
@@ -894,6 +1025,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
pending_monitoring_msg={},
|
pending_monitoring_msg={},
|
||||||
reply_to_monitoring_msg={},
|
reply_to_monitoring_msg={},
|
||||||
|
reply_message_card_ids={},
|
||||||
|
card_sequence_dict={},
|
||||||
|
card_id_to_source_ids={},
|
||||||
|
card_streaming_text={},
|
||||||
|
card_pre_pause_text={},
|
||||||
|
card_resume_transitioned=set(),
|
||||||
seq=1,
|
seq=1,
|
||||||
listeners={},
|
listeners={},
|
||||||
quart_app=quart_app,
|
quart_app=quart_app,
|
||||||
@@ -1015,28 +1152,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_domain(config) -> str:
|
|
||||||
domain = config.get('domain', lark_oapi.FEISHU_DOMAIN)
|
|
||||||
if domain == 'custom':
|
|
||||||
domain = config.get('custom_domain', '')
|
|
||||||
if not domain:
|
|
||||||
raise ValueError('Custom domain is required when domain is set to "custom"')
|
|
||||||
return domain.rstrip('/')
|
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
domain = self._resolve_domain(config)
|
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
|
||||||
if 'isv' == config.get('app_type', 'self'):
|
if 'isv' == config.get('app_type', 'self'):
|
||||||
api_client = (
|
api_client = (
|
||||||
lark_oapi.Client.builder()
|
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||||
.app_id(app_id)
|
|
||||||
.app_secret(app_secret)
|
|
||||||
.app_type(lark_oapi.AppType.ISV)
|
|
||||||
.domain(domain)
|
|
||||||
.build()
|
|
||||||
)
|
)
|
||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
@@ -1148,6 +1270,33 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
for k in expired:
|
for k in expired:
|
||||||
del self.reply_to_monitoring_msg[k]
|
del self.reply_to_monitoring_msg[k]
|
||||||
|
|
||||||
|
def _next_card_sequence(self, card_id: str, suggested: int = 1) -> int:
|
||||||
|
"""Return the next strictly increasing sequence for a card update."""
|
||||||
|
current = self.card_sequence_dict.get(card_id, 0)
|
||||||
|
next_seq = max(current + 1, suggested)
|
||||||
|
self.card_sequence_dict[card_id] = next_seq
|
||||||
|
return next_seq
|
||||||
|
|
||||||
|
def _register_card_for_source(self, card_id: str, *source_ids: str) -> None:
|
||||||
|
"""Register a card_id under one or more source message ids."""
|
||||||
|
bucket = self.card_id_to_source_ids.setdefault(card_id, set())
|
||||||
|
for sid in source_ids:
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
self.reply_message_card_ids[sid] = card_id
|
||||||
|
bucket.add(sid)
|
||||||
|
|
||||||
|
def _drop_card_state(self, card_id: str) -> None:
|
||||||
|
"""Pop all per-card state for the given card_id."""
|
||||||
|
if not card_id:
|
||||||
|
return
|
||||||
|
for sid in self.card_id_to_source_ids.pop(card_id, set()):
|
||||||
|
self.reply_message_card_ids.pop(sid, None)
|
||||||
|
self.card_sequence_dict.pop(card_id, None)
|
||||||
|
self.card_streaming_text.pop(card_id, None)
|
||||||
|
self.card_pre_pause_text.pop(card_id, None)
|
||||||
|
self.card_resume_transitioned.discard(card_id)
|
||||||
|
|
||||||
async def create_card_id(self, message_id):
|
async def create_card_id(self, message_id):
|
||||||
try:
|
try:
|
||||||
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
||||||
@@ -1343,6 +1492,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_id_dict[message_id] = response.data.card_id
|
self.card_id_dict[message_id] = response.data.card_id
|
||||||
|
|
||||||
card_id = response.data.card_id
|
card_id = response.data.card_id
|
||||||
|
self.card_sequence_dict[card_id] = 0
|
||||||
return card_id
|
return card_id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1355,6 +1505,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""
|
"""
|
||||||
# message_id = event.message_chain.message_id
|
# message_id = event.message_chain.message_id
|
||||||
|
|
||||||
|
source_message_id = str(event.message_chain.message_id)
|
||||||
|
existing_card_id = self.reply_message_card_ids.get(source_message_id)
|
||||||
|
if existing_card_id:
|
||||||
|
self.card_id_dict[message_id] = existing_card_id
|
||||||
|
return True
|
||||||
|
|
||||||
card_id = await self.create_card_id(message_id)
|
card_id = await self.create_card_id(message_id)
|
||||||
content = {
|
content = {
|
||||||
'type': 'card',
|
'type': 'card',
|
||||||
@@ -1393,6 +1549,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
user_msg_id = event.message_chain.message_id
|
user_msg_id = event.message_chain.message_id
|
||||||
reply_msg_id = getattr(response.data, 'message_id', None)
|
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||||
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
|
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
|
||||||
|
# Register the card under both the user-incoming msg id (so a
|
||||||
|
# second reply_message_first_chunk for the same user message
|
||||||
|
# reuses this card) AND the bot-reply msg id (so a synthetic
|
||||||
|
# event from a form-button callback — whose Source.id equals
|
||||||
|
# the bot's card message id — hits the same card and renders
|
||||||
|
# the resume content into it).
|
||||||
|
if reply_msg_id:
|
||||||
|
self._register_card_for_source(card_id, str(user_msg_id), str(reply_msg_id))
|
||||||
|
else:
|
||||||
|
self._register_card_for_source(card_id, str(user_msg_id))
|
||||||
if reply_msg_id and monitoring_msg_id:
|
if reply_msg_id and monitoring_msg_id:
|
||||||
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
||||||
self._cleanup_monitoring_mapping()
|
self._cleanup_monitoring_mapping()
|
||||||
@@ -1401,6 +1567,93 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _open_new_form_card(
|
||||||
|
self,
|
||||||
|
message_id: str,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
) -> str | None:
|
||||||
|
"""Spawn a fresh card to host a re-paused human-input prompt.
|
||||||
|
|
||||||
|
Creates a new card_id (rebinding ``self.card_id_dict[message_id]``),
|
||||||
|
replies it to the current incoming message so it appears as the next
|
||||||
|
step in the chat, registers the new reply_msg_id so subsequent button
|
||||||
|
callbacks resolve back to it, and renders the prompt + buttons on it.
|
||||||
|
|
||||||
|
Returns the new card_id, or ``None`` if creation failed (caller is
|
||||||
|
responsible for falling back to in-place update so the workflow
|
||||||
|
remains continuable).
|
||||||
|
"""
|
||||||
|
source_message_id = getattr(message_source.message_chain, 'message_id', None)
|
||||||
|
if not source_message_id:
|
||||||
|
await self.logger.error('Cannot open new form card: source message_id missing')
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_card_id = await self.create_card_id(message_id)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to create new form card: {traceback.format_exc()}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
tenant_key = (
|
||||||
|
message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None
|
||||||
|
)
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder()
|
||||||
|
.app_ticket(self.app_ticket)
|
||||||
|
.tenant_key(tenant_key)
|
||||||
|
.app_access_token(app_access_token)
|
||||||
|
.tenant_access_token(tenant_access_token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
content = {
|
||||||
|
'type': 'card',
|
||||||
|
'data': {'card_id': new_card_id, 'template_variable': {'content': ''}},
|
||||||
|
}
|
||||||
|
request: ReplyMessageRequest = (
|
||||||
|
ReplyMessageRequest.builder()
|
||||||
|
.message_id(str(source_message_id))
|
||||||
|
.request_body(
|
||||||
|
ReplyMessageRequestBody.builder()
|
||||||
|
.content(json.dumps(content))
|
||||||
|
.msg_type('interactive')
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to send new form card: {traceback.format_exc()}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
await self.logger.error(
|
||||||
|
f'Failed to send new form card: code={response.code}, msg={response.msg}, '
|
||||||
|
f'log_id={response.get_log_id()}'
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||||
|
if reply_msg_id:
|
||||||
|
self._register_card_for_source(new_card_id, str(source_message_id), str(reply_msg_id))
|
||||||
|
|
||||||
|
sequence = self._next_card_sequence(new_card_id, 1)
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=new_card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message='',
|
||||||
|
sequence=sequence,
|
||||||
|
form_data=form_data,
|
||||||
|
show_form_prompt=True,
|
||||||
|
)
|
||||||
|
return new_card_id
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -1520,11 +1773,38 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
回复消息变成更新卡片消息
|
回复消息变成更新卡片消息
|
||||||
|
|
||||||
|
Supports Dify form-action resume: when the runner yields a chunk with
|
||||||
|
``_resume_from_form=True``, the card transitions from buttons to a
|
||||||
|
grey "已选择" notice and a new ``streaming_txt_resume`` element is added
|
||||||
|
for subsequent resume chunks to stream into.
|
||||||
|
|
||||||
|
When ``_open_new_card=True`` on the final chunk, the existing card is
|
||||||
|
left as-is and the pipeline will create a new card (with fresh form
|
||||||
|
buttons) for the re-pause.
|
||||||
"""
|
"""
|
||||||
# self.seq += 1
|
|
||||||
message_id = bot_message.resp_message_id
|
message_id = bot_message.resp_message_id
|
||||||
msg_seq = bot_message.msg_sequence
|
msg_seq = bot_message.msg_sequence
|
||||||
if msg_seq % 8 == 0 or is_final:
|
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
resume_from = getattr(bot_message, '_resume_from_form', False)
|
||||||
|
action_title = getattr(bot_message, '_resume_action_title', '')
|
||||||
|
resume_node_title = getattr(bot_message, '_resume_node_title', '')
|
||||||
|
open_new_card = getattr(bot_message, '_open_new_card', False)
|
||||||
|
if action_title:
|
||||||
|
if resume_node_title:
|
||||||
|
selected_notice = f'**{resume_node_title}**\n已选择:{action_title}'
|
||||||
|
else:
|
||||||
|
selected_notice = f'**已选择**:{action_title}'
|
||||||
|
else:
|
||||||
|
selected_notice = ''
|
||||||
|
|
||||||
|
# ── decide whether this chunk needs a card update ────────────────────
|
||||||
|
card_id = self.card_id_dict.get(message_id)
|
||||||
|
if not card_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── convert message chain → text ─────────────────────────────────────
|
||||||
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
||||||
|
|
||||||
text_message = ''
|
text_message = ''
|
||||||
@@ -1536,33 +1816,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
parts.append(para_text)
|
parts.append(para_text)
|
||||||
text_message = '\n\n'.join(parts)
|
text_message = '\n\n'.join(parts)
|
||||||
|
|
||||||
# content = {
|
|
||||||
# 'type': 'card_json',
|
|
||||||
# 'data': {'card_id': self.card_id_dict[message_id], 'elements': {'content': text_message}},
|
|
||||||
# }
|
|
||||||
|
|
||||||
request: ContentCardElementRequest = (
|
|
||||||
ContentCardElementRequest.builder()
|
|
||||||
.card_id(self.card_id_dict[message_id])
|
|
||||||
.element_id('streaming_txt')
|
|
||||||
.request_body(
|
|
||||||
ContentCardElementRequestBody.builder()
|
|
||||||
# .uuid("a0d69e20-1dd1-458b-k525-dfeca4015204")
|
|
||||||
.content(text_message)
|
|
||||||
.sequence(msg_seq)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
|
||||||
# self.seq = 1 # 消息回复结束之后重置seq
|
|
||||||
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
|
|
||||||
|
|
||||||
tenant_key = (
|
tenant_key = (
|
||||||
message_source.source_platform_object.header.tenant_key
|
message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None
|
||||||
if message_source.source_platform_object
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
app_access_token = self.get_app_access_token()
|
app_access_token = self.get_app_access_token()
|
||||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||||
@@ -1574,17 +1829,143 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
.tenant_access_token(tenant_access_token)
|
.tenant_access_token(tenant_access_token)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
# 发起请求
|
|
||||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)
|
|
||||||
|
|
||||||
# 处理失败返回
|
card_sequence = self._next_card_sequence(card_id, msg_seq)
|
||||||
if not response.success():
|
|
||||||
raise Exception(
|
# ── RESUME: first chunk after button click ───────────────────────────
|
||||||
f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
if resume_from and card_id not in self.card_resume_transitioned:
|
||||||
|
# Transition the card from the form state into resume mode.
|
||||||
|
# Preserve the text that was shown before the pause, and seed the
|
||||||
|
# resume placeholder with the current resume content if we already
|
||||||
|
# have any on the first yielded chunk.
|
||||||
|
pre_pause_text = self.card_pre_pause_text.get(card_id) or self.card_streaming_text.get(card_id, '')
|
||||||
|
initial_resume_text = text_message or '\u200b'
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=pre_pause_text,
|
||||||
|
sequence=card_sequence,
|
||||||
|
form_data=None,
|
||||||
|
notice_text=selected_notice,
|
||||||
|
resume_placeholder_text=initial_resume_text,
|
||||||
)
|
)
|
||||||
|
self.card_resume_transitioned.add(card_id)
|
||||||
|
self.card_pre_pause_text[card_id] = pre_pause_text
|
||||||
|
self.card_streaming_text[card_id] = text_message
|
||||||
|
if not is_final:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send media messages when streaming is done
|
# ── RESUME: subsequent chunks → full card update ─────────────────────
|
||||||
|
if resume_from and card_id in self.card_resume_transitioned:
|
||||||
|
cached = self.card_streaming_text.get(card_id, '')
|
||||||
|
if text_message != cached:
|
||||||
|
self.card_streaming_text[card_id] = text_message
|
||||||
|
pre_pause_text = self.card_pre_pause_text.get(card_id, '')
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=pre_pause_text,
|
||||||
|
sequence=card_sequence,
|
||||||
|
form_data=None,
|
||||||
|
notice_text=selected_notice,
|
||||||
|
resume_placeholder_text=text_message,
|
||||||
|
)
|
||||||
|
if not is_final:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── NORMAL streaming (non-resume): update streaming_txt in-place ──────
|
||||||
|
if not resume_from and (msg_seq % 8 == 0 or is_final):
|
||||||
|
cached = self.card_streaming_text.get(card_id)
|
||||||
|
if text_message != cached:
|
||||||
|
self.card_streaming_text[card_id] = text_message
|
||||||
|
request: ContentCardElementRequest = (
|
||||||
|
ContentCardElementRequest.builder()
|
||||||
|
.card_id(card_id)
|
||||||
|
.element_id('streaming_txt')
|
||||||
|
.request_body(
|
||||||
|
ContentCardElementRequestBody.builder().content(text_message).sequence(card_sequence).build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent(
|
||||||
|
request, req_opt
|
||||||
|
)
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.cardkit.v1.card_element.acontent failed, code: {response.code}, '
|
||||||
|
f'msg: {response.msg}, log_id: {response.get_log_id()}, '
|
||||||
|
f'resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── FINAL chunk: full card layout update ─────────────────────────────
|
||||||
|
if is_final:
|
||||||
|
final_seq = self._next_card_sequence(card_id, card_sequence + 1)
|
||||||
|
pre_pause = self.card_pre_pause_text.get(card_id, text_message)
|
||||||
|
resume_cached = self.card_streaming_text.get(card_id, '')
|
||||||
|
if form_data:
|
||||||
|
if open_new_card:
|
||||||
|
# The old card has already been laid out into resume mode
|
||||||
|
# by the resume-transition block above (notice + resume
|
||||||
|
# placeholder). Finalise it as a frozen step snapshot and
|
||||||
|
# spawn a brand-new card to host the next human-input
|
||||||
|
# prompt — each step stays visible as its own card in the
|
||||||
|
# chat history.
|
||||||
|
new_card_id = await self._open_new_form_card(message_id, message_source, form_data)
|
||||||
|
if new_card_id is None:
|
||||||
|
# Fallback: keep the existing in-place behaviour so the
|
||||||
|
# workflow remains continuable even if creating the
|
||||||
|
# new card failed.
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=pre_pause,
|
||||||
|
sequence=final_seq,
|
||||||
|
form_data=form_data,
|
||||||
|
resume_placeholder_text=resume_cached,
|
||||||
|
show_form_prompt=True,
|
||||||
|
)
|
||||||
|
self.card_streaming_text.pop(card_id, None)
|
||||||
|
self.card_pre_pause_text.pop(card_id, None)
|
||||||
|
else:
|
||||||
|
# The old card is now a frozen snapshot; let go of its
|
||||||
|
# streaming-side state but keep its source registrations
|
||||||
|
# intact (no _drop_card_state) so historical button
|
||||||
|
# callbacks aimed at it can still be matched if needed.
|
||||||
|
self.card_streaming_text.pop(card_id, None)
|
||||||
|
self.card_pre_pause_text.pop(card_id, None)
|
||||||
|
self.card_resume_transitioned.discard(card_id)
|
||||||
|
else:
|
||||||
|
# Initial pause path: render prompt + buttons in place on
|
||||||
|
# the current card.
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=text_message,
|
||||||
|
sequence=final_seq,
|
||||||
|
form_data=form_data,
|
||||||
|
show_form_prompt=True,
|
||||||
|
)
|
||||||
|
# The human-input prompt itself is rendered as buttons only
|
||||||
|
# on Lark, so do not keep the hidden fallback text around;
|
||||||
|
# otherwise it will resurface after the button click.
|
||||||
|
self.card_streaming_text[card_id] = ''
|
||||||
|
self.card_pre_pause_text[card_id] = ''
|
||||||
|
else:
|
||||||
|
# Normal finish: keep pre-pause + resume content visible,
|
||||||
|
# remove buttons/notice, drop the resume placeholder.
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=pre_pause,
|
||||||
|
sequence=final_seq,
|
||||||
|
form_data=None,
|
||||||
|
notice_text=selected_notice if resume_from else '',
|
||||||
|
resume_placeholder_text=resume_cached,
|
||||||
|
)
|
||||||
|
self._drop_card_state(card_id)
|
||||||
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|
||||||
|
# ── media (images / files) appended at the end ───────────────────────
|
||||||
if is_final and media_items:
|
if is_final and media_items:
|
||||||
for media in media_items:
|
for media in media_items:
|
||||||
media_request: ReplyMessageRequest = (
|
media_request: ReplyMessageRequest = (
|
||||||
@@ -1608,6 +1989,313 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}'
|
f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _add_form_buttons_to_card(
|
||||||
|
self,
|
||||||
|
card_id: str,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
text_message: str = '',
|
||||||
|
sequence: int = 1,
|
||||||
|
):
|
||||||
|
"""Update the entire card to include form action buttons.
|
||||||
|
|
||||||
|
Uses card.aupdate to replace the card JSON with a template that
|
||||||
|
includes the streaming text content plus interactive buttons.
|
||||||
|
"""
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=text_message,
|
||||||
|
sequence=sequence,
|
||||||
|
form_data=form_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _remove_form_buttons_from_card(
|
||||||
|
self,
|
||||||
|
card_id: str,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
text_message: str = '',
|
||||||
|
sequence: int = 1,
|
||||||
|
):
|
||||||
|
"""Replace the human-input card layout with the plain final layout."""
|
||||||
|
await self._update_card_layout(
|
||||||
|
card_id=card_id,
|
||||||
|
message_source=message_source,
|
||||||
|
text_message=text_message,
|
||||||
|
sequence=sequence,
|
||||||
|
form_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_card_layout(
|
||||||
|
self,
|
||||||
|
card_id: str,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
text_message: str = '',
|
||||||
|
sequence: int = 1,
|
||||||
|
form_data: dict | None = None,
|
||||||
|
notice_text: str = '',
|
||||||
|
resume_placeholder_text: str = '',
|
||||||
|
show_form_prompt: bool = True,
|
||||||
|
):
|
||||||
|
"""Update the entire card layout.
|
||||||
|
|
||||||
|
• form_data → show interactive buttons (initial Dify pause)
|
||||||
|
• notice_text → replace buttons with a grey "已选择" notice (resume transition)
|
||||||
|
• resume_placeholder_text → add a streaming_txt_resume markdown element
|
||||||
|
"""
|
||||||
|
form_data = form_data or {}
|
||||||
|
actions = form_data.get('actions', [])
|
||||||
|
form_token = form_data.get('form_token', '')
|
||||||
|
workflow_run_id = form_data.get('workflow_run_id', '')
|
||||||
|
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
||||||
|
form_content = form_data.get('form_content', '')
|
||||||
|
|
||||||
|
# When form_data is set, the visible content is rendered inside the
|
||||||
|
# interactive container, so the top streaming text should stay empty
|
||||||
|
# to avoid duplicate text above the action area.
|
||||||
|
#
|
||||||
|
# For resume notice state, keep the existing text visible in the card
|
||||||
|
# and only add the grey "selected" notice below it.
|
||||||
|
if form_data:
|
||||||
|
render_text_message = ''
|
||||||
|
else:
|
||||||
|
render_text_message = text_message
|
||||||
|
|
||||||
|
# Determine session key from message source
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
session_key = f'group_{message_source.group.id}'
|
||||||
|
else:
|
||||||
|
session_key = f'person_{message_source.sender.id}'
|
||||||
|
|
||||||
|
# Build button elements matching the existing card template's thumbsup/down format
|
||||||
|
action_buttons = []
|
||||||
|
for action in actions:
|
||||||
|
action_id = action.get('id', '')
|
||||||
|
action_title = action.get('title', action_id)
|
||||||
|
button_style = action.get('button_style', 'default')
|
||||||
|
|
||||||
|
if button_style == 'primary':
|
||||||
|
lark_button_type = 'primary'
|
||||||
|
elif button_style == 'danger':
|
||||||
|
lark_button_type = 'danger'
|
||||||
|
else:
|
||||||
|
lark_button_type = 'default'
|
||||||
|
|
||||||
|
action_buttons.append(
|
||||||
|
{
|
||||||
|
'tag': 'button',
|
||||||
|
'text': {'tag': 'plain_text', 'content': action_title},
|
||||||
|
'type': lark_button_type,
|
||||||
|
'width': 'fill',
|
||||||
|
'size': 'medium',
|
||||||
|
'hover_tips': {'tag': 'plain_text', 'content': action_title},
|
||||||
|
'behaviors': [
|
||||||
|
{
|
||||||
|
'type': 'callback',
|
||||||
|
'value': {
|
||||||
|
'form_action': True,
|
||||||
|
'form_token': form_token,
|
||||||
|
'workflow_run_id': workflow_run_id,
|
||||||
|
'action_id': action_id,
|
||||||
|
'session_key': session_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'margin': '0px 0px 0px 0px',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interactive_elements = []
|
||||||
|
if form_data:
|
||||||
|
if show_form_prompt:
|
||||||
|
interactive_elements = [
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': f'**[Human Input Required] {node_title}**',
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'margin': '0px 0px 4px 0px',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if form_content:
|
||||||
|
interactive_elements.append(
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': form_content,
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'margin': '0px 0px 8px 0px',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
interactive_elements.append(
|
||||||
|
{
|
||||||
|
'tag': 'column_set',
|
||||||
|
'horizontal_spacing': '8px',
|
||||||
|
'horizontal_align': 'left',
|
||||||
|
'margin': '0px 0px 0px 0px',
|
||||||
|
'columns': [
|
||||||
|
{
|
||||||
|
'tag': 'column',
|
||||||
|
'width': 'weighted',
|
||||||
|
'elements': [btn],
|
||||||
|
'padding': '0px 0px 0px 0px',
|
||||||
|
}
|
||||||
|
for btn in action_buttons
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the full card JSON with buttons, same structure as create_card_id
|
||||||
|
# ── mid_section: either form buttons, resume notice, or empty ──
|
||||||
|
mid_section_elements = []
|
||||||
|
if form_data:
|
||||||
|
mid_section_elements = [
|
||||||
|
{
|
||||||
|
'tag': 'interactive_container',
|
||||||
|
'margin': '12px 0px 8px 0px',
|
||||||
|
'padding': '12px 12px 12px 12px',
|
||||||
|
'has_border': True,
|
||||||
|
'elements': interactive_elements,
|
||||||
|
},
|
||||||
|
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
|
||||||
|
]
|
||||||
|
elif notice_text:
|
||||||
|
mid_section_elements = [
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': notice_text,
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'margin': '8px 0px 4px 0px',
|
||||||
|
'text_color': 'grey',
|
||||||
|
},
|
||||||
|
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── resume placeholder element (empty, filled via acontent on each chunk) ──
|
||||||
|
resume_elements = []
|
||||||
|
if resume_placeholder_text:
|
||||||
|
resume_elements = [
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': resume_placeholder_text,
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'margin': '0px 0px 0px 0px',
|
||||||
|
'element_id': 'streaming_txt_resume',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
card_data = {
|
||||||
|
'schema': '2.0',
|
||||||
|
'config': {
|
||||||
|
'update_multi': True,
|
||||||
|
'streaming_mode': False,
|
||||||
|
},
|
||||||
|
'body': {
|
||||||
|
'direction': 'vertical',
|
||||||
|
'padding': '12px 12px 12px 12px',
|
||||||
|
'elements': [
|
||||||
|
{
|
||||||
|
'tag': 'div',
|
||||||
|
'text': {
|
||||||
|
'tag': 'plain_text',
|
||||||
|
'content': 'LangBot',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_color': 'default',
|
||||||
|
},
|
||||||
|
'icon': {
|
||||||
|
'tag': 'custom_icon',
|
||||||
|
'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': render_text_message,
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'normal',
|
||||||
|
'margin': '0px 0px 0px 0px',
|
||||||
|
'element_id': 'streaming_txt',
|
||||||
|
},
|
||||||
|
*mid_section_elements,
|
||||||
|
*resume_elements,
|
||||||
|
{
|
||||||
|
'tag': 'column_set',
|
||||||
|
'horizontal_spacing': '12px',
|
||||||
|
'horizontal_align': 'right',
|
||||||
|
'columns': [
|
||||||
|
{
|
||||||
|
'tag': 'column',
|
||||||
|
'width': 'weighted',
|
||||||
|
'elements': [
|
||||||
|
{
|
||||||
|
'tag': 'markdown',
|
||||||
|
'content': '<font color="grey-600">以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看</font>',
|
||||||
|
'text_align': 'left',
|
||||||
|
'text_size': 'notation',
|
||||||
|
'margin': '4px 0px 0px 0px',
|
||||||
|
'icon': {
|
||||||
|
'tag': 'standard_icon',
|
||||||
|
'token': 'robot_outlined',
|
||||||
|
'color': 'grey',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'padding': '0px 0px 0px 0px',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'horizontal_spacing': '8px',
|
||||||
|
'vertical_spacing': '8px',
|
||||||
|
'horizontal_align': 'left',
|
||||||
|
'vertical_align': 'top',
|
||||||
|
'margin': '0px 0px 0px 0px',
|
||||||
|
'weight': 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'margin': '0px 0px 4px 0px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_key = (
|
||||||
|
message_source.source_platform_object.header.tenant_key
|
||||||
|
if message_source.source_platform_object
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder()
|
||||||
|
.app_ticket(self.app_ticket)
|
||||||
|
.tenant_key(tenant_key)
|
||||||
|
.app_access_token(app_access_token)
|
||||||
|
.tenant_access_token(tenant_access_token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
request: UpdateCardRequest = (
|
||||||
|
UpdateCardRequest.builder()
|
||||||
|
.card_id(card_id)
|
||||||
|
.request_body(
|
||||||
|
UpdateCardRequestBody.builder()
|
||||||
|
.sequence(sequence)
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.card(Card.builder().type('card_json').data(json.dumps(card_data)).build())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request, req_opt)
|
||||||
|
if not response.success():
|
||||||
|
await self.logger.error(
|
||||||
|
f'Failed to update lark card with form buttons: code={response.code}, msg={response.msg}, '
|
||||||
|
f'log_id={response.get_log_id()}, resp={getattr(getattr(response, "raw", None), "content", None)}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error updating lark card with form buttons: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -23,57 +23,6 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
|
||||||
label:
|
|
||||||
en_US: Platform Domain
|
|
||||||
zh_Hans: 平台域名
|
|
||||||
zh_Hant: 平台域名
|
|
||||||
ja_JP: プラットフォームドメイン
|
|
||||||
description:
|
|
||||||
en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international
|
|
||||||
zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark
|
|
||||||
zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark
|
|
||||||
ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用
|
|
||||||
type: select
|
|
||||||
options:
|
|
||||||
- name: https://open.feishu.cn
|
|
||||||
label:
|
|
||||||
en_US: Feishu (open.feishu.cn)
|
|
||||||
zh_Hans: 飞书 (open.feishu.cn)
|
|
||||||
zh_Hant: 飛書 (open.feishu.cn)
|
|
||||||
ja_JP: 飛書 (open.feishu.cn)
|
|
||||||
- name: https://open.larksuite.com
|
|
||||||
label:
|
|
||||||
en_US: Lark (open.larksuite.com)
|
|
||||||
zh_Hans: Lark (open.larksuite.com)
|
|
||||||
zh_Hant: Lark (open.larksuite.com)
|
|
||||||
ja_JP: Lark (open.larksuite.com)
|
|
||||||
- name: custom
|
|
||||||
label:
|
|
||||||
en_US: Custom
|
|
||||||
zh_Hans: 自定义
|
|
||||||
zh_Hant: 自定義
|
|
||||||
ja_JP: カスタム
|
|
||||||
required: false
|
|
||||||
default: https://open.feishu.cn
|
|
||||||
- name: custom_domain
|
|
||||||
label:
|
|
||||||
en_US: Custom Domain
|
|
||||||
zh_Hans: 自定义域名
|
|
||||||
zh_Hant: 自定義域名
|
|
||||||
ja_JP: カスタムドメイン
|
|
||||||
description:
|
|
||||||
en_US: "Enter the full domain URL, e.g. https://open.example.com"
|
|
||||||
zh_Hans: "输入完整的域名 URL,例如 https://open.example.com"
|
|
||||||
zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com"
|
|
||||||
ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
show_if:
|
|
||||||
field: domain
|
|
||||||
operator: eq
|
|
||||||
value: custom
|
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
en_US: One-Click Create App
|
en_US: One-Click Create App
|
||||||
@@ -191,10 +140,10 @@ spec:
|
|||||||
zh_Hant: 應用類型
|
zh_Hant: 應用類型
|
||||||
ja_JP: アプリタイプ
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview"
|
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview"
|
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview"
|
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください"
|
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
import telegram
|
import telegram
|
||||||
import telegram.ext
|
import telegram.ext
|
||||||
from telegram import Update
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
|
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
|
||||||
import telegramify_markdown
|
import telegramify_markdown
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
import json
|
||||||
import base64
|
import base64
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
@@ -189,6 +189,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: telegram.Bot = pydantic.Field(exclude=True)
|
bot: telegram.Bot = pydantic.Field(exclude=True)
|
||||||
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
||||||
|
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
||||||
event_converter: TelegramEventConverter = TelegramEventConverter()
|
event_converter: TelegramEventConverter = TelegramEventConverter()
|
||||||
@@ -224,6 +225,102 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
telegram_callback,
|
telegram_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
try:
|
||||||
|
data = json.loads(query.data)
|
||||||
|
if data.get('form_action') or data.get('f'):
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
workflow_run_id = data.get('workflow_run_id', '')
|
||||||
|
w_suffix = data.get('w', '')
|
||||||
|
action_id = data.get('action_id') or data.get('a', '')
|
||||||
|
session_key = data.get('session_key') or data.get('s', '')
|
||||||
|
|
||||||
|
if session_key.startswith('group_') or session_key.startswith('g:'):
|
||||||
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('g:')
|
||||||
|
else session_key[len('group_') :]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
launcher_type = provider_session.LauncherTypes.PERSON
|
||||||
|
launcher_id = (
|
||||||
|
session_key.split(':', 1)[1]
|
||||||
|
if session_key.startswith('p:')
|
||||||
|
else session_key[len('person_') :]
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = str(query.from_user.id)
|
||||||
|
|
||||||
|
# Find bot_uuid and pipeline_uuid
|
||||||
|
bot_uuid = ''
|
||||||
|
pipeline_uuid = None
|
||||||
|
for b in self.ap.platform_mgr.bots:
|
||||||
|
if b.adapter is self:
|
||||||
|
bot_uuid = b.bot_entity.uuid
|
||||||
|
pipeline_uuid = b.bot_entity.use_pipeline_uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
form_action_data = {
|
||||||
|
'workflow_run_id': workflow_run_id,
|
||||||
|
'w_suffix': w_suffix,
|
||||||
|
'action_id': action_id,
|
||||||
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
|
'inputs': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=f'[Form Action: {action_id}]')]
|
||||||
|
)
|
||||||
|
|
||||||
|
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||||
|
synthetic_event = platform_events.GroupMessage(
|
||||||
|
sender=platform_entities.GroupMember(
|
||||||
|
id=user_id,
|
||||||
|
member_name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
group=platform_entities.Group(
|
||||||
|
id=launcher_id,
|
||||||
|
name='',
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthetic_event = platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=user_id,
|
||||||
|
nickname='',
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
source_platform_object=update,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.query_pool.add_query(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
sender_id=user_id,
|
||||||
|
message_event=synthetic_event,
|
||||||
|
message_chain=message_chain,
|
||||||
|
adapter=self,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
variables={
|
||||||
|
'_dify_form_action': form_action_data,
|
||||||
|
'_routed_by_rule': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
application.add_handler(CallbackQueryHandler(callback_query_handler))
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -319,14 +416,19 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
update = event.source_platform_object
|
update = event.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
chat_type = update.effective_chat.type
|
chat_type = update.effective_chat.type
|
||||||
message_thread_id = update.message.message_thread_id
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if chat_type == 'private':
|
if chat_type == 'private':
|
||||||
draft_id = int(time.time() * 1000)
|
import time as _time
|
||||||
self.msg_stream_id[message_id] = ('private', draft_id)
|
|
||||||
|
|
||||||
|
draft_id = int(_time.time() * 1000)
|
||||||
|
self.msg_stream_id[message_id] = ('private', draft_id)
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
||||||
|
try:
|
||||||
await self.bot.send_message_draft(**args)
|
await self.bot.send_message_draft(**args)
|
||||||
|
except (telegram.error.RetryAfter, telegram.error.BadRequest):
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||||
send_msg = await self.bot.send_message(**args)
|
send_msg = await self.bot.send_message(**args)
|
||||||
@@ -347,12 +449,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
assert isinstance(message_source.source_platform_object, Update)
|
assert isinstance(message_source.source_platform_object, Update)
|
||||||
update = message_source.source_platform_object
|
update = message_source.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
message_thread_id = update.message.message_thread_id
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if message_id not in self.msg_stream_id:
|
if message_id not in self.msg_stream_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_mode, draft_id = self.msg_stream_id[message_id]
|
chat_mode, stream_id = self.msg_stream_id[message_id]
|
||||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||||
|
|
||||||
if not components or components[0]['type'] != 'text':
|
if not components or components[0]['type'] != 'text':
|
||||||
@@ -361,16 +464,42 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
content = components[0]['text']
|
content = components[0]['text']
|
||||||
|
form_data = getattr(bot_message, '_form_data', None)
|
||||||
|
|
||||||
|
if form_data and is_final:
|
||||||
|
self.msg_stream_id.pop(message_id, None)
|
||||||
|
await self._send_form_action_buttons(message_source, form_data)
|
||||||
|
return
|
||||||
|
|
||||||
if chat_mode == 'private':
|
if chat_mode == 'private':
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
|
# Streaming via draft (ephemeral preview in the chat input area)
|
||||||
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
|
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id)
|
||||||
|
try:
|
||||||
await self.bot.send_message_draft(**args)
|
await self.bot.send_message_draft(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||||
|
try:
|
||||||
|
await self.bot.send_message_draft(**args)
|
||||||
|
except telegram.error.RetryAfter:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pass # Ignore other draft errors (cosmetic)
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
del args['draft_id']
|
# Finalise: send the real message, discard the draft
|
||||||
|
args = self._build_message_args(chat_id, content, message_thread_id)
|
||||||
|
try:
|
||||||
await self.bot.send_message(**args)
|
await self.bot.send_message(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||||
|
await self.bot.send_message(**args)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
else:
|
else:
|
||||||
stream_id = draft_id
|
# Streaming via edit_message_text (persistent message)
|
||||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
args = {
|
args = {
|
||||||
'message_id': stream_id,
|
'message_id': stream_id,
|
||||||
@@ -379,11 +508,68 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
if self.config.get('markdown_card', False):
|
if self.config.get('markdown_card', False):
|
||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
|
try:
|
||||||
await self.bot.edit_message_text(**args)
|
await self.bot.edit_message_text(**args)
|
||||||
|
except telegram.error.BadRequest as exc:
|
||||||
|
if 'Message_too_long' in str(exc):
|
||||||
|
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
|
||||||
|
await self.bot.edit_message_text(**args)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
|
|
||||||
|
async def _send_form_action_buttons(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
form_data: dict,
|
||||||
|
):
|
||||||
|
"""Send inline keyboard buttons for Dify human_input_required form actions."""
|
||||||
|
actions = form_data.get('actions', [])
|
||||||
|
node_title = form_data.get('node_title', '')
|
||||||
|
form_content = form_data.get('form_content', '')
|
||||||
|
workflow_run_id = form_data.get('workflow_run_id', '')
|
||||||
|
# Telegram callback_data is capped at 64 bytes, so we identify the
|
||||||
|
# paused workflow by the last 8 chars of workflow_run_id (unique
|
||||||
|
# within a session with overwhelming probability).
|
||||||
|
w_suffix = workflow_run_id[-8:] if workflow_run_id else ''
|
||||||
|
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage):
|
||||||
|
session_key = f'g:{message_source.group.id}'
|
||||||
|
else:
|
||||||
|
session_key = f'p:{message_source.sender.id}'
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
for action in actions:
|
||||||
|
action_id = action.get('id', '')
|
||||||
|
action_title = action.get('title', action_id)
|
||||||
|
callback_payload = {'f': 1, 'a': action_id, 's': session_key}
|
||||||
|
if w_suffix:
|
||||||
|
callback_payload['w'] = w_suffix
|
||||||
|
callback_data = json.dumps(callback_payload, separators=(',', ':'))
|
||||||
|
keyboard.append([InlineKeyboardButton(action_title, callback_data=callback_data)])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
update = message_source.source_platform_object
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
effective_message = update.effective_message
|
||||||
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
|
text_lines = [f'[{node_title}] Please select an action:']
|
||||||
|
if form_content:
|
||||||
|
text_lines.insert(0, form_content)
|
||||||
|
args = {
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'text': '\n\n'.join(text_lines),
|
||||||
|
'reply_markup': reply_markup,
|
||||||
|
}
|
||||||
|
if message_thread_id:
|
||||||
|
args['message_thread_id'] = message_thread_id
|
||||||
|
|
||||||
|
await self.bot.send_message(**args)
|
||||||
|
|
||||||
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
if not isinstance(event.source_platform_object, Update):
|
if not isinstance(event.source_platform_object, Update):
|
||||||
return None
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
|||||||
from ..core import app
|
from ..core import app
|
||||||
from . import handler
|
from . import handler
|
||||||
from ..utils import platform
|
from ..utils import platform
|
||||||
from ..utils.managed_runtime import ManagedRuntimeConnector
|
|
||||||
from langbot_plugin.runtime.io.controllers.stdio import (
|
from langbot_plugin.runtime.io.controllers.stdio import (
|
||||||
client as stdio_client_controller,
|
client as stdio_client_controller,
|
||||||
)
|
)
|
||||||
@@ -40,9 +39,11 @@ class PluginRuntimeNotConnectedError(RuntimeError):
|
|||||||
"""Raised when plugin runtime operations are requested before connection."""
|
"""Raised when plugin runtime operations are requested before connection."""
|
||||||
|
|
||||||
|
|
||||||
class PluginRuntimeConnector(ManagedRuntimeConnector):
|
class PluginRuntimeConnector:
|
||||||
"""Plugin runtime connector"""
|
"""Plugin runtime connector"""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
handler: handler.RuntimeConnectionHandler
|
handler: handler.RuntimeConnectionHandler
|
||||||
|
|
||||||
handler_task: asyncio.Task
|
handler_task: asyncio.Task
|
||||||
@@ -53,6 +54,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
|
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
|
||||||
|
|
||||||
|
runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
runtime_subprocess_on_windows_task: asyncio.Task | None = None
|
||||||
|
|
||||||
runtime_disconnect_callback: typing.Callable[
|
runtime_disconnect_callback: typing.Callable[
|
||||||
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
||||||
]
|
]
|
||||||
@@ -67,7 +72,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
super().__init__(ap)
|
self.ap = ap
|
||||||
self.runtime_disconnect_callback = runtime_disconnect_callback
|
self.runtime_disconnect_callback = runtime_disconnect_callback
|
||||||
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
||||||
|
|
||||||
@@ -103,16 +108,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
self.handler_task = asyncio.create_task(self.handler.run())
|
self.handler_task = asyncio.create_task(self.handler.run())
|
||||||
_ = await self.handler.ping()
|
_ = await self.handler.ping()
|
||||||
# Push the configured marketplace (Space) URL to the runtime so it
|
|
||||||
# downloads plugins from the same Space LangBot is bound to, rather
|
|
||||||
# than relying on the runtime's own env/default.
|
|
||||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
|
||||||
if space_url:
|
|
||||||
try:
|
|
||||||
await self.handler.set_runtime_config(cloud_service_url=space_url)
|
|
||||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to push runtime config: {e}')
|
|
||||||
self.ap.logger.info('Connected to plugin runtime.')
|
self.ap.logger.info('Connected to plugin runtime.')
|
||||||
await self.handler_task
|
await self.handler_task
|
||||||
|
|
||||||
@@ -145,7 +140,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
# We have to launch runtime via cmd but communicate via ws.
|
# We have to launch runtime via cmd but communicate via ws.
|
||||||
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
||||||
|
|
||||||
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
|
if self.runtime_subprocess_on_windows is None: # only launch once
|
||||||
|
python_path = sys.executable
|
||||||
|
env = os.environ.copy()
|
||||||
|
self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec(
|
||||||
|
python_path,
|
||||||
|
'-m',
|
||||||
|
'langbot_plugin.cli.__init__',
|
||||||
|
'rt',
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# hold the process
|
||||||
|
self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait())
|
||||||
|
|
||||||
ws_url = 'ws://localhost:5400/control/ws'
|
ws_url = 'ws://localhost:5400/control/ws'
|
||||||
|
|
||||||
@@ -187,15 +194,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')
|
||||||
@@ -238,81 +236,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return plugin_author, plugin_name
|
return plugin_author, plugin_name
|
||||||
|
|
||||||
async def _install_mcp_from_marketplace(
|
|
||||||
self,
|
|
||||||
mcp_data: dict[str, Any],
|
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
|
||||||
):
|
|
||||||
"""Install an MCP server from marketplace data.
|
|
||||||
|
|
||||||
Marketplace MCP records carry the runtime-ready ``mode`` and
|
|
||||||
``extra_args`` directly (the same shape LangBot stores in
|
|
||||||
``mcp_servers``), so they are used as-is rather than reconstructed.
|
|
||||||
For ``stdio`` this preserves ``command``/``args``/``env``/``box``;
|
|
||||||
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
|
|
||||||
``ssereadtimeout``.
|
|
||||||
"""
|
|
||||||
from ..entity.persistence import mcp as persistence_mcp
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
mode = mcp_data.get('mode') or 'stdio'
|
|
||||||
extra_args = mcp_data.get('extra_args') or {}
|
|
||||||
# Use __ instead of / to avoid URL routing issues with slashes
|
|
||||||
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
|
||||||
|
|
||||||
# Check if MCP server already exists
|
|
||||||
existing = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
self.ap.logger.info(f'MCP server {name} already exists, skipping installation')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create MCP server record
|
|
||||||
server_uuid = str(uuid.uuid4())
|
|
||||||
server_data = {
|
|
||||||
'uuid': server_uuid,
|
|
||||||
'name': name,
|
|
||||||
'enable': True,
|
|
||||||
'mode': mode,
|
|
||||||
'extra_args': extra_args,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
|
||||||
|
|
||||||
# Start the MCP server
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
|
||||||
)
|
|
||||||
server_entity = result.first()
|
|
||||||
if server_entity:
|
|
||||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
|
|
||||||
if self.ap.tool_mgr.mcp_tool_loader:
|
|
||||||
mcp_task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
|
||||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task)
|
|
||||||
|
|
||||||
self.ap.logger.info(f'Installed MCP server {name} from marketplace')
|
|
||||||
|
|
||||||
async def _install_skill_from_zip(
|
|
||||||
self,
|
|
||||||
file_bytes: bytes,
|
|
||||||
filename: str,
|
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
|
||||||
):
|
|
||||||
"""Install a skill from marketplace ZIP data."""
|
|
||||||
from ..api.http.service.skill import SkillService
|
|
||||||
|
|
||||||
skill_service = SkillService(self.ap)
|
|
||||||
|
|
||||||
self.ap.logger.info(f'Installing skill from marketplace ZIP ({len(file_bytes)} bytes)')
|
|
||||||
|
|
||||||
# Install from ZIP using skill service
|
|
||||||
result = await skill_service.install_from_zip_upload(
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
filename=filename + '.zip',
|
|
||||||
)
|
|
||||||
self.ap.logger.info(f'Skill installed successfully: {result}')
|
|
||||||
|
|
||||||
def _build_plugin_startup_failure_message(
|
def _build_plugin_startup_failure_message(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -375,122 +298,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_author = install_info.get('plugin_author')
|
plugin_author = install_info.get('plugin_author')
|
||||||
plugin_name = install_info.get('plugin_name')
|
plugin_name = install_info.get('plugin_name')
|
||||||
|
|
||||||
if install_source == PluginInstallSource.MARKETPLACE:
|
|
||||||
# Handle marketplace plugin/mcp/skill installation
|
|
||||||
plugin_author = install_info.get('plugin_author', '')
|
|
||||||
plugin_name = install_info.get('plugin_name', '')
|
|
||||||
space_url = (
|
|
||||||
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try MCP endpoint first
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
|
|
||||||
mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}')
|
|
||||||
if mcp_resp.status_code == 200:
|
|
||||||
mcp_data = mcp_resp.json().get('data', {}).get('mcp', {})
|
|
||||||
if mcp_data.get('mode'):
|
|
||||||
# It's an MCP - create server locally
|
|
||||||
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('installing mcp server')
|
|
||||||
await self._install_mcp_from_marketplace(mcp_data, task_context)
|
|
||||||
# Best-effort install report (bumps marketplace install_count).
|
|
||||||
try:
|
|
||||||
await client.post(
|
|
||||||
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install'
|
|
||||||
)
|
|
||||||
except Exception as report_err:
|
|
||||||
self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
raise Exception(f'MCP {plugin_author}/{plugin_name} has no mode')
|
|
||||||
elif mcp_resp.status_code == 404:
|
|
||||||
# Try skill endpoint - download ZIP and install
|
|
||||||
self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}')
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('checking skill marketplace')
|
|
||||||
|
|
||||||
# Get skill detail to find version
|
|
||||||
skill_resp = await client.get(
|
|
||||||
f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}'
|
|
||||||
)
|
|
||||||
if skill_resp.status_code == 200:
|
|
||||||
self.ap.logger.info(f'Installing skill from marketplace: {plugin_author}/{plugin_name}')
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('installing skill from marketplace')
|
|
||||||
|
|
||||||
# Download the skill ZIP (no version needed - uses latest)
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('downloading skill package')
|
|
||||||
|
|
||||||
download_resp = await client.get(
|
|
||||||
f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}'
|
|
||||||
)
|
|
||||||
if download_resp.status_code != 200:
|
|
||||||
raise Exception(
|
|
||||||
f'Failed to download skill {plugin_author}/{plugin_name}: {download_resp.status_code}'
|
|
||||||
)
|
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
|
||||||
file_size = len(file_bytes)
|
|
||||||
self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)')
|
|
||||||
|
|
||||||
# Install skill from ZIP using skill service
|
|
||||||
await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context)
|
|
||||||
return
|
|
||||||
elif skill_resp.status_code == 404:
|
|
||||||
# Try plugin endpoint - get versions and download
|
|
||||||
self.ap.logger.info(f'Trying plugin endpoint for: {plugin_author}/{plugin_name}')
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('checking plugin marketplace')
|
|
||||||
|
|
||||||
# Get plugin versions to find latest
|
|
||||||
versions_resp = await client.get(
|
|
||||||
f'{space_url}/api/v1/marketplace/plugins/{plugin_author}/{plugin_name}/versions'
|
|
||||||
)
|
|
||||||
if versions_resp.status_code == 200:
|
|
||||||
versions_data = versions_resp.json().get('data', {}).get('versions', [])
|
|
||||||
if versions_data:
|
|
||||||
latest_version = versions_data[0].get('version', '')
|
|
||||||
if latest_version:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Installing plugin from marketplace: {plugin_author}/{plugin_name} v{latest_version}'
|
|
||||||
)
|
|
||||||
if task_context:
|
|
||||||
task_context.set_current_action('downloading plugin package')
|
|
||||||
|
|
||||||
download_resp = await client.get(
|
|
||||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}'
|
|
||||||
)
|
|
||||||
if download_resp.status_code != 200:
|
|
||||||
raise Exception(
|
|
||||||
f'Failed to download plugin {plugin_author}/{plugin_name}: {download_resp.status_code}'
|
|
||||||
)
|
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(
|
|
||||||
file_bytes,
|
|
||||||
task_context,
|
|
||||||
)
|
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
|
||||||
install_info['plugin_file_key'] = file_key
|
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
|
||||||
# Continue to install via runtime
|
|
||||||
else:
|
|
||||||
raise Exception(f'No version found for plugin {plugin_author}/{plugin_name}')
|
|
||||||
else:
|
|
||||||
raise Exception(f'Plugin {plugin_author}/{plugin_name} has no versions')
|
|
||||||
else:
|
|
||||||
raise Exception(f'Plugin {plugin_author}/{plugin_name} not found in marketplace')
|
|
||||||
else:
|
|
||||||
skill_resp.raise_for_status()
|
|
||||||
raise Exception(f'Failed to get skill {plugin_author}/{plugin_name}')
|
|
||||||
else:
|
|
||||||
mcp_resp.raise_for_status()
|
|
||||||
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
@@ -560,7 +367,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 +385,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 +409,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 +599,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,
|
||||||
@@ -858,18 +613,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)
|
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)
|
||||||
|
|
||||||
def dispose(self):
|
def dispose(self):
|
||||||
# On non-Windows stdio mode, terminate via the controller's process handle.
|
# No need to consider the shutdown on Windows
|
||||||
# On Windows, the managed subprocess is cleaned up by the base class.
|
# for Windows can kill processes and subprocesses chainly
|
||||||
if (
|
|
||||||
self.is_enable_plugin
|
if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController):
|
||||||
and hasattr(self, 'ctrl')
|
|
||||||
and isinstance(self.ctrl, stdio_client_controller.StdioClientController)
|
|
||||||
):
|
|
||||||
self.ap.logger.info('Terminating plugin runtime process...')
|
self.ap.logger.info('Terminating plugin runtime process...')
|
||||||
self.ctrl.process.terminate()
|
self.ctrl.process.terminate()
|
||||||
|
|
||||||
self._dispose_subprocess()
|
|
||||||
|
|
||||||
if self.heartbeat_task is not None:
|
if self.heartbeat_task is not None:
|
||||||
self.heartbeat_task.cancel()
|
self.heartbeat_task.cancel()
|
||||||
self.heartbeat_task = None
|
self.heartbeat_task = None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user