mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
Compare commits
107 Commits
feat/litel
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1113bafe28 | ||
|
|
897a708a13 | ||
|
|
fa31ddfe9c | ||
|
|
47f3da823a | ||
|
|
14c9a3a8c6 | ||
|
|
6c186661e6 | ||
|
|
432033678d | ||
|
|
b23e7b4416 | ||
|
|
90fb7305d0 | ||
|
|
ea96d37e60 | ||
|
|
8938ef7412 | ||
|
|
02aa244785 | ||
|
|
e43067406c | ||
|
|
c859fc37bb | ||
|
|
818f5926cd | ||
|
|
e0b573acf7 | ||
|
|
8c291fc974 | ||
|
|
173dc58272 | ||
|
|
1ea61adde6 | ||
|
|
b0e576dbb8 | ||
|
|
b793409bed | ||
|
|
5a66ce2340 | ||
|
|
dbefd3364e | ||
|
|
75cf36b0ae | ||
|
|
e916c2e463 | ||
|
|
c0f5f30f57 | ||
|
|
bd690a79f0 | ||
|
|
3dc579feb3 | ||
|
|
86d5148534 | ||
|
|
efdc3678b1 | ||
|
|
c351a3daed | ||
|
|
bfa5db767c | ||
|
|
9caef840c7 | ||
|
|
0bc68f3d3a | ||
|
|
996a5f1c95 | ||
|
|
b27e9c80cb | ||
|
|
119fd9f482 | ||
|
|
a2b38f5bf2 | ||
|
|
9bdebcdc5a | ||
|
|
2fd2c6aadc | ||
|
|
f9e07df539 | ||
|
|
d8d811e307 | ||
|
|
f23f343edc | ||
|
|
a7d90d196f | ||
|
|
a7a359fb41 | ||
|
|
e2712a8993 | ||
|
|
085a767f97 | ||
|
|
8b0f51641a | ||
|
|
1b35ca67c5 | ||
|
|
4c98889566 | ||
|
|
7948291a08 | ||
|
|
07dcc0ec03 | ||
|
|
e5b511de8f | ||
|
|
6f962c7028 | ||
|
|
195d1a9c8e | ||
|
|
d419ee4139 | ||
|
|
0cdecbbf36 | ||
|
|
563131ad52 | ||
|
|
33f4c0e028 | ||
|
|
565a6df77a | ||
|
|
76582a578e | ||
|
|
056c63cd64 | ||
|
|
32161e2bfd | ||
|
|
d560a1eff3 | ||
|
|
d7f08cb29f | ||
|
|
790588fa22 | ||
|
|
78e6b9866b | ||
|
|
e1501f86c5 | ||
|
|
92f9cd9626 | ||
|
|
6244ee4985 | ||
|
|
2b6dcfe9c7 | ||
|
|
dd96da895c | ||
|
|
bca710dbd4 | ||
|
|
47ade18596 | ||
|
|
733c9cdf16 | ||
|
|
bbc508d42f | ||
|
|
0551d22689 | ||
|
|
53d4edb609 | ||
|
|
f897987ac1 | ||
|
|
8e558ad3a1 | ||
|
|
47fe9bde03 | ||
|
|
5c3a619e2d | ||
|
|
e223edeb45 | ||
|
|
d2c3146334 | ||
|
|
7d9c8e3065 | ||
|
|
f12ed81e1e | ||
|
|
6d4d19b6d7 | ||
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
7eca3cdfca | ||
|
|
c40354f838 | ||
|
|
21a5b4658a | ||
|
|
073acaa053 | ||
|
|
38759b229d | ||
|
|
efe32e34ae | ||
|
|
46db4de11a | ||
|
|
170a6756f4 | ||
|
|
7330732f62 | ||
|
|
b08e5ca09a | ||
|
|
dff80a0c0a | ||
|
|
f54ae4b91c | ||
|
|
e5b3cced1f |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -21,6 +21,7 @@
|
||||
*请在方括号间写`x`以打勾 / Please tick the box with `x`*
|
||||
|
||||
- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
|
||||
- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md).
|
||||
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
|
||||
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.
|
||||
|
||||
|
||||
41
.github/workflows/cla.yml
vendored
Normal file
41
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
actions: write # re-run the failed CLA check after signing
|
||||
contents: read # signatures are stored in the remote langbot-app/cla repo
|
||||
pull-requests: write # post guidance comments, lock PR after merge
|
||||
statuses: write # set the commit status
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA.
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# repo-scope PAT with write access to langbot-app/cla
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md'
|
||||
remote-organization-name: 'langbot-app'
|
||||
remote-repository-name: 'cla'
|
||||
path-to-signatures: 'signatures/version1/cla.json'
|
||||
branch: 'main'
|
||||
allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*'
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently.
|
||||
|
||||
感谢您的贡献!合并前请阅读并签署[贡献者许可协议(CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。
|
||||
custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。'
|
||||
lock-pullrequest-aftermerge: true
|
||||
# SECURITY: this workflow runs on pull_request_target (it holds secrets and has
|
||||
# write access to the base repository). NEVER add an actions/checkout step that
|
||||
# checks out the PR's code here.
|
||||
143
AGENTS.md
143
AGENTS.md
@@ -1,81 +1,134 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.
|
||||
This file guides code agents (Claude Code, GitHub Copilot, OpenAI Codex, etc.) working in the LangBot project. `CLAUDE.md` is a symlink to this file.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
||||
LangBot is an open-source, LLM-native instant-messaging bot development platform. It aims to provide an out-of-the-box IM bot development experience with Agent, RAG, MCP and other LLM application capabilities, supporting mainstream global IM platforms and exposing rich APIs for custom development.
|
||||
|
||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
||||
LangBot has a comprehensive web frontend — almost every operation can be performed through it.
|
||||
|
||||
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
|
||||
- `./pkg`: The core python package of the project backend.
|
||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
||||
- `./templates`: Templates of config files, components, etc.
|
||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
||||
- `./docker`: docker-compose deployment files.
|
||||
- **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
|
||||
- **Frontend**: `web/` is a **Vite + React Router 7 + shadcn/ui + Tailwind CSS** SPA, managed by `pnpm`. (Note: this is NOT Next.js — the `dev` script is `vite`.)
|
||||
- **Backend framework**: Quart (the async flavour of Flask). The HTTP API and the pre-built web UI are both served by the backend on `http://127.0.0.1:5300`.
|
||||
|
||||
## Backend Development
|
||||
## Repository Layout
|
||||
|
||||
We use `uv` to manage dependencies.
|
||||
```
|
||||
LangBot/
|
||||
├── main.py # Entrypoint shim -> langbot.__main__.main()
|
||||
├── pyproject.toml # Python project + deps (uv), pins langbot-plugin==<x.y.z>
|
||||
├── src/langbot/
|
||||
│ ├── __main__.py # Real entrypoint, CLI args (--standalone-runtime, --standalone-box, --debug)
|
||||
│ ├── pkg/ # Core backend package
|
||||
│ │ ├── api/ # HTTP API controllers + services (Quart)
|
||||
│ │ ├── core/ # App bootstrap, stages, task manager
|
||||
│ │ ├── platform/ # IM platform adapters, bot managers, session managers
|
||||
│ │ ├── provider/ # LLM providers, requesters, tool providers
|
||||
│ │ ├── pipeline/ # Pipelines, stages, query pool
|
||||
│ │ ├── plugin/ # Bridge connecting LangBot to the plugin runtime (see below)
|
||||
│ │ ├── box/ # Code-sandbox subsystem (Docker / nsjail / E2B backends)
|
||||
│ │ ├── skill/ # Skill subsystem
|
||||
│ │ ├── rag/ , vector/ # RAG + vector store
|
||||
│ │ ├── command/ # Built-in commands
|
||||
│ │ ├── persistence/ # ORM models + Alembic migrations (SQLite & PostgreSQL)
|
||||
│ │ ├── storage/ # Object/file storage abstractions
|
||||
│ │ ├── config/, entity/, discover/, utils/, telemetry/, survey/
|
||||
│ ├── libs/ # Vendored SDKs (qq_official_api, wecom_api, etc.)
|
||||
│ └── templates/ # Config/component templates (e.g. templates/config.yaml)
|
||||
├── web/ # Frontend SPA (Vite + React Router 7 + shadcn + Tailwind)
|
||||
└── docker/ # docker-compose deployment files
|
||||
```
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
Full guide lives in the wiki: **["开发配置" / Dev Config](https://docs.langbot.app/zh/develop/dev-config)**. Summary:
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
uv sync --dev
|
||||
uv sync --dev # uv creates a .venv/ for you; point your editor's interpreter at it
|
||||
uv run main.py # serves API + web UI on http://127.0.0.1:5300
|
||||
```
|
||||
|
||||
Start the backend and run the project in development mode.
|
||||
On first run the config file is generated at `data/config.yaml`. DB is SQLite by default (zero setup); PostgreSQL is supported. Migrations run automatically on startup.
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
### Frontend
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:5300`.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
We use `pnpm` to manage dependencies.
|
||||
Requires Node.js + [pnpm](https://pnpm.io/installation).
|
||||
|
||||
```bash
|
||||
cd web
|
||||
cp .env.example .env
|
||||
cp .env.example .env # Windows: copy .env.example .env
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
|
||||
```
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:3000`.
|
||||
`pnpm dev` reads `VITE_API_BASE_URL` from `web/.env` so the dev frontend can reach the backend on port `5300`. In production the frontend is pre-built into static files served by the backend on the same origin.
|
||||
|
||||
## Plugin System Architecture
|
||||
### Code formatting
|
||||
|
||||
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.
|
||||
The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
|
||||
|
||||
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.
|
||||
```bash
|
||||
uv run pre-commit install
|
||||
```
|
||||
|
||||
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.
|
||||
## Plugin System
|
||||
|
||||
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.
|
||||
LangBot's plugin system (Plugin SDK, CLI `lbp`, Plugin Runtime, and the shared entity/API definitions) lives in a **separate repository**: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). LangBot depends on it via the pinned `langbot-plugin` package in `pyproject.toml`.
|
||||
|
||||
## Some Development Tips and Standards
|
||||
### Architecture (what to know inside this repo)
|
||||
|
||||
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.
|
||||
- Thus you should consider the i18n support in all aspects.
|
||||
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.
|
||||
- If you were asked to make a commit, please follow the commit message format:
|
||||
- format: <type>(<scope>): <subject>
|
||||
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
||||
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
||||
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
|
||||
- Plugins run as independent processes managed by the **Plugin Runtime**. The Runtime supports two control transports: `stdio` and `websocket`.
|
||||
- When LangBot is started directly by a user (not in a container), it spawns and connects to the Runtime over **stdio** (lightweight/personal use).
|
||||
- When LangBot runs in a container, it connects to a standalone Runtime over **WebSocket** (production).
|
||||
- The bridge code lives in `src/langbot/pkg/plugin/` (`connector.py`, `handler.py`).
|
||||
- Relevant config (`data/config.yaml`): `plugin.runtime_ws_url` (e.g. `ws://langbot_plugin_runtime:5400/control/ws`). Start LangBot with `--standalone-runtime` to make it connect to an externally-launched Runtime over WebSocket instead of spawning one over stdio.
|
||||
|
||||
### Debugging the Plugin Runtime / CLI / SDK
|
||||
|
||||
This is documented in detail in the **SDK repo's `AGENTS.md`** and in the wiki page **["调试插件运行时、CLI、SDK" / Plugin Runtime](https://docs.langbot.app/zh/develop/plugin-runtime)**. The short version:
|
||||
|
||||
- Clone `LangBot` and `langbot-plugin-sdk` as siblings under one parent dir so the editor resolves shared entities.
|
||||
- Start a standalone Runtime from the SDK repo: `uv run --no-sync lbp rt` (control port `5400`, debug port `5401`).
|
||||
- To make LangBot use a locally-modified SDK: from the SDK dir, with LangBot's `.venv` active, run `uv pip install .`, then launch LangBot with `uv run --no-sync main.py --standalone-runtime` (keep `--no-sync` so your local SDK isn't overwritten).
|
||||
|
||||
### Debugging the Box (sandbox) runtime
|
||||
|
||||
The Box subsystem (`src/langbot/pkg/box/`) is the code sandbox. It picks the first available backend among **Docker / nsjail / E2B**. The standalone Box runtime is launched via the SDK CLI: `lbp box`. Backend selection details, the `lbp box` flags, and the SDK-side architecture are documented in the SDK repo's `AGENTS.md`.
|
||||
|
||||
Relevant config (`data/config.yaml`, `box:` section): `box.enabled` (master switch — disabling it also disables the native sandbox tools, skill add/edit, and stdio-mode MCP servers), `box.backend` (`'local'` = Docker/nsjail auto-pick, or `'docker'` / `'nsjail'` / `'e2b'`; also settable via `BOX__BACKEND`), and `box.runtime.endpoint` (external Box runtime base URL, e.g. `ws://127.0.0.1:5410`; empty = local auto-managed runtime). Like the plugin runtime, LangBot can connect to an externally-launched Box runtime by setting that endpoint and starting with `--standalone-box`.
|
||||
|
||||
> A common false "No supported sandbox backend (Docker / nsjail / E2B) is available" comes from Docker being installed and running but the current user not being in the `docker` group → `docker info` gets `permission denied` on the socket. Fix: `sudo usermod -aG docker <user>` and restart the backend in a shell that has the new group.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- LangBot is a global project: **all code comments and docstrings must be in English**, and every user-facing string must support **i18n** (`en_US` + `zh_Hans` at minimum, plus `ja_JP` where the repo already has it).
|
||||
- LangBot is adopted in both toC and toB scenarios — always consider compatibility and security.
|
||||
- **Commit message format**: `<type>(<scope>): <subject>`
|
||||
- `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, etc.
|
||||
- `scope`: the affected package/module/file/class.
|
||||
- `subject`: concise description of the change.
|
||||
|
||||
### Database migrations (Alembic)
|
||||
|
||||
LangBot uses [Alembic](https://alembic.sqlalchemy.org/) for migrations, supporting both SQLite and PostgreSQL from a single set of scripts. Migration files live in `src/langbot/pkg/persistence/alembic/versions/`.
|
||||
|
||||
If you change ORM model definitions, generate a migration:
|
||||
|
||||
```bash
|
||||
# Run from the project root (requires data/config.yaml to exist)
|
||||
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
||||
```
|
||||
|
||||
Review and edit the generated script before committing. Migrations execute automatically on startup. `autogenerate` detects schema changes (add/drop columns, tables, type changes) but **data migrations** (e.g. mutating JSON field contents) must be hand-written into the generated script. `env.py` sets `render_as_batch=True`, so SQLite's ALTER TABLE limits are handled automatically — no need to branch per database. More in the wiki ["开发配置"](https://docs.langbot.app/zh/develop/dev-config#数据库迁移).
|
||||
|
||||
## Some Principles
|
||||
|
||||
- Keep it simple, stupid.
|
||||
- Entities should not be multiplied unnecessarily
|
||||
- Entities should not be multiplied unnecessarily.
|
||||
- 八荣八耻
|
||||
|
||||
以瞎猜接口为耻,以认真查询为荣。
|
||||
|
||||
107
CLA.md
Normal file
107
CLA.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# LangBot Individual Contributor License Agreement (v1.0)
|
||||
|
||||
Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us").
|
||||
|
||||
This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses.
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
## 4. Authority; Employer
|
||||
|
||||
You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us.
|
||||
|
||||
## 5. Original Creation; Disclosure
|
||||
|
||||
You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
## 6. No Obligation of Support; Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. Third-Party Works
|
||||
|
||||
Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
## 8. Notification
|
||||
|
||||
You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
## 9. Electronic Signature
|
||||
|
||||
This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable).
|
||||
|
||||
## 10. Our Commitment
|
||||
|
||||
We commit that the Project's main repository will continue to make an open source version of the Work publicly available.
|
||||
|
||||
## 11. Miscellaneous
|
||||
|
||||
This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy.
|
||||
|
||||
---
|
||||
|
||||
# LangBot 个人贡献者许可协议(v1.0)中文参考译文
|
||||
|
||||
> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。
|
||||
|
||||
感谢您有意为 LangBot(下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。
|
||||
|
||||
本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。
|
||||
|
||||
## 1. 定义
|
||||
|
||||
"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。
|
||||
|
||||
"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"(Not a Contribution)的通信除外。
|
||||
|
||||
## 2. 版权许可的授予
|
||||
|
||||
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。
|
||||
|
||||
## 3. 专利许可的授予
|
||||
|
||||
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。
|
||||
|
||||
## 4. 权利能力与雇主
|
||||
|
||||
您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。
|
||||
|
||||
## 5. 原创性声明与披露义务
|
||||
|
||||
您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。
|
||||
|
||||
## 6. 无支持义务;免责声明
|
||||
|
||||
您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"(AS IS)提供,不附带任何明示或默示的保证或条件,包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。
|
||||
|
||||
## 7. 第三方作品
|
||||
|
||||
如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。
|
||||
|
||||
## 8. 通知义务
|
||||
|
||||
如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。
|
||||
|
||||
## 9. 电子签署
|
||||
|
||||
本协议以电子方式接受并签署:您通过您的 GitHub 账号,在本项目代码仓库的拉取请求(pull request)中发表包含我方指定语句(现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。
|
||||
|
||||
## 10. 我方承诺
|
||||
|
||||
我方承诺本项目主仓库将持续公开提供作品的开源版本。
|
||||
|
||||
## 11. 其他
|
||||
|
||||
本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。
|
||||
@@ -14,6 +14,12 @@
|
||||
- 在 PR 和 Commit Message 中请使用全英文
|
||||
- 对于中文用户,issue 中可以使用中文
|
||||
|
||||
### 贡献者许可协议(CLA)
|
||||
|
||||
为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。
|
||||
|
||||
签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。
|
||||
|
||||
<hr/>
|
||||
|
||||
## Guidelines
|
||||
@@ -29,3 +35,9 @@
|
||||
|
||||
- Use English in PRs and Commit Messages
|
||||
- For English users, you can use English in issues
|
||||
|
||||
### Contributor License Agreement (CLA)
|
||||
|
||||
To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution.
|
||||
|
||||
Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected.
|
||||
|
||||
26
Dockerfile
26
Dockerfile
@@ -6,6 +6,25 @@ COPY web ./web
|
||||
|
||||
RUN cd web && npm install && npx vite build
|
||||
|
||||
# Build nsjail from source so the image ships a self-contained sandbox backend
|
||||
# that needs no host Docker socket. Pinned to a release tag for reproducibility.
|
||||
# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev)
|
||||
# out of the final image; only the nsjail binary and its small runtime libs
|
||||
# (libprotobuf, libnl-route-3) are carried over.
|
||||
FROM python:3.12.7-slim AS nsjail-build
|
||||
|
||||
ARG NSJAIL_VERSION=3.6
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates git build-essential \
|
||||
autoconf bison flex libtool pkg-config \
|
||||
protobuf-compiler libprotobuf-dev libnl-route-3-dev \
|
||||
&& git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \
|
||||
&& make -C /nsjail \
|
||||
&& install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM python:3.12.7-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -14,8 +33,15 @@ COPY . .
|
||||
|
||||
COPY --from=node /app/web/dist ./web/dist
|
||||
|
||||
# nsjail binary built in the dedicated stage above. Self-contained sandbox
|
||||
# backend; lets the Box runtime isolate code without a host Docker socket.
|
||||
COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||
# nsjail runtime libraries (the build toolchain stays in the nsjail-build
|
||||
# stage; only these shared libs are needed to execute the binary).
|
||||
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \
|
||||
# Install the Docker CLI (client only) so the optional langbot_box
|
||||
# service can drive the mounted host Docker socket and create sandbox
|
||||
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||
|
||||
@@ -38,7 +38,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
|
||||
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
|
||||
@@ -78,7 +78,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://qm.qq.com/q/DxZZcNxM1W)
|
||||
[](https://qm.qq.com/q/IrlV8QFacU)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
@@ -38,7 +38,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
@@ -78,7 +78,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
|
||||
### Capacidades Clave
|
||||
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
|
||||
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
||||
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
|
||||
### Capacités Clés
|
||||
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
||||
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
|
||||
### 主な機能
|
||||
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
|
||||
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
|
||||
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
|
||||
### Ключевые возможности
|
||||
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
||||
@@ -79,7 +79,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
|
||||
### Khả năng chính
|
||||
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
||||
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide
|
||||
|
||||
[简体中文](#简体中文) | [English](#english)
|
||||
|
||||
---
|
||||
|
||||
## 简体中文
|
||||
|
||||
### 概述
|
||||
|
||||
本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Kubernetes 集群(版本 1.19+)
|
||||
- `kubectl` 命令行工具已配置并可访问集群
|
||||
- 集群中有可用的存储类(StorageClass)用于持久化存储(可选但推荐)
|
||||
- 至少 2 vCPU 和 4GB RAM 的可用资源
|
||||
|
||||
### 架构说明
|
||||
|
||||
Kubernetes 部署包含以下组件:
|
||||
|
||||
1. **langbot**: 主应用服务
|
||||
- 提供 Web UI(端口 5300)
|
||||
- 处理平台 webhook(端口 2280-2290)
|
||||
- 数据持久化卷
|
||||
|
||||
2. **langbot-plugin-runtime**: 插件运行时服务
|
||||
- WebSocket 通信(端口 5400)
|
||||
- 插件数据持久化卷
|
||||
|
||||
3. **持久化存储**:
|
||||
- `langbot-data`: LangBot 主数据
|
||||
- `langbot-plugins`: 插件文件
|
||||
- `langbot-plugin-runtime-data`: 插件运行时数据
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 下载部署文件
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
|
||||
# 或直接下载 kubernetes.yaml
|
||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
||||
```
|
||||
|
||||
#### 2. 部署到 Kubernetes
|
||||
|
||||
```bash
|
||||
# 应用所有配置
|
||||
kubectl apply -f kubernetes.yaml
|
||||
|
||||
# 检查部署状态
|
||||
kubectl get all -n langbot
|
||||
|
||||
# 查看 Pod 日志
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
```
|
||||
|
||||
#### 3. 访问 LangBot
|
||||
|
||||
默认情况下,LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问:
|
||||
|
||||
**选项 A: 端口转发(推荐用于测试)**
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
然后访问 http://localhost:5300
|
||||
|
||||
**选项 B: NodePort(适用于开发环境)**
|
||||
|
||||
编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 获取节点 IP
|
||||
kubectl get nodes -o wide
|
||||
# 访问 http://<NODE_IP>:30300
|
||||
```
|
||||
|
||||
**选项 C: LoadBalancer(适用于云环境)**
|
||||
|
||||
编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 获取外部 IP
|
||||
kubectl get svc -n langbot langbot-loadbalancer
|
||||
# 访问 http://<EXTERNAL_IP>
|
||||
```
|
||||
|
||||
**选项 D: Ingress(推荐用于生产环境)**
|
||||
|
||||
确保集群中已安装 Ingress Controller(如 nginx-ingress),然后:
|
||||
|
||||
1. 编辑 `kubernetes.yaml` 中的 Ingress 配置
|
||||
2. 修改域名为您的实际域名
|
||||
3. 应用配置:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 访问 http://langbot.yourdomain.com
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
#### 环境变量
|
||||
|
||||
在 `ConfigMap` 中配置环境变量:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: langbot-config
|
||||
namespace: langbot
|
||||
data:
|
||||
TZ: "Asia/Shanghai" # 修改为您的时区
|
||||
```
|
||||
|
||||
#### 存储配置
|
||||
|
||||
默认使用动态存储分配。如果您有特定的 StorageClass,请在 PVC 中指定:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
storageClassName: your-storage-class-name
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
#### 资源限制
|
||||
|
||||
根据您的需求调整资源限制:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2000m"
|
||||
```
|
||||
|
||||
### 常用操作
|
||||
|
||||
#### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看 LangBot 主服务日志
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
|
||||
# 查看插件运行时日志
|
||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
||||
```
|
||||
|
||||
#### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启 LangBot
|
||||
kubectl rollout restart deployment/langbot -n langbot
|
||||
|
||||
# 重启插件运行时
|
||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
||||
```
|
||||
|
||||
#### 更新镜像
|
||||
|
||||
```bash
|
||||
# 更新到最新版本
|
||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
||||
|
||||
# 检查更新状态
|
||||
kubectl rollout status deployment/langbot -n langbot
|
||||
```
|
||||
|
||||
#### 扩容(不推荐)
|
||||
|
||||
注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。
|
||||
|
||||
#### 备份数据
|
||||
|
||||
```bash
|
||||
# 备份 PVC 数据
|
||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
||||
```
|
||||
|
||||
### 卸载
|
||||
|
||||
```bash
|
||||
# 删除所有资源(保留 PVC)
|
||||
kubectl delete deployment,service,configmap -n langbot --all
|
||||
|
||||
# 删除 PVC(会删除数据)
|
||||
kubectl delete pvc -n langbot --all
|
||||
|
||||
# 删除命名空间
|
||||
kubectl delete namespace langbot
|
||||
```
|
||||
|
||||
### 故障排查
|
||||
|
||||
#### Pod 无法启动
|
||||
|
||||
```bash
|
||||
# 查看 Pod 状态
|
||||
kubectl get pods -n langbot
|
||||
|
||||
# 查看详细信息
|
||||
kubectl describe pod -n langbot <pod-name>
|
||||
|
||||
# 查看事件
|
||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
#### 存储问题
|
||||
|
||||
```bash
|
||||
# 检查 PVC 状态
|
||||
kubectl get pvc -n langbot
|
||||
|
||||
# 检查 PV
|
||||
kubectl get pv
|
||||
```
|
||||
|
||||
#### 网络访问问题
|
||||
|
||||
```bash
|
||||
# 检查 Service
|
||||
kubectl get svc -n langbot
|
||||
|
||||
# 检查端口转发
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
### 生产环境建议
|
||||
|
||||
1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0`
|
||||
2. **配置资源限制**:根据实际负载调整 CPU 和内存限制
|
||||
3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理
|
||||
4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具
|
||||
5. **定期备份**:配置自动备份策略保护数据
|
||||
6. **使用专用 StorageClass**:为生产环境配置高性能存储
|
||||
7. **配置亲和性规则**:确保 Pod 调度到合适的节点
|
||||
|
||||
### 高级配置
|
||||
|
||||
#### 使用 Secrets 管理敏感信息
|
||||
|
||||
如果需要配置 API 密钥等敏感信息:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: langbot-secrets
|
||||
namespace: langbot
|
||||
type: Opaque
|
||||
data:
|
||||
api_key: <base64-encoded-value>
|
||||
```
|
||||
|
||||
然后在 Deployment 中引用:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: langbot-secrets
|
||||
key: api_key
|
||||
```
|
||||
|
||||
#### 配置水平自动扩缩容(HPA)
|
||||
|
||||
注意:需要确保使用 ReadWriteMany 存储类型
|
||||
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: langbot-hpa
|
||||
namespace: langbot
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: langbot
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
### 参考资源
|
||||
|
||||
- [LangBot 官方文档](https://docs.langbot.app)
|
||||
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Overview
|
||||
|
||||
This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster (version 1.19+)
|
||||
- `kubectl` command-line tool configured with cluster access
|
||||
- Available StorageClass in the cluster for persistent storage (optional but recommended)
|
||||
- At least 2 vCPU and 4GB RAM of available resources
|
||||
|
||||
### Architecture
|
||||
|
||||
The Kubernetes deployment includes the following components:
|
||||
|
||||
1. **langbot**: Main application service
|
||||
- Provides Web UI (port 5300)
|
||||
- Handles platform webhooks (ports 2280-2290)
|
||||
- Data persistence volume
|
||||
|
||||
2. **langbot-plugin-runtime**: Plugin runtime service
|
||||
- WebSocket communication (port 5400)
|
||||
- Plugin data persistence volume
|
||||
|
||||
3. **Persistent Storage**:
|
||||
- `langbot-data`: LangBot main data
|
||||
- `langbot-plugins`: Plugin files
|
||||
- `langbot-plugin-runtime-data`: Plugin runtime data
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### 1. Download Deployment Files
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
|
||||
# Or download kubernetes.yaml directly
|
||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
||||
```
|
||||
|
||||
#### 2. Deploy to Kubernetes
|
||||
|
||||
```bash
|
||||
# Apply all configurations
|
||||
kubectl apply -f kubernetes.yaml
|
||||
|
||||
# Check deployment status
|
||||
kubectl get all -n langbot
|
||||
|
||||
# View Pod logs
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
```
|
||||
|
||||
#### 3. Access LangBot
|
||||
|
||||
By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:
|
||||
|
||||
**Option A: Port Forwarding (Recommended for testing)**
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
Then visit http://localhost:5300
|
||||
|
||||
**Option B: NodePort (Suitable for development)**
|
||||
|
||||
Edit `kubernetes.yaml`, uncomment the NodePort Service section, then:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Get node IP
|
||||
kubectl get nodes -o wide
|
||||
# Visit http://<NODE_IP>:30300
|
||||
```
|
||||
|
||||
**Option C: LoadBalancer (Suitable for cloud environments)**
|
||||
|
||||
Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Get external IP
|
||||
kubectl get svc -n langbot langbot-loadbalancer
|
||||
# Visit http://<EXTERNAL_IP>
|
||||
```
|
||||
|
||||
**Option D: Ingress (Recommended for production)**
|
||||
|
||||
Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:
|
||||
|
||||
1. Edit the Ingress configuration in `kubernetes.yaml`
|
||||
2. Change the domain to your actual domain
|
||||
3. Apply configuration:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Visit http://langbot.yourdomain.com
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
Configure environment variables in ConfigMap:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: langbot-config
|
||||
namespace: langbot
|
||||
data:
|
||||
TZ: "Asia/Shanghai" # Change to your timezone
|
||||
```
|
||||
|
||||
#### Storage Configuration
|
||||
|
||||
Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
storageClassName: your-storage-class-name
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
#### Resource Limits
|
||||
|
||||
Adjust resource limits based on your needs:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2000m"
|
||||
```
|
||||
|
||||
### Common Operations
|
||||
|
||||
#### View Logs
|
||||
|
||||
```bash
|
||||
# View LangBot main service logs
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
|
||||
# View plugin runtime logs
|
||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
||||
```
|
||||
|
||||
#### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart LangBot
|
||||
kubectl rollout restart deployment/langbot -n langbot
|
||||
|
||||
# Restart plugin runtime
|
||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
||||
```
|
||||
|
||||
#### Update Images
|
||||
|
||||
```bash
|
||||
# Update to latest version
|
||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
||||
|
||||
# Check update status
|
||||
kubectl rollout status deployment/langbot -n langbot
|
||||
```
|
||||
|
||||
#### Scaling (Not Recommended)
|
||||
|
||||
Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.
|
||||
|
||||
#### Backup Data
|
||||
|
||||
```bash
|
||||
# Backup PVC data
|
||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Delete all resources (keep PVCs)
|
||||
kubectl delete deployment,service,configmap -n langbot --all
|
||||
|
||||
# Delete PVCs (will delete data)
|
||||
kubectl delete pvc -n langbot --all
|
||||
|
||||
# Delete namespace
|
||||
kubectl delete namespace langbot
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Pods Not Starting
|
||||
|
||||
```bash
|
||||
# Check Pod status
|
||||
kubectl get pods -n langbot
|
||||
|
||||
# View detailed information
|
||||
kubectl describe pod -n langbot <pod-name>
|
||||
|
||||
# View events
|
||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
#### Storage Issues
|
||||
|
||||
```bash
|
||||
# Check PVC status
|
||||
kubectl get pvc -n langbot
|
||||
|
||||
# Check PV
|
||||
kubectl get pv
|
||||
```
|
||||
|
||||
#### Network Access Issues
|
||||
|
||||
```bash
|
||||
# Check Service
|
||||
kubectl get svc -n langbot
|
||||
|
||||
# Test port forwarding
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
### Production Recommendations
|
||||
|
||||
1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`
|
||||
2. **Configure resource limits**: Adjust CPU and memory limits based on actual load
|
||||
3. **Use Ingress + TLS**: Configure HTTPS access and certificate management
|
||||
4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana
|
||||
5. **Regular backups**: Configure automated backup strategy to protect data
|
||||
6. **Use dedicated StorageClass**: Configure high-performance storage for production
|
||||
7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
#### Using Secrets for Sensitive Information
|
||||
|
||||
If you need to configure sensitive information like API keys:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: langbot-secrets
|
||||
namespace: langbot
|
||||
type: Opaque
|
||||
data:
|
||||
api_key: <base64-encoded-value>
|
||||
```
|
||||
|
||||
Then reference in Deployment:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: langbot-secrets
|
||||
key: api_key
|
||||
```
|
||||
|
||||
#### Configure Horizontal Pod Autoscaling (HPA)
|
||||
|
||||
Note: Requires ReadWriteMany storage type
|
||||
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: langbot-hpa
|
||||
namespace: langbot
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: langbot
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Docker Compose configuration for LangBot
|
||||
# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl apply -f kubernetes.yaml
|
||||
#
|
||||
@@ -8,13 +10,15 @@
|
||||
# - A Kubernetes cluster (1.19+)
|
||||
# - kubectl configured to communicate with your cluster
|
||||
# - (Optional) A StorageClass for dynamic volume provisioning
|
||||
# - For the Box sandbox runtime: a node with a reachable Docker daemon
|
||||
# (the box mounts the node's /var/run/docker.sock). See the deployment guide.
|
||||
#
|
||||
# Components:
|
||||
# - Namespace: langbot
|
||||
# - PersistentVolumeClaims for data persistence
|
||||
# - Deployments for langbot and langbot_plugin_runtime
|
||||
# - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox)
|
||||
# - Services for network access
|
||||
# - ConfigMap for timezone configuration
|
||||
# - ConfigMap for timezone + runtime endpoints
|
||||
|
||||
---
|
||||
# Namespace
|
||||
@@ -83,6 +87,11 @@ metadata:
|
||||
data:
|
||||
TZ: "Asia/Shanghai"
|
||||
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
|
||||
# Box sandbox runtime endpoint. LangBot connects to the Box runtime over
|
||||
# WebSocket. The hostname MUST match the langbot-box Service name. Note the
|
||||
# in-container default ("langbot_box") uses an underscore, which is an
|
||||
# invalid Kubernetes DNS name — so the endpoint is always set explicitly here.
|
||||
BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410"
|
||||
|
||||
---
|
||||
# Deployment for LangBot Plugin Runtime
|
||||
@@ -169,6 +178,136 @@ spec:
|
||||
protocol: TCP
|
||||
name: runtime
|
||||
|
||||
---
|
||||
# Deployment for LangBot Box (sandbox) runtime
|
||||
#
|
||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||
# dashboard renders cleanly with sandbox features disabled.
|
||||
#
|
||||
# IMPORTANT — how the sandbox actually runs:
|
||||
# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box
|
||||
# runtime therefore creates sandbox containers by talking to a Docker daemon
|
||||
# over the mounted socket (`/var/run/docker.sock`). Because that daemon
|
||||
# resolves bind-mount paths on the NODE filesystem, the Box workspace root
|
||||
# must be the SAME absolute path inside the box container, inside every
|
||||
# sandbox container it spawns, AND on the node. That is why this manifest uses
|
||||
# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box
|
||||
# to the same node via podAffinity. A normal PVC will NOT work for the box
|
||||
# workspace, because the node's dockerd cannot see paths that exist only
|
||||
# inside the pod's mount namespace.
|
||||
#
|
||||
# Security note: mounting the host Docker socket grants the Box runtime (and any
|
||||
# code executed in the sandbox) effective root on the node. Only deploy Box on
|
||||
# nodes you trust for this workload, ideally a dedicated node pool. For a
|
||||
# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and
|
||||
# drop the docker.sock mount + hostPath entirely.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: langbot-box
|
||||
namespace: langbot
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: langbot-box
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
# Pin to the same node as langbot so they share the hostPath box root.
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: langbot
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: langbot-box
|
||||
image: rockchin/langbot:latest
|
||||
imagePullPolicy: Always
|
||||
# Launched through the same CLI entry point as the plugin runtime.
|
||||
# No flag => WebSocket control transport (default), listening on 5410.
|
||||
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
||||
ports:
|
||||
- containerPort: 5410
|
||||
name: box-rpc
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: TZ
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: TZ
|
||||
# The Box runtime does NOT read box.local.* / BOX__* from its own env;
|
||||
# it receives its configuration from LangBot via the INIT RPC action.
|
||||
# Do not add BOX__* here — they would be silently ignored.
|
||||
volumeMounts:
|
||||
# Box workspace root — identical path on node, box, and sandbox
|
||||
# containers (see the IMPORTANT note above).
|
||||
- name: box-root
|
||||
mountPath: /app/data/box
|
||||
# Host Docker socket — the sandbox backend uses it to create containers.
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: box-root
|
||||
hostPath:
|
||||
path: /app/data/box
|
||||
type: DirectoryOrCreate
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
type: Socket
|
||||
restartPolicy: Always
|
||||
|
||||
---
|
||||
# Service for LangBot Box runtime
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: langbot-box
|
||||
namespace: langbot
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: langbot-box
|
||||
ports:
|
||||
- port: 5410
|
||||
targetPort: 5410
|
||||
protocol: TCP
|
||||
name: box-rpc
|
||||
|
||||
---
|
||||
# Deployment for LangBot
|
||||
apiVersion: apps/v1
|
||||
@@ -213,11 +352,36 @@ spec:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: PLUGIN__RUNTIME_WS_URL
|
||||
# Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box
|
||||
# Service over WebSocket. Remove this (and the langbot-box Deployment)
|
||||
# and set BOX__ENABLED=false if you do not want the sandbox.
|
||||
- name: BOX__RUNTIME__ENDPOINT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: BOX__RUNTIME__ENDPOINT
|
||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||
# identically on both sides and on the node's Docker daemon.
|
||||
- name: BOX__LOCAL__HOST_ROOT
|
||||
value: "/app/data/box"
|
||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||
value: "default"
|
||||
- name: BOX__LOCAL__SKILLS_ROOT
|
||||
value: "skills"
|
||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||
value: "/app/data/box"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: plugins
|
||||
mountPath: /app/plugins
|
||||
# Same node-level box root as the langbot-box Deployment. Mounted over
|
||||
# the data PVC's /app/data/box subpath so both LangBot and the Box
|
||||
# runtime (and the node's dockerd) agree on one absolute path.
|
||||
- name: box-root
|
||||
mountPath: /app/data/box
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
@@ -250,6 +414,13 @@ spec:
|
||||
- name: plugins
|
||||
persistentVolumeClaim:
|
||||
claimName: langbot-plugins
|
||||
# Node-level box workspace root, shared with the langbot-box Deployment.
|
||||
# hostPath (not PVC) because the node's Docker daemon must see the same
|
||||
# absolute path when bind-mounting workspaces into sandbox containers.
|
||||
- name: box-root
|
||||
hostPath:
|
||||
path: /app/data/box
|
||||
type: DirectoryOrCreate
|
||||
restartPolicy: Always
|
||||
|
||||
---
|
||||
|
||||
150
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
150
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Agent-owned Context 协议设计
|
||||
|
||||
本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。
|
||||
|
||||
> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
### 1.1 Agent 拥有上下文策略
|
||||
|
||||
不同 runner 背后的 runtime 差异很大:
|
||||
|
||||
- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。
|
||||
- Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。
|
||||
- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。
|
||||
|
||||
因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / artifact / state API、可投影给外部 harness 的 scoped context / SDK-owned MCP bridge / resource handles、payload hard cap 和权限 guardrail。
|
||||
|
||||
### 1.2 Host 不定义通用历史窗口
|
||||
|
||||
历史窗口策略不是 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。
|
||||
|
||||
正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是:
|
||||
|
||||
- 这类 runner 是否自管 context?
|
||||
- 事件到来时 host 应 inline 哪些最小信息?
|
||||
- agent 需要更多上下文时通过什么 API 拉取?
|
||||
- host 如何保证安全、可审计和可分页?
|
||||
|
||||
### 1.3 Host 保存事实源,Agent 管理 working context
|
||||
|
||||
三类数据要分开:
|
||||
|
||||
- `EventLog`: Host 保存原始事件、工具调用、投递结果、错误和系统事件。
|
||||
- `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
|
||||
- `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。
|
||||
|
||||
LangBot 不提供 host-side inline history window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。
|
||||
|
||||
## 2. Event 到来时传什么
|
||||
|
||||
默认 `AgentRunContext`(PROTOCOL_V1 §5.2)应尽量小且稳定。默认规则:
|
||||
|
||||
- Host MUST NOT inline full history by default.
|
||||
- Host SHOULD inline only current event / input and context handles.
|
||||
- Runner owns working-context assembly.
|
||||
- Runner MAY use Host history / event / artifact / state / storage API when authorized.
|
||||
- Official runners MUST consume Host infrastructure through the same public API as third-party runners.
|
||||
|
||||
### 2.1 必须 inline 的内容
|
||||
|
||||
当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata 和 artifact ref;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。
|
||||
|
||||
### 2.2 默认不 inline 的内容
|
||||
|
||||
完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。
|
||||
|
||||
### 2.3 不提供 Host Inline History Window
|
||||
|
||||
`AgentRunContext` 不包含 `bootstrap` 字段。Host 不下发历史窗口,也不通过 Pipeline 配置决定窗口大小。runner 若需要类似 `recent_tail` 的策略,应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 history API 读取、裁剪和压缩。Host 只负责权限、分页、hard cap 和事实源。
|
||||
|
||||
## 3. ContextAccess 的作用
|
||||
|
||||
`ContextAccess`(PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent:当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限。
|
||||
|
||||
## 4. Agent 如何获取更多上下文
|
||||
|
||||
所有 API 都走 `AgentRunAPIProxy`(PROTOCOL_V1 §8),由 host 用 `run_id` 校验。
|
||||
|
||||
外部 harness 不能直接访问 LangBot 资源。无论是 history、event、artifact、state、model、tool、knowledge base,还是 LangBot skills,都必须通过 SDK runtime 转发到 Host API,并由 Host 按 active `run_id`、runner identity、binding resource policy 和 caller plugin identity 校验。harness 自己的 native tools 只属于 harness 执行环境,不能绕过 SDK runtime 访问 LangBot 内部资源。
|
||||
|
||||
### 4.1 History
|
||||
|
||||
```python
|
||||
await api.history_page(conversation_id=ctx.context.conversation_id,
|
||||
before_cursor=ctx.context.latest_cursor,
|
||||
limit=50, direction="backward", include_artifacts=False)
|
||||
```
|
||||
|
||||
返回 `HistoryPage`(schema 见 PROTOCOL_V1 §8)。
|
||||
|
||||
约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;返回 artifact ref,不默认返回大文件内容。
|
||||
|
||||
### 4.2 Search
|
||||
|
||||
```python
|
||||
await api.history_search(query="用户之前提到的数据库连接信息",
|
||||
filters={"conversation_id": ..., "event_types": ["message.received"]},
|
||||
top_k=10)
|
||||
```
|
||||
|
||||
Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。
|
||||
|
||||
### 4.3 Event / Artifact / State
|
||||
|
||||
- Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。
|
||||
- Artifact API(`artifact_metadata` / `artifact_read` / `artifact_read_range`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/file-key 读取,工具大结果也应 artifact 化。
|
||||
- State API(`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id`、`summary.checkpoint`。
|
||||
|
||||
### 4.4 大文件与工具协作
|
||||
|
||||
大文件、多模态输入和工具产物不要内联进 prompt 或 tool result:message/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref(`artifact_id`、`mime_type`、`size`、`digest`、`summary`、`expires_at`、`permissions`)。工具之间传递大结果时传 artifact ref,不传完整 blob。Host 校验 artifact 是否属于当前 run / scope,默认不允许插件直接读任意本地路径;临时文件应有 TTL 和清理机制。
|
||||
|
||||
### 4.5 External harness context projection
|
||||
|
||||
外部 harness 的总体边界以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8 为准。本节只描述 context projection 的推荐形态。
|
||||
|
||||
Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态:
|
||||
|
||||
- `agent-context.json`:结构化 JSON,包含 `run_id`、`event`、`actor`、`subject`、`input`、`delivery`、`resources`、`context`、`state`、`runtime`。
|
||||
- `LANGBOT_CONTEXT.md`:人类可读摘要。
|
||||
- `resources`:只包含本次 run 授权后的资源句柄和能力摘要,不暴露 Host 内部私有对象、secret 或资源内容。
|
||||
- `skills`:LangBot skills 不是直接投影给 harness native tool loop 的文件能力;已授权 skill 应由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools`、`AgentRunAPIProxy` 或 SDK-owned MCP bridge 暴露。
|
||||
- `MCP config`:只投影 per-run、scoped 的 SDK-owned bridge 或外部 MCP 连接配置;LangBot 资源访问必须回到 SDK runtime / Host API,不允许 harness 通过自带 MCP/native tool 直接读 Host 内部资源。
|
||||
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。
|
||||
|
||||
当前官方外部 harness 路径由 LiteLLM Agent Platform runner 承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。
|
||||
|
||||
## 5. Runner 上下文边界
|
||||
|
||||
Host 只给当前事件、当前输入和 context handles。Runner 是否能拉取历史、事件、artifact、state 或 storage,以运行时 `ctx.context.available_apis` 为准;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。
|
||||
|
||||
## 6. KV cache 友好的上下文管理
|
||||
|
||||
支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt:
|
||||
|
||||
- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。
|
||||
- 每轮只传 delta:当前 event、artifact refs、少量 runtime metadata。
|
||||
- 历史 append-only:不要每轮改写同一段 history 文本。
|
||||
- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。
|
||||
- 大文件和工具结果 artifact 化。
|
||||
- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。
|
||||
- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。
|
||||
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。
|
||||
|
||||
稳定 session key 的用途是隔离外部 runtime 的 resume/cache/state,不是改变 PROTOCOL_V1 §13 定义的 Agent 复用和 dispatch 边界。只有当某个外部 harness 的同一 native session 不支持并发 turn 时,runner 或 future runtime control plane 才应按 external session key 做 turn-level 串行化。
|
||||
|
||||
对长期运行的 external harness / daemon,推荐运行形态是 reader 与 writer 分离:一个 session reader 独占读取 stdout/SSE/native event stream,并把 native event 转成 `AgentRunResult` 或 task progress;用户输入只作为 turn write 进入该 session。当前一次性 CLI subprocess runner 可以继续在单次 `run(ctx)` 内同步收集 stdout,但后续改成长连接时不应让多个 request 同时读取同一 native stream。
|
||||
|
||||
## 7. Host guardrail
|
||||
|
||||
Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / artifact read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。
|
||||
|
||||
外部 harness 的 native tools、shell、MCP 或 skill 机制不构成 LangBot 资源授权边界。只要访问的是 LangBot 持有的资源,就必须经 SDK runtime 转发并接受 Host 校验;完整边界见 HOST_SDK §4.8。
|
||||
|
||||
## 8. 官方 runner 与业务编排边界
|
||||
|
||||
官方 runner 插件可以把状态寄宿在 LangBot,但必须和第三方 runner 一样通过公开 Host API 消费。LangBot core 不内置官方 agent 的业务流程(prompt 组装、tool loop、RAG 编排、summary/compaction、"local-agent 专用"状态字段)。
|
||||
|
||||
官方 local-agent 应作为"依附 LangBot 基础设施的复杂 runner 参考实现":transcript/history 通过 `api.history_page()` / `api.history_search()` 读取,summary/checkpoint/外部 session id/用户偏好通过 `api.state_get()` / `api.state_set()` 或 storage 方法保存,图片/文件/工具大结果通过 `api.artifact_metadata()` / `api.artifact_read_range()` 读取,模型/工具/知识库通过 `api.invoke_llm()` / `api.call_tool()` / `api.retrieve_knowledge()` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
|
||||
227
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal file
227
docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Agent Runner QA 指南
|
||||
|
||||
本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。
|
||||
|
||||
目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。
|
||||
|
||||
## 1. 测试边界
|
||||
|
||||
当前主线验证的是 AgentRunner Protocol v1:
|
||||
|
||||
```text
|
||||
event -> binding -> runner.run(ctx) -> result stream
|
||||
```
|
||||
|
||||
本指南验证:
|
||||
|
||||
- Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。
|
||||
- Runner 来自插件 registry,而不是旧内置 runner 分支。
|
||||
- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。
|
||||
- 外部 harness runner(当前为 LiteLLM Agent Platform 统一入口)能消费 event-first context,并把外部 session 指针写回 host-owned state。
|
||||
- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。
|
||||
|
||||
本指南不验证:
|
||||
|
||||
- Runtime Control Plane v2。
|
||||
- EventGateway / EventRouter 完整落地由外部 EBA 分支联调;本指南只验证本分支 Host 底座。
|
||||
- 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。
|
||||
- 所有外部服务 runner 的真实凭据联调。
|
||||
|
||||
这些属于后续能力或发布门槛,分别见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 与 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
|
||||
## 2. 状态定义
|
||||
|
||||
测试报告只使用以下状态:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| PASS | 按步骤执行,用户可见行为和日志证据都满足通过条件。 |
|
||||
| FAIL | 环境可用,但行为不满足通过条件。 |
|
||||
| BLOCKED | 凭据、CLI、外部服务、测试数据或本地配置缺失导致无法执行。必须写清阻塞原因。 |
|
||||
| N/A | 当前 runner 或平台明确不支持该能力。必须引用 manifest、文档或配置说明。 |
|
||||
|
||||
不能使用“看起来正常”“大概通过”“基本没问题”等模糊状态。
|
||||
|
||||
## 3. 执行顺序
|
||||
|
||||
推荐按以下顺序执行,前一层失败时不要继续扩大测试面:
|
||||
|
||||
1. Host / SDK / runner 单测。
|
||||
2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。
|
||||
3. `local-agent` 高价值场景。
|
||||
4. LiteLLM Agent Platform 外部 harness smoke。
|
||||
5. 权限和错误路径补充检查。
|
||||
6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。
|
||||
|
||||
用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。
|
||||
|
||||
## 4. 必跑基线
|
||||
|
||||
### 4.1 单测基线
|
||||
|
||||
在 LangBot 仓库运行:
|
||||
|
||||
```bash
|
||||
uv run --frozen pytest tests/unit_tests/agent
|
||||
```
|
||||
|
||||
如果本次改动只触及默认配置或 API service,也至少补跑相关目标测试,例如:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py
|
||||
```
|
||||
|
||||
通过条件:
|
||||
|
||||
- agent 单测全 PASS,或失败项已确认与本次 agent-runner 路径无关。
|
||||
- 若失败来自 `context_builder`、`orchestrator`、`session_registry`、`resource_builder`、`plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。
|
||||
|
||||
### 4.2 环境基线
|
||||
|
||||
用 `langbot-skills` 做环境检查:
|
||||
|
||||
```bash
|
||||
cd "$LANGBOT_SKILLS_REPO"
|
||||
bin/lbs env doctor
|
||||
bin/lbs case list
|
||||
```
|
||||
|
||||
`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case,而不是临时发明测试路径。
|
||||
|
||||
推荐首批 case:
|
||||
|
||||
- `webui-login-state`
|
||||
- `pipeline-debug-chat`
|
||||
- `local-agent-basic-debug-chat`
|
||||
- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge)
|
||||
- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy)
|
||||
|
||||
## 5. WebUI 主链路 Smoke
|
||||
|
||||
### 5.1 Runner registry
|
||||
|
||||
步骤:
|
||||
|
||||
1. 打开 WebUI Pipeline 配置页。
|
||||
2. 查看 AI runner 下拉列表。
|
||||
3. 选择 `plugin:langbot/local-agent/default`。
|
||||
4. 保存并刷新页面。
|
||||
|
||||
通过条件:
|
||||
|
||||
- runner 选项来自插件 registry。
|
||||
- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。
|
||||
- `runner_config` 表示 Agent/runner config,不表示插件实例状态。
|
||||
- 不读取或回写旧 `ai.runner.runner` 字段。
|
||||
- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。
|
||||
- 插件没有循环重启或 metadata 加载失败。
|
||||
|
||||
### 5.2 主聊天路径
|
||||
|
||||
步骤:
|
||||
|
||||
1. 使用绑定 `plugin:langbot/local-agent/default` 的 Pipeline。
|
||||
2. 在 Debug Chat 发送确定性普通文本。
|
||||
3. 查看 WebUI 回复和后端日志。
|
||||
|
||||
通过条件:
|
||||
|
||||
- 用户可见回复正常。
|
||||
- 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`。
|
||||
- 不走旧内置 local-agent 主执行分支。
|
||||
- conversation transcript 写入用户消息和助手消息。
|
||||
|
||||
## 6. `local-agent` 高价值测试
|
||||
|
||||
只保留最能覆盖架构边界的场景。
|
||||
|
||||
| ID | 场景 | 操作 | 通过条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 |
|
||||
| LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 inline history window。 |
|
||||
| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 |
|
||||
| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 |
|
||||
| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 |
|
||||
| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 |
|
||||
| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 |
|
||||
| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 |
|
||||
| LA-09 | steering / 运行中追加消息 | 使用支持 steering 的 runner,第一条消息触发长 run;run 未结束时在同 conversation 追加第二条消息。 | 第二条消息被 active run claim,不启动并发 run;runner 通过 `steering_pull` 看到追加输入;EventLog 有 `queued` -> `steering.injected`,若未消费则有 `steering.dropped` 终态;后续普通消息仍可处理。 |
|
||||
|
||||
Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。
|
||||
|
||||
## 7. LiteLLM Agent Platform Harness Smoke
|
||||
|
||||
这些测试用于验证 Claude Code / Codex 这类自管 runtime 经 LiteLLM Agent Platform 能走同一条 Host 协议路径。若 LiteLLM Agent Platform 服务不可用、目标 harness 没有 CLI/登录态/代理配置,标记 BLOCKED,不要伪造 PASS。
|
||||
|
||||
Smoke 前应优先保留一层轻量单测或 fixture 测试:LiteLLM Agent Platform HTTP session、消息发送、结果解析、`run_id` 提示词注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。
|
||||
|
||||
### 7.1 LiteLLM Agent Platform runner
|
||||
|
||||
步骤:
|
||||
|
||||
1. 确认 LiteLLM Agent Platform 服务可访问,目标 harness(例如 Claude Code 或 Codex)在该服务所在机器上可执行且已登录。
|
||||
2. 绑定 `plugin:langbot/litellm-agent-platform-agent/default`。
|
||||
3. 配置 `base-url`、`api-mode`、`agent-id` 或 `harness` 等必要字段。
|
||||
4. 在 Debug Chat 执行一次确定性真实 smoke。
|
||||
5. 检查 LangBot MCP gateway、`run_id` 回填和 host-owned state。
|
||||
|
||||
通过条件:
|
||||
|
||||
- WebUI 可见回复包含预期 sentinel。
|
||||
- 发送给 LiteLLM 的消息包含当前 LangBot `run_id` 和可访问资源摘要。
|
||||
- Harness 通过 gateway 调用 `langbot_history_page`、`langbot_retrieve_knowledge` 或 `langbot_call_tool` 时必须携带正确 `run_id`;错误 run id 被拒绝。
|
||||
- `external.session_id` 写入 host-owned state。
|
||||
- LiteLLM 服务错误、timeout、empty output 都转成受控 `run.failed`。
|
||||
- resume 到同一 external session 时,全局锁边界符合 PROTOCOL_V1 §13。
|
||||
|
||||
### 7.2 API 型外部 runner
|
||||
|
||||
Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。
|
||||
|
||||
通过条件:
|
||||
|
||||
- runner 可选,配置可保存。
|
||||
- 请求成功,或外部服务错误被清晰返回。
|
||||
- 外部服务凭据缺失时标记 BLOCKED,并记录缺失项。
|
||||
|
||||
## 8. 权限与隔离补充
|
||||
|
||||
以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。
|
||||
|
||||
| 场景 | 推荐证据 |
|
||||
| --- | --- |
|
||||
| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 |
|
||||
| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 |
|
||||
| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 |
|
||||
| run_id 结束后复用被拒绝 | session registry 注销测试。 |
|
||||
| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 |
|
||||
| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 |
|
||||
| 未注册 Runtime 连接伪造插件身份被剥离 | SDK runtime forwarding 测试:请求自带 `caller_plugin_identity` 时,未注册连接转发前必须 `pop`,已注册连接必须覆盖为真实插件身份。 |
|
||||
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
|
||||
| steering claim 异常不杀 consumer loop | controller 单测:无效 runner / registry 异常只让当前消息回到普通 session 槽位路径,消息消费循环继续。 |
|
||||
| steering queue 未消费有终态 | session registry / orchestrator 单测:队列有上限;run unregister 时未 pull 项写 `steering.dropped` 审计。 |
|
||||
|
||||
如果这些单测失败,不能用 WebUI 正常回复替代。
|
||||
|
||||
## 9. 证据要求
|
||||
|
||||
每轮测试报告至少记录:
|
||||
|
||||
- LangBot commit、SDK commit、相关 runner 插件 commit。
|
||||
- Pipeline UUID/name、runner id、关键 runner config 摘要。
|
||||
- WebUI 截图或 Playwright 操作记录。
|
||||
- 后端日志中对应 query id / run id 的关键行。
|
||||
- `langbot-skills` case/report 路径。
|
||||
- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。
|
||||
- FAIL/BLOCKED 的复现步骤和归属仓库建议。
|
||||
|
||||
报告结论必须回答:
|
||||
|
||||
- 是否建议继续进入下一阶段测试。
|
||||
- 是否存在主聊天路径阻塞。
|
||||
- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。
|
||||
- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。
|
||||
|
||||
## 10. 历史高价值记录
|
||||
|
||||
历史高价值记录与当前 runner 验收状态见 [STATUS.md](./STATUS.md)。本指南只保留可重复执行的测试步骤和证据要求。
|
||||
92
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal file
92
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Event Based Agent 接入设计
|
||||
|
||||
> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。
|
||||
>
|
||||
> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。
|
||||
> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
|
||||
本文描述 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把协议边界说清楚,避免当前消息入口继续绑死 Pipeline 和用户文本消息。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。
|
||||
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`。
|
||||
- AgentRunner 通过同一套 orchestrator 被调用。
|
||||
- 非消息事件不伪造成用户文本消息。
|
||||
- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。
|
||||
|
||||
## 2. 事件不是消息
|
||||
|
||||
`message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。
|
||||
|
||||
| event_type | actor | subject | input |
|
||||
| --- | --- | --- | --- |
|
||||
| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 |
|
||||
| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 |
|
||||
| `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 |
|
||||
| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 |
|
||||
| `schedule.triggered` | 系统 | 定时任务 | 任务 payload |
|
||||
| `api.invoked` | API caller | API request | request payload |
|
||||
|
||||
## 3. 稳定事件名
|
||||
|
||||
先保留的稳定事件名(作为插件协议的一部分保持稳定):
|
||||
|
||||
- `message.received`
|
||||
- `message.recalled`
|
||||
- `group.member_joined`
|
||||
- `friend.request_received`
|
||||
|
||||
平台原始事件名只能进入 `ctx.event.source_event_type` / `raw_ref`,不能成为 `ctx.event.event_type` 的公共契约。
|
||||
|
||||
## 4. Event Envelope 与 Binding
|
||||
|
||||
- 入口事件用 `AgentEventEnvelope`(HOST_SDK §4.1)承载;顶层字段使用 LangBot 稳定协议名,平台原始事件名和原始 payload 放 `metadata` / `raw_ref`。
|
||||
- 触发关系用 `AgentBinding`(HOST_SDK §4.2)表达。EBA 阶段 binding 通过 `event_types`、`scope`、`filters` 决定哪些事件触发当前 bot / channel 绑定的 Agent。
|
||||
|
||||
EBA dispatch 基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准;本节只说明外部 EBA 分支的 EventRouter 如何产出当前 v1 主线需要的 binding。
|
||||
|
||||
Binding scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。旧 Pipeline 可迁移为 `message.received` 的临时 binding source,但目标持久配置应是 Agent,不是 Pipeline。
|
||||
|
||||
Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui`、`http_api`、`scheduler`、`system`。EventRouter 不应写死平台 adapter 的类名。
|
||||
|
||||
## 5. EventRouter 调用链
|
||||
|
||||
```text
|
||||
Platform Adapter / WebUI / API
|
||||
-> Event Gateway normalize payload
|
||||
-> EventLog append raw event
|
||||
-> EventRouter resolve one effective AgentBinding
|
||||
-> AgentRunOrchestrator.run(event, binding)
|
||||
-> AgentRunContextBuilder.build(event, binding)
|
||||
-> PluginRuntimeConnector.run_agent()
|
||||
-> AgentRunResult stream
|
||||
-> DeliveryController render / platform action
|
||||
```
|
||||
|
||||
约束:必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。
|
||||
|
||||
## 6. 平台动作执行
|
||||
|
||||
EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作:
|
||||
|
||||
```json
|
||||
{ "type": "action.requested",
|
||||
"data": { "action": "friend.request.accept",
|
||||
"target": {"platform": "wechat", "request_id": "..."},
|
||||
"payload": {"reason": "policy matched"} } }
|
||||
```
|
||||
|
||||
Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。
|
||||
|
||||
Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。
|
||||
|
||||
## 7. 与 Context 协议的关系
|
||||
|
||||
EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/artifact ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。
|
||||
|
||||
## 8. EBA 分支联调内容
|
||||
|
||||
外部 EBA 分支负责联调 EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。
|
||||
|
||||
当前底座已完成:① 把当前 Pipeline 消息入口适配成 `message.received` event → ② 增加 `AgentBinding` 抽象,先由 current config 生成 → ③ context builder 改为从 event + binding 构造 → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调:⑤ 非消息事件协议测试与真实事件来源 → ⑥ 真实 EventRouter、binding persistence / UI 和 platform action。
|
||||
51
docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md
Normal file
51
docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# AgentRunner 外化扩展边界矩阵
|
||||
|
||||
本文用于回答一个问题:本分支只做 AgentRunner 外化时,哪些能力已经作为扩展底座完成,哪些由外部 EBA / Agent Platform / Runtime Control Plane 分支接入,后续分支接入时应该走哪个扩展点。
|
||||
|
||||
结论:本分支不实现完整 Agent Platform,也不实现完整 EBA。EBA 完整事件网关与事件路由由外部 EBA 分支联调。本分支必须把 runner 外化的 Host / SDK 边界做干净,让外部分支只需要接入持久模型、事件路由或 runtime task,而不需要重写 `AgentRunner Protocol v1`。
|
||||
|
||||
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的单一事实源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;本矩阵只说明后续能力应该接入哪个扩展点。
|
||||
|
||||
## 1. 分支边界
|
||||
|
||||
| 范围 | 本分支职责 | 不在本分支做 |
|
||||
| --- | --- | --- |
|
||||
| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同:discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 |
|
||||
| Host runner 外化底座 | 提供 `AgentEventEnvelope`、`AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / Artifact / State。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 |
|
||||
| 当前 Pipeline 入口 | 通过 `QueryEntryAdapter` 把旧 Query / Pipeline config 投影成 event + binding,作为迁移期入口。 | 不继续把 Pipeline 当作长期 agent 配置中心。 |
|
||||
| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 |
|
||||
|
||||
## 2. 扩展矩阵
|
||||
|
||||
| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Product `Agent` | 已有运行期 `AgentConfig` / `AgentBinding` 投影;还没有正式持久化产品对象。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 |
|
||||
| Bot / channel 绑定 Agent | 已有单次运行前的 `AgentBinding` 解析投影;目标调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。 | 不在本矩阵重定义 fan-out / observer 语义;需要时按 §3 新增设计。 |
|
||||
| Agent session / run | 当前只有 `run_id` 和 active `AgentRunSessionRegistry`,用于权限校验和生命周期。 | Agent Platform / Runtime Control Plane。 | 如需要可新增持久 `AgentRun` / `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 |
|
||||
| EventLog / Transcript / Artifact | 已完成 Host-owned store 和 pull API;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript / Artifact。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 |
|
||||
| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State API;storage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;大对象用 storage / artifact。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 |
|
||||
| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 |
|
||||
| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 |
|
||||
| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 |
|
||||
| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 |
|
||||
| Runtime registry / worker / task queue | 不实现。当前官方外部 harness 通过 LiteLLM Agent Platform runner 调用外部平台,不在本分支维护本机 subprocess worker。 | Runtime Control Plane v2。 | 第一阶段先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;完整 runtime registry、heartbeat、task queue、daemon claim、progress/audit 是后续可选阶段。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 |
|
||||
| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 |
|
||||
| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage/artifact 基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 |
|
||||
| External harness native session | LiteLLM Agent Platform runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部平台调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 |
|
||||
|
||||
## 3. 后续分支接入规则
|
||||
|
||||
外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则:
|
||||
|
||||
- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。
|
||||
- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。
|
||||
- Host-owned facts 继续写入 EventLog / Transcript / Artifact / State;产品层可以新增更高阶视图,但不能替代这些事实源。
|
||||
- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。
|
||||
- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。
|
||||
- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。
|
||||
|
||||
## 4. 与 LiteLLM Agent Platform 的关系
|
||||
|
||||
这里的 LiteLLM Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。
|
||||
|
||||
LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。
|
||||
257
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
257
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# LangBot Host 与 SDK 基础设施设计
|
||||
|
||||
本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。
|
||||
|
||||
- SDK ↔ Host 的协议数据结构(`AgentRunContext`、`AgentRunnerManifest`、`AgentRunResult`、`AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。
|
||||
- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`AgentRunnerDescriptor`)不属于 SDK 协议字段。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
LangBot 要转为 agent host,而不是内置 runner 容器:
|
||||
|
||||
- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。
|
||||
- 根据事件、bot、workspace、scope 解析应该调用的 Agent / agent binding。
|
||||
- 发现、校验和调用插件提供的 AgentRunner。
|
||||
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
|
||||
- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不把 Pipeline 当作长期架构中心。
|
||||
- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。
|
||||
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。
|
||||
- 不在 host 中实现通用 agentic prompt assembler。
|
||||
- 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。
|
||||
- 不实现 EventGateway / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。
|
||||
|
||||
## 3. 分层架构
|
||||
|
||||
```text
|
||||
IM / WebUI / API / EventRouter (external EBA branch)
|
||||
|
|
||||
v
|
||||
Event Gateway (external EBA branch)
|
||||
|
|
||||
v
|
||||
AgentBindingResolver
|
||||
|
|
||||
v
|
||||
AgentRunOrchestrator
|
||||
|-- AgentRunnerRegistry
|
||||
|-- AgentResourceBuilder
|
||||
|-- AgentContextBuilder
|
||||
|-- AgentRunSessionRegistry
|
||||
|-- PersistentStateStore / EventLogStore / TranscriptStore / ArtifactStore
|
||||
v
|
||||
Plugin Runtime / AgentRunner
|
||||
|
|
||||
v
|
||||
AgentRunResult stream
|
||||
|
|
||||
v
|
||||
Delivery / Renderer / Platform API
|
||||
```
|
||||
|
||||
目标产品模型、单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。本文只说明 Host 如何把当前入口投影为内部模型。当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received` 并投影出临时 `AgentConfig` / `AgentBinding`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway / EventRouter 由外部 EBA 分支实现并联调。
|
||||
|
||||
## 4. LangBot 侧能力
|
||||
|
||||
### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point)
|
||||
|
||||
> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 event-first 入口和 envelope/binding models。
|
||||
|
||||
Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型):
|
||||
|
||||
```python
|
||||
class AgentEventEnvelope(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
event_time: int | None
|
||||
source: str
|
||||
bot_id: str | None
|
||||
workspace_id: str | None
|
||||
conversation_id: str | None
|
||||
thread_id: str | None
|
||||
actor: ActorRef | None
|
||||
subject: SubjectRef | None
|
||||
input: AgentInput # 见 PROTOCOL_V1 §5.6
|
||||
delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7
|
||||
raw_ref: RawEventRef | None
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
`AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`(PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 artifact ref,不扩散到 runner 协议顶层。
|
||||
|
||||
**当前 adapter source**:`QueryEntryAdapter.query_to_event(query)` 从 Query 生成 `AgentEventEnvelope`。
|
||||
|
||||
### 4.2 AgentConfig 与 AgentBinding
|
||||
|
||||
`AgentConfig` 是迁移期的 Host 内部 Agent 配置投影(不暴露给 SDK)。当前 Query entry adapter 从 Pipeline config 投影出它;未来持久 Agent 也应先投影成这个运行期配置,再由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`。
|
||||
|
||||
```python
|
||||
class AgentConfig(BaseModel):
|
||||
agent_id: str | None = None
|
||||
runner_id: str
|
||||
runner_config: dict[str, Any] = {}
|
||||
resource_policy: ResourcePolicy = ResourcePolicy()
|
||||
state_policy: StatePolicy = StatePolicy()
|
||||
delivery_policy: DeliveryPolicy = DeliveryPolicy()
|
||||
event_types: list[str] = ["message.received"]
|
||||
enabled: bool = True
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
`AgentBinding` 是"什么事件调用哪个 AgentRunner、带什么 Agent 配置"的 Host 内部运行投影(不暴露给 SDK)。它是 EventRouter / 当前 QueryEntryAdapter 在一次运行前解析出的有效绑定。
|
||||
|
||||
```python
|
||||
class AgentBinding(BaseModel):
|
||||
binding_id: str
|
||||
enabled: bool
|
||||
scope: BindingScope
|
||||
event_types: list[str]
|
||||
filters: list[EventFilter] = [] # EBA 阶段使用,见 EVENT_BASED_AGENT
|
||||
runner_id: str
|
||||
runner_config: dict[str, Any]
|
||||
resource_policy: ResourcePolicy
|
||||
state_policy: StatePolicy
|
||||
delivery_policy: DeliveryPolicy
|
||||
```
|
||||
|
||||
BindingResolver 的基数、fan-out 和冲突处理约束见 PROTOCOL_V1 §13;本节只定义 Host 内部投影形态。
|
||||
|
||||
**当前 adapter source**:`QueryEntryAdapter.config_to_agent_config(query, runner_id)`
|
||||
先把 current config 投影为迁移期 `AgentConfig`,再由
|
||||
`AgentBindingResolver.resolve_one(event, [agent_config])` 解析出唯一
|
||||
`AgentBinding`。Pipeline 当前只是迁移期 Agent config source(AI runner config
|
||||
→ runner_config、extension preference → resource_policy、output settings →
|
||||
delivery_policy),但新设计不再把这些字段命名为 Pipeline 专属概念。
|
||||
|
||||
### 4.3 AgentRunnerRegistry
|
||||
|
||||
Registry 收集 runner descriptor(来自插件 runtime、开发期本地插件):
|
||||
|
||||
```python
|
||||
class AgentRunnerDescriptor(BaseModel):
|
||||
id: str
|
||||
source: Literal["plugin"]
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
runner_name: str
|
||||
capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3
|
||||
permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4
|
||||
config_schema: list[DynamicFormItemSchema]
|
||||
plugin_version: str | None = None
|
||||
raw_manifest: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 typed `AgentRunnerManifest`、输出 descriptor、缓存 discovery 结果并提供 `refresh()`。单个插件 manifest 失败只记 warning,不影响其它 runner。`plugin:author/name/runner` 是稳定 id 格式;插件实例边界见 PROTOCOL_V1 §13。
|
||||
|
||||
Host 内置 runner / adapter 不能作为 `AgentRunnerDescriptor.source` 绕过插件
|
||||
runtime、`run_id`、`ctx.resources` 和 `AgentRunAPIProxy` 权限链。若需要
|
||||
开发期调试 adapter,应放在 Host 内部测试入口,不进入可选 runner 列表。
|
||||
|
||||
刷新触发点:插件安装/卸载/升级/重启后;Pipeline metadata 请求时发现缓存为空;可选 TTL(优先保证正确性)。
|
||||
|
||||
### 4.4 AgentRunOrchestrator
|
||||
|
||||
Orchestrator 是唯一运行入口:
|
||||
|
||||
```text
|
||||
run(event, binding)
|
||||
-> resolve runner descriptor
|
||||
-> build resources
|
||||
-> build context
|
||||
-> register run session
|
||||
-> call plugin runtime
|
||||
-> normalize result stream
|
||||
-> update state
|
||||
-> unregister run session
|
||||
```
|
||||
|
||||
它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。
|
||||
|
||||
典型 run 时序:
|
||||
|
||||
```text
|
||||
QueryEntryAdapter / EventRouter
|
||||
-> AgentRunOrchestrator.run(event, binding)
|
||||
-> AgentRunnerRegistry.resolve(runner_id)
|
||||
-> AgentResourceBuilder.freeze_snapshot(binding, event)
|
||||
-> AgentRunSessionRegistry.register(run_id, runner_id, snapshot)
|
||||
-> AgentContextBuilder.build(event, binding, snapshot)
|
||||
-> PluginRuntimeConnector.run_agent(ctx)
|
||||
-> AgentRunAPIProxy action
|
||||
-> validate active run session + caller identity + snapshot
|
||||
-> Host API / Store
|
||||
<- AgentRunResult stream
|
||||
-> apply state.updated to PersistentStateStore
|
||||
-> write message.completed / artifact.created to Transcript / ArtifactStore
|
||||
-> render delivery or raise RunnerExecutionError
|
||||
-> AgentRunSessionRegistry.unregister(run_id)
|
||||
```
|
||||
|
||||
`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;跨请求持久化状态必须走授权 storage / 外部服务。
|
||||
|
||||
### 4.5 Resource Authorization
|
||||
|
||||
LangBot 在每次 run 前生成 `ctx.resources`(PROTOCOL_V1 §6),来自 manifest permissions 与 binding policy 的交集:
|
||||
|
||||
1. `descriptor.permissions` 声明 runner 需要的 LangBot 资源访问上限。
|
||||
2. binding / resource policy 允许的资源范围。
|
||||
3. Agent/runner config 中选择的模型、知识库、文件等资源。
|
||||
4. 当前 event / actor / bot / workspace 的实际权限。
|
||||
5. `ctx.context.available_apis` 暴露的 pull API 能力。
|
||||
|
||||
这次裁剪结果必须冻结为 run-scoped authorization snapshot,并由
|
||||
`AgentRunSessionRegistry` 按 `run_id` 保存。`ctx.resources` 是投影给 runner
|
||||
看的同一份授权结果;运行期每个 proxy action 只依据该 snapshot 校验 active
|
||||
run session、caller plugin identity、resource id、scope、payload size、rate
|
||||
limit 和 deadline。Handler 不应重新执行授权裁剪,否则 build-time 与 runtime
|
||||
授权逻辑会漂移。
|
||||
|
||||
SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 才是安全边界。`spec.capabilities` 只帮助 Host 判断 runner 是否需要 tool / knowledge / skill 等资源投影,不能替代 permissions 或 binding policy。
|
||||
|
||||
资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
|
||||
|
||||
执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runner;runner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。
|
||||
|
||||
### 4.6 State / Storage
|
||||
|
||||
LangBot 可提供 host-owned state 让 runner 寄宿状态(conversation / actor / subject / runner / binding / workspace state),但**不是强制**。Host 只需提供:授权开关、scope key、get/set/list/delete API(见 PROTOCOL_V1 §8)、持久化 backend、审计和清理策略。外部 agent runtime 可维护自己的 session 和 memory。进程内 state store 只能作为过渡实现,不能作为正式生产语义。
|
||||
|
||||
### 4.7 EventLog / Transcript / Artifact(事实源)
|
||||
|
||||
- `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。
|
||||
- `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。
|
||||
- `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。
|
||||
|
||||
三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。
|
||||
|
||||
### 4.8 External harness resource projection
|
||||
|
||||
Claude Code、Codex、Kimi Code 等外部 harness runner 可能不直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源句柄投影到自己的 harness 执行。Host 侧仍保持统一边界:Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计;Host 或 binding policy 决定哪些 MCP bridge、skill-backed tool、artifact、history/state 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;所有 LangBot 资源访问必须经 SDK runtime / `AgentRunAPIProxy` / SDK-owned MCP bridge 转发并接受 Host 校验;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume,但不能用 native tools 绕过 Host 授权。
|
||||
|
||||
投影的具体形态(context 文件、resource handles、LangBot MCP gateway、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;当前 LiteLLM Agent Platform runner 形态见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。
|
||||
|
||||
## 5. SDK 侧协议
|
||||
|
||||
SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。
|
||||
|
||||
```python
|
||||
class AgentRunner(BaseComponent):
|
||||
__kind__ = "AgentRunner"
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> list[dict]: ...
|
||||
|
||||
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ...
|
||||
# ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7
|
||||
```
|
||||
|
||||
- Manifest / capabilities / effective access:PROTOCOL_V1 §4。Capabilities 来自组件 manifest 的 `spec.capabilities`,不是 SDK 基类 classmethod。
|
||||
- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。
|
||||
- `AgentRunResult`:PROTOCOL_V1 §7。
|
||||
- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。
|
||||
134
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
134
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 官方 AgentRunner 插件迁移计划
|
||||
|
||||
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
||||
|
||||
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。
|
||||
|
||||
## 1. 仓库组织
|
||||
|
||||
官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。
|
||||
|
||||
当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局:
|
||||
|
||||
```text
|
||||
langbot-app/
|
||||
langbot-local-agent/ # plugin:langbot/local-agent/default
|
||||
manifest.yaml
|
||||
components/agent_runner/default.{yaml,py}
|
||||
langbot-agent-runner/ # 外部服务 runner 仓库
|
||||
litellm-agent-platform-agent/ dify-agent/ n8n-agent/ ...
|
||||
```
|
||||
|
||||
后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。
|
||||
|
||||
## 2. 插件命名和 runner id
|
||||
|
||||
| 旧 runner | 官方插件 | runner id |
|
||||
| --- | --- | --- |
|
||||
| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` |
|
||||
| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` |
|
||||
| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` |
|
||||
| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` |
|
||||
| - | `langbot/litellm-agent-platform-agent` | `plugin:langbot/litellm-agent-platform-agent/default` |
|
||||
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
|
||||
| `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`(能力最完整基准)、`litellm-agent-platform-agent`(外部 code-agent harness 统一入口)、`dify-agent`(传统 service API runner)。
|
||||
- **Batch 2(外部 workflow)**:`n8n-agent`、`langflow-agent`(webhook/workflow 输入输出、timeout、外部 conversation id)。
|
||||
- **Batch 3(平台 Agent API)**:`coze-agent`、`dashscope-agent`、`tbox-agent`(平台特有响应格式、引用资料、文件/图片输入)。
|
||||
|
||||
## 4. 每个官方插件的组件要求
|
||||
|
||||
每个插件至少包含一个 `AgentRunner` 组件,manifest 示例:
|
||||
|
||||
```yaml
|
||||
apiVersion: langbot/v1
|
||||
kind: AgentRunner
|
||||
metadata:
|
||||
name: default
|
||||
label: { en_US: Dify Agent, zh_Hans: Dify Agent }
|
||||
description:
|
||||
en_US: Run a Dify application as a LangBot AgentRunner.
|
||||
zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。
|
||||
spec:
|
||||
config: []
|
||||
capabilities: # 字段语义见 PROTOCOL_V1 §4.3
|
||||
streaming: true
|
||||
execution:
|
||||
python: { path: ./main.py, attr: DefaultAgentRunner }
|
||||
```
|
||||
|
||||
## 5. local-agent 插件方向
|
||||
|
||||
`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
|
||||
|
||||
迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。
|
||||
|
||||
责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束:
|
||||
|
||||
- 从 `ctx.config` 读取静态绑定 `prompt`,**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。
|
||||
- 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。
|
||||
- `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。
|
||||
- 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。
|
||||
- manifest 声明功能能力、LangBot 资源 permissions 和配置表单;实际授权来自 manifest permissions 与 binding resource policy、runner config、`ctx.context.available_apis` 和 Host run session snapshot 的交集。
|
||||
|
||||
### 5.1 Native Execution / Skills 后续接入
|
||||
|
||||
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools` 和 SDK runtime 转发暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。
|
||||
|
||||
## 6. 外部 runner 插件要求
|
||||
|
||||
外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state,不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`。
|
||||
|
||||
### 6.1 Code-agent harness runner
|
||||
|
||||
Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行,可以依赖自己的 harness,但仍必须遵守统一 Host 边界。总体边界见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8;context projection 形态见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) §4.5;发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
|
||||
本文件只补充官方 runner 的实现要求:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage;插件实例边界见 PROTOCOL_V1 §13;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。
|
||||
|
||||
实现结构应把 provider-native output 解析与 LangBot result stream 组装分开:Claude stream-json、Codex JSONL、Kimi / OpenCode 事件等只在 runner adapter 内解析,输出统一归一为 `AgentRunResult`(`message.completed` / `message.delta`、`state.updated`、`artifact.created`、`run.completed` / `run.failed`)。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。
|
||||
|
||||
并发约束应按外部 session 粒度表达,而不是按 Agent / runner id / 插件实例表达;Agent 复用和全局锁边界见 PROTOCOL_V1 §13。若 runner 使用 `external.session_id` / `thread_id` resume 到同一 native session,且该 harness 不支持并发 turn,runner 应按稳定 external session key 串行写入;一次性 subprocess runner 可以只在单次 `run(ctx)` 内处理,长连接/daemon runner 则应采用 reader 独占 native stream、turn writer 串行写入的结构。
|
||||
|
||||
### 6.2 LangBot MCP gateway
|
||||
|
||||
外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,也不能用自己的 native tools 直接访问 LangBot 资源。当前 LiteLLM Agent Platform runner 通过稳定 HTTP MCP gateway 把 harness 的工具请求转回 SDK runtime / Host API:
|
||||
|
||||
- Gateway 由 runner 插件启动,暴露稳定的 `langbot_history_page`、`langbot_retrieve_knowledge`、`langbot_call_tool` 等最小工具面。
|
||||
- Harness 每次调用必须携带当前 LangBot `run_id`;Host 仍按 run session、caller identity 和授权快照校验。
|
||||
- Gateway 只转发 LangBot 资产访问,不承担外部 harness 的文件、进程或 native tool 权限边界。
|
||||
|
||||
第一批工具保持很小:history page、knowledge retrieve、authorized tool call。新增工具必须先有 Host action 权限与 run-scoped authorization,再由 gateway 投影。
|
||||
|
||||
## 7. LiteLLM Agent Platform runner 当前形态
|
||||
|
||||
`litellm-agent-platform-agent` 是当前外部 harness runner 的统一入口,用来把 Claude Code、Codex 等具体执行器交给 LiteLLM Agent Platform / lite-harness 管理,而不是在 LangBot 官方 runner 仓库中维护每个 CLI provider 的独立适配器。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
||||
|
||||
当前形态:
|
||||
|
||||
- Runner ID:`plugin:langbot/litellm-agent-platform-agent/default`。
|
||||
- Runner 通过 HTTP 调用 LiteLLM Agent Platform,外部 harness 的安装、登录态、workspace 和 provider-native 权限由该平台所在运行环境负责。
|
||||
- Runner 会把当前 LangBot `run_id`、可访问资源摘要和 gateway 使用规则注入本次消息;harness 通过 gateway 回填 `run_id` 后访问 LangBot 资产。
|
||||
- 外部 session id 写回 Host state,后续轮次可复用目标平台会话。
|
||||
|
||||
### 7.1 当前限制
|
||||
|
||||
这不是发布级安全边界实现;LangBot 只约束 LangBot 持有资产的访问,外部 harness 的文件、进程、workspace、provider-native MCP 和模型凭据由 LiteLLM Agent Platform 部署侧承担。当前 `run_id` 由系统提示词传递给 harness 并由 gateway 校验,后续若 LiteLLM 原生支持 run-scoped MCP session,可切换为平台级传递。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
|
||||
|
||||
## 8. 发布和安装策略
|
||||
|
||||
最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id;当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`。
|
||||
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
|
||||
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
|
||||
- `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。
|
||||
- `litellm-agent-platform-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。
|
||||
- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。
|
||||
727
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
727
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# LangBot AgentRunner Protocol v1
|
||||
|
||||
本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。
|
||||
|
||||
- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。
|
||||
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
||||
|
||||
## 1. 协议目标
|
||||
|
||||
Protocol v1 只解决四件事:
|
||||
|
||||
- LangBot 如何发现插件提供的 AgentRunner。
|
||||
- LangBot 如何把一次事件调用封装成 `AgentRunContext`。
|
||||
- AgentRunner 如何以事件流形式返回运行结果。
|
||||
- AgentRunner 如何通过受限 API 访问 LangBot host 能力。
|
||||
|
||||
Protocol v1 **不定义**:
|
||||
|
||||
- LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK)。
|
||||
- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md))。
|
||||
- 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md))。
|
||||
- Pipeline 的长期配置模型。
|
||||
- 发布级安全 hardening 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。
|
||||
|
||||
## 2. 参与方
|
||||
|
||||
| 名称 | 职责 |
|
||||
| --- | --- |
|
||||
| LangBot Host | 事件入口、绑定解析、权限、资源、存储、生命周期、结果投递。 |
|
||||
| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 |
|
||||
| AgentRunner | 插件提供的 agent 执行组件。 |
|
||||
| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 |
|
||||
| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2)。 |
|
||||
|
||||
产品层的 `Agent` 替代旧 Pipeline 承载 agent 配置:bot / IM channel
|
||||
绑定一个 Agent,一个 Agent 可以被多个 bot / channel 复用。Host 内部的
|
||||
`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的
|
||||
`ctx.config`、`ctx.resources`、`ctx.context` 和 `ctx.delivery`。SDK 不需要知道
|
||||
Agent / binding 的持久化形态。
|
||||
|
||||
外部 harness runner(Claude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact API 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。
|
||||
|
||||
## 3. 协议演进
|
||||
|
||||
当前 AgentRunner 合同不暴露显式 `protocol_version` 字段。协议演进先按字段级兼容规则处理:
|
||||
|
||||
- 新增可选字段保持向后兼容。
|
||||
- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。
|
||||
- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。
|
||||
- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;manifest、result payload、page/result 返回与错误模型偏严格,未知字段默认禁止。安全边界仍在 Host,SDK 校验只提升开发体验。
|
||||
|
||||
## 4. Discovery 协议
|
||||
|
||||
### 4.1 LIST_AGENT_RUNNERS
|
||||
|
||||
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回:
|
||||
|
||||
```python
|
||||
class ListAgentRunnersResponse(BaseModel):
|
||||
runners: list[AgentRunnerDiscovery]
|
||||
|
||||
class AgentRunnerDiscovery(BaseModel):
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
runner_name: str
|
||||
runner_description: I18nObject | None = None
|
||||
manifest: AgentRunnerManifest
|
||||
capabilities: AgentRunnerCapabilities # compatibility alias of manifest.capabilities
|
||||
permissions: AgentRunnerPermissions # compatibility alias of manifest.permissions
|
||||
config: list[DynamicFormItemSchema] = []
|
||||
```
|
||||
|
||||
`manifest` 是 SDK typed `AgentRunnerManifest`,由 Runtime 从插件组件 manifest 解析并校验后返回。`plugin_author` / `plugin_name` / `runner_name` 保留为 transport 寻址字段;Host 以它们生成稳定 runner id,并把 `manifest.id` 校验为 `plugin:author/name/runner`。单个 runner manifest 解析失败时 Runtime/Host 记录 warning 并跳过该 runner,不影响同一插件或其它插件的 runner discovery。
|
||||
|
||||
`capabilities` / `permissions` 顶层字段是兼容旧 discovery 消费方的冗余别名;新代码必须以 `manifest.capabilities` / `manifest.permissions` 为准。
|
||||
|
||||
### 4.2 AgentRunnerManifest
|
||||
|
||||
这里的 manifest 指 Runtime 返回给 Host 的 typed runner manifest:
|
||||
|
||||
```python
|
||||
class AgentRunnerManifest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
capabilities: AgentRunnerCapabilities = AgentRunnerCapabilities()
|
||||
permissions: AgentRunnerPermissions = AgentRunnerPermissions()
|
||||
config_schema: list[DynamicFormItemSchema] = []
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
- runner id 由 Host 生成,格式 `plugin:author/name/runner`。
|
||||
- `name` 是插件内 runner 名称,例如 `default`。
|
||||
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。
|
||||
- `capabilities` 是 Host 用于 UI 和资源投影的 typed bool model;它不是权限授予。
|
||||
- `permissions` 是 runner 申请的 LangBot 资源访问上限;实际授权仍必须与 binding policy 求交。
|
||||
- `metadata` 只放展示、诊断、非稳定扩展信息。
|
||||
|
||||
### 4.3 Capabilities
|
||||
|
||||
```python
|
||||
class AgentRunnerCapabilities(BaseModel):
|
||||
streaming: bool = False
|
||||
tool_calling: bool = False
|
||||
knowledge_retrieval: bool = False
|
||||
multimodal_input: bool = False
|
||||
skill_authoring: bool = False
|
||||
interrupt: bool = False
|
||||
steering: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
|
||||
- `streaming`: runner 可以返回 `message.delta`。
|
||||
- `tool_calling`: runner 可能调用 Host tool API。
|
||||
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
|
||||
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
|
||||
- `skill_authoring`: runner 需要 Host 提供 skill facts 以及 skill authoring tools,例如 `activate` / `register_skill`。
|
||||
- `interrupt`: runner 支持取消或中断。
|
||||
- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。
|
||||
|
||||
Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。
|
||||
|
||||
### 4.4 Permissions 与 Effective Access
|
||||
|
||||
```python
|
||||
class AgentRunnerPermissions(BaseModel):
|
||||
models: list[Literal["invoke", "stream", "rerank"]] = []
|
||||
tools: list[Literal["detail", "call"]] = []
|
||||
knowledge_bases: list[Literal["list", "retrieve"]] = []
|
||||
history: list[Literal["page", "search"]] = []
|
||||
events: list[Literal["get", "page"]] = []
|
||||
artifacts: list[Literal["metadata", "read"]] = []
|
||||
storage: list[Literal["plugin", "workspace"]] = []
|
||||
files: list[Literal["config", "knowledge"]] = []
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
|
||||
平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。
|
||||
|
||||
Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照:
|
||||
|
||||
```text
|
||||
effective_access = manifest.permissions ∩ binding.resource_policy ∩ current scope/config
|
||||
```
|
||||
|
||||
具体落地:
|
||||
|
||||
1. `AgentResourceBuilder` 先用 manifest permissions 与 binding resource policy / runner config 求交,生成 `ctx.resources`。
|
||||
2. `AgentContextBuilder` 用 manifest permissions 与 binding state/storage policy 求交,生成 `ctx.context.available_apis`。
|
||||
3. `AgentRunSessionRegistry` 冻结 run-scoped resources 与 available APIs。
|
||||
4. Runtime handler / `AgentRunAPIProxy` 按 active `run_id`、runner identity、caller plugin identity、resource id、scope、payload size、rate limit 和 deadline 校验每次调用。
|
||||
|
||||
反承诺:manifest permissions **只约束 LangBot 持有的资源访问**。它不承诺限制外部 harness 的 native shell、文件系统、CLI、MCP、网络或本机权限;这些能力由 operator/runtime/sandbox 另行约束,见 HOST_SDK §4.8 与 SECURITY_HARDENING。
|
||||
|
||||
默认原则:
|
||||
|
||||
- Host 不得默认 inline 全量历史。
|
||||
- Host 只 inline 当前 event / input 和 context handles。
|
||||
- Runner 拥有 working context assembly。
|
||||
- Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。
|
||||
- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。
|
||||
|
||||
context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
||||
|
||||
## 5. Run 协议
|
||||
|
||||
### 5.1 RUN_AGENT
|
||||
|
||||
Host 调用 Runtime:
|
||||
|
||||
```python
|
||||
class AgentRunRequest(BaseModel):
|
||||
runner_id: str
|
||||
runner_name: str
|
||||
context: AgentRunContext
|
||||
```
|
||||
|
||||
Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id` 和 `context` 为准。
|
||||
|
||||
### 5.2 AgentRunContext
|
||||
|
||||
这是 SDK 看到的**唯一权威 context 定义**。
|
||||
|
||||
```python
|
||||
class AgentRunContext(BaseModel):
|
||||
run_id: str
|
||||
trigger: AgentTrigger
|
||||
event: AgentEventContext
|
||||
conversation: ConversationContext | None = None
|
||||
actor: ActorContext | None = None
|
||||
subject: SubjectContext | None = None
|
||||
input: AgentInput
|
||||
delivery: DeliveryContext
|
||||
resources: AgentResources
|
||||
context: ContextAccess
|
||||
state: AgentRunState
|
||||
runtime: AgentRuntimeContext
|
||||
config: dict[str, Any] = {}
|
||||
adapter: AdapterContext | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
核心约束:
|
||||
|
||||
- `event` 是必选字段,Protocol v1 是 event-first。
|
||||
- `input` 表示当前事件的主输入,不等于历史消息。
|
||||
- `bootstrap` / `messages` **不是协议字段**;Host 不内联历史窗口。
|
||||
- `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。
|
||||
- `config` 是 Agent/runner config,不是插件实例状态。
|
||||
|
||||
### 5.3 AgentTrigger
|
||||
|
||||
```python
|
||||
class AgentTrigger(BaseModel):
|
||||
type: str
|
||||
source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"]
|
||||
timestamp: int | None = None
|
||||
```
|
||||
|
||||
`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时:
|
||||
|
||||
```json
|
||||
{ "type": "message.received", "source": "host_adapter" }
|
||||
```
|
||||
|
||||
### 5.4 AgentEventContext
|
||||
|
||||
```python
|
||||
class AgentEventContext(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
event_time: int | None = None
|
||||
source: str
|
||||
source_event_type: str | None = None
|
||||
raw_ref: RawEventRef | None = None
|
||||
data: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
|
||||
- 平台原始事件名放入 `source_event_type`。
|
||||
- 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`。
|
||||
|
||||
### 5.5 Conversation / Actor / Subject
|
||||
|
||||
```python
|
||||
class ConversationContext(BaseModel):
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
launcher_type: str | None = None
|
||||
launcher_id: str | None = None
|
||||
sender_id: str | None = None
|
||||
bot_id: str | None = None
|
||||
workspace_id: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
class ActorContext(BaseModel):
|
||||
actor_type: str
|
||||
actor_id: str | None = None
|
||||
actor_name: str | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class SubjectContext(BaseModel):
|
||||
subject_type: str
|
||||
subject_id: str | None = None
|
||||
data: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
- 消息事件:actor 是发消息的人,subject 是当前消息。
|
||||
- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。
|
||||
- 定时事件:actor 可以是 system,subject 是 schedule。
|
||||
|
||||
### 5.6 AgentInput
|
||||
|
||||
```python
|
||||
class AgentInput(BaseModel):
|
||||
text: str | None = None
|
||||
contents: list[ContentElement] = []
|
||||
attachments: list[ArtifactRef] = []
|
||||
```
|
||||
|
||||
- 文本、多模态、附件都属于当前 event input。
|
||||
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。
|
||||
- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。
|
||||
|
||||
### 5.7 DeliveryContext
|
||||
|
||||
```python
|
||||
class DeliveryContext(BaseModel):
|
||||
surface: str
|
||||
reply_target: dict[str, Any] | None = None
|
||||
supports_streaming: bool = False
|
||||
supports_edit: bool = False
|
||||
supports_reaction: bool = False
|
||||
max_message_size: int | None = None
|
||||
platform_capabilities: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。
|
||||
|
||||
### 5.8 ContextAccess
|
||||
|
||||
```python
|
||||
class ContextAccess(BaseModel):
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
latest_cursor: str | None = None
|
||||
event_seq: int | None = None
|
||||
transcript_seq: int | None = None
|
||||
has_history_before: bool = False
|
||||
inline_policy: InlineContextPolicy
|
||||
available_apis: ContextAPICapabilities
|
||||
|
||||
class InlineContextPolicy(BaseModel):
|
||||
mode: Literal["none", "current_event", "recent_tail", "summary_tail"]
|
||||
delivered_count: int = 0
|
||||
source_total_count: int | None = None
|
||||
messages_complete: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
class ContextAPICapabilities(BaseModel):
|
||||
history_page: bool = False
|
||||
history_search: bool = False
|
||||
event_get: bool = False
|
||||
event_page: bool = False
|
||||
artifact_metadata: bool = False
|
||||
artifact_read: bool = False
|
||||
state: bool = False
|
||||
storage: bool = False
|
||||
steering_pull: bool = False
|
||||
```
|
||||
|
||||
`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。
|
||||
|
||||
### 5.9 AgentRuntimeContext
|
||||
|
||||
```python
|
||||
class AgentRuntimeContext(BaseModel):
|
||||
langbot_version: str | None = None
|
||||
trace_id: str | None = None
|
||||
deadline_at: float | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
### 5.10 AgentRunState
|
||||
|
||||
```python
|
||||
class AgentRunState(BaseModel):
|
||||
conversation: dict[str, Any] = {}
|
||||
actor: dict[str, Any] = {}
|
||||
subject: dict[str, Any] = {}
|
||||
runner: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
|
||||
|
||||
## 6. Resources
|
||||
|
||||
```python
|
||||
class SkillResource(BaseModel):
|
||||
skill_name: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
class AgentResources(BaseModel):
|
||||
models: list[ModelResource] = []
|
||||
tools: list[ToolResource] = []
|
||||
knowledge_bases: list[KnowledgeBaseResource] = []
|
||||
skills: list[SkillResource] = []
|
||||
files: list[FileResource] = []
|
||||
storage: StorageResource = StorageResource()
|
||||
platform_capabilities: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
`skills` 只包含本次 run 中 pipeline-visible 的 skill facts,例如 `skill_name`、`display_name` 和 `description`。Host 不把这些 facts 追加到 system prompt,也不把它们编排进工具描述;runner 可以自行决定是否放入 model prompt、转换成 MCP surface,或只在自己的策略层使用。
|
||||
|
||||
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 `ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
|
||||
|
||||
## 7. Result Stream
|
||||
|
||||
### 7.1 AgentRunResult envelope
|
||||
|
||||
```python
|
||||
JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
|
||||
|
||||
ResultType = Literal[
|
||||
"message.delta",
|
||||
"message.completed",
|
||||
"tool.call.started",
|
||||
"tool.call.completed",
|
||||
"artifact.created",
|
||||
"state.updated",
|
||||
"action.requested",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
]
|
||||
|
||||
class AgentRunResult(BaseModel):
|
||||
run_id: str
|
||||
type: AgentRunResultType | str
|
||||
data: dict[str, Any] = {}
|
||||
sequence: int | None = None
|
||||
timestamp: int | None = None
|
||||
```
|
||||
|
||||
SDK 当前实现是单一 envelope:`type` 枚举 + `data` dict。Payload 由 SDK typed model 构造并 dump,但 wire 不改成 discriminated union;这样新旧版本偏斜时 Host 仍可按 §3 忽略未知 `type`。
|
||||
|
||||
Host 边界分级校验:
|
||||
|
||||
- `message.delta`、`message.completed`、`artifact.created`、`state.updated`、`action.requested`、`run.completed`、`run.failed` 属于会影响投递或 Host 副作用的严格 payload;校验失败时丢弃该 result 并记录 warning。
|
||||
- `tool.call.started`、`tool.call.completed` 当前只作为 telemetry,payload 宽松兼容。
|
||||
- 未知 `type` 忽略并记录 warning。
|
||||
|
||||
### 7.2 稳定 result payloads
|
||||
|
||||
| type | `data` payload |
|
||||
| --- | --- |
|
||||
| `message.delta` | `{ "chunk": MessageChunk }` |
|
||||
| `message.completed` | `{ "message": Message }` |
|
||||
| `tool.call.started` | `{ "tool_call_id": str, "tool_name": str, "parameters": dict }` |
|
||||
| `tool.call.completed` | `{ "tool_call_id": str, "tool_name": str, "result": dict \| None, "error": str \| None }` |
|
||||
| `artifact.created` | `{ "artifact_type": str, "artifact_id"?: str, "mime_type"?: str, "name"?: str, "size_bytes"?: int, "sha256"?: str, "metadata"?: dict, "content_base64"?: str }` |
|
||||
| `state.updated` | `{ "scope": "conversation" \| "actor" \| "subject" \| "runner", "key": str, "value": JSONValue }` |
|
||||
| `action.requested` | `{ "action": str, "target": dict \| None, "payload": dict \| None }` |
|
||||
| `run.completed` | `{ "finish_reason": str, "message"?: Message }` |
|
||||
| `run.failed` | `{ "code": str, "error": str, "retryable": bool }` |
|
||||
|
||||
`artifact.created.content_base64` 是小 artifact 的 inline 通道;Host 解码后写入 ArtifactStore,当前 hard cap 是 1 MiB。大 artifact 应使用外部存储 / file key / 后续上传通道,不应塞入 result event。
|
||||
|
||||
### 7.3 稳定 result types
|
||||
|
||||
| type | 说明 | 当前消费 |
|
||||
| --- | --- | --- |
|
||||
| `message.delta` | 流式消息片段。 | ✅ |
|
||||
| `message.completed` | 完整消息。 | ✅ |
|
||||
| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry |
|
||||
| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry |
|
||||
| `artifact.created` | runner 生成 artifact。 | ✅ |
|
||||
| `state.updated` | runner 请求更新 host-owned state。 | ✅ |
|
||||
| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** |
|
||||
| `run.completed` | run 正常结束。 | ✅ |
|
||||
| `run.failed` | run 失败。 | ✅ |
|
||||
|
||||
`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。
|
||||
|
||||
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。
|
||||
|
||||
### 7.4 Stream delivery semantics
|
||||
|
||||
- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay,也不承诺 at-least-once;从 Host 视角,收到的 result 最多应用一次。
|
||||
- `sequence` 是单个 `run_id` 内的结果序号。in-process / stdio 这类天然有序的在线 stream 可以省略;任何会缓冲、重放、跨进程队列或 runtime-managed task 的 transport 必须提供从 1 开始严格递增的 `sequence`。
|
||||
- Host 看到已提供 `sequence` 的 result 时,应按 `(run_id, sequence)` 做重复检测,并在缺号或乱序时记录 warning;除非 transport 明确声明 replay 语义,Host 不应自行等待缺失序号重排用户可见输出。
|
||||
- `run.failed.data.retryable` 只表示整次 run 理论上可由上层重试;Protocol v1 不自动重试 run,也不自动重试 proxy action。
|
||||
- History / Event / Transcript cursor 是 opaque token。runner 不得解析 cursor,也不得假设 cursor 在不同 API、conversation、thread 或 retention window 之间可比较;当前实现即使返回数字字符串,也只是实现细节。
|
||||
|
||||
### 7.5 示例
|
||||
|
||||
```json
|
||||
{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } }
|
||||
{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } }
|
||||
{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } }
|
||||
{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } }
|
||||
```
|
||||
|
||||
## 8. AgentRunAPIProxy
|
||||
|
||||
所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。
|
||||
|
||||
```python
|
||||
# Model
|
||||
await api.invoke_llm(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)
|
||||
await api.steering_pull(mode="all", limit=None)
|
||||
|
||||
# Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围)
|
||||
await api.artifact_metadata(artifact_id)
|
||||
await api.artifact_read(artifact_id, offset=0, limit=None)
|
||||
await api.artifact_read_range(artifact_id, offset=0, length=65536)
|
||||
|
||||
# State / Storage
|
||||
await api.state_get(scope, key); await api.state_set(scope, key, value); await api.state_delete(scope, key)
|
||||
await api.state_list(scope, prefix=None)
|
||||
await api.get_plugin_storage(key); await api.set_plugin_storage(key, value); await api.delete_plugin_storage(key)
|
||||
await api.get_plugin_storage_keys()
|
||||
await api.get_workspace_storage(key); await api.set_workspace_storage(key, value); await api.delete_workspace_storage(key)
|
||||
await api.get_workspace_storage_keys()
|
||||
|
||||
# Files / Host info
|
||||
await api.get_file(file_key)
|
||||
await api.get_langbot_version()
|
||||
```
|
||||
|
||||
`steering_pull(mode="all")` 是推荐默认:Host 按 claim 顺序返回全部 pending steering 输入并清空对应队列。`mode="one-at-a-time"` 仅用于 runner 主动节流,每次返回一条。Host 不合并多条用户消息;runner 负责在 turn 边界决定模型侧格式。
|
||||
|
||||
Steering 审计使用 EventLog 而不是 Transcript schema 扩展:被 active run 吸收的原始 `message.received` 事件保留原事件类型,并在 `metadata.steering` 标记 `status="queued"`、`trigger_behavior="absorbed_into_active_run"`、`claimed_by_run_id`、`claimed_runner_id`、`claimed_at`。Runner 成功 pull 后,Host 追加 `steering.injected` EventLog 记录,`metadata.steering.status="injected"` 并引用 `source_event_id`。若 run 结束时仍有已 claim 但未 pull 的 steering 输入,Host 追加 `steering.dropped` EventLog 记录,`metadata.steering.status="dropped"` 并引用 `source_event_id`;这不是用户消息事实的删除,只是 dispatch 终态。Transcript 继续只表示会话事实,不承担 dispatch 行为标记。
|
||||
|
||||
`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / subject / runner),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。
|
||||
|
||||
Compaction checkpoint 的推荐 state 约定:
|
||||
|
||||
- scope: `conversation`
|
||||
- key: `runner.compaction.checkpoint`
|
||||
- value:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "langbot.local_agent.compaction_checkpoint.v1",
|
||||
"summary": "<conversation_summary>...</conversation_summary>",
|
||||
"covers_until": "transcript-cursor-or-seq",
|
||||
"tokens_before": 12345,
|
||||
"created_at": 1710000000,
|
||||
"conversation_id": "conv-..."
|
||||
}
|
||||
```
|
||||
|
||||
`covers_until` 是摘要覆盖到的 transcript 游标锚点。Runner 读取 checkpoint 后应只拉取该游标之后的 transcript;若 checkpoint 缺失、schema 不匹配、conversation 不匹配或游标不可用,应回退到无 checkpoint 的尾部历史拉取行为。
|
||||
|
||||
Proxy 返回数据结构也属于本协议:
|
||||
|
||||
```python
|
||||
class TranscriptItem(BaseModel):
|
||||
transcript_id: str
|
||||
event_id: str
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
role: str
|
||||
item_type: str = "message"
|
||||
content: str | None = None
|
||||
content_json: dict[str, Any] | None = None
|
||||
artifact_refs: list[dict[str, Any]] = []
|
||||
seq: int | None = None
|
||||
cursor: str | None = None
|
||||
created_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class HistoryPage(BaseModel):
|
||||
items: list[TranscriptItem] = []
|
||||
next_cursor: str | None = None
|
||||
prev_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
total_count: int | None = None
|
||||
|
||||
class HistorySearchResult(BaseModel):
|
||||
items: list[TranscriptItem] = []
|
||||
total_count: int | None = None
|
||||
query: str
|
||||
|
||||
class AgentEventRecord(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
event_time: int | None = None
|
||||
source: str
|
||||
bot_id: str | None = None
|
||||
workspace_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
actor_type: str | None = None
|
||||
actor_id: str | None = None
|
||||
actor_name: str | None = None
|
||||
subject_type: str | None = None
|
||||
subject_id: str | None = None
|
||||
input_summary: str | None = None
|
||||
input_ref: str | None = None
|
||||
raw_ref: str | None = None
|
||||
seq: int | None = None
|
||||
cursor: str | None = None
|
||||
created_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class EventPage(BaseModel):
|
||||
items: list[AgentEventRecord] = []
|
||||
next_cursor: str | None = None
|
||||
prev_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
total_count: int | None = None
|
||||
|
||||
class SteeringInputItem(BaseModel):
|
||||
claimed_run_id: str
|
||||
runner_id: str
|
||||
claimed_at: int | None = None
|
||||
event: AgentEventContext
|
||||
input: AgentInput
|
||||
conversation: ConversationContext | None = None
|
||||
actor: ActorContext | None = None
|
||||
subject: SubjectContext | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class SteeringPullResult(BaseModel):
|
||||
items: list[SteeringInputItem] = []
|
||||
|
||||
class ArtifactMetadata(BaseModel):
|
||||
artifact_id: str
|
||||
artifact_type: str
|
||||
mime_type: str | None = None
|
||||
name: str | None = None
|
||||
size_bytes: int | None = None
|
||||
sha256: str | None = None
|
||||
source: str
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
runner_id: str | None = None
|
||||
created_at: int | None = None
|
||||
expires_at: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
class ArtifactReadResult(BaseModel):
|
||||
artifact_id: str
|
||||
mime_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
offset: int = 0
|
||||
length: int | None = None
|
||||
content_base64: str | None = None
|
||||
file_key: str | None = None
|
||||
has_more: bool = False
|
||||
```
|
||||
|
||||
## 9. 错误模型
|
||||
|
||||
```python
|
||||
class AgentAPIError(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
retryable: bool = False
|
||||
details: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
| code | 说明 |
|
||||
| --- | --- |
|
||||
| `unauthorized` | 未授权访问资源或 scope。 |
|
||||
| `not_found` | 资源不存在或对当前 runner 不可见。 |
|
||||
| `deadline_exceeded` | 超过 run deadline。 |
|
||||
| `payload_too_large` | 请求或响应过大。 |
|
||||
| `rate_limited` | Host 限流。 |
|
||||
| `invalid_argument` | 参数错误。 |
|
||||
| `runtime_error` | Host 或下游能力错误。 |
|
||||
|
||||
SDK runner-facing proxy 在 Host 返回结构化错误或畸形响应时抛出 `AgentAPIException`,其中 `error` 字段为 `AgentAPIError`。Legacy transport 只返回字符串错误时,SDK 使用 `host.action_error` 包装,避免 runner 继续依赖裸 `KeyError` 或字符串匹配。
|
||||
|
||||
Runner 失败使用 `run.failed`:
|
||||
|
||||
```json
|
||||
{ "type": "run.failed", "data": { "code": "runner.error", "error": "failed to call external agent", "retryable": false } }
|
||||
```
|
||||
|
||||
## 10. Timeout 与 Cancellation
|
||||
|
||||
- Host 在 `ctx.runtime.deadline_at` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。
|
||||
- Host 可以取消 active run;Runtime 应尽力中断 runner。
|
||||
- Protocol v1 的 run 绑定当前 Host 进程和当前 runtime channel,不保证跨 Host 重启恢复。Host 重启、runtime channel 断开或 run session 丢失时,Runtime / external harness connector 必须 fail-fast 并尽力取消仍在执行的 runner,不得继续使用旧 `run_id` 调用 Host API。
|
||||
- Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。
|
||||
- Host 必须 unregister active run session。
|
||||
|
||||
## 11. Security 与 Guardrail(协议层)
|
||||
|
||||
Protocol v1 的安全边界在 Host:
|
||||
|
||||
- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。
|
||||
- SDK 本地校验只提升开发体验,不能替代 Host 校验。
|
||||
- 所有 resource id 对 runner 来说都是 opaque。
|
||||
- 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。
|
||||
- 大 payload 必须 artifact 化;`artifact.created.content_base64` 只用于小 artifact,当前 Host hard cap 是 1 MiB。
|
||||
- Host 必须记录 run_id、runner_id、action、resource、scope、result。
|
||||
|
||||
Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。
|
||||
|
||||
外部 harness runner 的边界统一见 HOST_SDK §4.8。简言之:harness native permission mode、allowed/disallowed tools、shell/MCP 权限只是额外执行约束,不能替代 Host 对 LangBot 资源的授权。
|
||||
|
||||
> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
|
||||
## 12. Pipeline Adapter 边界
|
||||
|
||||
Pipeline 是当前入口 adapter,不是协议中心。目标产品模型中 Agent 会替代
|
||||
Pipeline 承载 runner config、resource policy 和 delivery policy;当前 Query
|
||||
entry adapter 只是迁移桥。它负责:
|
||||
|
||||
- 从 `Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。
|
||||
- 从当前 Agent/runner config 构造 `ctx.config`。
|
||||
- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`。
|
||||
|
||||
约束:
|
||||
|
||||
- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。
|
||||
- `ctx.adapter.extra` 只允许承载一次性、JSON-safe、入口相关的非核心元数据,例如 `params`;不得承载 `prompt`、history window、RAG 结果、tool schema 或授权资源。
|
||||
- 静态绑定 prompt 属于 `ctx.config.prompt`。preprocessing / hook 后的动态有效指令不通过 `ctx.adapter.extra` 主动推送;后续如需要保留这类能力,应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。
|
||||
- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。
|
||||
|
||||
## 13. 已确认约束
|
||||
|
||||
- v1 / EBA 主线是 `one event -> one AgentBinding -> one run_id -> one runner`。
|
||||
- 一个 bot / IM channel 在同一时间只绑定一个负责 agentic 处理的 Agent;一个 Agent 可以被多个 bot / channel 复用。
|
||||
- 如果配置层出现多个匹配 AgentBinding,BindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。
|
||||
- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。
|
||||
- `AgentRunnerDescriptor.source` 只允许 `plugin`;Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。
|
||||
- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshot;runtime handler 不应重新执行资源裁剪。
|
||||
- v1 不要求 Agent、AgentRunner 插件实例或 runner id 全局串行。多个 bot / channel 可复用同一个 Agent;并发隔离依赖 `run_id`、binding、conversation / thread scope 和 Host authorization snapshot。
|
||||
- 外部 harness runner 当前是 MVP / dev path,证明协议可接入,不代表发布级安全边界或 Docker 生产可用性完成。
|
||||
|
||||
## 14. 开放问题
|
||||
|
||||
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
|
||||
- ArtifactStore 是否复用现有 BinaryStorage backend,还是引入独立实体。
|
||||
- State 与 Storage 的边界是否需要更强类型。
|
||||
- platform action 的审批模型如何表达。
|
||||
- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。
|
||||
153
docs/agent-runner-pluginization/README.md
Normal file
153
docs/agent-runner-pluginization/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Agent Runner 插件化文档入口
|
||||
|
||||
本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 接入边界和官方 runner 迁移混在同一份 README 里。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
旧 runner 路径主要围绕 Pipeline / Query 和 `pkg/provider/runners` 内置实现展开,扩展外部 agent runtime 时容易把 runner 选择、上下文裁剪、资源授权和消息投递绑在同一条聊天链路里。这个分支要把 LangBot 收敛成 Agent Host:Host 负责事件、绑定、授权、事实源和结果投递;AgentRunner 作为插件或外部 harness 消费统一协议并自主管理 prompt / history / memory。
|
||||
|
||||
## 文档维护原则(单一事实源)
|
||||
|
||||
- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。
|
||||
- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
||||
- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。
|
||||
- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。
|
||||
|
||||
## 本分支目标
|
||||
|
||||
**本分支目标:AgentRunner 外化 / 插件化基础设施**
|
||||
|
||||
本分支只做 LangBot 作为 Agent Host 的基础能力建设,为后续用 `Agent`
|
||||
替代 Pipeline 承载 agent 配置打底:
|
||||
|
||||
- LangBot 与 SDK 的稳定协议合同(Protocol v1)
|
||||
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型
|
||||
- `run(event, binding)` event-first 入口
|
||||
- `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding
|
||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore
|
||||
- History / Event / Artifact / State pull APIs
|
||||
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
|
||||
|
||||
## 本分支不实现
|
||||
|
||||
以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调:
|
||||
|
||||
- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理
|
||||
- **Event subscription / Event notification**:事件订阅、推送通知
|
||||
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
|
||||
- **Scheduler / Background event source**:定时任务、后台事件源
|
||||
- **Runtime control plane v2 / Run Ledger**:先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;runtime registry、heartbeat、task queue 和 daemon claim 是后续可选阶段,不进入 Protocol v1 主线。
|
||||
|
||||
EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。
|
||||
|
||||
本分支与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
|
||||
## 目标产品模型
|
||||
|
||||
未来产品层应把 `Agent` 理解为 Pipeline 的替代物:原先 bot 绑定 Pipeline,Pipeline 携带 agent/provider/RAG/tool 等配置;后续应改为 bot 或 IM channel 绑定一个 Agent,Agent 携带 runner id、runner config、resource/state/delivery policy 等 agent 配置。
|
||||
|
||||
调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;README 不复写这些约束。
|
||||
|
||||
## 当前入口关系
|
||||
|
||||
**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。**
|
||||
|
||||
主入口仍可由 Pipeline 触发,但内部已转换成 event-first path:`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Pipeline path 因此获得了 event-first host capabilities(EventLog / Transcript / ArtifactStore / PersistentStateStore 写入,History / Event / Artifact / State pull API 可用)。
|
||||
|
||||
下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同:discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 |
|
||||
| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 |
|
||||
| AgentConfig | Host 内部迁移期配置投影,由当前 Pipeline config 或未来持久 Agent 生成。 |
|
||||
| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 |
|
||||
| envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 |
|
||||
| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 |
|
||||
| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 |
|
||||
| harness runner | LiteLLM Agent Platform、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 |
|
||||
| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 |
|
||||
| Runtime Control Plane | v2 Host 能力层,第一阶段重点是 Host-owned run/result ledger 与 control primitives;runtime registry、heartbeat、task queue 和 daemon claim 是后续可选阶段,不是 Protocol v1 主线。 |
|
||||
|
||||
## 设计文档
|
||||
|
||||
| 文档 | 关注点 |
|
||||
| --- | --- |
|
||||
| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | **🔒 唯一 schema 事实源**。LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:版本协商、discovery、run context、result stream、proxy actions、错误和 adapter 边界。 |
|
||||
| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 |
|
||||
| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 |
|
||||
| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 |
|
||||
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 |
|
||||
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:第一阶段优先把 `AgentRun` / `AgentRunEvent` / run control 做成 Host 事实源;完整 runtime registry / daemon 管控是后续可选阶段。**标注为 future design note**。 |
|
||||
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
|
||||
| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 |
|
||||
| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 |
|
||||
| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
|
||||
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
|
||||
|
||||
## 工作拆分
|
||||
|
||||
### 1. LangBot + SDK 基础设施
|
||||
|
||||
目标是把 LangBot 从内置 runner 执行器变成 agent host:
|
||||
|
||||
- LangBot 与 SDK 的稳定协议合同
|
||||
- runner manifest / descriptor / registry
|
||||
- Agent / binding 配置解析
|
||||
- run orchestration 和生命周期管理
|
||||
- resource authorization 与 `run_id` 级权限校验
|
||||
- host-owned state / storage / event log / transcript / artifact 能力
|
||||
- SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy`
|
||||
|
||||
协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
|
||||
|
||||
详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
||||
|
||||
### 2. Agent-owned context
|
||||
|
||||
LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。
|
||||
|
||||
Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 按需拉取历史并自行管理 working context。
|
||||
|
||||
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
||||
|
||||
### 3. Event Based Agent(External Branch)
|
||||
|
||||
消息只是事件的一种。外部 EBA 分支中的 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。
|
||||
|
||||
EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支提供给外部 EBA 分支复用的入口点。
|
||||
|
||||
**本分支不实现 EBA 完整能力,只提供:**
|
||||
- event-first envelope (`AgentEventEnvelope`)
|
||||
- AgentBinding model
|
||||
- `run(event, binding)` 入口
|
||||
- QueryEntryAdapter(当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source)
|
||||
|
||||
详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
|
||||
|
||||
### 4. 官方 runner 插件
|
||||
|
||||
官方 `local-agent` 和外部 runner 迁移是下游工作。它们需要依附 LangBot 提供的宿主能力,但不应反过来决定宿主协议。
|
||||
|
||||
`local-agent` 可以外移,也可以重写。验收重点是它能完整消费 LangBot 的模型、工具、知识库、存储、事件、history API 和 result stream,而不是保留旧内置 runner 的内部结构。
|
||||
|
||||
详见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
|
||||
|
||||
### 5. Runtime Control Plane v2(Future)
|
||||
|
||||
当前 AgentRunner v1 主线只负责 `event -> binding -> runner.run(ctx) -> result stream`。
|
||||
后续 Agent Platform v2 应先在 Host 侧新增持久 `AgentRun` / `AgentRunEvent`、result persistence、cancel/finalize/query 等通用 run control primitives。完整 runtime registry、heartbeat、task queue、daemon claim 和 runtime audit 只有在复用需求明确后再作为可选阶段下沉到 Host。
|
||||
|
||||
在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验,runtime/task 的事实源仍由 Host 持有。
|
||||
|
||||
详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
|
||||
|
||||
## 约束事实源
|
||||
|
||||
本分支已确认约束不在 README 重写:
|
||||
|
||||
- Runner 可见协议、result stream 和调度边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
|
||||
- Host 内部 `AgentConfig` / `AgentBinding` 投影见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
||||
- 外部 EBA / Agent Platform / Runtime Control Plane 接入边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
505
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
505
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# Agent Platform / Runtime Control Plane Decision Note
|
||||
|
||||
本文档记录 AgentRunner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `AgentRunner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。
|
||||
|
||||
> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
|
||||
## 1. 当前决策
|
||||
|
||||
LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer layer**,而不是把某个完整 Agent Platform 产品固化进 core。
|
||||
|
||||
结论:
|
||||
|
||||
- **Agent Platform 产品形态做成插件**。插件负责 agent 管理、策略、业务队列、UI、编排、多 agent 协作和产品体验。
|
||||
- **Agent Platform 所需的基础事实源做进 Host**。Host 保存 event、run、result、artifact、state、transcript、权限快照、审计和通用控制状态。
|
||||
- **不在第一阶段把 runtime registry / daemon worker 管控做成 Host 必选能力**。远程 harness / daemon 可以先由 AgentRunner 插件和 SDK remote layer 自己维护连接、心跳和本地执行。
|
||||
- **不把业务调度写进 Host**。Host 提供通用 run/result/control primitives,Platform 插件决定哪些事件触发哪些 agent、如何排队、如何分配、是否 fan-out。
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
LangBot Host
|
||||
Event / Agent / Binding / Run / RunEvent / Artifact / State / Transcript
|
||||
Authorization / audit / delivery / result persistence / control primitives
|
||||
|
||||
Agent Platform plugin
|
||||
Agent management UI / project-task model / event routing policy
|
||||
Business queue / multi-agent orchestration / runtime selection policy
|
||||
|
||||
AgentRunner plugin / external harness runtime
|
||||
Connects LiteLLM Agent Platform / remote agent / subprocess / HTTP API
|
||||
Executes and converts provider-native events to AgentRunResult
|
||||
```
|
||||
|
||||
## 2. Platform 与非 Platform 的区别
|
||||
|
||||
当前 LangBot 已经具备 Agent Host 的核心特征:
|
||||
|
||||
- 抹平不同 AgentRunner。
|
||||
- 从 IM / Pipeline 入口触发 runner。
|
||||
- 有 event-first context 方向。
|
||||
- 有 Host-owned EventLog / Transcript / Artifact / State 的一部分。
|
||||
- 有 runner config 下发和 run-scoped authorization。
|
||||
|
||||
这还不是完整 Agent Platform。完整 Platform 至少还需要:
|
||||
|
||||
- 可管理的 agent 资产:agent profile、binding、resource policy、runner config、可用状态。
|
||||
- 可观察的执行生命周期:run status、result stream、失败原因、artifact、审计、回放。
|
||||
- 可运营的控制面:取消、重试、排队、并发、超时、恢复、诊断。
|
||||
- 可产品化的调度体验:事件订阅、路由策略、任务板、多 agent 协作、项目/工作区视图。
|
||||
|
||||
因此,区别不只是“有没有调度”,而是是否具备:
|
||||
|
||||
```text
|
||||
managed agent assets + observable run lifecycle + operational run control
|
||||
```
|
||||
|
||||
Host 负责这些能力的通用事实源和安全边界;Platform 插件负责把它们组装成具体产品。
|
||||
|
||||
## 3. 基础概念
|
||||
|
||||
### 3.1 Event
|
||||
|
||||
Event 表示“发生了什么”:
|
||||
|
||||
```text
|
||||
message.received
|
||||
github.issue.opened
|
||||
scheduler.tick
|
||||
user.approved
|
||||
system.webhook.received
|
||||
```
|
||||
|
||||
EBA 负责把外部输入标准化成 event。Event 本身不是 queue,也不等同于一次 agent 执行。
|
||||
|
||||
### 3.2 Run
|
||||
|
||||
Run 表示“某个 agent / binding / runner 针对某个 event 的一次执行”。
|
||||
|
||||
Run 应由 Host 持久化,成为执行状态、结果、权限和审计的事实源:
|
||||
|
||||
```text
|
||||
run_id
|
||||
event_id
|
||||
agent_id / binding_id
|
||||
runner_id
|
||||
status
|
||||
created_at / started_at / finished_at
|
||||
error / failure_reason
|
||||
delivery target
|
||||
metadata
|
||||
```
|
||||
|
||||
当前 `AgentRunSessionRegistry` 只保存 active run 的内存态授权信息,不足以支撑 Platform 的回放、审计、取消、重试和异步执行。
|
||||
|
||||
### 3.3 RunEvent / RunResult
|
||||
|
||||
RunEvent 是一次 run 过程中产生的结果事件流,对应 `AgentRunResult`:
|
||||
|
||||
```text
|
||||
message.delta
|
||||
message.completed
|
||||
tool.call.started
|
||||
tool.call.completed
|
||||
artifact.created
|
||||
state.updated
|
||||
action.requested
|
||||
run.completed
|
||||
run.failed
|
||||
```
|
||||
|
||||
Host 应保存这些事件,按 `run_id + sequence` 可回放。Transcript、Artifact、State 可以由这些 result event 触发写入现有 store。
|
||||
|
||||
### 3.4 Queue
|
||||
|
||||
Queue 不是 EBA 的替代品。
|
||||
|
||||
EBA 负责产生 event;queue 负责处理“这个 event 对应的执行 work item 何时执行、谁来执行、如何取消/重试/恢复”。
|
||||
|
||||
队列可以分两层:
|
||||
|
||||
- **业务队列**:由 Platform 插件管理,例如项目任务、优先级、agent team、workflow、人工审批。
|
||||
- **执行队列 / run queue**:可选 Host 原语,例如 queued / running / completed / failed / cancelled、claim lease、dispatch timeout、orphan recovery。
|
||||
|
||||
第一阶段不要求 Host 内置完整执行队列。Platform 插件可以先管理业务队列,然后调用 Host 创建 run、保存 result。
|
||||
|
||||
### 3.5 Runtime / Daemon
|
||||
|
||||
Runtime / daemon 表示执行位置或执行能力,例如某台机器上的 Claude Code / Codex CLI。
|
||||
|
||||
当前决策:
|
||||
|
||||
- Host 不在第一阶段维护完整 runtime registry。
|
||||
- AgentRunner 插件可以通过 SDK remote layer 与 daemon 保持连接、心跳和执行通道。
|
||||
- 外部 harness / agent 不应直接访问 LangBot Host。访问 LangBot 资源必须通过 daemon / AgentRunner plugin / SDK runtime / `AgentRunAPIProxy` / MCP bridge。
|
||||
- 如果后续多个插件都需要共享 runtime 状态,再把薄的 `RuntimeLease` / registry 下沉为 Host 通用能力。
|
||||
|
||||
## 4. Host 应新增的最小能力
|
||||
|
||||
第一阶段最重要的不是 daemon registry,而是让 Host 成为 run/result 的事实源。
|
||||
|
||||
### 4.1 AgentRun Store
|
||||
|
||||
新增持久 `AgentRun`:
|
||||
|
||||
```text
|
||||
id / run_id
|
||||
event_id
|
||||
agent_id
|
||||
binding_id
|
||||
runner_id
|
||||
conversation_id / thread_id
|
||||
workspace_id / bot_id
|
||||
status
|
||||
status_reason
|
||||
created_at / started_at / finished_at / updated_at
|
||||
deadline_at
|
||||
cancel_requested_at
|
||||
metadata_json
|
||||
```
|
||||
|
||||
建议 status 至少包含:
|
||||
|
||||
```text
|
||||
created
|
||||
running
|
||||
completed
|
||||
failed
|
||||
cancelled
|
||||
timeout
|
||||
```
|
||||
|
||||
如果后续加执行队列,再引入:
|
||||
|
||||
```text
|
||||
queued
|
||||
claimed
|
||||
dispatching
|
||||
```
|
||||
|
||||
### 4.2 AgentRunEvent Store
|
||||
|
||||
新增持久 `AgentRunEvent`:
|
||||
|
||||
```text
|
||||
id
|
||||
run_id
|
||||
sequence
|
||||
type
|
||||
data_json
|
||||
created_at
|
||||
source
|
||||
artifact_refs_json
|
||||
metadata_json
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 同一 `run_id` 内 `sequence` 单调递增。
|
||||
- append 必须幂等,支持远程 daemon / plugin 重试。
|
||||
- 未知 result type 可保存但 Host 只对已知类型执行副作用。
|
||||
- 大 payload 仍应转 artifact,不直接塞入 result event。
|
||||
|
||||
### 4.3 Run Control API
|
||||
|
||||
Host 提供通用控制原语:
|
||||
|
||||
```text
|
||||
run.create
|
||||
run.get
|
||||
run.list
|
||||
run.events.page
|
||||
run.cancel
|
||||
run.append_result
|
||||
run.finalize
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `run.create` 创建 Host-owned run 和授权快照。
|
||||
- `run.append_result` 只允许受信 SDK/runtime 路径调用,写入 `AgentRunEvent` 并触发 transcript/artifact/state/delivery 副作用。
|
||||
- `run.finalize` 关闭 run,更新 terminal status。
|
||||
- `run.cancel` 设置取消意图;同步 runner 通过 context/deadline 感知,远程 runner 通过插件/daemon 通道感知。
|
||||
|
||||
第一阶段可以只暴露给插件 runtime action,不一定先做公开 HTTP API。
|
||||
|
||||
### 4.4 Result Persistence In Orchestrator
|
||||
|
||||
当前 `AgentRunOrchestrator.run()` 已经处理:
|
||||
|
||||
```text
|
||||
event -> binding -> context -> runner invocation -> result normalization
|
||||
```
|
||||
|
||||
需要补齐:
|
||||
|
||||
- run 开始时创建 `AgentRun`。
|
||||
- 每个 `AgentRunResult` 进入 `AgentRunEvent`。
|
||||
- `run.completed` / 正常 generator 结束时标记 completed。
|
||||
- `run.failed` / exception / timeout 标记 failed 或 timeout。
|
||||
- `state.updated`、`artifact.created`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。
|
||||
|
||||
### 4.5 Authorization Snapshot
|
||||
|
||||
异步或远程执行时,run 创建时必须固化授权快照:
|
||||
|
||||
- runner identity
|
||||
- binding identity
|
||||
- caller plugin identity
|
||||
- resource policy
|
||||
- allowed tools/models/files/knowledge bases/storage scopes
|
||||
- state scopes
|
||||
- conversation/thread/workspace scope
|
||||
|
||||
后续 append result、state API、artifact API、history API 都以这个 snapshot 校验,不重新扩大权限。
|
||||
|
||||
## 5. SDK 侧应新增的最小能力
|
||||
|
||||
SDK 不需要马上定义完整 daemon registry,但需要让插件和 runner 使用 Host run/result 能力。
|
||||
|
||||
### 5.1 Entities
|
||||
|
||||
新增或补齐:
|
||||
|
||||
```text
|
||||
AgentRun
|
||||
AgentRunStatus
|
||||
AgentRunEvent
|
||||
RunEventPage
|
||||
RunCreateRequest / RunCreateResult
|
||||
RunAppendResultRequest
|
||||
```
|
||||
|
||||
这些是 Host control primitives,不替代 `AgentRunContext` / `AgentRunResult`。
|
||||
|
||||
### 5.2 Proxy Methods
|
||||
|
||||
在 SDK proxy 中提供:
|
||||
|
||||
```python
|
||||
create_run(...)
|
||||
get_run(run_id)
|
||||
list_runs(...)
|
||||
page_run_events(run_id, cursor=None, limit=...)
|
||||
cancel_run(run_id)
|
||||
append_run_result(run_id, result, sequence=None)
|
||||
finalize_run(run_id, status, error=None)
|
||||
```
|
||||
|
||||
访问边界:
|
||||
|
||||
- 普通 AgentRunner 在同步 `run(ctx)` 内不一定需要直接调用这些 API;Host orchestrator 可自动记录。
|
||||
- Platform 插件可以创建/查询/取消 run。
|
||||
- AgentRunner 插件或 daemon bridge 可以 append/finalize 自己负责的 run。
|
||||
- 外部 harness 仍不能直接调用 Host;必须经 SDK runtime / proxy / bridge。
|
||||
|
||||
### 5.3 Plugin-Daemon Heartbeat
|
||||
|
||||
远程 daemon 的初始心跳可以是 SDK / AgentRunner plugin 私有能力:
|
||||
|
||||
```text
|
||||
daemon <-> AgentRunner plugin / SDK remote layer <-> LangBot plugin runtime <-> Host
|
||||
```
|
||||
|
||||
Host 第一阶段只需要知道:
|
||||
|
||||
- 相关插件是否在线。
|
||||
- run 是否有 progress/result。
|
||||
- run 是否超时或取消。
|
||||
|
||||
如果后续需要跨插件共享 daemon 可用性,再把 heartbeat/registry 下沉为 Host 能力。
|
||||
|
||||
## 6. Platform 插件应负责什么
|
||||
|
||||
Agent Platform 插件可以负责:
|
||||
|
||||
- 管理哪些 agent 可用。
|
||||
- 维护产品层 agent profile、项目、任务板、workflow、team。
|
||||
- 订阅 EBA event,决定哪些 event 触发哪些 agent。
|
||||
- 维护业务 queue:优先级、重试策略、人工审批、分配规则。
|
||||
- 选择 runner / runtime / daemon。
|
||||
- 调用 Host run API 创建、取消、查询执行。
|
||||
- 展示 run status、result stream、artifact、失败原因和审计。
|
||||
|
||||
Platform 插件不应负责:
|
||||
|
||||
- 私有保存通用 run/result 事实源。
|
||||
- 绕过 Host 直接写 transcript/artifact/state。
|
||||
- 让外部 harness 直接访问 LangBot DB 或 Host 内部资源。
|
||||
- 把某个业务队列语义强塞进 AgentRunner Protocol v1。
|
||||
|
||||
## 7. 与 EBA 的关系
|
||||
|
||||
EBA 做好后,事件流可以进入两种路径。
|
||||
|
||||
直接执行路径:
|
||||
|
||||
```text
|
||||
EventGateway
|
||||
-> EventRouter resolves AgentBinding
|
||||
-> AgentRunOrchestrator.run(event, binding)
|
||||
-> Host records AgentRun / AgentRunEvent
|
||||
-> delivery
|
||||
```
|
||||
|
||||
Platform 插件编排路径:
|
||||
|
||||
```text
|
||||
EventGateway
|
||||
-> Platform plugin receives/subscribes event
|
||||
-> plugin applies policy / business queue
|
||||
-> plugin creates Host run
|
||||
-> runner/plugin/daemon executes
|
||||
-> Host records result and state
|
||||
-> plugin displays / Host delivers
|
||||
```
|
||||
|
||||
这两条路径共享 Host run/result/artifact/state 事实源。区别在于是否有 Platform 插件参与产品化调度和业务队列。
|
||||
|
||||
## 8. 与 AgentRunner Protocol v1 的关系
|
||||
|
||||
本设计不改变 v1 的 runner 可见合同:
|
||||
|
||||
```text
|
||||
AgentRunContext -> AgentRunner.run(ctx) -> AgentRunResult stream
|
||||
```
|
||||
|
||||
必须保持:
|
||||
|
||||
- `AgentRunContext` 不塞入 daemon/worker/pod 细节。
|
||||
- `AgentRunResult` 仍是 runner 输出的统一事件流。
|
||||
- 普通 runner 不需要知道 task queue / runtime registry。
|
||||
- 远程 harness 可以自管 session、tool loop、MCP、上下文压缩,但访问 LangBot 资源必须通过 SDK proxy / bridge。
|
||||
- Runtime-managed execution 是 placement / transport 选择,不是普通 runner 协议的强制概念。
|
||||
|
||||
## 9. 分阶段实施建议
|
||||
|
||||
### Phase 1: Run Ledger
|
||||
|
||||
目标:Host 成为执行状态和结果事实源。
|
||||
|
||||
范围:
|
||||
|
||||
- `AgentRun` 表。
|
||||
- `AgentRunEvent` 表。
|
||||
- Orchestrator 自动创建/更新 run。
|
||||
- Journal 持久化每个 `AgentRunResult`。
|
||||
- Run 查询和事件分页 API。
|
||||
- SDK entities + proxy 方法。
|
||||
|
||||
复杂度:中等。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 12-20 个文件
|
||||
SDK: 4-8 个文件
|
||||
Tests: 8-15 个文件
|
||||
```
|
||||
|
||||
### Phase 2: Platform Plugin Queue On Host Run Primitives
|
||||
|
||||
目标:Platform 插件管理业务 queue,Host 提供 run/result/cancel 原语。
|
||||
|
||||
范围:
|
||||
|
||||
- `run.create`
|
||||
- `run.cancel`
|
||||
- `run.append_result`
|
||||
- `run.finalize`
|
||||
- result append 的 sequence/idempotency。
|
||||
- 受权限保护的远程 append/finalize。
|
||||
- Platform 插件可基于 Host run 构建任务板和调度体验。
|
||||
|
||||
复杂度:中等偏高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 20-35 个文件
|
||||
SDK: 8-14 个文件
|
||||
Tests: 15-25 个文件
|
||||
```
|
||||
|
||||
### Phase 3: Optional Host Execution Queue / Claim Lease
|
||||
|
||||
目标:当多个插件重复实现 claim/cancel/retry/recovery 时,再下沉执行队列到 Host。
|
||||
|
||||
范围:
|
||||
|
||||
- `queued/running/completed/failed/cancelled` 状态机扩展。
|
||||
- `claim_run` / `lease_until`。
|
||||
- dispatch timeout。
|
||||
- retry / orphan recovery。
|
||||
- cancel propagation。
|
||||
- 并发 claim 防重。
|
||||
|
||||
复杂度:高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 35-55 个文件
|
||||
SDK: 12-20 个文件
|
||||
Tests: 25-40 个文件
|
||||
```
|
||||
|
||||
### Phase 4: Optional Runtime Registry
|
||||
|
||||
目标:当 Host 需要统一管理多个 daemon / worker 时,再引入 runtime registry。
|
||||
|
||||
范围:
|
||||
|
||||
- runtime register / heartbeat / deregister。
|
||||
- capability report:provider、version、login status、workspace access、slot。
|
||||
- runtime online/offline。
|
||||
- runtime scoped auth。
|
||||
- runtime audit。
|
||||
- runtime gone recovery。
|
||||
- task wakeup / long polling / websocket。
|
||||
- 多 Host 实例下的 relay / distributed lock。
|
||||
|
||||
复杂度:很高。
|
||||
|
||||
预计改动:
|
||||
|
||||
```text
|
||||
Host: 55-80+ 个文件
|
||||
SDK: 18-30 个文件
|
||||
Tests: 40+ 个文件
|
||||
```
|
||||
|
||||
不建议现在直接进入此阶段。
|
||||
|
||||
## 10. 设计原则
|
||||
|
||||
- 先把 run/result 事实源做进 Host,再谈完整 runtime control plane。
|
||||
- Agent Platform 产品做插件;Host 做基础设施。
|
||||
- Host 不写业务调度策略,但要保存通用状态、结果、权限和审计。
|
||||
- EBA event 不是 queue;queue 是执行生命周期问题。
|
||||
- 业务 queue 可以先在 Platform 插件里;执行 queue 只有在复用需求明确后再下沉 Host。
|
||||
- Daemon registry 不应污染 AgentRunner Protocol v1。
|
||||
- 外部 harness 不直接访问 LangBot Host 或 DB。
|
||||
- 所有 LangBot 资源访问必须走 SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge。
|
||||
- Docker / remote / local subprocess 只是 runtime placement,不是 runner 协议差异。
|
||||
|
||||
## 11. 非目标
|
||||
|
||||
当前阶段不做:
|
||||
|
||||
- 完整 Multica 式 runtime registry。
|
||||
- Host 内置项目管理、任务板、agent team、workflow 产品逻辑。
|
||||
- 把 daemon heartbeat / worker liveness 放进 `AgentRunContext`。
|
||||
- 把业务 queue 定义为 AgentRunner Protocol 字段。
|
||||
- 让 Platform 插件私有保存 run/result 事实源。
|
||||
- 让外部 agent/harness 直连 Host 内部资源。
|
||||
|
||||
## 12. 待定问题
|
||||
|
||||
- Host 是否需要最小持久 `Agent` / `Binding` 模型,还是继续由 Pipeline / Platform 插件投影运行期 `AgentBinding`。
|
||||
- Platform 插件创建 run 时,是否传完整 `AgentBinding` snapshot,还是引用 Host-owned binding id。
|
||||
- `AgentRunEvent` 与现有 `EventLog` / `Transcript` 的查询关系:直接 join,还是通过专门 view 聚合。
|
||||
- `run.append_result` 的认证粒度:runner plugin identity、run token、scoped capability token,或 SDK runtime 内部 channel。
|
||||
- 取消语义:同步 runner、external harness runtime/session 如何统一感知 cancel。
|
||||
- 何时把插件私有 daemon heartbeat 提升为 Host `RuntimeLease`。
|
||||
- 若未来 Host 做 claim lease,Platform 插件业务 queue 与 Host execution queue 如何避免双队列混乱。
|
||||
154
docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md
Normal file
154
docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Run Steering 与 Compaction Checkpoint(Design Note)
|
||||
|
||||
本文档记录两项 Host/runner 协作能力:**运行中消息注入(steering / follow-up)**和
|
||||
**压缩摘要持久化(compaction checkpoint)**。两者来自官方 local-agent 对照
|
||||
Pi agent harness(`pi-mono/packages/agent`,下称 pi-agent-core)的差距分析:
|
||||
local-agent 已移植 Pi 的事件生命周期、并行工具语义、hook 扩展点和压缩预算模型,
|
||||
这两项需要 Host 协议、授权与 runner turn 边界协同才能闭环。
|
||||
|
||||
> 本文是设计备忘,不是 schema 事实源。涉及的数据结构最终落到
|
||||
> [PROTOCOL_V1.md](./PROTOCOL_V1.md);上下文边界语义以
|
||||
> [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 为准;
|
||||
> run 持久化与控制原语以 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 为准。
|
||||
|
||||
## 1. Run Steering / Follow-up(运行中消息注入)
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
IM 场景下用户在 agent 运行中追加消息非常常见(补充信息、纠正方向、"算了别查了")。
|
||||
当前主线是 `one event -> one AgentBinding -> one run_id -> one runner`
|
||||
(PROTOCOL_V1 §13):同会话的新消息要么等待当前 run 结束后触发新 run,
|
||||
要么并发触发独立 run。两种行为都无法把新消息送进**正在执行的 tool loop**,
|
||||
用户体验是"agent 自顾自跑完过期任务,然后才看到新消息"。
|
||||
|
||||
cancel(PROTOCOL_V1 §10)不解决这个问题:cancel 丢弃已完成的工作;
|
||||
steering 是在保留当前进度的前提下改变后续方向。
|
||||
|
||||
### 1.2 Pi 的参考语义
|
||||
|
||||
pi-agent-core 区分两个队列,注入时机都在 turn 边界,不打断进行中的模型流或工具执行:
|
||||
|
||||
- **steering**:运行中插入。当前 assistant 消息的全部 tool call 完成后、
|
||||
下一次模型调用前,注入排队的用户消息;模型在下一 turn 看到它们。
|
||||
- **follow-up**:排队后续工作。仅当没有 pending tool call 且没有 steering 消息、
|
||||
run 即将自然结束时检查;若有排队消息则注入并继续下一 turn,而不是结束 run。
|
||||
|
||||
两个队列各自支持 `one-at-a-time`(每次注入一条)和 `all`(一次注入全部)模式。
|
||||
|
||||
### 1.3 设计方向
|
||||
|
||||
职责划分遵循既有原则:Host 拥有事件路由和会话事实源,runner 拥有 turn 边界。
|
||||
|
||||
- **Host 侧**:BindingResolver / dispatch 层识别"同 conversation 存在 active run
|
||||
且 runner 声明支持 steering"的新消息事件,将其写入 run-scoped steering queue,
|
||||
并标记该事件已被在途 run 认领(不再触发新 run,避免破坏 §13 的基数约束)。
|
||||
事件仍照常进 EventLog / Transcript(事实源不变,改变的只是触发行为)。
|
||||
- **Runner 侧**:在 turn 边界(tool batch 完成后、下一次模型调用前,以及 run
|
||||
即将自然结束前)通过 run-scoped pull API 拉取 pending steering 输入,
|
||||
注入 working context。local-agent 的 `AgentLoopHooks.prepare_next_turn` /
|
||||
`should_stop_after_turn` 已预留了对应的注入点。
|
||||
- **能力协商**:runner manifest 声明 `steering` capability(参照 PROTOCOL_V1 §4.3);
|
||||
未声明的 runner 保持现状(新消息按现有规则另起 run)。
|
||||
- **回执**:被 steering 消费的事件通过 EventLog 审计。原始 `message.received`
|
||||
记录在 `metadata.steering` 标记 queued/absorbed 与 `claimed_by_run_id`;
|
||||
runner 成功 pull 后,Host 追加 `steering.injected` 记录并引用源事件。
|
||||
run 结束时仍未被 pull 的已 claim 输入,Host 追加 `steering.dropped` 记录作为
|
||||
dispatch 终态;原始 Transcript 事实不删除。
|
||||
Transcript 继续只表示会话事实,不扩展 dispatch 行为字段。
|
||||
|
||||
已落地的协议面(最终定义归 PROTOCOL_V1):
|
||||
|
||||
1. `ContextAccess.available_apis` 增加 steering pull 能力位。
|
||||
2. `AgentRunAPIProxy` 增加 steering 拉取 action:默认 `mode=all`,Host 保序返回全部
|
||||
pending 输入;`one-at-a-time` 仅作为 runner 主动节流选项。
|
||||
3. dispatch 层的"认领"规则:`message.received` 可被同 conversation 的 active run
|
||||
吸收,原事件写 EventLog / Transcript,dispatch 行为写入 EventLog metadata。
|
||||
4. Host 对单 run steering queue 设置内存上限,队列满时不再 claim 新消息,消息回到
|
||||
正常 dispatch 路径,避免 active run 无限吞入同会话输入。
|
||||
|
||||
### 1.4 边界
|
||||
|
||||
- 不引入 Host 替 runner 做 prompt 拼接:Host 只递队列,注入位置和格式由 runner 决定。
|
||||
- 不与 observer / fan-out 混淆:steering 仍是单 run 内的输入补充,不产生第二个 runner。
|
||||
- 远程 / 外部 harness runner(claude-code、codex 等)若其底层 session 自带
|
||||
steering 能力,adapter 可以直接转发;协议面保持一致。
|
||||
|
||||
## 2. Compaction Checkpoint 持久化
|
||||
|
||||
### 2.1 问题
|
||||
|
||||
local-agent 当前是无状态 runner:每次 run 重新拉取 transcript 尾部
|
||||
(默认 50 条)、重新估算 token、重新生成压缩摘要。后果:
|
||||
|
||||
- 长会话中每 run 重复压缩计算,摘要每次重新生成,不同 run 之间措辞漂移,
|
||||
对 provider KV cache 不友好(AGENT_CONTEXT_PROTOCOL §"Summary checkpoint 稳定"
|
||||
已写明期望:只有压缩发生时才产生新 checkpoint)。
|
||||
- 历史一旦超过 fetch limit,更早的内容永久不可见——没有 checkpoint 记录
|
||||
"已压缩到哪里、压缩出了什么"。
|
||||
|
||||
pi-agent-core 把 compaction 条目持久化进 session tree:摘要带
|
||||
`tokensBefore` 和覆盖范围,后续 turn 直接复用,只在再次越过阈值时增量压缩。
|
||||
|
||||
### 2.2 现状盘点
|
||||
|
||||
协议面和主消费路径已具备:
|
||||
|
||||
- State / Storage API 已定义(PROTOCOL_V1 §8 "State / Storage"),
|
||||
且 AGENT_CONTEXT_PROTOCOL 已点名 `summary.checkpoint` 是 state 的预期用法。
|
||||
- Host 会根据 binding state policy 暴露 `ContextAccess.available_apis.state`。
|
||||
- local-agent 会在 state API 可用时读取/写入 `runner.compaction.checkpoint`;
|
||||
缺失、schema 不匹配、conversation 不匹配或游标失败时回退尾部历史拉取。
|
||||
- LLM 生成摘要**不依赖**本项 Host 能力——runner 用已授权的 `invoke_llm`
|
||||
即可生成;checkpoint 只解决"存下来、下次复用"。
|
||||
|
||||
### 2.3 设计方向
|
||||
|
||||
- **存放位置**:state,scope=`conversation`(小 JSON,符合 PROTOCOL_V1 §8
|
||||
对 state/storage 的边界建议)。若未来摘要膨胀,超出部分放 storage 并在
|
||||
state 中留引用。
|
||||
- **key 约定**:`runner.compaction.checkpoint`(runner 命名空间内)。
|
||||
- **内容约定**(schema 落 PROTOCOL_V1 或 runner 文档,此处只列语义):
|
||||
- `schema_version`
|
||||
- `summary`:压缩摘要文本(LLM 生成或确定性生成)
|
||||
- `covers_until`:已被摘要覆盖的 transcript 游标(seq / message id),
|
||||
是增量压缩和"从哪继续拉历史"的锚点
|
||||
- `tokens_before` / `created_at`:诊断与失效判断
|
||||
- **消费流程**:run 开始时读 checkpoint → 只拉取 `covers_until` 之后的
|
||||
transcript → 压缩触发时基于旧摘要增量生成新摘要、写回新 checkpoint。
|
||||
checkpoint 缺失或解析失败时回退到现行为(全量拉尾部),保证向后兼容。
|
||||
- **失效规则**:`covers_until` 在 Host transcript 中不存在(会话被清理 / 重置)
|
||||
即作废;runner 不得信任跨 conversation 的 checkpoint。
|
||||
- **授权**:Host 对声明需要 state 的 runner binding 开启
|
||||
`available_apis.state`;校验沿用现有 run-scoped state 校验
|
||||
(scope、key、value 大小、JSON 可序列化,见 PROTOCOL_V1 §7.2 对
|
||||
`state.updated` 的要求)。
|
||||
|
||||
### 2.4 相关但独立的工作
|
||||
|
||||
- **tokenizer / usage metadata 透传**:runner 目前用 chars/4 启发式估 token,
|
||||
对 CJK 偏低 3-4 倍,压缩触发系统性偏晚。Host 应在模型响应或
|
||||
`ctx.runtime.metadata` 透传 provider usage(prompt/completion tokens)与
|
||||
model context window(LiteLLM model-info 工作)。该项不阻塞 checkpoint
|
||||
落地,但决定压缩触发的准确性。
|
||||
|
||||
## 3. 实施拆分
|
||||
|
||||
| 项 | 归属 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| steering queue、事件认领、基础审计 | LangBot Host(dispatch / binding 层) | 已落地,含队列上限与未消费 dropped 终态 |
|
||||
| steering pull API + capability 位 | PROTOCOL_V1 + SDK proxy | 已落地 |
|
||||
| turn 边界拉取与注入 | langbot-local-agent | 已落地 |
|
||||
| local-agent 对 state API 的 checkpoint 读写 | langbot-local-agent | 已落地 |
|
||||
| checkpoint key / 内容 / 失效约定 | PROTOCOL_V1 + local-agent README | 已落地 |
|
||||
| LLM 压缩摘要生成 | langbot-local-agent | 已落地(`invoke_llm`,失败回退确定性摘要) |
|
||||
| usage / context-window metadata 透传 | LangBot Host(model 层) | LiteLLM model-info |
|
||||
|
||||
剩余工作应优先补 usage / context-window metadata。streaming delivery 衔接依赖
|
||||
`ctx.delivery` 编辑/追加语义,不建议在协议能力缺失时硬编码。
|
||||
|
||||
## 4. 开放问题
|
||||
|
||||
- streaming delivery 下 steering 注入后,前序 turn 已流出的内容与新 turn
|
||||
输出在 IM 消息编辑面的衔接(涉及 `ctx.delivery` 能力,待 delivery 演进定)。
|
||||
- checkpoint 是否需要 Host 侧主动失效通知(如会话清空时删除对应 state key)。
|
||||
当前实现靠 runner 读取时校验并回退,功能不阻塞。
|
||||
111
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
111
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Agent Runner Security Hardening
|
||||
|
||||
本文档记录 agent-runner 插件化进入生产发布前需要补齐的安全与稳定加固项。
|
||||
|
||||
## 状态
|
||||
|
||||
**当前结论:暂不塞进本阶段 agent-runner plugin 协议闭环。**
|
||||
|
||||
本阶段目标是验证 LangBot 可以通过统一的 `run(event, binding)` 协议接入 `local-agent` 与外部 harness runner(当前官方路径为 LiteLLM Agent Platform runner),并能传递事件、上下文、资源句柄、状态和结果流。
|
||||
|
||||
安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。
|
||||
|
||||
> **硬规则**:能执行代码 / 访问工作目录的外部 harness runner(Claude Code、Codex、Kimi Code 等)不得在生产环境默认启用或隐式开启。self-host stdio / 容器内部署可以作为管理员显式 opt-in,并在配置或 UI 中标明 operator-owned execution risk;只有生产默认启用、托管云 runner 或 LangBot 承诺提供受管执行环境时,才要求完成本文 full Release Gate。
|
||||
|
||||
## Multica 对比结论
|
||||
|
||||
对照 Multica 当前 daemon / runtime 模型,可以采用类似边界:
|
||||
|
||||
- Multica 的 agent 不运行在 Multica server 上,而是由用户机器上的 daemon 调用本机已安装的 AI coding tool;runtime 不是 server,也不是 container。
|
||||
- 标准任务由 daemon 在 workspace root 下创建 per-task environment;但 `local_directory` 场景会直接在用户指定目录原地操作,只做绝对路径、路径清理、系统根目录 / home 黑名单、symlink realpath、读写能力和同路径串行锁校验。
|
||||
- 子进程通过 `exec.CommandContext`、timeout、cwd 和 env 运行;custom args 只过滤 protocol-critical flags,custom env 只阻止覆盖 daemon 内部变量和关键路径变量。它没有尝试阻止外部 CLI 读取该 OS 用户本来能访问的所有宿主路径。
|
||||
- MCP / secret 的约束更具体:Claude 走 `--mcp-config` + strict config;Codex 把 managed MCP 写入 per-task `$CODEX_HOME/config.toml`,避免 secret 出现在 argv / 日志;agent token 优先使用 task-scoped token。
|
||||
- Skill 安全边界也明确留给用户和目标工具:第三方 skill 不由 Multica 签名、审计或沙箱化。
|
||||
- provider-native sandbox 是 opportunistic guardrail,不是统一安全承诺。例如 Codex 在部分平台可写 managed sandbox config,但平台限制下也可能退回更宽松模式;Claude daemon mode 也会使用自动授权 / bypass 类能力以保证无人值守执行。
|
||||
|
||||
因此,LangBot 不应把“完整约束外部 harness 的宿主文件 / 进程 / CPU / 内存 / native tool 能力”作为当前协议闭环或 self-host opt-in 的前置条件。当前阶段应承认外部 harness 是 operator-owned execution,并把 LangBot 可控的最小护栏补齐。
|
||||
|
||||
## 启用级别
|
||||
|
||||
| 场景 | 当前策略 | LangBot 必须负责 | 不作为当前阶段目标 |
|
||||
| --- | --- | --- | --- |
|
||||
| self-host stdio 外部 harness | 管理员显式 opt-in,默认关闭。 | 风险提示、runner/binding 权限摘要、Host 资源授权、Host 生成路径约束、env / secret 过滤、MCP scoped projection、timeout / cancel / output bound、state / audit。 | 阻止该 CLI 访问同一 OS 用户本来可访问的任意宿主文件、进程或全局 CLI 配置。 |
|
||||
| 容器内部署外部 harness | operator 通过容器镜像、挂载、环境变量和网络策略承担执行边界。 | 不假设 privileged container;只投影授权资源;文档提示最小挂载和最小 env;沿用 self-host 最小护栏。 | 在容器内再实现一套完整 VM / cgroup / seccomp 策略。 |
|
||||
| managed/cloud/default external harness | 只有完成 full Release Gate 后才能默认启用。 | 受管 workspace、容器/VM/process isolation、CPU / memory / disk / network / output quotas、完整 lifecycle cleanup、first-class audit 和 admin control。 | 无。 |
|
||||
|
||||
## 责任边界
|
||||
|
||||
### LangBot Host 负责
|
||||
|
||||
- 资源授权:决定某个 `run_id` / binding 可以访问哪些模型、RAG、MCP、skill、artifact、history、state。
|
||||
- 资源投影:只把授权后的资源句柄、配置片段或上下文文件传给 runner。
|
||||
- 路径策略:限制 Host 生成的 workspace / context file / artifact 的允许路径和清理策略;对管理员显式指定的本地工作目录做规范化、黑名单和风险提示。
|
||||
- Secret 策略:过滤环境变量、配置、日志和 transcript 中的 secret。
|
||||
- 运行约束:配置超时、轮次、并发、配额、输出大小和取消路径。
|
||||
- 审计记录:记录事件、绑定、资源授权、runner 调用、外部 harness session id、关键错误和结果摘要。
|
||||
|
||||
### Runner Plugin 负责
|
||||
|
||||
- 遵守 LangBot 下发的 Agent/runner config、授权资源和运行约束。
|
||||
- 将 LangBot 资源投影成目标 runner 可消费的形式,例如 context 文件、MCP 配置、环境变量或 CLI 参数。
|
||||
- 遵守 PROTOCOL_V1 §13 的插件实例边界;需要跨轮次保存的外部 session id / working directory 等状态应写入 host-owned state。
|
||||
- 对外部进程做最小必要封装,包括命令参数构造、超时、取消、输出解析和错误映射。
|
||||
|
||||
### 外部 Harness 负责
|
||||
|
||||
Claude Code、Codex、Kimi Code 等外部 harness 可以继续使用自身的权限模型、工具 allow / deny 规则、MCP 加载策略、session/resume 机制和沙箱能力。
|
||||
|
||||
但外部 harness 不是 LangBot 的唯一安全边界。LangBot 仍必须在 Host 可控范围内完成资源授权、路径限制、secret 过滤和审计记录;stdio / 容器内显式启用时,外部 harness 对宿主 OS 的最终访问能力由 operator 的 CLI、账户、容器和挂载策略承担。
|
||||
|
||||
## 当前 MVP 可接受边界
|
||||
|
||||
当前阶段可以接受以下前提:
|
||||
|
||||
- 由可信管理员配置 runner binding,并显式启用外部 harness 风险模式。
|
||||
- 工作目录和 context 输出目录为显式配置或 host 生成路径。
|
||||
- 外部 runner 应尽量使用保守权限,例如 plan / no-write 模式或禁用高风险工具;具体 provider-native 高风险模式只能作为管理员显式 opt-in 的 dev / smoke path。
|
||||
- 通过 timeout、max turns、输出长度和进程取消降低失控风险。
|
||||
- 通过 host-owned state 保存 `external.session_id`、`external.working_directory` 等 resume 所需指针。
|
||||
|
||||
这些前提足够做本地 E2E 与协议验收,不等同于生产发布完成。
|
||||
|
||||
## Admin Opt-in Minimum Guardrails
|
||||
|
||||
外部 harness 如果只作为 self-host stdio / 容器内部署的管理员显式 opt-in,本阶段不要求完成 full OS sandbox,但至少需要:
|
||||
|
||||
- 默认关闭外部 harness binding;启用时显示 runner 权限、工作目录、MCP / skill 投影和危险权限提示。
|
||||
- Host 生成的 workspace / context / artifact 路径必须在 allowlist root 内;管理员显式工作目录必须做 absolute path、`realpath`、系统根目录 / home 黑名单、`..` 逃逸和 symlink 检查。
|
||||
- 子进程环境使用 allowlist 或强 denylist,禁止覆盖 LangBot 内部变量、token、workspace root、runner state root、`PATH` / `HOME` 等关键变量;日志、错误、transcript 和 artifact metadata 必须 redaction。
|
||||
- MCP 配置必须是 scoped projection;secret 不应出现在 argv 或普通日志;LangBot MCP bridge 只暴露当前 run 授权的 tool surface。
|
||||
- Skill 投影必须来自 Host 已授权资源;记录来源、版本 / hash 或摘要;投影目录在 run / workspace 生命周期内可清理。
|
||||
- CLI 参数需要过滤 protocol-critical flags;高风险 permission mode 必须是显式配置或显式 MVP 标记,不能作为用户不可见的安全承诺。
|
||||
- 子进程必须支持 timeout、cancel、进程组清理和输出上限;CPU / memory / container hard quota 仅对 managed/cloud/default external harness 强制。
|
||||
- state / workspace / artifact 至少要有 owner scope、session id 记录、cleanup path 和 audit-lite 事件。
|
||||
- 测试覆盖 path escape、env / secret 泄漏、MCP deny、timeout、cancel、resume、cleanup 和 audit 字段完整性。
|
||||
|
||||
## Release Gate Checklist
|
||||
|
||||
下表是进入“生产默认启用 / managed external harness / LangBot 承诺提供受管执行环境”前的 full gate。状态快照必须与 [STATUS.md](./STATUS.md) 的日期同步更新;“已补”只代表 self-host stdio / 容器内管理员显式 opt-in 的最小护栏,不代表 managed/default runner 已具备完整生产隔离。
|
||||
|
||||
| 项目 | 状态 | 当前已补 | 仍缺口 / 发布前要求 |
|
||||
| --- | --- | --- | --- |
|
||||
| Path isolation | Partial | ArtifactStore 对 file artifact 使用 `realpath` + root containment 复核;Host 侧 run/session 生命周期和 resource authorization 已建立。 | LiteLLM Agent Platform 所在机器的 workspace、挂载、CLI 可访问路径和 cleanup 由部署侧承担;Host 生成 workspace / context / artifact root 还缺统一 allowlist、mount 策略、TTL cleanup 和 orphan cleanup。 |
|
||||
| Permission boundary | Partial | Host 已有 manifest permissions 与 binding resource policy 交集、run-scoped authorization snapshot、`ctx.context.available_apis`、proxy action `caller_plugin_identity` 校验;LiteLLM gateway 回访 LangBot 资产时必须携带 `run_id` 并接受 Host 校验。 | 外部 harness 的 native 文件 / 进程 / tool 能力仍属于 operator-owned execution;manifest permissions 只约束 LangBot 持有资源,生产默认或 managed runner 需要容器/VM/OS 级隔离、tool allow/deny 和可审计审批。 |
|
||||
| Secret handling | Partial | LangBot 持有的资源访问不直接投影 secret 给 harness;LiteLLM gateway 使用 bearer token 保护入口,真实 LangBot 资产请求回到 Host action 校验。 | 仍缺 Host 全链路统一 redaction policy、transcript / artifact metadata / admin UI 脱敏规则、secret 来源与轮换策略、跨 runner 的配置脱敏审计;LiteLLM 部署侧的 provider token、CLI auth 和日志脱敏另行负责。 |
|
||||
| MCP policy | Partial | LiteLLM runner 暴露稳定 HTTP MCP gateway,只提供 history page、knowledge retrieve、authorized tool call 等最小工具面;错误或过期 `run_id` 会被 Host 拒绝。 | 缺 Host / Admin 级外部 MCP server allowlist、scoped token 生命周期、tool allow / deny 策略、危险工具审批和 MCP 调用审计;后续如 LiteLLM 原生支持 run-scoped MCP session,应改为平台级传递 run scope。 |
|
||||
| Skill access policy | Partial | Host resource builder 会按 runner capability 和 resource policy 暴露 skill-backed scoped tool;当前 code-agent runner 不再接受用户手写 `skills-json`,避免 runner binding 任意投影 skill;skill tool 路径和可见性已有部分单测。 | 缺 code-agent harness 的发布级 skill 来源验证、版本 / hash 记录、projection cleanup 和审计;如后续需要 harness-native skill 文件,也必须由 Host / sandbox 生成受限 tool surface,不能绕过 SDK runtime 访问 LangBot 资源。 |
|
||||
| Process isolation | Partial | Host runtime deadline 和 runner timeout 已有;LiteLLM runner 对 HTTP 调用设置 timeout 并把服务错误映射为受控失败。 | 外部 harness 子进程、取消、输出上限、CPU / 内存 / 文件 / 容器 hard quota、网络策略、长期 workspace GC 和平台级 cancel/audit 由 LiteLLM 部署侧或后续 managed/cloud/default external harness gate 负责。 |
|
||||
| State lifecycle | Partial | PersistentStateStore 有 runner / binding / scope 隔离、JSON size limit、state get / set / list / delete;LiteLLM runner 会写回外部 session id,避免把具体 provider 的内部路径当成 Host resume 事实。 | 缺 session / workspace / artifact TTL、过期清理、迁移策略、orphan cleanup 和 lifecycle audit;managed/default runner 需要 Host first-class workspace 生命周期。 |
|
||||
| Audit first-class | Partial | EventLog、Transcript、ArtifactStore、PersistentStateStore 已能记录主链路事实;proxy 校验失败会写 warning。 | 资源授权快照、外部命令、MCP tool 决策、secret redaction、cleanup、resume / workspace 生命周期还不是一等 audit surface。 |
|
||||
| UI / Admin control | Missing | 当前 Pipeline runner 配置能选择插件 runner。 | 缺管理员可见的 runner 权限摘要、风险提示、生产禁用 / 启用入口、resource binding 管理、MCP / skill / workspace 策略 UI。 |
|
||||
| Test matrix | Partial | 已有 run authorization、caller identity、artifact、state、history / event pull API、LiteLLM HTTP session、run_id prompt 注入、gateway MCP 回访、错误 run_id 拒绝、skill visibility 等单测;runner 仓库 `pytest` / `ruff` 应保持通过。 | 仍缺 Host UI smoke、真实 LiteLLM Agent Platform harness E2E、生产禁用入口、MCP deny / dangerous tool 审计、workspace cleanup / audit 完整性矩阵;CPU / memory / container quota 测试属于 managed/cloud/default full gate。 |
|
||||
|
||||
## 非当前范围
|
||||
|
||||
以下内容不属于本阶段协议闭环:
|
||||
|
||||
- 完整异步队列与 issue-centric 产品模型。
|
||||
- 复杂 workflow engine。
|
||||
- 具体 CLI provider 直连适配器全量接入。
|
||||
- EBA 分支的完整迁移由外部 EBA 分支联调;本阶段只复用其需要的 AgentRunner Host 底座。
|
||||
- 发布级安全 hardening 的完整实现。
|
||||
49
docs/agent-runner-pluginization/STATUS.md
Normal file
49
docs/agent-runner-pluginization/STATUS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# AgentRunner Pluginization Status
|
||||
|
||||
本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
|
||||
|
||||
状态快照日期:2026-06-12。
|
||||
|
||||
## 实现状态
|
||||
|
||||
| 领域 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| SDK manifest schema | Done | `AgentRunnerManifest` 包含 typed `capabilities` / `permissions`;未知 capability / permission key 禁止进入 typed model。 |
|
||||
| Runner discovery | Done | Runtime 返回 typed manifest;Host registry 校验单个 runner,失败 warning + skip,不影响其它 runner。 |
|
||||
| Host resource authorization | Done | `ctx.resources` 和 `ctx.context.available_apis` 由 manifest permissions 与 binding policy / run scope 求交后生成。 |
|
||||
| Run authorization snapshot | Done | active run session 冻结 run-scoped resources 与 available APIs;runtime handler 按 snapshot 校验 pull API。 |
|
||||
| Result payload validation | Done | Wire 保持 `{type, data}`;Host 对投递/副作用类 payload 严格校验,tool-call telemetry 宽松,未知 type 忽略并 warning。 |
|
||||
| Old built-in runners | Done | 旧 `src/langbot/pkg/provider/runners/*` 与 `RequestRunner` 路径已从本分支删除。 |
|
||||
| Official runner manifests | Done | `local-agent`、LiteLLM Agent Platform、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 |
|
||||
| Runtime Control Plane v2 | Future | 第一阶段设计为 Host-owned Run Ledger;runtime registry / heartbeat / daemon claim 是后续可选阶段。 |
|
||||
| Full release security gate | Future | self-host / container opt-in 可继续;managed/default external harness 需完成 SECURITY_HARDENING full gate。 |
|
||||
| Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
|
||||
| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 |
|
||||
|
||||
## Spec 与实现已知差距
|
||||
|
||||
- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。
|
||||
- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。
|
||||
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
|
||||
- Artifact 读取路径已检查 `expires_at`,EventLog / Transcript / Artifact 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和大 payload 去重仍是运维收尾项,应在 Runtime Control Plane Phase 1 前补齐。
|
||||
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。
|
||||
- Managed/cloud/default external harness 的 OS/process/network quota、workspace GC、完整 audit/admin control 仍是发布门槛,不是 Protocol v1 已完成能力。
|
||||
|
||||
## Runner 验收状态
|
||||
|
||||
| Runner | 状态 | 最近证据 |
|
||||
| --- | --- | --- |
|
||||
| `plugin:langbot/local-agent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 |
|
||||
| `plugin:langbot/litellm-agent-platform-agent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 HTTP session、run_id prompt 注入和 LangBot MCP gateway;真实 harness E2E 取决于 LiteLLM Agent Platform 部署和 provider 登录态。 |
|
||||
| Dify / n8n / Coze / DashScope / Langflow / Tbox | Unit-pass; credential smoke optional | 2026-06-10 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
|
||||
|
||||
## 历史高价值记录
|
||||
|
||||
历史报告已合并为本状态页和 QA 指南,不再保留单独进度文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。
|
||||
|
||||
截至 2026-05-29,已有本地 smoke 证明:
|
||||
|
||||
- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。
|
||||
- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 LiteLLM Agent Platform runner,具体 Claude Code / Codex CLI provider 不再由本仓库直接维护。
|
||||
|
||||
这些记录只证明本地协议闭环可用,不代表发布级 security hardening 已完成。
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.0"
|
||||
version = "4.10.1"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
"aiohttp>=3.13.4",
|
||||
"aiohttp>=3.14.0",
|
||||
"aioshutil>=1.5",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
@@ -31,27 +31,27 @@ dependencies = [
|
||||
"psutil>=7.0.0",
|
||||
"pycryptodome>=3.22.0",
|
||||
"pydantic>2.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pyjwt>=2.12.0",
|
||||
"python-telegram-bot>=22.0",
|
||||
"pyyaml>=6.0.2",
|
||||
"qq-botpy-rc>=1.2.1.6",
|
||||
"qrcode>=7.4",
|
||||
"quart>=0.20.0",
|
||||
"quart-cors>=0.8.0",
|
||||
"requests>=2.32.3",
|
||||
"requests>=2.33.0",
|
||||
"slack-sdk>=3.35.0",
|
||||
"alembic>=1.15.0",
|
||||
"sqlalchemy[asyncio]>=2.0.40",
|
||||
"sqlmodel>=0.0.24",
|
||||
"telegramify-markdown>=0.5.1",
|
||||
"tiktoken>=0.9.0",
|
||||
"urllib3>=2.4.0",
|
||||
"urllib3>=2.7.0",
|
||||
"websockets>=15.0.1",
|
||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||
"pip>=25.1.1",
|
||||
"pip>=26.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
"uv>=0.11.6",
|
||||
"uv>=0.11.15",
|
||||
"mypy>=1.16.0",
|
||||
"PyPDF2>=3.0.1",
|
||||
"python-docx>=1.1.0",
|
||||
@@ -62,10 +62,10 @@ dependencies = [
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-core>=1.2.28",
|
||||
"langsmith>=0.7.31",
|
||||
"python-multipart>=0.0.26",
|
||||
"Mako>=1.3.11",
|
||||
"langchain-core>=1.3.3",
|
||||
"langsmith>=0.8.0",
|
||||
"python-multipart>=0.0.27",
|
||||
"Mako>=1.3.12",
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
@@ -105,6 +105,9 @@ classifiers = [
|
||||
"Topic :: Communications :: Chat",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
langbot-plugin = { path = "../langbot-plugin-sdk", editable = true }
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.10.0'
|
||||
__version__ = '4.10.1'
|
||||
|
||||
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .client import AsyncDeerFlowClient
|
||||
from .errors import DeerFlowAPIError
|
||||
from . import stream_utils
|
||||
|
||||
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']
|
||||
204
src/langbot/libs/deerflow_api/client.py
Normal file
204
src/langbot/libs/deerflow_api/client.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""DeerFlow LangGraph HTTP API 客户端
|
||||
|
||||
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import typing
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DeerFlowAPIError
|
||||
|
||||
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定"""
|
||||
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
|
||||
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
||||
raw_data = '\n'.join(data_lines)
|
||||
try:
|
||||
return json.loads(raw_data)
|
||||
except json.JSONDecodeError:
|
||||
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
|
||||
# 发送多段 JSON 片段(例如 tuple payload)
|
||||
parsed_lines: list[typing.Any] = []
|
||||
can_parse_all = True
|
||||
for line in data_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed_lines.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
can_parse_all = False
|
||||
break
|
||||
if can_parse_all and parsed_lines:
|
||||
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||
return raw_data
|
||||
|
||||
|
||||
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
||||
if not block.strip():
|
||||
return None
|
||||
|
||||
event_name = 'message'
|
||||
data_lines: list[str] = []
|
||||
for line in block.splitlines():
|
||||
if line.startswith('event:'):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
|
||||
if not data_lines:
|
||||
return None
|
||||
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
|
||||
|
||||
|
||||
class AsyncDeerFlowClient:
|
||||
"""DeerFlow LangGraph HTTP API 客户端"""
|
||||
|
||||
api_base: str
|
||||
headers: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = 'http://127.0.0.1:2026',
|
||||
api_key: str = '',
|
||||
auth_header: str = '',
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip('/')
|
||||
self.headers: dict[str, str] = {}
|
||||
if auth_header:
|
||||
self.headers['Authorization'] = auth_header
|
||||
elif api_key:
|
||||
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
||||
"""创建一个新的 LangGraph thread
|
||||
|
||||
Returns:
|
||||
包含 thread_id 等信息的字典
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads'
|
||||
payload = {'metadata': {}}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise DeerFlowAPIError(
|
||||
operation='create thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
"""删除指定 thread"""
|
||||
url = f'{self.api_base}/api/threads/{thread_id}'
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
if response.status_code not in (200, 202, 204, 404):
|
||||
raise DeerFlowAPIError(
|
||||
operation='delete thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
payload: dict[str, typing.Any],
|
||||
timeout: float = 120,
|
||||
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""运行一次 LangGraph stream 请求,逐事件 yield
|
||||
|
||||
Yields:
|
||||
事件字典 {'event': event_name, 'data': parsed_data}
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
|
||||
|
||||
# 流式请求使用单独的 read timeout 控制
|
||||
stream_timeout = httpx.Timeout(
|
||||
connect=min(timeout, 30),
|
||||
read=timeout,
|
||||
write=timeout,
|
||||
pool=timeout,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=stream_timeout,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
'POST',
|
||||
url,
|
||||
headers={
|
||||
**self.headers,
|
||||
'Accept': 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
body = await resp.aread()
|
||||
raise DeerFlowAPIError(
|
||||
operation='runs/stream request',
|
||||
status=resp.status_code,
|
||||
body=body.decode('utf-8', errors='replace'),
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
buffer = ''
|
||||
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||
# 缓冲区过大,强制 flush
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
buffer = ''
|
||||
|
||||
# flush 剩余内容
|
||||
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
if buffer.strip():
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
30
src/langbot/libs/deerflow_api/errors.py
Normal file
30
src/langbot/libs/deerflow_api/errors.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DeerFlowAPIError(Exception):
|
||||
"""DeerFlow API 请求失败"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
operation: str = '',
|
||||
status: int = 0,
|
||||
body: str = '',
|
||||
url: str = '',
|
||||
thread_id: str | None = None,
|
||||
message: str = '',
|
||||
) -> None:
|
||||
self.operation = operation
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
self.thread_id = thread_id
|
||||
|
||||
if message:
|
||||
super().__init__(message)
|
||||
return
|
||||
|
||||
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
|
||||
if thread_id is not None:
|
||||
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
|
||||
super().__init__(msg)
|
||||
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""DeerFlow LangGraph 流式响应解析工具
|
||||
|
||||
参考 astrbot 实现的 deerflow_stream_utils。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def extract_text(content: typing.Any) -> str:
|
||||
"""从消息 content 中提取纯文本"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
if isinstance(content.get('text'), str):
|
||||
return content['text']
|
||||
if 'content' in content:
|
||||
return extract_text(content.get('content'))
|
||||
if 'kwargs' in content and isinstance(content['kwargs'], dict):
|
||||
return extract_text(content['kwargs'].get('content'))
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
item_type = item.get('type')
|
||||
if item_type == 'text' and isinstance(item.get('text'), str):
|
||||
parts.append(item['text'])
|
||||
elif 'content' in item:
|
||||
parts.append(extract_text(item['content']))
|
||||
return '\n'.join([p for p in parts if p]).strip()
|
||||
return str(content) if content is not None else ''
|
||||
|
||||
|
||||
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
|
||||
"""从 values 事件中提取 messages 列表"""
|
||||
candidates: list[typing.Any] = []
|
||||
if isinstance(data, dict):
|
||||
candidates.append(data)
|
||||
if isinstance(data.get('values'), dict):
|
||||
candidates.append(data['values'])
|
||||
elif isinstance(data, list):
|
||||
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||
|
||||
for item in candidates:
|
||||
messages = item.get('messages')
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
return []
|
||||
|
||||
|
||||
def is_ai_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为 AI/assistant 消息"""
|
||||
role = str(message.get('role', '')).lower()
|
||||
if role in {'assistant', 'ai'}:
|
||||
return True
|
||||
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
|
||||
return True
|
||||
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""获取最近一条 AI 消息的文本内容"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
|
||||
"""获取最近一条 AI 消息对象"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为澄清问题工具消息"""
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
tool_name = str(message.get('name', '')).lower()
|
||||
return msg_type == 'tool' and tool_name == 'ask_clarification'
|
||||
|
||||
|
||||
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""提取最近的澄清问题文本"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_clarification_tool_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def get_message_id(message: typing.Any) -> str:
|
||||
"""提取消息 ID"""
|
||||
if not isinstance(message, dict):
|
||||
return ''
|
||||
msg_id = message.get('id')
|
||||
return msg_id if isinstance(msg_id, str) else ''
|
||||
|
||||
|
||||
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
|
||||
"""从事件 data 中提取消息对象"""
|
||||
msg_obj = data
|
||||
if isinstance(data, (list, tuple)) and data:
|
||||
msg_obj = data[0]
|
||||
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
|
||||
msg_obj = msg_obj['data']
|
||||
return msg_obj if isinstance(msg_obj, dict) else None
|
||||
|
||||
|
||||
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
|
||||
"""从 messages-tuple 事件中提取 AI delta 文本"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_ai_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def extract_clarification_from_event_data(data: typing.Any) -> str:
|
||||
"""从事件中提取澄清问题"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_clarification_tool_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
|
||||
items: list[dict[str, typing.Any]] = []
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
items.append(item)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
for nested in item:
|
||||
if isinstance(nested, dict):
|
||||
items.append(nested)
|
||||
return items
|
||||
|
||||
|
||||
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
|
||||
"""从 custom 事件中提取子任务失败信息"""
|
||||
failures: list[str] = []
|
||||
for item in _iter_custom_event_items(data):
|
||||
event_type = str(item.get('type', '')).lower()
|
||||
if event_type not in {'task_failed', 'task_timed_out'}:
|
||||
continue
|
||||
|
||||
task_id = str(item.get('task_id', '')).strip()
|
||||
error_text = extract_text(item.get('error')).strip()
|
||||
if task_id and error_text:
|
||||
failures.append(f'{task_id}: {error_text}')
|
||||
elif error_text:
|
||||
failures.append(error_text)
|
||||
elif task_id:
|
||||
failures.append(f'{task_id}: unknown error')
|
||||
else:
|
||||
failures.append('unknown task failure')
|
||||
return failures
|
||||
|
||||
|
||||
def build_task_failure_summary(failures: list[str]) -> str:
|
||||
"""构建任务失败摘要"""
|
||||
if not failures:
|
||||
return ''
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for failure in failures:
|
||||
if failure not in seen:
|
||||
seen.add(failure)
|
||||
deduped.append(failure)
|
||||
if len(deduped) == 1:
|
||||
return f'DeerFlow subtask failed: {deduped[0]}'
|
||||
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
|
||||
return f'DeerFlow subtasks failed:\n{joined}'
|
||||
@@ -145,7 +145,7 @@ class AsyncDifyServiceClient:
|
||||
'file': file,
|
||||
},
|
||||
data={
|
||||
'user': (None, user),
|
||||
'user': user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
4
src/langbot/libs/weknora_api/__init__.py
Normal file
4
src/langbot/libs/weknora_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .client import AsyncWeKnoraClient
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']
|
||||
180
src/langbot/libs/weknora_api/client.py
Normal file
180
src/langbot/libs/weknora_api/client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
|
||||
class AsyncWeKnoraClient:
|
||||
"""WeKnora API 客户端"""
|
||||
|
||||
api_key: str
|
||||
base_url: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = 'http://localhost:80/api/v1',
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
title: str = '',
|
||||
description: str = '',
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
"""创建会话,返回 session_id"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {}
|
||||
if title:
|
||||
payload['title'] = title
|
||||
if description:
|
||||
payload['description'] = description
|
||||
|
||||
response = await client.post(
|
||||
'/sessions',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||
|
||||
data = response.json()
|
||||
return data['data']['id']
|
||||
|
||||
async def agent_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = '',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
web_search_enabled: bool = False,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
Agent 智能对话(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- agent_query: Agent 开始处理
|
||||
- thinking: 思考过程
|
||||
- tool_call: 工具调用
|
||||
- tool_result: 工具结果
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
- reflection: 反思
|
||||
- session_title: 会话标题
|
||||
- error: 错误
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'agent_enabled': True,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
if web_search_enabled:
|
||||
payload['web_search_enabled'] = True
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/agent-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
|
||||
async def knowledge_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = 'builtin-quick-answer',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
知识库 RAG 问答(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/knowledge-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
6
src/langbot/libs/weknora_api/errors.py
Normal file
6
src/langbot/libs/weknora_api/errors.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class WeKnoraAPIError(Exception):
|
||||
"""WeKnora API 请求失败"""
|
||||
|
||||
def __init__(self, message: str = ''):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
37
src/langbot/pkg/agent/__init__.py
Normal file
37
src/langbot/pkg/agent/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Agent runner subsystem for LangBot."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .runner.descriptor import AgentRunnerDescriptor
|
||||
from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id
|
||||
from .runner.errors import (
|
||||
AgentRunnerError,
|
||||
RunnerNotFoundError,
|
||||
RunnerNotAuthorizedError,
|
||||
RunnerProtocolError,
|
||||
RunnerExecutionError,
|
||||
)
|
||||
from .runner.registry import AgentRunnerRegistry
|
||||
from .runner.context_builder import AgentRunContextBuilder
|
||||
from .runner.resource_builder import AgentResourceBuilder
|
||||
from .runner.result_normalizer import AgentResultNormalizer
|
||||
from .runner.orchestrator import AgentRunOrchestrator
|
||||
from .runner.config_migration import ConfigMigration
|
||||
|
||||
__all__ = [
|
||||
'AgentRunnerDescriptor',
|
||||
'parse_runner_id',
|
||||
'format_runner_id',
|
||||
'is_plugin_runner_id',
|
||||
'RunnerIdParts',
|
||||
'AgentRunnerError',
|
||||
'RunnerNotFoundError',
|
||||
'RunnerNotAuthorizedError',
|
||||
'RunnerProtocolError',
|
||||
'RunnerExecutionError',
|
||||
'AgentRunnerRegistry',
|
||||
'AgentRunContextBuilder',
|
||||
'AgentResourceBuilder',
|
||||
'AgentResultNormalizer',
|
||||
'AgentRunOrchestrator',
|
||||
'ConfigMigration',
|
||||
]
|
||||
63
src/langbot/pkg/agent/runner/__init__.py
Normal file
63
src/langbot/pkg/agent/runner/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Agent runner modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .id import parse_runner_id, format_runner_id, RunnerIdParts
|
||||
from .errors import (
|
||||
AgentRunnerError,
|
||||
RunnerNotFoundError,
|
||||
RunnerNotAuthorizedError,
|
||||
RunnerProtocolError,
|
||||
RunnerExecutionError,
|
||||
)
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .context_builder import AgentRunContextBuilder
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .orchestrator import AgentRunOrchestrator
|
||||
from .config_migration import ConfigMigration
|
||||
from .default_config import AgentRunnerDefaultConfigService
|
||||
from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError
|
||||
from .session_registry import (
|
||||
AgentRunSessionRegistry,
|
||||
AgentRunSession,
|
||||
RunAuthorizationSnapshot,
|
||||
get_session_registry,
|
||||
)
|
||||
from .events import (
|
||||
MESSAGE_RECEIVED,
|
||||
MESSAGE_RECALLED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
FRIEND_REQUEST_RECEIVED,
|
||||
RESERVED_EVENT_TYPES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AgentRunnerDescriptor',
|
||||
'parse_runner_id',
|
||||
'format_runner_id',
|
||||
'RunnerIdParts',
|
||||
'AgentRunnerError',
|
||||
'RunnerNotFoundError',
|
||||
'RunnerNotAuthorizedError',
|
||||
'RunnerProtocolError',
|
||||
'RunnerExecutionError',
|
||||
'AgentRunnerRegistry',
|
||||
'AgentRunContextBuilder',
|
||||
'AgentResourceBuilder',
|
||||
'AgentResultNormalizer',
|
||||
'AgentRunOrchestrator',
|
||||
'ConfigMigration',
|
||||
'AgentRunnerDefaultConfigService',
|
||||
'AgentBindingResolver',
|
||||
'AgentBindingResolutionError',
|
||||
'AgentRunSessionRegistry',
|
||||
'AgentRunSession',
|
||||
'RunAuthorizationSnapshot',
|
||||
'get_session_registry',
|
||||
'MESSAGE_RECEIVED',
|
||||
'MESSAGE_RECALLED',
|
||||
'GROUP_MEMBER_JOINED',
|
||||
'FRIEND_REQUEST_RECEIVED',
|
||||
'RESERVED_EVENT_TYPES',
|
||||
]
|
||||
517
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
517
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""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'
|
||||
_ARTIFACT_THREAD_METADATA_KEY = '_langbot_thread_id'
|
||||
|
||||
|
||||
class ArtifactStore:
|
||||
"""Store for AgentArtifact records.
|
||||
|
||||
Handles artifact metadata registration and content retrieval.
|
||||
Actual blob storage is delegated to BinaryStorage or external storage.
|
||||
|
||||
All methods are async and use the provided database engine.
|
||||
"""
|
||||
|
||||
engine: AsyncEngine
|
||||
|
||||
# Hard limits
|
||||
MAX_INLINE_READ_BYTES = 1024 * 1024 # 1MB max for inline base64
|
||||
MAX_RANGE_READ_BYTES = 10 * 1024 * 1024 # 10MB max for range reads
|
||||
|
||||
def __init__(self, engine: AsyncEngine):
|
||||
self.engine = engine
|
||||
self._session_factory = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async def register_file_artifact(
|
||||
self,
|
||||
*,
|
||||
artifact_id: str | None,
|
||||
host_path: str,
|
||||
host_root: str,
|
||||
artifact_type: str = 'file',
|
||||
source: str = 'tool',
|
||||
mime_type: str | None = None,
|
||||
name: str | None = None,
|
||||
size_bytes: int | None = None,
|
||||
sha256: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
runner_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
) -> str:
|
||||
"""Register a Host-owned artifact backed by a bounded local file path.
|
||||
|
||||
The public metadata intentionally excludes the real host path. Reads go
|
||||
through read_artifact(), which revalidates the path against host_root.
|
||||
"""
|
||||
real_path, real_root = self._validate_file_artifact_path(host_path, host_root)
|
||||
if not os.path.isfile(real_path):
|
||||
raise ValueError('file artifact path must point to a file')
|
||||
|
||||
public_metadata = dict(metadata or {})
|
||||
public_metadata[_FILE_ARTIFACT_METADATA_KEY] = {
|
||||
'path': real_path,
|
||||
'root': real_root,
|
||||
}
|
||||
|
||||
if size_bytes is None:
|
||||
size_bytes = os.path.getsize(real_path)
|
||||
|
||||
return await self.register_artifact(
|
||||
artifact_id=artifact_id,
|
||||
artifact_type=artifact_type,
|
||||
source=source,
|
||||
storage_key=f'file:{uuid.uuid4().hex}',
|
||||
storage_type='file',
|
||||
mime_type=mime_type,
|
||||
name=name or os.path.basename(real_path),
|
||||
size_bytes=size_bytes,
|
||||
sha256=sha256,
|
||||
conversation_id=conversation_id,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
expires_at=expires_at,
|
||||
metadata=public_metadata,
|
||||
content=None,
|
||||
)
|
||||
|
||||
async def register_artifact(
|
||||
self,
|
||||
artifact_id: str | None,
|
||||
artifact_type: str,
|
||||
source: str,
|
||||
storage_key: str | None = None,
|
||||
storage_type: str = 'binary_storage',
|
||||
mime_type: str | None = None,
|
||||
name: str | None = None,
|
||||
size_bytes: int | None = None,
|
||||
sha256: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
runner_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
content: bytes | None = None,
|
||||
) -> str:
|
||||
"""Register a new artifact.
|
||||
|
||||
If content is provided and storage_key is None, stores content
|
||||
in BinaryStorage automatically.
|
||||
|
||||
Args:
|
||||
artifact_id: Unique artifact ID (generated if None)
|
||||
artifact_type: Type of artifact (image, file, voice, tool_result, etc.)
|
||||
source: Source of artifact (platform, runner, tool, system)
|
||||
storage_key: Key in BinaryStorage or external reference
|
||||
storage_type: Storage type (binary_storage, file, url)
|
||||
mime_type: MIME type
|
||||
name: Original file name
|
||||
size_bytes: Size in bytes
|
||||
sha256: SHA256 hash
|
||||
conversation_id: Conversation ID
|
||||
run_id: Run ID that created this
|
||||
runner_id: Runner ID that created this
|
||||
bot_id: Bot UUID
|
||||
workspace_id: Workspace ID
|
||||
thread_id: Thread ID stored as Host-only metadata
|
||||
expires_at: Expiration time
|
||||
metadata: Additional metadata
|
||||
content: Optional content to store in BinaryStorage
|
||||
|
||||
Returns:
|
||||
The artifact_id
|
||||
"""
|
||||
if artifact_id is None:
|
||||
artifact_id = str(uuid.uuid4())
|
||||
|
||||
metadata_payload = dict(metadata or {})
|
||||
if thread_id is not None:
|
||||
metadata_payload[_ARTIFACT_THREAD_METADATA_KEY] = thread_id
|
||||
|
||||
# If content provided, store in BinaryStorage
|
||||
if content is not None and storage_key is None:
|
||||
storage_key = f"artifact:{artifact_id}"
|
||||
storage_type = 'binary_storage'
|
||||
if size_bytes is None:
|
||||
size_bytes = len(content)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
# Store content in BinaryStorage if provided
|
||||
if content is not None:
|
||||
binary_storage = BinaryStorage(
|
||||
unique_key=f'artifact:{artifact_id}',
|
||||
key=storage_key,
|
||||
owner_type='artifact',
|
||||
owner='host',
|
||||
value=content,
|
||||
)
|
||||
session.add(binary_storage)
|
||||
|
||||
# Store artifact metadata
|
||||
artifact = AgentArtifact(
|
||||
artifact_id=artifact_id,
|
||||
artifact_type=artifact_type,
|
||||
mime_type=mime_type,
|
||||
name=name,
|
||||
size_bytes=size_bytes,
|
||||
sha256=sha256,
|
||||
source=source,
|
||||
storage_key=storage_key,
|
||||
storage_type=storage_type,
|
||||
conversation_id=conversation_id,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.dumps(metadata_payload) if metadata_payload else None,
|
||||
)
|
||||
session.add(artifact)
|
||||
await session.commit()
|
||||
|
||||
return artifact_id
|
||||
|
||||
async def get_metadata(
|
||||
self,
|
||||
artifact_id: str,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Get artifact metadata (public fields only, no internal storage info).
|
||||
|
||||
Args:
|
||||
artifact_id: Artifact ID
|
||||
|
||||
Returns:
|
||||
Artifact metadata dict compatible with SDK ArtifactMetadata, or None if not found
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentArtifact).where(
|
||||
AgentArtifact.artifact_id == artifact_id
|
||||
)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if self._is_expired(row):
|
||||
return None
|
||||
return self._row_to_public_dict(row)
|
||||
|
||||
async def get_authorization_metadata(
|
||||
self,
|
||||
artifact_id: str,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Get artifact metadata with Host-only scope fields for authorization."""
|
||||
row = await self._get_internal_record(artifact_id)
|
||||
if row is None:
|
||||
return None
|
||||
metadata = self._row_to_public_dict(row)
|
||||
metadata.update({
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_id,
|
||||
'thread_id': self._load_metadata(row.metadata_json).get(_ARTIFACT_THREAD_METADATA_KEY),
|
||||
})
|
||||
return metadata
|
||||
|
||||
async def _get_internal_record(
|
||||
self,
|
||||
artifact_id: str,
|
||||
) -> AgentArtifact | None:
|
||||
"""Get full artifact record including internal fields.
|
||||
|
||||
Used internally by read_artifact to access storage_key/storage_type.
|
||||
|
||||
Args:
|
||||
artifact_id: Artifact ID
|
||||
|
||||
Returns:
|
||||
AgentArtifact ORM instance, or None if not found
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentArtifact).where(
|
||||
AgentArtifact.artifact_id == artifact_id
|
||||
)
|
||||
)
|
||||
record = result.scalars().first()
|
||||
if record is not None and self._is_expired(record):
|
||||
return None
|
||||
return record
|
||||
|
||||
async def read_artifact(
|
||||
self,
|
||||
artifact_id: str,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Read artifact content.
|
||||
|
||||
For small artifacts, returns content_base64 directly.
|
||||
For large artifacts, returns file_key for chunked transfer.
|
||||
|
||||
Args:
|
||||
artifact_id: Artifact ID
|
||||
offset: Byte offset to start reading from (must be >= 0)
|
||||
limit: Maximum bytes to read (must be > 0 if provided)
|
||||
|
||||
Returns:
|
||||
ArtifactReadResult dict, or None if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If offset < 0 or limit <= 0
|
||||
"""
|
||||
# Validate offset and limit
|
||||
if offset < 0:
|
||||
raise ValueError("offset must be >= 0")
|
||||
|
||||
if limit is not None and limit <= 0:
|
||||
raise ValueError("limit must be > 0")
|
||||
|
||||
# Get internal record (includes storage_key/storage_type)
|
||||
record = await self._get_internal_record(artifact_id)
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
storage_type = record.storage_type or 'binary_storage'
|
||||
storage_key = record.storage_key
|
||||
size_bytes = record.size_bytes or 0
|
||||
|
||||
# Cap limit at hard limit
|
||||
if limit is None:
|
||||
limit = self.MAX_INLINE_READ_BYTES
|
||||
limit = min(limit, self.MAX_RANGE_READ_BYTES)
|
||||
|
||||
# For binary_storage, read content
|
||||
if storage_type == 'binary_storage' and storage_key:
|
||||
content = await self._read_binary_storage(storage_key)
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
# Apply offset and limit
|
||||
if offset > 0:
|
||||
content = content[offset:]
|
||||
if limit and len(content) > limit:
|
||||
content = content[:limit]
|
||||
has_more = True
|
||||
else:
|
||||
has_more = False
|
||||
|
||||
return {
|
||||
'artifact_id': artifact_id,
|
||||
'mime_type': record.mime_type,
|
||||
'size_bytes': size_bytes,
|
||||
'offset': offset,
|
||||
'length': len(content),
|
||||
'content_base64': base64.b64encode(content).decode('utf-8'),
|
||||
'file_key': None,
|
||||
'has_more': has_more,
|
||||
}
|
||||
|
||||
if storage_type == 'file':
|
||||
return self._read_file_storage(record, artifact_id, offset, limit)
|
||||
|
||||
# For other storage types, return storage reference
|
||||
# (caller can use file_key for chunked transfer)
|
||||
return {
|
||||
'artifact_id': artifact_id,
|
||||
'mime_type': record.mime_type,
|
||||
'size_bytes': size_bytes,
|
||||
'offset': offset,
|
||||
'length': None,
|
||||
'content_base64': None,
|
||||
'file_key': storage_key,
|
||||
'has_more': False,
|
||||
}
|
||||
|
||||
async def cleanup_expired_artifacts(
|
||||
self,
|
||||
*,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> int:
|
||||
"""Delete expired artifact metadata and Host-owned binary blobs.
|
||||
|
||||
Returns the number of artifact metadata rows removed. External/file
|
||||
storage references are only dereferenced from LangBot metadata; their
|
||||
backing lifecycle remains owned by the storage provider.
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.datetime.utcnow()
|
||||
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(AgentArtifact).where(
|
||||
AgentArtifact.expires_at.is_not(None),
|
||||
AgentArtifact.expires_at <= now,
|
||||
)
|
||||
)
|
||||
expired = result.scalars().all()
|
||||
if not expired:
|
||||
return 0
|
||||
|
||||
binary_storage_keys = [
|
||||
artifact.storage_key
|
||||
for artifact in expired
|
||||
if artifact.storage_type == 'binary_storage' and artifact.storage_key
|
||||
]
|
||||
if binary_storage_keys:
|
||||
await session.execute(
|
||||
sqlalchemy.delete(BinaryStorage).where(
|
||||
BinaryStorage.unique_key.in_(binary_storage_keys)
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
sqlalchemy.delete(AgentArtifact).where(
|
||||
AgentArtifact.id.in_([artifact.id for artifact in expired])
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return len(expired)
|
||||
|
||||
async def _read_binary_storage(self, key: str) -> bytes | None:
|
||||
"""Read content from BinaryStorage.
|
||||
|
||||
Uses unique_key for isolation to prevent cross-artifact access.
|
||||
|
||||
Args:
|
||||
key: The unique_key used when storing the artifact
|
||||
|
||||
Returns:
|
||||
Content bytes, or None if not found
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(BinaryStorage).where(BinaryStorage.unique_key == key)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return row.value
|
||||
|
||||
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)
|
||||
metadata.pop(_ARTIFACT_THREAD_METADATA_KEY, None)
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _is_expired(
|
||||
row: AgentArtifact,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> bool:
|
||||
if row.expires_at is None:
|
||||
return False
|
||||
if now is None:
|
||||
now = datetime.datetime.utcnow()
|
||||
return row.expires_at <= now
|
||||
|
||||
def _row_to_public_dict(self, row: AgentArtifact) -> dict[str, typing.Any]:
|
||||
"""Convert an AgentArtifact row to public dict.
|
||||
|
||||
Returns only fields that match SDK ArtifactMetadata entity.
|
||||
Host-only fields (bot_id, workspace_id, storage_key, storage_type) are excluded.
|
||||
"""
|
||||
return {
|
||||
'artifact_id': row.artifact_id,
|
||||
'artifact_type': row.artifact_type,
|
||||
'mime_type': row.mime_type,
|
||||
'name': row.name,
|
||||
'size_bytes': row.size_bytes,
|
||||
'sha256': row.sha256,
|
||||
'source': row.source,
|
||||
'conversation_id': row.conversation_id,
|
||||
'run_id': row.run_id,
|
||||
'runner_id': row.runner_id,
|
||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
||||
'expires_at': int(row.expires_at.timestamp()) if row.expires_at else None,
|
||||
'metadata': self._public_metadata(row.metadata_json),
|
||||
}
|
||||
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Resolve host events to one effective Agent binding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope
|
||||
|
||||
|
||||
class AgentBindingResolutionError(Exception):
|
||||
"""Raised when an event cannot resolve to exactly one Agent binding."""
|
||||
|
||||
|
||||
class AgentBindingResolver:
|
||||
"""Resolve an event to a single AgentBinding.
|
||||
|
||||
The target product model is one bot / IM channel -> one Agent. Fan-out,
|
||||
observer agents, or multi-runner arbitration require separate delivery and
|
||||
state semantics and are intentionally not hidden in this resolver.
|
||||
"""
|
||||
|
||||
def resolve_one(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
agents: list[AgentConfig],
|
||||
) -> AgentBinding:
|
||||
"""Resolve exactly one enabled Agent for the event."""
|
||||
matches = [
|
||||
agent
|
||||
for agent in agents
|
||||
if agent.enabled and event.event_type in agent.event_types
|
||||
]
|
||||
|
||||
if not matches:
|
||||
raise AgentBindingResolutionError(
|
||||
f'No Agent binding matches event_type={event.event_type}'
|
||||
)
|
||||
|
||||
if len(matches) > 1:
|
||||
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
|
||||
raise AgentBindingResolutionError(
|
||||
f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}'
|
||||
)
|
||||
|
||||
return self._to_binding(matches[0])
|
||||
|
||||
def _to_binding(self, agent: AgentConfig) -> AgentBinding:
|
||||
"""Project product-level Agent config into the run-time binding model."""
|
||||
scope = BindingScope(
|
||||
scope_type='agent',
|
||||
scope_id=agent.agent_id,
|
||||
)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
|
||||
scope=scope,
|
||||
event_types=list(agent.event_types),
|
||||
runner_id=agent.runner_id,
|
||||
runner_config=agent.runner_config,
|
||||
resource_policy=agent.resource_policy,
|
||||
state_policy=agent.state_policy,
|
||||
delivery_policy=agent.delivery_policy,
|
||||
enabled=agent.enabled,
|
||||
agent_id=agent.agent_id,
|
||||
)
|
||||
169
src/langbot/pkg/agent/runner/config_migration.py
Normal file
169
src/langbot/pkg/agent/runner/config_migration.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Helpers for the current AgentRunner config shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
LEGACY_RUNNER_ID_MAP: dict[str, str] = {
|
||||
'local-agent': 'plugin:langbot/local-agent/default',
|
||||
'dify-service-api': 'plugin:langbot/dify-agent/default',
|
||||
'n8n-service-api': 'plugin:langbot/n8n-agent/default',
|
||||
'coze-api': 'plugin:langbot/coze-agent/default',
|
||||
'dashscope-app-api': 'plugin:langbot/dashscope-agent/default',
|
||||
'langflow-api': 'plugin:langbot/langflow-agent/default',
|
||||
'tbox-app-api': 'plugin:langbot/tbox-agent/default',
|
||||
}
|
||||
|
||||
|
||||
class ConfigMigration:
|
||||
"""Configuration helper for agent runner IDs.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve runner ID from ai.runner.id
|
||||
- Migrate legacy ai.runner.runner + ai.<runner-name> blocks
|
||||
- Extract current Agent/runner config from ai.runner_config
|
||||
- Keep the current config container shape stable on save
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
|
||||
"""Resolve runner ID from current configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Runner ID string, or None if not configured
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {})
|
||||
runner_config = ai_config.get('runner', {})
|
||||
|
||||
runner_id = runner_config.get('id')
|
||||
if runner_id:
|
||||
return runner_id
|
||||
|
||||
legacy_runner = runner_config.get('runner')
|
||||
if isinstance(legacy_runner, str):
|
||||
return LEGACY_RUNNER_ID_MAP.get(legacy_runner)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_runner_config(
|
||||
pipeline_config: dict[str, typing.Any],
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Resolve Agent/runner configuration from the current container.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
runner_id: Resolved runner ID
|
||||
|
||||
Returns:
|
||||
Runner configuration dict (empty if not found)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {})
|
||||
|
||||
runner_configs = ai_config.get('runner_config', {})
|
||||
if runner_id in runner_configs:
|
||||
return runner_configs[runner_id]
|
||||
|
||||
legacy_runner = ConfigMigration._legacy_runner_name_for_id(runner_id)
|
||||
if legacy_runner and isinstance(ai_config.get(legacy_runner), dict):
|
||||
return ConfigMigration._normalize_legacy_runner_config(
|
||||
legacy_runner,
|
||||
ai_config[legacy_runner],
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
|
||||
"""Get conversation expire time from configuration.
|
||||
|
||||
Args:
|
||||
pipeline_config: Current configuration container
|
||||
|
||||
Returns:
|
||||
Expire time in seconds (0 means no expiry)
|
||||
"""
|
||||
ai_config = pipeline_config.get('ai', {})
|
||||
runner_config = ai_config.get('runner', {})
|
||||
return runner_config.get('expire-time', 0)
|
||||
|
||||
@staticmethod
|
||||
def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
"""Normalize the current config container before saving.
|
||||
|
||||
Args:
|
||||
pipeline_config: Original configuration
|
||||
|
||||
Returns:
|
||||
Configuration with explicit ai.runner and ai.runner_config containers
|
||||
"""
|
||||
new_config = dict(pipeline_config)
|
||||
if 'ai' not in new_config:
|
||||
return new_config
|
||||
|
||||
ai_config = dict(new_config.get('ai', {}))
|
||||
|
||||
runner_config = dict(ai_config.get('runner', {}))
|
||||
runner_configs = dict(ai_config.get('runner_config', {}))
|
||||
|
||||
legacy_runner = runner_config.get('runner')
|
||||
mapped_runner_id = None
|
||||
if isinstance(legacy_runner, str):
|
||||
mapped_runner_id = LEGACY_RUNNER_ID_MAP.get(legacy_runner)
|
||||
|
||||
if mapped_runner_id and not runner_config.get('id'):
|
||||
runner_config = {
|
||||
key: value
|
||||
for key, value in runner_config.items()
|
||||
if key != 'runner'
|
||||
}
|
||||
runner_config['id'] = mapped_runner_id
|
||||
|
||||
if mapped_runner_id and mapped_runner_id not in runner_configs:
|
||||
legacy_config = ai_config.get(legacy_runner)
|
||||
if isinstance(legacy_config, dict):
|
||||
runner_configs[mapped_runner_id] = ConfigMigration._normalize_legacy_runner_config(
|
||||
legacy_runner,
|
||||
legacy_config,
|
||||
)
|
||||
|
||||
ai_config['runner'] = runner_config
|
||||
ai_config['runner_config'] = runner_configs
|
||||
if mapped_runner_id and legacy_runner in ai_config:
|
||||
ai_config.pop(legacy_runner, None)
|
||||
new_config['ai'] = ai_config
|
||||
|
||||
return new_config
|
||||
|
||||
@staticmethod
|
||||
def _legacy_runner_name_for_id(runner_id: str) -> str | None:
|
||||
for legacy_runner, mapped_runner_id in LEGACY_RUNNER_ID_MAP.items():
|
||||
if mapped_runner_id == runner_id:
|
||||
return legacy_runner
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_legacy_runner_config(
|
||||
legacy_runner: str,
|
||||
legacy_config: dict[str, typing.Any],
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Normalize legacy runner config blocks to current plugin schema quirks."""
|
||||
normalized = dict(legacy_config)
|
||||
|
||||
if legacy_runner == 'local-agent':
|
||||
model = normalized.get('model')
|
||||
if isinstance(model, str):
|
||||
normalized['model'] = {
|
||||
'primary': model,
|
||||
'fallbacks': [],
|
||||
}
|
||||
knowledge_base = normalized.pop('knowledge-base', None)
|
||||
if 'knowledge-bases' not in normalized and isinstance(knowledge_base, str):
|
||||
normalized['knowledge-bases'] = [] if knowledge_base in {'', '__none__', '__none'} else [knowledge_base]
|
||||
|
||||
return normalized
|
||||
243
src/langbot/pkg/agent/runner/config_schema.py
Normal file
243
src/langbot/pkg/agent/runner/config_schema.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Helpers for interpreting AgentRunner DynamicForm configuration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
|
||||
|
||||
FORM_ITEM_TYPE_ALIASES = {
|
||||
'select-llm-model': 'llm-model-selector',
|
||||
'select-knowledge-bases': 'knowledge-base-multi-selector',
|
||||
}
|
||||
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
|
||||
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
||||
FILE_SELECTOR_TYPES = {'file', 'array[file]'}
|
||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
||||
|
||||
|
||||
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
|
||||
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
|
||||
if not isinstance(item_type, str):
|
||||
return item_type
|
||||
return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type)
|
||||
|
||||
|
||||
def iter_schema_items(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
field_types: set[str],
|
||||
) -> typing.Iterator[dict[str, typing.Any]]:
|
||||
"""Yield descriptor config schema items whose type is in field_types."""
|
||||
if descriptor is None:
|
||||
return
|
||||
for item in descriptor.config_schema or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if normalize_schema_item_type(item.get('type')) in field_types:
|
||||
yield item
|
||||
|
||||
|
||||
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should resolve model resources for this runner."""
|
||||
return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
||||
|
||||
|
||||
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should expose tool resources to this runner."""
|
||||
return descriptor is not None and descriptor.supports_tool_calling()
|
||||
|
||||
|
||||
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether LangBot should expose knowledge-base resources to this runner."""
|
||||
return descriptor is not None and descriptor.supports_knowledge_retrieval()
|
||||
|
||||
|
||||
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether the runner wants Host skill-authoring tools."""
|
||||
if descriptor is None:
|
||||
return False
|
||||
return descriptor.capabilities.skill_authoring
|
||||
|
||||
|
||||
def extract_prompt_config(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
default_prompt: list[dict[str, typing.Any]],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Extract the prompt-editor value selected by the runner schema."""
|
||||
for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
if field_name and field_name in runner_config:
|
||||
configured_prompt = runner_config[field_name]
|
||||
if isinstance(configured_prompt, list):
|
||||
return configured_prompt
|
||||
default_value = item.get('default')
|
||||
if isinstance(default_value, list):
|
||||
return default_value
|
||||
return default_prompt
|
||||
|
||||
|
||||
def extract_model_selection(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Extract primary/fallback LLM selections from schema-defined fields."""
|
||||
primary_uuid = ''
|
||||
fallback_uuids: list[str] = []
|
||||
|
||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
value = runner_config.get(field_name, item.get('default'))
|
||||
item_type = normalize_schema_item_type(item.get('type'))
|
||||
if item_type == 'model-fallback-selector':
|
||||
if isinstance(value, str):
|
||||
primary_uuid = value
|
||||
elif isinstance(value, dict):
|
||||
primary_uuid = value.get('primary') or ''
|
||||
fallbacks = value.get('fallbacks', [])
|
||||
if isinstance(fallbacks, list):
|
||||
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
|
||||
break
|
||||
|
||||
if item_type == 'llm-model-selector' and isinstance(value, str):
|
||||
primary_uuid = value
|
||||
break
|
||||
|
||||
return primary_uuid, fallback_uuids
|
||||
|
||||
|
||||
def extract_knowledge_base_uuids(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[str]:
|
||||
"""Extract configured knowledge-base UUIDs from schema-defined fields."""
|
||||
if not uses_host_knowledge_bases(descriptor):
|
||||
return []
|
||||
|
||||
kb_uuids: list[str] = []
|
||||
for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
if not field_name:
|
||||
continue
|
||||
value = runner_config.get(field_name, item.get('default', []))
|
||||
if isinstance(value, list):
|
||||
kb_uuids.extend(
|
||||
kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS
|
||||
)
|
||||
|
||||
return list(dict.fromkeys(kb_uuids))
|
||||
|
||||
|
||||
def extract_config_file_resources(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Extract uploaded config file resources from schema-defined file fields."""
|
||||
files: list[dict[str, typing.Any]] = []
|
||||
|
||||
def append_file(value: typing.Any) -> None:
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
file_key = value.get('file_key') or value.get('file_id')
|
||||
if not isinstance(file_key, str) or file_key in NONE_SENTINELS:
|
||||
return
|
||||
files.append({
|
||||
'file_id': file_key,
|
||||
'file_name': value.get('file_name') or value.get('name'),
|
||||
'mime_type': value.get('mime_type') or value.get('mimetype'),
|
||||
'source': 'config',
|
||||
})
|
||||
|
||||
for item in iter_schema_items(descriptor, FILE_SELECTOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
if not field_name:
|
||||
continue
|
||||
value = runner_config.get(field_name, item.get('default'))
|
||||
item_type = normalize_schema_item_type(item.get('type'))
|
||||
if item_type == 'file':
|
||||
append_file(value)
|
||||
elif isinstance(value, list):
|
||||
for entry in value:
|
||||
append_file(entry)
|
||||
|
||||
deduped: dict[str, dict[str, typing.Any]] = {}
|
||||
for file_resource in files:
|
||||
deduped.setdefault(file_resource['file_id'], file_resource)
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def iter_config_model_refs(
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> typing.Iterator[tuple[str, str]]:
|
||||
"""Yield model references declared by schema-defined model selector fields."""
|
||||
for item in descriptor.config_schema or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
field_name = item.get('name')
|
||||
field_type = normalize_schema_item_type(item.get('type'))
|
||||
if not field_name or field_name not in runner_config:
|
||||
continue
|
||||
|
||||
value = runner_config.get(field_name)
|
||||
if field_type == 'model-fallback-selector':
|
||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
||||
yield 'llm', value
|
||||
elif isinstance(value, dict):
|
||||
primary = value.get('primary')
|
||||
if isinstance(primary, str) and primary not in NONE_SENTINELS:
|
||||
yield 'llm', primary
|
||||
fallbacks = value.get('fallbacks', [])
|
||||
if isinstance(fallbacks, list):
|
||||
for fallback_uuid in fallbacks:
|
||||
if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS:
|
||||
yield 'llm', fallback_uuid
|
||||
elif field_type == 'llm-model-selector':
|
||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
||||
yield 'llm', value
|
||||
elif field_type == 'rerank-model-selector':
|
||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
||||
yield 'rerank', value
|
||||
|
||||
|
||||
def set_empty_llm_model_selection(
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
model_uuid: str,
|
||||
) -> bool:
|
||||
"""Set the first empty schema-defined LLM selector to model_uuid."""
|
||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
||||
field_name = item.get('name')
|
||||
field_type = normalize_schema_item_type(item.get('type'))
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
value = runner_config.get(field_name, item.get('default'))
|
||||
if field_type == 'model-fallback-selector':
|
||||
if isinstance(value, dict):
|
||||
primary = value.get('primary') or ''
|
||||
if primary not in NONE_SENTINELS:
|
||||
return False
|
||||
fallbacks = value.get('fallbacks', [])
|
||||
runner_config[field_name] = {
|
||||
'primary': model_uuid,
|
||||
'fallbacks': fallbacks if isinstance(fallbacks, list) else [],
|
||||
}
|
||||
return True
|
||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
||||
return False
|
||||
runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []}
|
||||
return True
|
||||
|
||||
if field_type == 'llm-model-selector':
|
||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
||||
return False
|
||||
runner_config[field_name] = model_uuid
|
||||
return True
|
||||
|
||||
return False
|
||||
435
src/langbot/pkg/agent/runner/context_builder.py
Normal file
435
src/langbot/pkg/agent/runner/context_builder.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""Agent run context builder for provisioning AgentRunContext envelopes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .persistent_state_store import get_persistent_state_store
|
||||
from .host_models import AgentEventEnvelope, AgentBinding
|
||||
|
||||
|
||||
DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
# Internal models for the agent runner context protocol.
|
||||
|
||||
|
||||
class AgentTrigger(typing.TypedDict):
|
||||
"""Agent trigger information."""
|
||||
|
||||
type: str
|
||||
source: str
|
||||
timestamp: int | None
|
||||
|
||||
|
||||
class ConversationContext(typing.TypedDict):
|
||||
"""Conversation context."""
|
||||
|
||||
conversation_id: str | None
|
||||
thread_id: str | None
|
||||
launcher_type: str | None
|
||||
launcher_id: str | None
|
||||
sender_id: str | None
|
||||
bot_id: str | None
|
||||
workspace_id: str | None
|
||||
session_id: str | None
|
||||
|
||||
|
||||
class AgentInput(typing.TypedDict):
|
||||
"""Agent input."""
|
||||
|
||||
text: str | None
|
||||
contents: list[dict[str, typing.Any]]
|
||||
attachments: list[dict[str, typing.Any]]
|
||||
|
||||
|
||||
class AgentRunState(typing.TypedDict):
|
||||
"""Agent run state with 4 scopes."""
|
||||
|
||||
conversation: dict[str, typing.Any]
|
||||
actor: dict[str, typing.Any]
|
||||
subject: dict[str, typing.Any]
|
||||
runner: dict[str, typing.Any]
|
||||
|
||||
|
||||
# Resource payload models matching langbot-plugin-sdk/resources.py.
|
||||
|
||||
|
||||
class ModelResource(typing.TypedDict):
|
||||
"""Model resource payload."""
|
||||
|
||||
model_id: str
|
||||
model_type: str | None
|
||||
provider: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class ToolResource(typing.TypedDict):
|
||||
"""Tool resource payload."""
|
||||
|
||||
tool_name: str
|
||||
tool_type: str | None
|
||||
description: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class KnowledgeBaseResource(typing.TypedDict):
|
||||
"""Knowledge base resource payload."""
|
||||
|
||||
kb_id: str
|
||||
kb_name: str | None
|
||||
kb_type: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class SkillResource(typing.TypedDict):
|
||||
"""Skill resource payload."""
|
||||
|
||||
skill_name: str
|
||||
display_name: str | None
|
||||
description: str | None
|
||||
|
||||
|
||||
class FileResource(typing.TypedDict):
|
||||
"""File resource payload."""
|
||||
|
||||
file_id: str
|
||||
file_name: str | None
|
||||
mime_type: str | None
|
||||
source: str | None
|
||||
operations: list[str]
|
||||
|
||||
|
||||
class StorageResource(typing.TypedDict):
|
||||
"""Storage resource payload."""
|
||||
|
||||
plugin_storage: bool
|
||||
workspace_storage: bool
|
||||
|
||||
|
||||
class AgentResources(typing.TypedDict):
|
||||
"""Agent resources payload."""
|
||||
|
||||
models: list[ModelResource]
|
||||
tools: list[ToolResource]
|
||||
knowledge_bases: list[KnowledgeBaseResource]
|
||||
skills: list[SkillResource]
|
||||
files: list[FileResource]
|
||||
storage: StorageResource
|
||||
platform_capabilities: dict[str, typing.Any]
|
||||
|
||||
|
||||
class AgentRuntimeContext(typing.TypedDict):
|
||||
"""Agent runtime context."""
|
||||
|
||||
langbot_version: str | None
|
||||
trace_id: str | None
|
||||
deadline_at: float | None
|
||||
metadata: dict[str, typing.Any]
|
||||
|
||||
|
||||
class AgentRunContextPayload(typing.TypedDict):
|
||||
"""AgentRunContext payload passed to an agent runner.
|
||||
|
||||
Protocol v1 structure - matches SDK AgentRunContext.
|
||||
|
||||
Note: The 'config' field contains the current Agent/runner config
|
||||
from ai.runner_config[runner_id] while the current Query entry remains
|
||||
a temporary configuration container. It is not plugin instance config.
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
trigger: AgentTrigger
|
||||
conversation: ConversationContext | None
|
||||
event: dict[str, typing.Any] # REQUIRED for Protocol v1
|
||||
actor: dict[str, typing.Any] | None
|
||||
subject: dict[str, typing.Any] | None
|
||||
input: AgentInput
|
||||
delivery: dict[str, typing.Any] # REQUIRED for Protocol v1
|
||||
resources: AgentResources
|
||||
context: dict[str, typing.Any] # ContextAccess - REQUIRED for Protocol v1
|
||||
state: AgentRunState
|
||||
runtime: AgentRuntimeContext
|
||||
config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id]
|
||||
adapter: dict[str, typing.Any] | None # Entry adapter context
|
||||
metadata: dict[str, typing.Any] # Additional metadata
|
||||
|
||||
|
||||
class AgentRunContextBuilder:
|
||||
"""Builder for provisioning AgentRunContext.
|
||||
|
||||
Responsibilities:
|
||||
- Generate new run_id (UUID, not query id)
|
||||
- Set trigger type based on event source
|
||||
- Build conversation context from event
|
||||
- Build input from event
|
||||
- Build state snapshot from PersistentStateStore
|
||||
- Build runtime context with host info, trace_id, deadline
|
||||
- Set config from current Agent/runner configuration.
|
||||
|
||||
Query adaptation belongs to QueryEntryAdapter, not this builder.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def build_context_from_event(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
resources: AgentResources,
|
||||
) -> AgentRunContextPayload:
|
||||
"""Build AgentRunContext from event-first envelope.
|
||||
|
||||
This is the main entry point for Protocol v1.
|
||||
Does NOT inline full history by default.
|
||||
|
||||
Args:
|
||||
event: Event envelope
|
||||
binding: Agent binding
|
||||
descriptor: Runner descriptor
|
||||
resources: Built resources
|
||||
|
||||
Returns:
|
||||
AgentRunContextPayload for the runner
|
||||
"""
|
||||
# Generate new run_id
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
# Build trigger from event
|
||||
trigger: AgentTrigger = {
|
||||
'type': event.event_type,
|
||||
'source': event.source,
|
||||
'timestamp': event.event_time or int(time.time()),
|
||||
}
|
||||
|
||||
# Build conversation context from event
|
||||
conversation: ConversationContext | None = None
|
||||
if event.conversation_id:
|
||||
conversation = {
|
||||
'session_id': None,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'launcher_type': None, # Will be filled from actor/subject if needed
|
||||
'launcher_id': None,
|
||||
'sender_id': event.actor.actor_id if event.actor else None,
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
}
|
||||
|
||||
# Build event context (Protocol v1 event-first)
|
||||
event_context = {
|
||||
'event_id': event.event_id,
|
||||
'event_type': event.event_type,
|
||||
'event_time': event.event_time,
|
||||
'source': event.source,
|
||||
'source_event_type': event.source_event_type,
|
||||
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
||||
'data': event.data,
|
||||
}
|
||||
|
||||
# Build actor context
|
||||
actor_context = None
|
||||
if event.actor:
|
||||
actor_context = {
|
||||
'actor_type': event.actor.actor_type,
|
||||
'actor_id': event.actor.actor_id,
|
||||
'actor_name': event.actor.actor_name,
|
||||
}
|
||||
|
||||
# Build subject context
|
||||
subject_context = None
|
||||
if event.subject:
|
||||
subject_context = {
|
||||
'subject_type': event.subject.subject_type,
|
||||
'subject_id': event.subject.subject_id,
|
||||
'data': event.subject.data,
|
||||
}
|
||||
|
||||
# Build input from event
|
||||
input: AgentInput = {
|
||||
'text': event.input.text,
|
||||
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
||||
'attachments': [
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
||||
],
|
||||
}
|
||||
|
||||
# Build context access (no history inlined by default for Protocol v1)
|
||||
# Populate with actual values from stores
|
||||
context_access = await self._build_context_access(event, descriptor, binding)
|
||||
|
||||
# Build state snapshot from persistent state store (event-first Protocol v1)
|
||||
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
|
||||
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
|
||||
|
||||
# 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
|
||||
permissions = descriptor.permissions
|
||||
history_perms = set(permissions.history)
|
||||
event_perms = set(permissions.events)
|
||||
artifact_perms = set(permissions.artifacts)
|
||||
storage_perms = set(permissions.storage)
|
||||
|
||||
history_page_enabled = 'page' in history_perms and conversation_id is not None
|
||||
history_search_enabled = 'search' in history_perms and conversation_id is not None
|
||||
event_get_enabled = 'get' in event_perms
|
||||
event_page_enabled = 'page' in event_perms and conversation_id is not None
|
||||
artifact_metadata_enabled = 'metadata' in artifact_perms
|
||||
artifact_read_enabled = 'read' in artifact_perms
|
||||
steering_pull_enabled = bool(getattr(descriptor.capabilities, 'steering', False)) and conversation_id is not None
|
||||
|
||||
# Determine state API availability based on binding state_policy.
|
||||
state_enabled = False
|
||||
storage_enabled = False
|
||||
if binding is not None:
|
||||
state_policy = binding.state_policy
|
||||
if state_policy.enable_state and state_policy.state_scopes:
|
||||
state_enabled = True
|
||||
|
||||
resource_policy = binding.resource_policy
|
||||
storage_enabled = (
|
||||
('plugin' in storage_perms and resource_policy.allow_plugin_storage)
|
||||
or ('workspace' in storage_perms and resource_policy.allow_workspace_storage)
|
||||
)
|
||||
|
||||
# Get latest cursor and has_history_before if conversation exists
|
||||
latest_cursor = None
|
||||
has_history_before = False
|
||||
|
||||
if conversation_id:
|
||||
try:
|
||||
from .transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
latest_cursor = await store.get_latest_cursor(conversation_id)
|
||||
if latest_cursor:
|
||||
has_history_before = True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to get transcript cursor: {e}')
|
||||
|
||||
return {
|
||||
'conversation_id': conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'latest_cursor': latest_cursor,
|
||||
'event_seq': None, # Will be populated when EventLog is written
|
||||
'transcript_seq': int(latest_cursor) if latest_cursor else None,
|
||||
'has_history_before': has_history_before,
|
||||
'inline_policy': {
|
||||
'mode': 'current_event',
|
||||
'delivered_count': 0,
|
||||
'source_total_count': None,
|
||||
'messages_complete': False,
|
||||
'reason': 'current_event_only',
|
||||
},
|
||||
'available_apis': {
|
||||
'history_page': history_page_enabled,
|
||||
'history_search': history_search_enabled,
|
||||
'event_get': event_get_enabled,
|
||||
'event_page': event_page_enabled,
|
||||
'artifact_metadata': artifact_metadata_enabled,
|
||||
'artifact_read': artifact_read_enabled,
|
||||
'state': state_enabled,
|
||||
'storage': storage_enabled,
|
||||
'steering_pull': steering_pull_enabled,
|
||||
},
|
||||
}
|
||||
72
src/langbot/pkg/agent/runner/default_config.py
Normal file
72
src/langbot/pkg/agent/runner/default_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Default AgentRunner binding configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...core import app
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
from . import config_schema
|
||||
from .config_migration import ConfigMigration
|
||||
|
||||
|
||||
class AgentRunnerDefaultConfigService:
|
||||
"""Apply AgentRunner schema-defined defaults to host binding config."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def _get_runner_descriptor(self, runner_id: str):
|
||||
registry = getattr(self.ap, 'agent_runner_registry', None)
|
||||
if registry is None:
|
||||
return None
|
||||
try:
|
||||
return await registry.get(runner_id, bound_plugins=None)
|
||||
except Exception as e:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger:
|
||||
logger.warning(f'Failed to load AgentRunner descriptor while setting default model: {e}')
|
||||
return None
|
||||
|
||||
async def auto_set_default_pipeline_llm_model(self, model_uuid: str) -> bool:
|
||||
"""Set model_uuid into the default pipeline runner config when the selector is empty."""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
if pipeline is None:
|
||||
return False
|
||||
|
||||
return await self.set_pipeline_llm_model_if_empty(pipeline, model_uuid)
|
||||
|
||||
async def set_pipeline_llm_model_if_empty(
|
||||
self,
|
||||
pipeline: persistence_pipeline.LegacyPipeline,
|
||||
model_uuid: str,
|
||||
) -> bool:
|
||||
"""Set model_uuid into a pipeline's schema-defined LLM selector if it is empty."""
|
||||
pipeline_config = pipeline.config
|
||||
if not isinstance(pipeline_config, dict):
|
||||
return False
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
if not runner_id:
|
||||
return False
|
||||
|
||||
descriptor = await self._get_runner_descriptor(runner_id)
|
||||
if descriptor is None:
|
||||
return False
|
||||
|
||||
ai_config = pipeline_config.setdefault('ai', {})
|
||||
runner_configs = ai_config.setdefault('runner_config', {})
|
||||
runner_config = runner_configs.setdefault(runner_id, {})
|
||||
|
||||
if not config_schema.set_empty_llm_model_selection(descriptor, runner_config, model_uuid):
|
||||
return False
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config})
|
||||
return True
|
||||
82
src/langbot/pkg/agent/runner/descriptor.py
Normal file
82
src/langbot/pkg/agent/runner/descriptor.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Agent runner descriptor."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerCapabilities,
|
||||
AgentRunnerPermissions,
|
||||
)
|
||||
|
||||
|
||||
class AgentRunnerDescriptor(pydantic.BaseModel):
|
||||
"""Descriptor for an agent runner.
|
||||
|
||||
Represents the discovered metadata for a runner, including
|
||||
its identity, capabilities, permissions, and configuration schema.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""Unique runner ID: plugin:author/plugin_name/runner_name"""
|
||||
|
||||
source: typing.Literal['plugin']
|
||||
"""Runner source type"""
|
||||
|
||||
label: dict[str, str]
|
||||
"""Display labels keyed by locale (e.g., en_US, zh_Hans)"""
|
||||
|
||||
description: dict[str, str] | None = None
|
||||
"""Optional description keyed by locale"""
|
||||
|
||||
plugin_author: str
|
||||
"""Plugin author from manifest"""
|
||||
|
||||
plugin_name: str
|
||||
"""Plugin name from manifest"""
|
||||
|
||||
runner_name: str
|
||||
"""AgentRunner component name from manifest"""
|
||||
|
||||
plugin_version: str | None = None
|
||||
"""Optional plugin version"""
|
||||
|
||||
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
|
||||
"""Configuration schema using DynamicForm format"""
|
||||
|
||||
capabilities: AgentRunnerCapabilities = pydantic.Field(
|
||||
default_factory=AgentRunnerCapabilities
|
||||
)
|
||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
||||
|
||||
permissions: AgentRunnerPermissions = pydantic.Field(
|
||||
default_factory=AgentRunnerPermissions
|
||||
)
|
||||
"""Requested LangBot resource permissions."""
|
||||
|
||||
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Original manifest for reference"""
|
||||
|
||||
model_config = pydantic.ConfigDict(
|
||||
extra='allow',
|
||||
)
|
||||
|
||||
def get_plugin_id(self) -> str:
|
||||
"""Return plugin identifier as author/name."""
|
||||
return f'{self.plugin_author}/{self.plugin_name}'
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Check if runner supports streaming output."""
|
||||
return self.capabilities.streaming
|
||||
|
||||
def supports_tool_calling(self) -> bool:
|
||||
"""Check if runner supports tool calling."""
|
||||
return self.capabilities.tool_calling
|
||||
|
||||
def supports_knowledge_retrieval(self) -> bool:
|
||||
"""Check if runner supports knowledge retrieval."""
|
||||
return self.capabilities.knowledge_retrieval
|
||||
|
||||
def supports_steering(self) -> bool:
|
||||
"""Check if runner supports run steering/follow-up input."""
|
||||
return bool(getattr(self.capabilities, 'steering', False))
|
||||
37
src/langbot/pkg/agent/runner/errors.py
Normal file
37
src/langbot/pkg/agent/runner/errors.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Agent runner errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AgentRunnerError(Exception):
|
||||
"""Base error for agent runner operations."""
|
||||
pass
|
||||
|
||||
|
||||
class RunnerNotFoundError(AgentRunnerError):
|
||||
"""Runner not found in registry."""
|
||||
def __init__(self, runner_id: str):
|
||||
self.runner_id = runner_id
|
||||
super().__init__(f'Agent runner not found: {runner_id}')
|
||||
|
||||
|
||||
class RunnerNotAuthorizedError(AgentRunnerError):
|
||||
"""Runner not authorized for this binding."""
|
||||
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
||||
self.runner_id = runner_id
|
||||
self.bound_plugins = bound_plugins
|
||||
super().__init__(f'Agent runner {runner_id} not authorized for bound_plugins={bound_plugins}')
|
||||
|
||||
|
||||
class RunnerProtocolError(AgentRunnerError):
|
||||
"""Runner protocol version mismatch or invalid manifest."""
|
||||
def __init__(self, runner_id: str, message: str):
|
||||
self.runner_id = runner_id
|
||||
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
||||
|
||||
|
||||
class RunnerExecutionError(AgentRunnerError):
|
||||
"""Runner execution failed."""
|
||||
def __init__(self, runner_id: str, message: str, retryable: bool = False):
|
||||
self.runner_id = runner_id
|
||||
self.retryable = retryable
|
||||
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
||||
298
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
298
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> tuple[list[dict[str, typing.Any]], int | None, bool]:
|
||||
"""Page through event records.
|
||||
|
||||
Args:
|
||||
conversation_id: Filter by conversation ID
|
||||
event_types: Filter by event types
|
||||
before_seq: Get events before this sequence number
|
||||
limit: Maximum items to return (capped at 100)
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
Returns:
|
||||
Tuple of (items, next_seq, has_more)
|
||||
"""
|
||||
limit = min(limit, 100) # Hard cap
|
||||
|
||||
async with self._session_factory() as session:
|
||||
query = sqlalchemy.select(EventLog)
|
||||
|
||||
if conversation_id is not None:
|
||||
query = query.where(EventLog.conversation_id == conversation_id)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
if event_types:
|
||||
query = query.where(EventLog.event_type.in_(event_types))
|
||||
|
||||
if before_seq is not None:
|
||||
query = query.where(EventLog.id < before_seq)
|
||||
|
||||
query = query.order_by(EventLog.id.desc()).limit(limit + 1)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
items = [self._row_to_dict(row) for row in rows[:limit]]
|
||||
has_more = len(rows) > limit
|
||||
next_seq = items[-1]['id'] if items and has_more else None
|
||||
|
||||
return items, next_seq, has_more
|
||||
|
||||
async def get_latest_cursor(
|
||||
self,
|
||||
conversation_id: str,
|
||||
) -> str | None:
|
||||
"""Get the latest cursor for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Cursor string (seq number), or None if no events
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(EventLog.id)
|
||||
.where(EventLog.conversation_id == conversation_id)
|
||||
.order_by(EventLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return str(row)
|
||||
|
||||
async def has_events_before(
|
||||
self,
|
||||
conversation_id: str,
|
||||
seq: int,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> bool:
|
||||
"""Check if there are events before a sequence number.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
seq: Sequence number
|
||||
|
||||
Returns:
|
||||
True if there are events before
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
query = (
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(EventLog)
|
||||
.where(EventLog.conversation_id == conversation_id, EventLog.id < seq)
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
result = await session.execute(query)
|
||||
count = result.scalar()
|
||||
return count > 0
|
||||
|
||||
def _apply_scope_filters(
|
||||
self,
|
||||
query: typing.Any,
|
||||
bot_id: str | None,
|
||||
workspace_id: str | None,
|
||||
thread_id: str | None,
|
||||
strict_thread: bool,
|
||||
) -> typing.Any:
|
||||
if bot_id is not None:
|
||||
query = query.where(EventLog.bot_id == bot_id)
|
||||
if workspace_id is not None:
|
||||
query = query.where(EventLog.workspace_id == workspace_id)
|
||||
if strict_thread:
|
||||
if thread_id is None:
|
||||
query = query.where(EventLog.thread_id.is_(None))
|
||||
else:
|
||||
query = query.where(EventLog.thread_id == thread_id)
|
||||
return query
|
||||
|
||||
async def cleanup_events_older_than(
|
||||
self,
|
||||
before: datetime.datetime,
|
||||
) -> int:
|
||||
"""Delete EventLog rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]:
|
||||
"""Convert an EventLog row to dict."""
|
||||
return {
|
||||
'id': row.id,
|
||||
'event_id': row.event_id,
|
||||
'event_type': row.event_type,
|
||||
'event_time': int(row.event_time.timestamp()) if row.event_time else None,
|
||||
'source': row.source,
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_id,
|
||||
'conversation_id': row.conversation_id,
|
||||
'thread_id': row.thread_id,
|
||||
'actor_type': row.actor_type,
|
||||
'actor_id': row.actor_id,
|
||||
'actor_name': row.actor_name,
|
||||
'subject_type': row.subject_type,
|
||||
'subject_id': row.subject_id,
|
||||
'input_summary': row.input_summary,
|
||||
'input_json': json.loads(row.input_json) if row.input_json else None,
|
||||
'raw_ref': row.raw_ref,
|
||||
'run_id': row.run_id,
|
||||
'runner_id': row.runner_id,
|
||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
||||
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
|
||||
}
|
||||
25
src/langbot/pkg/agent/runner/events.py
Normal file
25
src/langbot/pkg/agent/runner/events.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Canonical AgentRunner event names reserved for future EBA integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
MESSAGE_RECEIVED = 'message.received'
|
||||
"""A normal message entered the current Pipeline."""
|
||||
|
||||
MESSAGE_RECALLED = 'message.recalled'
|
||||
"""A platform message was recalled or deleted."""
|
||||
|
||||
GROUP_MEMBER_JOINED = 'group.member_joined'
|
||||
"""A new member joined a group/channel conversation."""
|
||||
|
||||
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
|
||||
"""A new friend/contact request was received."""
|
||||
|
||||
|
||||
RESERVED_EVENT_TYPES = frozenset(
|
||||
{
|
||||
MESSAGE_RECEIVED,
|
||||
MESSAGE_RECALLED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
FRIEND_REQUEST_RECEIVED,
|
||||
}
|
||||
)
|
||||
210
src/langbot/pkg/agent/runner/host_models.py
Normal file
210
src/langbot/pkg/agent/runner/host_models.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Agent event envelope and binding models for LangBot Host.
|
||||
|
||||
These are Host-internal models, not exposed to SDK.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
||||
ActorContext,
|
||||
SubjectContext,
|
||||
RawEventRef,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
|
||||
class AgentEventEnvelope(pydantic.BaseModel):
|
||||
"""Event envelope for LangBot Host event gateway.
|
||||
|
||||
This is the unified input model that replaces Query-first approach.
|
||||
IM / WebUI / API / EventRouter all produce this envelope.
|
||||
"""
|
||||
|
||||
event_id: str
|
||||
"""Unique event identifier."""
|
||||
|
||||
event_type: str
|
||||
"""Event type (message.received, message.recalled, etc.)."""
|
||||
|
||||
event_time: int | None = None
|
||||
"""Event timestamp (epoch seconds)."""
|
||||
|
||||
source: str
|
||||
"""Event source (platform, webui, api, scheduler, system)."""
|
||||
|
||||
source_event_type: str | None = None
|
||||
"""Original source event type, when available."""
|
||||
|
||||
bot_id: str | None = None
|
||||
"""Bot UUID handling this event."""
|
||||
|
||||
workspace_id: str | None = None
|
||||
"""Workspace ID (for multi-tenant)."""
|
||||
|
||||
conversation_id: str | None = None
|
||||
"""Conversation ID."""
|
||||
|
||||
thread_id: str | None = None
|
||||
"""Thread ID (for platforms supporting threads)."""
|
||||
|
||||
actor: ActorContext | None = None
|
||||
"""Actor (who triggered the event)."""
|
||||
|
||||
subject: SubjectContext | None = None
|
||||
"""Subject (what the event is about)."""
|
||||
|
||||
input: AgentInput
|
||||
"""Event input."""
|
||||
|
||||
delivery: DeliveryContext
|
||||
"""Delivery context."""
|
||||
|
||||
raw_ref: RawEventRef | None = None
|
||||
"""Reference to raw event payload."""
|
||||
|
||||
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Small structured event payload. Large payloads should be referenced via raw_ref/artifacts."""
|
||||
|
||||
|
||||
# Binding scope types
|
||||
class BindingScope(pydantic.BaseModel):
|
||||
"""Scope for agent binding."""
|
||||
|
||||
scope_type: typing.Literal["agent", "bot", "workspace", "global"] = "agent"
|
||||
"""Scope type."""
|
||||
|
||||
scope_id: str | None = None
|
||||
"""Scope identifier (agent_id, bot_uuid, etc.)."""
|
||||
|
||||
|
||||
class ResourcePolicy(pydantic.BaseModel):
|
||||
"""Resource policy for agent binding.
|
||||
|
||||
Controls what resources the runner can access.
|
||||
"""
|
||||
|
||||
allowed_model_uuids: list[str] | None = None
|
||||
"""Additional model UUID grants. None means no additional model grants."""
|
||||
|
||||
allowed_tool_names: list[str] | None = None
|
||||
"""Additional tool name grants. None means no additional tool grants."""
|
||||
|
||||
allowed_kb_uuids: list[str] | None = None
|
||||
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
||||
|
||||
allowed_skill_names: list[str] | None = None
|
||||
"""Allowed skill names. None means all currently visible skills are allowed."""
|
||||
|
||||
allow_plugin_storage: bool = True
|
||||
"""Whether plugin storage is allowed."""
|
||||
|
||||
allow_workspace_storage: bool = False
|
||||
"""Whether workspace storage is allowed."""
|
||||
|
||||
|
||||
class StatePolicy(pydantic.BaseModel):
|
||||
"""State policy for agent binding.
|
||||
|
||||
Controls state management behavior.
|
||||
"""
|
||||
|
||||
enable_state: bool = True
|
||||
"""Whether host-owned state is enabled."""
|
||||
|
||||
state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = (
|
||||
pydantic.Field(default_factory=lambda: ["conversation", "actor"])
|
||||
)
|
||||
"""Enabled state scopes."""
|
||||
|
||||
|
||||
class DeliveryPolicy(pydantic.BaseModel):
|
||||
"""Delivery policy for agent binding.
|
||||
|
||||
Controls how results are delivered.
|
||||
"""
|
||||
|
||||
enable_streaming: bool = True
|
||||
"""Whether streaming output is enabled."""
|
||||
|
||||
enable_reply: bool = True
|
||||
"""Whether reply is enabled."""
|
||||
|
||||
max_message_size: int | None = None
|
||||
"""Maximum message size."""
|
||||
|
||||
|
||||
class AgentConfig(pydantic.BaseModel):
|
||||
"""Host-side Agent configuration.
|
||||
|
||||
Product-level Agent is the target replacement for Pipeline-owned agent
|
||||
config. Current Pipeline entry paths can project their config into this
|
||||
model during migration.
|
||||
"""
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier."""
|
||||
|
||||
runner_id: str
|
||||
"""Runner ID to invoke."""
|
||||
|
||||
runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Agent/runner binding configuration."""
|
||||
|
||||
resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy)
|
||||
"""Resource policy for this Agent."""
|
||||
|
||||
state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy)
|
||||
"""State policy for this Agent."""
|
||||
|
||||
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
||||
"""Delivery policy for this Agent."""
|
||||
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
|
||||
"""Event types this Agent handles."""
|
||||
|
||||
enabled: bool = True
|
||||
"""Whether this Agent can be selected by a binding resolver."""
|
||||
|
||||
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Non-protocol diagnostic metadata, such as legacy config source."""
|
||||
|
||||
|
||||
class AgentBinding(pydantic.BaseModel):
|
||||
"""Binding configuration for mapping events to runners.
|
||||
|
||||
This is Host-internal model for event-to-runner binding.
|
||||
It replaces the old Pipeline runner config role.
|
||||
"""
|
||||
|
||||
binding_id: str
|
||||
"""Unique binding identifier."""
|
||||
|
||||
scope: BindingScope = pydantic.Field(default_factory=BindingScope)
|
||||
"""Binding scope."""
|
||||
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
|
||||
"""Event types this binding handles."""
|
||||
|
||||
runner_id: str
|
||||
"""Runner ID to invoke."""
|
||||
|
||||
runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Current Agent/runner configuration."""
|
||||
|
||||
resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy)
|
||||
"""Resource policy."""
|
||||
|
||||
state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy)
|
||||
"""State policy."""
|
||||
|
||||
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
||||
"""Delivery policy."""
|
||||
|
||||
enabled: bool = True
|
||||
"""Whether binding is enabled."""
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier for this binding."""
|
||||
91
src/langbot/pkg/agent/runner/id.py
Normal file
91
src/langbot/pkg/agent/runner/id.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Agent runner ID parsing and formatting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RunnerIdParts:
|
||||
"""Parsed runner ID components."""
|
||||
source: str # 'plugin' (future: 'builtin')
|
||||
plugin_author: str
|
||||
plugin_name: str
|
||||
runner_name: str
|
||||
|
||||
def to_plugin_id(self) -> str:
|
||||
"""Return plugin identifier as author/name."""
|
||||
return f'{self.plugin_author}/{self.plugin_name}'
|
||||
|
||||
|
||||
def parse_runner_id(runner_id: str) -> RunnerIdParts:
|
||||
"""Parse runner ID string into components.
|
||||
|
||||
Args:
|
||||
runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name'
|
||||
|
||||
Returns:
|
||||
RunnerIdParts with parsed components
|
||||
|
||||
Raises:
|
||||
ValueError: If runner_id format is invalid
|
||||
"""
|
||||
if runner_id.startswith('plugin:'):
|
||||
parts = runner_id[7:].split('/')
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
f'Invalid plugin runner ID format: {runner_id}. '
|
||||
f'Expected: plugin:author/plugin_name/runner_name'
|
||||
)
|
||||
plugin_author, plugin_name, runner_name = parts
|
||||
if not plugin_author or not plugin_name or not runner_name:
|
||||
raise ValueError(
|
||||
f'Invalid plugin runner ID: {runner_id}. '
|
||||
f'author, plugin_name, and runner_name must be non-empty'
|
||||
)
|
||||
return RunnerIdParts(
|
||||
source='plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
else:
|
||||
# Only plugin runner IDs are valid at the protocol boundary.
|
||||
raise ValueError(
|
||||
f'Invalid runner ID format: {runner_id}. '
|
||||
f'Expected: plugin:author/plugin_name/runner_name'
|
||||
)
|
||||
|
||||
|
||||
def format_runner_id(
|
||||
source: str,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
runner_name: str,
|
||||
) -> str:
|
||||
"""Format runner ID from components.
|
||||
|
||||
Args:
|
||||
source: Runner source ('plugin')
|
||||
plugin_author: Plugin author
|
||||
plugin_name: Plugin name
|
||||
runner_name: Runner component name
|
||||
|
||||
Returns:
|
||||
Runner ID string
|
||||
"""
|
||||
if source == 'plugin':
|
||||
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
|
||||
else:
|
||||
raise ValueError(f'Invalid runner source: {source}')
|
||||
|
||||
|
||||
def is_plugin_runner_id(runner_id: str) -> bool:
|
||||
"""Check if runner ID is a plugin runner.
|
||||
|
||||
Args:
|
||||
runner_id: Runner ID string
|
||||
|
||||
Returns:
|
||||
True if runner ID starts with 'plugin:'
|
||||
"""
|
||||
return runner_id.startswith('plugin:')
|
||||
131
src/langbot/pkg/agent/runner/invoker.py
Normal file
131
src/langbot/pkg/agent/runner/invoker.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Plugin-runtime invocation for AgentRunner executions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
|
||||
|
||||
from ...core import app
|
||||
from .context_builder import AgentRunContextPayload
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .errors import RunnerExecutionError
|
||||
|
||||
|
||||
class AgentRunnerInvoker:
|
||||
"""Invoke an AgentRunner through the plugin runtime.
|
||||
|
||||
This keeps runtime transport, deadline enforcement, and transport error
|
||||
mapping out of the orchestration state machine.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""Invoke the runner and yield raw result dictionaries."""
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
'Plugin system is disabled',
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
try:
|
||||
gen = self.ap.plugin_connector.run_agent(
|
||||
plugin_author=descriptor.plugin_author,
|
||||
plugin_name=descriptor.plugin_name,
|
||||
runner_name=descriptor.runner_name,
|
||||
context=context,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
result_dict = await self._next_with_deadline(gen, descriptor, context)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
yield result_dict
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
'Runner timed out (code: runner.timeout)',
|
||||
retryable=True,
|
||||
) from e
|
||||
except ActionCallTimeoutError as e:
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
f'{e} (code: runner.timeout)',
|
||||
retryable=True,
|
||||
) from e
|
||||
except RunnerExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
|
||||
)
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
str(e),
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
async def _next_with_deadline(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Read the next runner result while enforcing the run deadline."""
|
||||
remaining = self._remaining_deadline_seconds(context)
|
||||
if remaining is not None and remaining <= 0:
|
||||
await self._close_generator(gen, descriptor)
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
try:
|
||||
if remaining is None:
|
||||
return await anext(gen)
|
||||
return await asyncio.wait_for(anext(gen), timeout=remaining)
|
||||
except StopAsyncIteration:
|
||||
if self._is_deadline_exhausted(context):
|
||||
raise asyncio.TimeoutError
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
await self._close_generator(gen, descriptor)
|
||||
raise
|
||||
|
||||
def _remaining_deadline_seconds(
|
||||
self,
|
||||
context: AgentRunContextPayload,
|
||||
) -> float | None:
|
||||
runtime = context.get('runtime') or {}
|
||||
deadline_at = runtime.get('deadline_at')
|
||||
if deadline_at is None:
|
||||
return None
|
||||
try:
|
||||
return float(deadline_at) - time.time()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool:
|
||||
remaining = self._remaining_deadline_seconds(context)
|
||||
return remaining is not None and remaining <= 0
|
||||
|
||||
async def _close_generator(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> None:
|
||||
try:
|
||||
await gen.aclose()
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}')
|
||||
473
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
473
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Agent run orchestrator for coordinating runner execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from ...core import app
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .context_builder import AgentRunContextBuilder, AgentRunContextPayload
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .invoker import AgentRunnerInvoker
|
||||
from .query_bridge import QueryRunBridge
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
from .state_scope import build_state_context
|
||||
from ...provider.tools.loaders import skill as skill_loader
|
||||
|
||||
|
||||
MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES
|
||||
|
||||
|
||||
class AgentRunOrchestrator:
|
||||
"""Coordinate one AgentRunner execution.
|
||||
|
||||
The orchestrator keeps the run state machine readable and delegates
|
||||
transport, Query bridging, and persistence side effects to narrower
|
||||
collaborators.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
registry: AgentRunnerRegistry
|
||||
context_builder: AgentRunContextBuilder
|
||||
resource_builder: AgentResourceBuilder
|
||||
result_normalizer: AgentResultNormalizer
|
||||
binding_resolver: AgentBindingResolver
|
||||
query_bridge: QueryRunBridge
|
||||
invoker: AgentRunnerInvoker
|
||||
journal: AgentRunJournal
|
||||
_session_registry: AgentRunSessionRegistry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
registry: AgentRunnerRegistry,
|
||||
):
|
||||
self.ap = ap
|
||||
self.registry = registry
|
||||
self.context_builder = AgentRunContextBuilder(ap)
|
||||
self.resource_builder = AgentResourceBuilder(ap)
|
||||
self.result_normalizer = AgentResultNormalizer(ap)
|
||||
self.binding_resolver = AgentBindingResolver()
|
||||
self.query_bridge = QueryRunBridge(self.binding_resolver)
|
||||
self.invoker = AgentRunnerInvoker(ap)
|
||||
self.journal = AgentRunJournal(ap)
|
||||
self._session_registry = get_session_registry()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
bound_plugins: list[str] | None = None,
|
||||
adapter_context: dict[str, typing.Any] | None = None,
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run an AgentRunner from an event-first envelope."""
|
||||
runner_id = binding.runner_id
|
||||
descriptor = await self.registry.get(runner_id, bound_plugins)
|
||||
|
||||
resources = await self.resource_builder.build_resources_from_binding(
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
context = await self.context_builder.build_context_from_event(
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
session_query_id = None
|
||||
if adapter_context:
|
||||
query = adapter_context.get('_query')
|
||||
if query is not None:
|
||||
skill_loader.restore_activated_skills_from_state(
|
||||
self.ap,
|
||||
query,
|
||||
context.get('state', {}),
|
||||
)
|
||||
session_query_id = adapter_context.get('query_id')
|
||||
if '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,
|
||||
available_apis=context.get('context', {}).get('available_apis'),
|
||||
conversation_id=event.conversation_id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
state_policy={
|
||||
'enable_state': binding.state_policy.enable_state,
|
||||
'state_scopes': list(binding.state_policy.state_scopes),
|
||||
},
|
||||
state_context=state_context,
|
||||
)
|
||||
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
event=event,
|
||||
binding=binding,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
await self.journal.register_input_artifacts(
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
if event.event_type == 'message.received' and event.conversation_id:
|
||||
await self.journal.write_user_transcript(
|
||||
event=event,
|
||||
event_log_id=event_log_id,
|
||||
)
|
||||
|
||||
pending_artifact_refs: list[dict[str, typing.Any]] = []
|
||||
seen_sequences: set[int] = set()
|
||||
last_sequence = 0
|
||||
assistant_transcript_written = False
|
||||
|
||||
try:
|
||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
||||
sequence = result_dict.get('sequence')
|
||||
if sequence is not None:
|
||||
try:
|
||||
sequence_int = int(sequence)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned invalid result sequence: {sequence}'
|
||||
)
|
||||
else:
|
||||
if sequence_int in seen_sequences:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned duplicate result sequence '
|
||||
f'{sequence_int} for run {run_id}; dropping duplicate'
|
||||
)
|
||||
continue
|
||||
if sequence_int <= 0:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned non-positive result sequence '
|
||||
f'{sequence_int} for run {run_id}'
|
||||
)
|
||||
elif last_sequence and sequence_int != last_sequence + 1:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} result sequence gap or out-of-order '
|
||||
f'for run {run_id}: previous={last_sequence}, current={sequence_int}'
|
||||
)
|
||||
seen_sequences.add(sequence_int)
|
||||
last_sequence = max(last_sequence, sequence_int)
|
||||
|
||||
result_type = result_dict.get('type')
|
||||
if result_type and not self.result_normalizer.validate_payload(
|
||||
result_type,
|
||||
result_dict.get('data', {}),
|
||||
descriptor,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'artifact.created':
|
||||
artifact_ref = await self.journal.handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
pending_artifact_refs.append(artifact_ref)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'state.updated':
|
||||
await self.journal.handle_state_updated_event(
|
||||
result_dict,
|
||||
event,
|
||||
binding,
|
||||
descriptor,
|
||||
run_id=run_id,
|
||||
)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
has_completed_message = (
|
||||
result_type == 'message.completed'
|
||||
or (
|
||||
result_type == 'run.completed'
|
||||
and isinstance(result_dict.get('data'), dict)
|
||||
and bool(result_dict['data'].get('message'))
|
||||
)
|
||||
)
|
||||
if has_completed_message and event.conversation_id and not assistant_transcript_written:
|
||||
merged_refs = self.journal.merge_artifact_refs(
|
||||
pending_artifact_refs,
|
||||
result_dict,
|
||||
)
|
||||
pending_artifact_refs.clear()
|
||||
|
||||
await self.journal.write_assistant_transcript(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
artifact_refs=merged_refs if merged_refs else None,
|
||||
)
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
if result is not None:
|
||||
yield result
|
||||
finally:
|
||||
session = await self._session_registry.unregister(run_id)
|
||||
pending_steering = session.get('steering_queue', []) if session else []
|
||||
if pending_steering:
|
||||
try:
|
||||
await self.journal.write_steering_dropped_audits(
|
||||
pending_steering,
|
||||
run_id,
|
||||
descriptor.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to write dropped steering audit for run {run_id}: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def run_from_query(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run an AgentRunner from the current Pipeline Query entry point."""
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
adapter_context = dict(plan.adapter_context)
|
||||
adapter_context['_query'] = query
|
||||
async for result in self.run(
|
||||
plan.event,
|
||||
plan.binding,
|
||||
bound_plugins=plan.bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
yield result
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return self.query_bridge.resolve_runner_id_for_telemetry(query)
|
||||
|
||||
async def try_claim_steering_from_query(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> bool:
|
||||
"""Claim a query as steering input for an active run when possible."""
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
event = plan.event
|
||||
binding = plan.binding
|
||||
|
||||
if event.event_type != 'message.received' or not event.conversation_id:
|
||||
return False
|
||||
|
||||
descriptor = await self.registry.get(binding.runner_id, plan.bound_plugins)
|
||||
if not descriptor.supports_steering():
|
||||
return False
|
||||
|
||||
target_run_id = await self._session_registry.find_steering_target(
|
||||
conversation_id=event.conversation_id,
|
||||
runner_id=descriptor.id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
)
|
||||
if target_run_id is None:
|
||||
return False
|
||||
|
||||
steering_item = self._build_steering_item(event, target_run_id, descriptor.id)
|
||||
if not await self._session_registry.enqueue_steering(target_run_id, steering_item):
|
||||
return False
|
||||
|
||||
try:
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
event=event,
|
||||
binding=binding,
|
||||
run_id=target_run_id,
|
||||
runner_id=descriptor.id,
|
||||
metadata={
|
||||
'steering': {
|
||||
'status': 'queued',
|
||||
'trigger_behavior': 'absorbed_into_active_run',
|
||||
'claimed_by_run_id': target_run_id,
|
||||
'claimed_runner_id': descriptor.id,
|
||||
'claimed_at': steering_item.get('claimed_at'),
|
||||
},
|
||||
},
|
||||
)
|
||||
await self.journal.register_input_artifacts(
|
||||
event=event,
|
||||
run_id=target_run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
await self.journal.write_user_transcript(event, event_log_id)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to persist steering event {event.event_id} for run {target_run_id}: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Claimed event {event.event_id} as steering input for run {target_run_id}'
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_steering_item(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Build the run-scoped steering item returned by the Host pull API."""
|
||||
return {
|
||||
'claimed_run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'claimed_at': int(time.time()),
|
||||
'event': {
|
||||
'event_id': event.event_id,
|
||||
'event_type': event.event_type,
|
||||
'event_time': event.event_time,
|
||||
'source': event.source,
|
||||
'source_event_type': event.source_event_type,
|
||||
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
||||
'data': event.data,
|
||||
},
|
||||
'conversation': {
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
},
|
||||
'actor': event.actor.model_dump(mode='json') if event.actor else None,
|
||||
'subject': event.subject.model_dump(mode='json') if event.subject else None,
|
||||
'input': {
|
||||
'text': event.input.text if event.input else None,
|
||||
'contents': [
|
||||
c.model_dump(mode='json') if hasattr(c, 'model_dump') else c
|
||||
for c in (event.input.contents if event.input else [])
|
||||
],
|
||||
'attachments': [
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a
|
||||
for a in (event.input.attachments if event.input else [])
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async def _invoke_runner(
|
||||
self,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""Compatibility delegate for older tests and internal callers."""
|
||||
async for result in self.invoker.invoke(descriptor, context):
|
||||
yield result
|
||||
|
||||
async def _next_with_deadline(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
context: AgentRunContextPayload,
|
||||
) -> dict[str, typing.Any]:
|
||||
return await self.invoker._next_with_deadline(gen, descriptor, context)
|
||||
|
||||
def _remaining_deadline_seconds(
|
||||
self,
|
||||
context: AgentRunContextPayload,
|
||||
) -> float | None:
|
||||
return self.invoker._remaining_deadline_seconds(context)
|
||||
|
||||
def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool:
|
||||
return self.invoker._is_deadline_exhausted(context)
|
||||
|
||||
async def _close_generator(
|
||||
self,
|
||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> None:
|
||||
await self.invoker._close_generator(gen, descriptor)
|
||||
|
||||
async def _handle_state_updated_event(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> None:
|
||||
await self.journal.handle_state_updated_event(result_dict, event, binding, descriptor)
|
||||
|
||||
async def _write_event_log(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> str:
|
||||
return await self.journal.write_event_log(event, binding, run_id, runner_id)
|
||||
|
||||
async def _register_input_artifacts(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> None:
|
||||
await self.journal.register_input_artifacts(event, run_id, runner_id)
|
||||
|
||||
def _decode_attachment_content(
|
||||
self,
|
||||
content: typing.Any,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
return self.journal.decode_attachment_content(content)
|
||||
|
||||
async def _write_user_transcript(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
event_log_id: str,
|
||||
) -> None:
|
||||
await self.journal.write_user_transcript(event, event_log_id)
|
||||
|
||||
async def _handle_artifact_created(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
return await self.journal.handle_artifact_created(result_dict, event, run_id, runner_id)
|
||||
|
||||
def _merge_artifact_refs(
|
||||
self,
|
||||
pending_refs: list[dict[str, typing.Any]],
|
||||
result_dict: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
return self.journal.merge_artifact_refs(pending_refs, result_dict)
|
||||
|
||||
async def _write_assistant_transcript(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
||||
) -> None:
|
||||
await self.journal.write_assistant_transcript(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
artifact_refs=artifact_refs,
|
||||
)
|
||||
435
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
435
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""Persistent state store for AgentRunner protocol state.
|
||||
|
||||
This module provides a database-backed state store for event-first Protocol v1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy import select, delete, update
|
||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .host_models import AgentEventEnvelope, AgentBinding
|
||||
from .state_scope import (
|
||||
VALID_STATE_SCOPES,
|
||||
build_state_scope_key,
|
||||
get_binding_identity,
|
||||
normalize_state_key,
|
||||
)
|
||||
from ...entity.persistence.agent_runner_state import AgentRunnerState
|
||||
|
||||
|
||||
# Maximum value_json size (256KB)
|
||||
MAX_VALUE_JSON_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class PersistentStateStore:
|
||||
"""Database-backed state store for AgentRunner protocol state.
|
||||
|
||||
IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state.
|
||||
|
||||
This store provides:
|
||||
1. Persistent storage across runs via database
|
||||
2. Scope isolation by runner_id + binding_identity + scope
|
||||
3. Policy enforcement (enable_state, state_scopes)
|
||||
4. JSON value validation and size limits
|
||||
|
||||
Used by:
|
||||
- Event-first Protocol v1 (async methods)
|
||||
- State API handlers (get/set/delete/list)
|
||||
"""
|
||||
|
||||
def __init__(self, db_engine: AsyncEngine):
|
||||
self._db_engine = db_engine
|
||||
|
||||
def _get_scope_key(
|
||||
self,
|
||||
scope: str,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> str | None:
|
||||
"""Get scope key for given scope."""
|
||||
return build_state_scope_key(scope, event, binding, descriptor)
|
||||
|
||||
def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool:
|
||||
"""Check if scope is enabled by binding's state_policy."""
|
||||
state_policy = binding.state_policy
|
||||
if not state_policy.enable_state:
|
||||
return False
|
||||
return scope in state_policy.state_scopes
|
||||
|
||||
def _validate_json_value(
|
||||
self,
|
||||
value: typing.Any,
|
||||
logger: typing.Any = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Validate and serialize value to JSON.
|
||||
|
||||
Returns:
|
||||
Tuple of (json_string, error_message). If error_message is not None,
|
||||
json_string will be None.
|
||||
"""
|
||||
try:
|
||||
json_str = json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError) as e:
|
||||
return None, f'Value is not JSON-serializable: {e}'
|
||||
|
||||
# Check size limit
|
||||
json_bytes = len(json_str.encode('utf-8'))
|
||||
if json_bytes > MAX_VALUE_JSON_BYTES:
|
||||
return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes'
|
||||
|
||||
return json_str, None
|
||||
|
||||
async def _upsert_state_row(
|
||||
self,
|
||||
conn: typing.Any,
|
||||
values: dict[str, typing.Any],
|
||||
) -> None:
|
||||
"""Insert or update a state row by the logical scope/key identity."""
|
||||
update_values = {
|
||||
'value_json': values['value_json'],
|
||||
'updated_at': values['updated_at'],
|
||||
}
|
||||
constraint_columns = ['scope_key', 'state_key']
|
||||
dialect_name = self._db_engine.dialect.name
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
stmt = sqlite_insert(AgentRunnerState).values(**values)
|
||||
await conn.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=constraint_columns,
|
||||
set_=update_values,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if dialect_name == 'postgresql':
|
||||
stmt = postgresql_insert(AgentRunnerState).values(**values)
|
||||
await conn.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=constraint_columns,
|
||||
set_=update_values,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await conn.execute(sqlalchemy.insert(AgentRunnerState).values(**values))
|
||||
except IntegrityError:
|
||||
await conn.execute(
|
||||
update(AgentRunnerState)
|
||||
.where(AgentRunnerState.scope_key == values['scope_key'])
|
||||
.where(AgentRunnerState.state_key == values['state_key'])
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
# ========== Async DB Operations ==========
|
||||
|
||||
async def build_snapshot_from_event(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> dict[str, dict[str, typing.Any]]:
|
||||
"""Build state snapshot for all scopes from event and binding.
|
||||
|
||||
Reads from database, respects state_policy.
|
||||
"""
|
||||
state_policy = binding.state_policy
|
||||
|
||||
# If state is disabled, return all empty scopes
|
||||
if not state_policy.enable_state:
|
||||
return {
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
|
||||
snapshot: dict[str, dict[str, typing.Any]] = {
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
|
||||
async with self._db_engine.connect() as conn:
|
||||
for scope in VALID_STATE_SCOPES:
|
||||
if not self._check_scope_enabled(scope, binding):
|
||||
continue
|
||||
|
||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
||||
if not scope_key:
|
||||
continue
|
||||
|
||||
# Query all state entries for this scope_key
|
||||
result = await conn.execute(
|
||||
select(AgentRunnerState.state_key, AgentRunnerState.value_json)
|
||||
.where(AgentRunnerState.scope_key == scope_key)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
key = row.state_key
|
||||
value_json = row.value_json
|
||||
if value_json:
|
||||
try:
|
||||
snapshot[scope][key] = json.loads(value_json)
|
||||
except json.JSONDecodeError:
|
||||
pass # Skip invalid JSON
|
||||
|
||||
# Seed external.conversation_id from event.conversation_id if not set
|
||||
if self._check_scope_enabled('conversation', binding) and event.conversation_id:
|
||||
if 'external.conversation_id' not in snapshot['conversation']:
|
||||
snapshot['conversation']['external.conversation_id'] = event.conversation_id
|
||||
|
||||
return snapshot
|
||||
|
||||
async def apply_update_from_event(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
scope: str,
|
||||
key: str,
|
||||
value: typing.Any,
|
||||
logger: typing.Any = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Apply a state update from event context.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_message). If success is False, error_message
|
||||
contains the reason.
|
||||
"""
|
||||
state_policy = binding.state_policy
|
||||
|
||||
# Check if state is disabled
|
||||
if not state_policy.enable_state:
|
||||
return False, 'State is disabled by binding policy'
|
||||
|
||||
# Validate scope
|
||||
if scope not in VALID_STATE_SCOPES:
|
||||
return False, f'Invalid scope: {scope}'
|
||||
|
||||
# Check if scope is enabled
|
||||
if not self._check_scope_enabled(scope, binding):
|
||||
return False, f'Scope "{scope}" not enabled by binding policy'
|
||||
|
||||
# Map accepted key aliases
|
||||
key = normalize_state_key(key)
|
||||
|
||||
# Get scope key
|
||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
||||
if not scope_key:
|
||||
return False, f'Missing identity for scope "{scope}"'
|
||||
|
||||
# Validate and serialize value
|
||||
value_json, error = self._validate_json_value(value, logger)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
# Build context fields
|
||||
binding_identity = get_binding_identity(binding)
|
||||
|
||||
now = datetime.utcnow()
|
||||
async with self._db_engine.begin() as conn:
|
||||
await self._upsert_state_row(
|
||||
conn,
|
||||
{
|
||||
'runner_id': descriptor.id,
|
||||
'binding_identity': binding_identity,
|
||||
'scope': scope,
|
||||
'scope_key': scope_key,
|
||||
'state_key': key,
|
||||
'value_json': value_json,
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'actor_type': event.actor.actor_type if event.actor else None,
|
||||
'actor_id': event.actor.actor_id if event.actor else None,
|
||||
'subject_type': event.subject.subject_type if event.subject else None,
|
||||
'subject_id': event.subject.subject_id if event.subject else None,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
async def state_get(
|
||||
self,
|
||||
scope_key: str,
|
||||
state_key: str,
|
||||
) -> typing.Any:
|
||||
"""Get a single state value by scope_key and state_key.
|
||||
|
||||
Used by State API handlers.
|
||||
"""
|
||||
state_key = normalize_state_key(state_key)
|
||||
|
||||
async with self._db_engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
select(AgentRunnerState.value_json)
|
||||
.where(AgentRunnerState.scope_key == scope_key)
|
||||
.where(AgentRunnerState.state_key == state_key)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if not row or not row.value_json:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(row.value_json)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
async def state_set(
|
||||
self,
|
||||
scope_key: str,
|
||||
state_key: str,
|
||||
value: typing.Any,
|
||||
runner_id: str,
|
||||
binding_identity: str,
|
||||
scope: str,
|
||||
context: dict[str, typing.Any] | None = None,
|
||||
logger: typing.Any = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Set a state value.
|
||||
|
||||
Used by State API handlers.
|
||||
Context contains optional fields like bot_id, conversation_id, etc.
|
||||
"""
|
||||
state_key = normalize_state_key(state_key)
|
||||
|
||||
# Validate and serialize value
|
||||
value_json, error = self._validate_json_value(value, logger)
|
||||
if error:
|
||||
return False, error
|
||||
|
||||
context = context or {}
|
||||
|
||||
now = datetime.utcnow()
|
||||
async with self._db_engine.begin() as conn:
|
||||
await self._upsert_state_row(
|
||||
conn,
|
||||
{
|
||||
'runner_id': runner_id,
|
||||
'binding_identity': binding_identity,
|
||||
'scope': scope,
|
||||
'scope_key': scope_key,
|
||||
'state_key': state_key,
|
||||
'value_json': value_json,
|
||||
'bot_id': context.get('bot_id'),
|
||||
'workspace_id': context.get('workspace_id'),
|
||||
'conversation_id': context.get('conversation_id'),
|
||||
'thread_id': context.get('thread_id'),
|
||||
'actor_type': context.get('actor_type'),
|
||||
'actor_id': context.get('actor_id'),
|
||||
'subject_type': context.get('subject_type'),
|
||||
'subject_id': context.get('subject_id'),
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
async def state_delete(
|
||||
self,
|
||||
scope_key: str,
|
||||
state_key: str,
|
||||
) -> bool:
|
||||
"""Delete a state value.
|
||||
|
||||
Returns True if deleted, False if not found.
|
||||
"""
|
||||
state_key = normalize_state_key(state_key)
|
||||
|
||||
async with self._db_engine.begin() as conn:
|
||||
result = await conn.execute(
|
||||
delete(AgentRunnerState)
|
||||
.where(AgentRunnerState.scope_key == scope_key)
|
||||
.where(AgentRunnerState.state_key == state_key)
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def state_list(
|
||||
self,
|
||||
scope_key: str,
|
||||
prefix: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[str], bool]:
|
||||
"""List state keys in a scope.
|
||||
|
||||
Returns tuple of (keys, has_more).
|
||||
"""
|
||||
# Enforce limit cap
|
||||
limit = min(limit, 100)
|
||||
|
||||
async with self._db_engine.connect() as conn:
|
||||
query = (
|
||||
select(AgentRunnerState.state_key)
|
||||
.where(AgentRunnerState.scope_key == scope_key)
|
||||
.order_by(AgentRunnerState.state_key)
|
||||
.limit(limit + 1) # Fetch one extra to check has_more
|
||||
)
|
||||
|
||||
if prefix:
|
||||
prefix = normalize_state_key(prefix)
|
||||
query = query.where(
|
||||
AgentRunnerState.state_key.like(f'{prefix}%')
|
||||
)
|
||||
|
||||
result = await conn.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
keys = [row.state_key for row in rows[:limit]]
|
||||
has_more = len(rows) > limit
|
||||
|
||||
return keys, has_more
|
||||
|
||||
async def clear_all(self) -> None:
|
||||
"""Clear all state entries (for testing)."""
|
||||
async with self._db_engine.begin() as conn:
|
||||
await conn.execute(delete(AgentRunnerState))
|
||||
|
||||
|
||||
# Global singleton persistent state store
|
||||
_persistent_state_store: PersistentStateStore | None = None
|
||||
_persistent_state_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore:
|
||||
"""Get the global persistent state store singleton.
|
||||
|
||||
Args:
|
||||
db_engine: Database engine (required on first call)
|
||||
|
||||
Returns:
|
||||
PersistentStateStore singleton
|
||||
"""
|
||||
global _persistent_state_store
|
||||
with _persistent_state_store_lock:
|
||||
if _persistent_state_store is None:
|
||||
if db_engine is None:
|
||||
raise RuntimeError("db_engine required for first call to get_persistent_state_store")
|
||||
_persistent_state_store = PersistentStateStore(db_engine)
|
||||
return _persistent_state_store
|
||||
|
||||
|
||||
def reset_persistent_state_store() -> None:
|
||||
"""Reset the global persistent state store (for testing)."""
|
||||
global _persistent_state_store
|
||||
with _persistent_state_store_lock:
|
||||
_persistent_state_store = None
|
||||
56
src/langbot/pkg/agent/runner/query_bridge.py
Normal file
56
src/langbot/pkg/agent/runner/query_bridge.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Pipeline Query bridge for AgentRunner execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .config_migration import ConfigMigration
|
||||
from .errors import RunnerNotFoundError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class QueryRunPlan:
|
||||
"""Projected event-first execution request for a Query-backed run."""
|
||||
|
||||
event: AgentEventEnvelope
|
||||
binding: AgentBinding
|
||||
bound_plugins: list[str] | None
|
||||
adapter_context: dict[str, typing.Any]
|
||||
|
||||
|
||||
class QueryRunBridge:
|
||||
"""Project the current Pipeline Query entry point into Protocol v1 inputs."""
|
||||
|
||||
binding_resolver: AgentBindingResolver
|
||||
|
||||
def __init__(self, binding_resolver: AgentBindingResolver):
|
||||
self.binding_resolver = binding_resolver
|
||||
|
||||
def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan:
|
||||
"""Build an event-first run plan from a Pipeline Query."""
|
||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
if not runner_id:
|
||||
raise RunnerNotFoundError('no runner configured')
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
|
||||
binding = self.binding_resolver.resolve_one(event, [agent_config])
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins')
|
||||
adapter_context = QueryEntryAdapter.build_adapter_context(query, binding)
|
||||
|
||||
return QueryRunPlan(
|
||||
event=event,
|
||||
binding=binding,
|
||||
bound_plugins=bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
)
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
604
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal file
604
src/langbot/pkg/agent/runner/query_entry_adapter.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""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 not contents:
|
||||
message_chain = getattr(query, 'message_chain', None) or []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
component_text = getattr(component, 'text', '')
|
||||
if component_text:
|
||||
text_parts.append(component_text)
|
||||
contents.append({'type': 'text', 'text': component_text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
image_base64 = getattr(component, 'base64', None)
|
||||
image_url = getattr(component, 'url', None)
|
||||
if image_base64:
|
||||
contents.append({'type': 'image_base64', 'image_base64': image_base64})
|
||||
elif image_url:
|
||||
contents.append({'type': 'image_url', 'image_url': {'url': image_url}})
|
||||
|
||||
if text_parts:
|
||||
text = ''.join(text_parts)
|
||||
|
||||
attachments = cls._build_attachments(query, contents)
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_attachments(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
contents: list[dict[str, typing.Any]],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Extract attachments from query."""
|
||||
import uuid
|
||||
|
||||
attachments: list[dict[str, typing.Any]] = []
|
||||
|
||||
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]
|
||||
348
src/langbot/pkg/agent/runner/registry.py
Normal file
348
src/langbot/pkg/agent/runner/registry.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Agent runner registry for discovering and caching runner descriptors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
|
||||
AgentRunnerManifest,
|
||||
)
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .id import parse_runner_id, format_runner_id
|
||||
from .errors import RunnerNotFoundError, RunnerNotAuthorizedError
|
||||
|
||||
|
||||
class AgentRunnerRegistry:
|
||||
"""Registry for discovering and managing agent runners.
|
||||
|
||||
Responsibilities:
|
||||
- Discover runners from plugin runtime via LIST_AGENT_RUNNERS
|
||||
- Validate runner manifests (kind, metadata, spec)
|
||||
- Cache discovered runners for performance
|
||||
- Filter runners by bound plugins
|
||||
- Handle manifest errors gracefully (log warning, skip runner)
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
_cache: dict[str, AgentRunnerDescriptor] | None
|
||||
"""Cached runner descriptors keyed by runner ID"""
|
||||
|
||||
_cache_lock: asyncio.Lock
|
||||
"""Lock for cache refresh operations"""
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self._cache = None
|
||||
self._cache_lock = asyncio.Lock()
|
||||
|
||||
async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]:
|
||||
"""Discover runners from plugin runtime.
|
||||
|
||||
Always discovers ALL runners (no bound_plugins filter).
|
||||
The cache should contain unfiltered discovery results.
|
||||
|
||||
Returns:
|
||||
Dict of runner descriptors keyed by runner ID
|
||||
"""
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return {}
|
||||
|
||||
runners: dict[str, AgentRunnerDescriptor] = {}
|
||||
|
||||
try:
|
||||
# Always list all runners (bound_plugins=None)
|
||||
plugin_runners = await self.ap.plugin_connector.list_agent_runners(None)
|
||||
|
||||
for runner_data in plugin_runners:
|
||||
try:
|
||||
descriptor = self._validate_and_build_descriptor(runner_data)
|
||||
if descriptor is not None:
|
||||
runners[descriptor.id] = descriptor
|
||||
except Exception as e:
|
||||
plugin_author = runner_data.get('plugin_author', 'unknown')
|
||||
plugin_name = runner_data.get('plugin_name', 'unknown')
|
||||
runner_name = runner_data.get('runner_name', 'unknown')
|
||||
self.ap.logger.warning(
|
||||
f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}'
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}')
|
||||
return {}
|
||||
|
||||
return runners
|
||||
|
||||
def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None:
|
||||
"""Validate runner manifest and build descriptor.
|
||||
|
||||
Args:
|
||||
runner_data: Raw runner data from plugin runtime with fields:
|
||||
- plugin_author, plugin_name, runner_name
|
||||
- manifest (typed AgentRunnerManifest or legacy component manifest)
|
||||
- capabilities, permissions, config (extracted from spec)
|
||||
|
||||
Returns:
|
||||
AgentRunnerDescriptor if valid, None if invalid
|
||||
"""
|
||||
plugin_author = runner_data.get('plugin_author', '')
|
||||
plugin_name = runner_data.get('plugin_name', '')
|
||||
runner_name = runner_data.get('runner_name', '')
|
||||
|
||||
if not plugin_author or not plugin_name or not runner_name:
|
||||
return None
|
||||
|
||||
manifest = runner_data.get('manifest', {})
|
||||
runner_id = format_runner_id(
|
||||
source='plugin',
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
is_typed_manifest = self._looks_like_typed_manifest(manifest)
|
||||
if is_typed_manifest:
|
||||
typed_manifest = AgentRunnerManifest.model_validate(manifest)
|
||||
else:
|
||||
typed_manifest = self._build_typed_manifest_from_legacy_data(
|
||||
runner_id=runner_id,
|
||||
runner_name=runner_name,
|
||||
runner_data=runner_data,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
if runner_data.get('config'):
|
||||
config_schema = runner_data['config']
|
||||
elif not is_typed_manifest and isinstance(manifest.get('spec'), dict):
|
||||
config_schema = manifest['spec'].get('config', [])
|
||||
else:
|
||||
config_schema = [
|
||||
item.model_dump(mode='json') for item in typed_manifest.config_schema
|
||||
]
|
||||
|
||||
return AgentRunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label=typed_manifest.label,
|
||||
description=typed_manifest.description or runner_data.get('runner_description'),
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
runner_name=runner_name,
|
||||
plugin_version=runner_data.get('plugin_version'),
|
||||
config_schema=config_schema,
|
||||
capabilities=typed_manifest.capabilities,
|
||||
permissions=typed_manifest.permissions,
|
||||
raw_manifest=manifest,
|
||||
)
|
||||
|
||||
def _looks_like_typed_manifest(self, manifest: dict[str, typing.Any]) -> bool:
|
||||
"""Return whether manifest is the SDK typed AgentRunnerManifest shape."""
|
||||
return (
|
||||
isinstance(manifest, dict)
|
||||
and 'id' in manifest
|
||||
and 'name' in manifest
|
||||
and 'label' in manifest
|
||||
)
|
||||
|
||||
def _build_typed_manifest_from_legacy_data(
|
||||
self,
|
||||
*,
|
||||
runner_id: str,
|
||||
runner_name: str,
|
||||
runner_data: dict[str, typing.Any],
|
||||
manifest: dict[str, typing.Any],
|
||||
) -> AgentRunnerManifest:
|
||||
"""Validate legacy raw component manifest data as typed runner manifest."""
|
||||
|
||||
# Validate kind
|
||||
kind = manifest.get('kind', '')
|
||||
if kind != 'AgentRunner':
|
||||
raise ValueError(f'Invalid AgentRunner kind: {kind or "<missing>"}')
|
||||
|
||||
# Validate metadata
|
||||
metadata = manifest.get('metadata', {})
|
||||
name = metadata.get('name', '')
|
||||
if not name:
|
||||
raise ValueError('Missing AgentRunner metadata.name')
|
||||
|
||||
# metadata.label must exist
|
||||
label = metadata.get('label', {})
|
||||
if not label:
|
||||
label = {name: name} # fallback
|
||||
|
||||
spec = manifest.get('spec', {})
|
||||
|
||||
# SDK now provides these directly extracted from spec. Fall back to
|
||||
# manifest.spec for older runtimes/tests that return the raw manifest.
|
||||
config_schema = runner_data.get('config') or spec.get('config', [])
|
||||
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
|
||||
permissions = runner_data.get('permissions') or spec.get('permissions', {})
|
||||
|
||||
try:
|
||||
return AgentRunnerManifest(
|
||||
id=runner_id,
|
||||
name=runner_name,
|
||||
label=label,
|
||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
||||
capabilities=capabilities,
|
||||
permissions=permissions,
|
||||
config_schema=config_schema,
|
||||
)
|
||||
except pydantic.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError(f'Invalid AgentRunner manifest: {exc}') from exc
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""Refresh runner cache.
|
||||
|
||||
Always discovers ALL runners (no bound_plugins filter).
|
||||
The cache contains unfiltered discovery results.
|
||||
"""
|
||||
async with self._cache_lock:
|
||||
self._cache = await self._discover_runners()
|
||||
|
||||
async def list_runners(
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> list[AgentRunnerDescriptor]:
|
||||
"""List available runners.
|
||||
|
||||
Args:
|
||||
bound_plugins: Optional filter for bound plugins (applied locally)
|
||||
use_cache: Use cached data if available
|
||||
|
||||
Returns:
|
||||
List of runner descriptors
|
||||
"""
|
||||
if use_cache and self._cache is not None:
|
||||
# Filter from cache
|
||||
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
|
||||
|
||||
# Discover fresh (always full list)
|
||||
runners = await self._discover_runners()
|
||||
|
||||
# Update cache (full list, unfiltered)
|
||||
async with self._cache_lock:
|
||||
self._cache = runners
|
||||
|
||||
# Filter locally
|
||||
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
|
||||
|
||||
def _filter_runners_by_bound_plugins(
|
||||
self,
|
||||
runners: dict[str, AgentRunnerDescriptor],
|
||||
bound_plugins: list[str] | None,
|
||||
) -> list[AgentRunnerDescriptor]:
|
||||
"""Filter runners by bound plugins.
|
||||
|
||||
Args:
|
||||
runners: Dict of runner descriptors
|
||||
bound_plugins: Optional filter (None means all plugins allowed)
|
||||
|
||||
Returns:
|
||||
Filtered list of runner descriptors
|
||||
"""
|
||||
if bound_plugins is None:
|
||||
# All plugins allowed
|
||||
return list(runners.values())
|
||||
|
||||
allowed_plugin_ids = set(bound_plugins)
|
||||
filtered = []
|
||||
for descriptor in runners.values():
|
||||
plugin_id = descriptor.get_plugin_id()
|
||||
if plugin_id in allowed_plugin_ids:
|
||||
filtered.append(descriptor)
|
||||
|
||||
return filtered
|
||||
|
||||
async def get(
|
||||
self,
|
||||
runner_id: str,
|
||||
bound_plugins: list[str] | None = None,
|
||||
) -> AgentRunnerDescriptor:
|
||||
"""Get a specific runner descriptor.
|
||||
|
||||
Args:
|
||||
runner_id: Runner ID to lookup
|
||||
bound_plugins: Optional bound plugins filter
|
||||
|
||||
Returns:
|
||||
AgentRunnerDescriptor
|
||||
|
||||
Raises:
|
||||
RunnerNotFoundError: If runner not found
|
||||
RunnerNotAuthorizedError: If runner not in bound plugins
|
||||
"""
|
||||
# Parse and validate runner ID format
|
||||
try:
|
||||
parse_runner_id(runner_id)
|
||||
except ValueError as e:
|
||||
raise RunnerNotFoundError(runner_id) from e
|
||||
|
||||
# Get from cache or discover (always full list)
|
||||
if self._cache is None:
|
||||
await self.refresh()
|
||||
|
||||
if self._cache is None:
|
||||
raise RunnerNotFoundError(runner_id)
|
||||
|
||||
descriptor = self._cache.get(runner_id)
|
||||
if descriptor is None:
|
||||
raise RunnerNotFoundError(runner_id)
|
||||
|
||||
# Check authorization
|
||||
if bound_plugins is not None:
|
||||
plugin_id = descriptor.get_plugin_id()
|
||||
if plugin_id not in bound_plugins:
|
||||
raise RunnerNotAuthorizedError(runner_id, bound_plugins)
|
||||
|
||||
return descriptor
|
||||
|
||||
async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]:
|
||||
"""Get runner metadata for pipeline configuration UI.
|
||||
|
||||
Returns runner options and their config schemas for the DynamicForm.
|
||||
"""
|
||||
# Get all runners (no bound plugin filter for metadata listing)
|
||||
runners = await self.list_runners(bound_plugins=None)
|
||||
|
||||
options = []
|
||||
stages = []
|
||||
|
||||
for descriptor in runners:
|
||||
config_schema = []
|
||||
for index, config_item in enumerate(descriptor.config_schema):
|
||||
item = dict(config_item)
|
||||
if not item.get('id'):
|
||||
item_name = item.get('name') or str(index)
|
||||
item['id'] = f'{descriptor.id}.{item_name}'
|
||||
config_schema.append(item)
|
||||
|
||||
# Add runner option
|
||||
options.append(
|
||||
{
|
||||
'name': descriptor.id,
|
||||
'label': descriptor.label,
|
||||
'description': descriptor.description,
|
||||
}
|
||||
)
|
||||
|
||||
# Add config schema as stage if not empty
|
||||
if descriptor.config_schema:
|
||||
stages.append(
|
||||
{
|
||||
'name': descriptor.id,
|
||||
'label': descriptor.label,
|
||||
'description': descriptor.description,
|
||||
'config': config_schema,
|
||||
}
|
||||
)
|
||||
|
||||
return options, stages
|
||||
331
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
331
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Agent resource builder for constructing authorized resources."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .context_builder import (
|
||||
AgentResources,
|
||||
ModelResource,
|
||||
ToolResource,
|
||||
KnowledgeBaseResource,
|
||||
SkillResource,
|
||||
FileResource,
|
||||
StorageResource,
|
||||
)
|
||||
from . import config_schema
|
||||
from .host_models import AgentEventEnvelope, AgentBinding
|
||||
|
||||
|
||||
class AgentResourceBuilder:
|
||||
"""Builder for constructing run-scoped AgentResources with permission filtering.
|
||||
|
||||
Responsibilities:
|
||||
- Apply manifest permissions intersected with binding resource policy
|
||||
- Build models list from authorized models
|
||||
- Build tools list from bound plugins/MCP servers
|
||||
- Build knowledge_bases list from config
|
||||
- Build storage and files access summary
|
||||
|
||||
Note: This only builds the resource declaration. The actual proxy actions
|
||||
in handler.py must still validate against ctx.resources at runtime.
|
||||
|
||||
Resource field names match the plugin SDK payload:
|
||||
- ModelResource: model_id, model_type, provider
|
||||
- ToolResource: tool_name, tool_type, description
|
||||
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
||||
- SkillResource: skill_name, display_name, description
|
||||
- StorageResource: plugin_storage, workspace_storage
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def build_resources_from_binding(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> AgentResources:
|
||||
"""Build AgentResources from event and binding.
|
||||
|
||||
This is the main entry point for Protocol v1.
|
||||
|
||||
Args:
|
||||
event: Event envelope
|
||||
binding: Agent binding with resource policy
|
||||
descriptor: Runner descriptor with capabilities, permissions, and config schema
|
||||
|
||||
Returns:
|
||||
AgentResources dict with filtered resource lists
|
||||
"""
|
||||
resource_policy = binding.resource_policy
|
||||
runner_config = binding.runner_config
|
||||
manifest_perms = descriptor.permissions
|
||||
|
||||
# Build each resource category
|
||||
models = await self._build_models_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
tools = await self._build_tools_from_binding(
|
||||
manifest_perms, resource_policy, descriptor
|
||||
)
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
skills = self._build_skills_from_binding(
|
||||
resource_policy, descriptor
|
||||
)
|
||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||
files = self._build_files_from_binding(manifest_perms, descriptor, runner_config)
|
||||
|
||||
return {
|
||||
'models': models,
|
||||
'tools': tools,
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'skills': skills,
|
||||
'files': files,
|
||||
'storage': storage,
|
||||
'platform_capabilities': {}, # Reserved for EBA
|
||||
}
|
||||
|
||||
async def _build_models_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[ModelResource]:
|
||||
"""Build models list from binding."""
|
||||
models: list[ModelResource] = []
|
||||
seen_model_ids: set[str] = set()
|
||||
|
||||
model_perms = set(manifest_perms.models)
|
||||
include_llm = bool({'invoke', 'stream'} & model_perms)
|
||||
include_rerank = 'rerank' in model_perms
|
||||
llm_operations = [operation for operation in ('invoke', 'stream') if operation in model_perms]
|
||||
if not include_llm and not include_rerank:
|
||||
return models
|
||||
|
||||
# Get additional model UUID grants from resource policy.
|
||||
allowed_uuids = resource_policy.allowed_model_uuids
|
||||
|
||||
# Add model resources from Agent/runner config schema
|
||||
await self._append_config_declared_model_resources(
|
||||
models=models,
|
||||
seen_model_ids=seen_model_ids,
|
||||
descriptor=descriptor,
|
||||
runner_config=runner_config,
|
||||
include_llm=include_llm,
|
||||
include_rerank=include_rerank,
|
||||
llm_operations=llm_operations,
|
||||
)
|
||||
|
||||
# Add explicitly allowed models
|
||||
if allowed_uuids and include_llm:
|
||||
for model_uuid in allowed_uuids:
|
||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations)
|
||||
|
||||
return models
|
||||
|
||||
async def _build_tools_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[ToolResource]:
|
||||
"""Build tools list from binding."""
|
||||
tools: list[ToolResource] = []
|
||||
tool_perms = set(manifest_perms.tools)
|
||||
if not ({'detail', 'call'} & tool_perms):
|
||||
return tools
|
||||
|
||||
if not config_schema.uses_host_tools(descriptor):
|
||||
return tools
|
||||
|
||||
# Get tool names from resource policy
|
||||
allowed_names = resource_policy.allowed_tool_names
|
||||
tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms]
|
||||
|
||||
if allowed_names:
|
||||
for tool_name in allowed_names:
|
||||
tools.append({
|
||||
'tool_name': tool_name,
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'operations': tool_operations,
|
||||
})
|
||||
|
||||
return tools
|
||||
|
||||
async def _build_knowledge_bases_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[KnowledgeBaseResource]:
|
||||
"""Build knowledge bases list from binding."""
|
||||
kb_resources: list[KnowledgeBaseResource] = []
|
||||
kb_perms = set(manifest_perms.knowledge_bases)
|
||||
if not ({'list', 'retrieve'} & kb_perms):
|
||||
return kb_resources
|
||||
kb_operations = [operation for operation in ('list', 'retrieve') if operation in kb_perms]
|
||||
|
||||
if not config_schema.uses_host_knowledge_bases(descriptor):
|
||||
return kb_resources
|
||||
|
||||
# Get KB UUID grants from schema-defined config fields.
|
||||
kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
|
||||
|
||||
# Also include resource policy grants.
|
||||
allowed_uuids = resource_policy.allowed_kb_uuids
|
||||
if allowed_uuids:
|
||||
kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids]))
|
||||
|
||||
for kb_uuid in kb_uuids:
|
||||
try:
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
if kb:
|
||||
kb_resources.append({
|
||||
'kb_id': kb_uuid,
|
||||
'kb_name': kb.get_name(),
|
||||
'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None,
|
||||
'operations': kb_operations,
|
||||
})
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
|
||||
|
||||
return kb_resources
|
||||
|
||||
def _build_skills_from_binding(
|
||||
self,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[SkillResource]:
|
||||
"""Build pipeline-visible skill resource facts."""
|
||||
if not config_schema.supports_skill_authoring(descriptor):
|
||||
return []
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
loaded_skills = getattr(skill_mgr, 'skills', {}) or {}
|
||||
allowed_names = resource_policy.allowed_skill_names
|
||||
if allowed_names is None:
|
||||
names = sorted(loaded_skills.keys())
|
||||
else:
|
||||
names = sorted(name for name in allowed_names if name in loaded_skills)
|
||||
|
||||
skills: list[SkillResource] = []
|
||||
for skill_name in names:
|
||||
skill_data = loaded_skills.get(skill_name) or {}
|
||||
skills.append({
|
||||
'skill_name': skill_name,
|
||||
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||
'description': skill_data.get('description') or None,
|
||||
})
|
||||
return skills
|
||||
|
||||
def _build_files_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
) -> list[FileResource]:
|
||||
"""Build config/knowledge file resources selected in runner config."""
|
||||
file_perms = set(manifest_perms.files)
|
||||
operations = [operation for operation in ('config', 'knowledge') if operation in file_perms]
|
||||
if not operations:
|
||||
return []
|
||||
|
||||
files: list[FileResource] = []
|
||||
if 'config' in file_perms:
|
||||
for file_resource in config_schema.extract_config_file_resources(descriptor, runner_config):
|
||||
files.append({
|
||||
**file_resource,
|
||||
'operations': ['config'],
|
||||
})
|
||||
return files
|
||||
|
||||
def _build_storage_from_binding(
|
||||
self,
|
||||
manifest_perms: typing.Any,
|
||||
binding: AgentBinding,
|
||||
) -> StorageResource:
|
||||
"""Build storage access summary from manifest and binding policy."""
|
||||
resource_policy = binding.resource_policy
|
||||
storage_perms = set(manifest_perms.storage)
|
||||
|
||||
return {
|
||||
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,
|
||||
'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage,
|
||||
}
|
||||
|
||||
async def _append_config_declared_model_resources(
|
||||
self,
|
||||
models: list[ModelResource],
|
||||
seen_model_ids: set[str],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
runner_config: dict[str, typing.Any],
|
||||
include_llm: bool,
|
||||
include_rerank: bool,
|
||||
llm_operations: list[str],
|
||||
) -> None:
|
||||
"""Authorize model-like values selected through DynamicForm fields."""
|
||||
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
|
||||
if model_type == 'llm' and include_llm:
|
||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations)
|
||||
elif model_type == 'rerank' and include_rerank:
|
||||
await self._append_rerank_model_resource(models, seen_model_ids, model_uuid)
|
||||
|
||||
async def _append_llm_model_resource(
|
||||
self,
|
||||
models: list[ModelResource],
|
||||
seen_model_ids: set[str],
|
||||
model_uuid: str | None,
|
||||
operations: list[str],
|
||||
) -> None:
|
||||
"""Append an LLM model resource if it exists and has not been added."""
|
||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
if model and model.model_entity:
|
||||
models.append({
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', None),
|
||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
||||
'operations': operations,
|
||||
})
|
||||
seen_model_ids.add(model_uuid)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}')
|
||||
|
||||
async def _append_rerank_model_resource(
|
||||
self,
|
||||
models: list[ModelResource],
|
||||
seen_model_ids: set[str],
|
||||
model_uuid: str | None,
|
||||
) -> None:
|
||||
"""Append a rerank model resource if it exists and has not been added."""
|
||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid)
|
||||
if model and model.model_entity:
|
||||
models.append({
|
||||
'model_id': model_uuid,
|
||||
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
|
||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
||||
'operations': ['rerank'],
|
||||
})
|
||||
seen_model_ids.add(model_uuid)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')
|
||||
241
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
241
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
ActionRequestedPayload,
|
||||
ArtifactCreatedPayload,
|
||||
MessageCompletedPayload,
|
||||
MessageDeltaPayload,
|
||||
RunCompletedPayload,
|
||||
RunFailedPayload,
|
||||
StateUpdatedPayload,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .errors import RunnerExecutionError, RunnerProtocolError
|
||||
|
||||
|
||||
# Maximum size for a single result payload (prevent memory exhaustion)
|
||||
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
||||
|
||||
STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
|
||||
'message.delta': MessageDeltaPayload,
|
||||
'message.completed': MessageCompletedPayload,
|
||||
'state.updated': StateUpdatedPayload,
|
||||
'artifact.created': ArtifactCreatedPayload,
|
||||
'action.requested': ActionRequestedPayload,
|
||||
'run.completed': RunCompletedPayload,
|
||||
'run.failed': RunFailedPayload,
|
||||
}
|
||||
|
||||
|
||||
class AgentResultNormalizer:
|
||||
"""Normalizer for converting AgentRunResult to Pipeline messages.
|
||||
|
||||
Responsibilities:
|
||||
- Accept only supported result types (message.delta, message.completed, etc.)
|
||||
- Map message.delta -> MessageChunk
|
||||
- Map message.completed -> Message
|
||||
- Map run.completed (with message) -> Message
|
||||
- Handle run.failed as controlled error
|
||||
- Ignore unknown types with warning
|
||||
- Validate result size
|
||||
- Validate message schema
|
||||
|
||||
Accepted result types:
|
||||
- message.delta
|
||||
- message.completed
|
||||
- tool.call.started
|
||||
- tool.call.completed
|
||||
- state.updated
|
||||
- run.completed
|
||||
- run.failed
|
||||
- action.requested (log only, don't execute)
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def normalize(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> provider_message.Message | provider_message.MessageChunk | None:
|
||||
"""Normalize AgentRunResult to Message or MessageChunk.
|
||||
|
||||
Args:
|
||||
result_dict: Raw result dict from plugin runtime
|
||||
descriptor: Runner descriptor for error context
|
||||
|
||||
Returns:
|
||||
Message, MessageChunk, or None (for non-message events)
|
||||
|
||||
Raises:
|
||||
RunnerExecutionError: On run.failed
|
||||
RunnerProtocolError: On invalid result format
|
||||
"""
|
||||
# Validate result type
|
||||
result_type = result_dict.get('type')
|
||||
if not result_type:
|
||||
raise RunnerProtocolError(descriptor.id, 'Missing result type')
|
||||
|
||||
# Validate result size
|
||||
try:
|
||||
import json
|
||||
result_json = json.dumps(result_dict)
|
||||
if len(result_json) > MAX_RESULT_SIZE_BYTES:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating'
|
||||
)
|
||||
# Truncate content if possible
|
||||
data = result_dict.get('data', {})
|
||||
if 'chunk' in data or 'message' in data:
|
||||
content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '')
|
||||
if isinstance(content, str) and len(content) > 10000:
|
||||
# Keep reasonable length
|
||||
data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'}
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}')
|
||||
|
||||
# Handle each result type
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
if not self.validate_payload(result_type, data, descriptor):
|
||||
return None
|
||||
|
||||
if result_type == 'message.delta':
|
||||
return self._normalize_message_delta(data, descriptor)
|
||||
|
||||
elif result_type == 'message.completed':
|
||||
return self._normalize_message_completed(data, descriptor)
|
||||
|
||||
elif result_type == 'tool.call.started':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
return None
|
||||
|
||||
elif result_type == 'tool.call.completed':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
return None
|
||||
|
||||
elif result_type == 'state.updated':
|
||||
# Log for telemetry, don't yield to pipeline
|
||||
# Orchestrator already handles the actual PersistentStateStore update.
|
||||
scope = data.get('scope', 'unknown')
|
||||
key = data.get('key', 'unknown')
|
||||
value_repr = repr(data.get('value', '...'))[:100] # Truncate for log
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}'
|
||||
)
|
||||
return None
|
||||
|
||||
elif result_type == 'run.completed':
|
||||
# May include final message
|
||||
if 'message' in data:
|
||||
return self._normalize_message_completed(data, descriptor)
|
||||
# If no message, it's just completion signal
|
||||
return None
|
||||
|
||||
elif result_type == 'run.failed':
|
||||
error_msg = data.get('error', 'Unknown error')
|
||||
error_code = data.get('code', 'unknown')
|
||||
retryable = data.get('retryable', False)
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
f'{error_msg} (code: {error_code})',
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
elif result_type == 'action.requested':
|
||||
# Reserved for EBA - log only, don't execute
|
||||
self.ap.logger.info(
|
||||
f'Runner {descriptor.id} requested action (not executed in current phase): '
|
||||
f'{data.get("action", "unknown")}'
|
||||
)
|
||||
return None
|
||||
|
||||
elif result_type == 'artifact.created':
|
||||
# Log for telemetry, consumed by orchestrator
|
||||
artifact_id = data.get('artifact_id', 'unknown')
|
||||
artifact_type = data.get('artifact_type', 'unknown')
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} artifact.created logged: artifact_id={artifact_id}, type={artifact_type}'
|
||||
)
|
||||
return None
|
||||
|
||||
else:
|
||||
# Unknown type - warn and ignore.
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned unknown result type: {result_type}. '
|
||||
f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)'
|
||||
)
|
||||
return None
|
||||
|
||||
def validate_payload(
|
||||
self,
|
||||
result_type: str,
|
||||
data: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> bool:
|
||||
"""Validate typed payloads that affect Host state or delivery.
|
||||
|
||||
Tool-call telemetry stays intentionally loose so older runners can keep
|
||||
emitting diagnostic fields. Unknown result types are handled by the
|
||||
caller and are not validated here.
|
||||
"""
|
||||
payload_model = STRICT_RESULT_PAYLOADS.get(result_type)
|
||||
if payload_model is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
payload_model.model_validate(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}'
|
||||
)
|
||||
return False
|
||||
|
||||
def _normalize_message_delta(
|
||||
self,
|
||||
data: dict[str, typing.Any],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> provider_message.MessageChunk:
|
||||
"""Normalize message.delta to MessageChunk."""
|
||||
chunk_data = data.get('chunk', {})
|
||||
if not chunk_data:
|
||||
raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data')
|
||||
|
||||
try:
|
||||
chunk = provider_message.MessageChunk.model_validate(chunk_data)
|
||||
return chunk
|
||||
except Exception as e:
|
||||
raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}')
|
||||
|
||||
def _normalize_message_completed(
|
||||
self,
|
||||
data: dict[str, typing.Any],
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> provider_message.Message:
|
||||
"""Normalize message.completed to Message."""
|
||||
message_data = data.get('message', {})
|
||||
if not message_data:
|
||||
raise RunnerProtocolError(descriptor.id, 'message.completed missing message data')
|
||||
|
||||
try:
|
||||
msg = provider_message.Message.model_validate(message_data)
|
||||
return msg
|
||||
except Exception as e:
|
||||
raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}')
|
||||
564
src/langbot/pkg/agent/runner/run_journal.py
Normal file
564
src/langbot/pkg/agent/runner/run_journal.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""Run-side effects for AgentRunner executions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .errors import RunnerProtocolError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .persistent_state_store import PersistentStateStore, get_persistent_state_store
|
||||
|
||||
|
||||
# Maximum inline artifact content size (1MB)
|
||||
MAX_ARTIFACT_INLINE_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
class AgentRunJournal:
|
||||
"""Persist run events, transcript records, artifacts, and state updates."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
_persistent_state_store: PersistentStateStore | None
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self._persistent_state_store = None
|
||||
|
||||
@staticmethod
|
||||
def _to_plain_dict(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if hasattr(value, 'model_dump'):
|
||||
value = value.model_dump(mode='json')
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _sanitize_content_item(cls, value: typing.Any) -> typing.Any:
|
||||
item = cls._to_plain_dict(value)
|
||||
if not item:
|
||||
return value
|
||||
item_type = item.get('type')
|
||||
if item_type == 'image_base64' and item.get('image_base64'):
|
||||
item['image_base64'] = None
|
||||
item['content_redacted'] = True
|
||||
elif item_type == 'file_base64' and item.get('file_base64'):
|
||||
item['file_base64'] = None
|
||||
item['content_redacted'] = True
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _sanitize_attachment_ref(cls, value: typing.Any) -> dict[str, typing.Any]:
|
||||
item = cls._to_plain_dict(value)
|
||||
if item.get('content'):
|
||||
item['content'] = None
|
||||
item['content_redacted'] = True
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _sanitize_contents(cls, contents: typing.Iterable[typing.Any]) -> list[typing.Any]:
|
||||
return [cls._sanitize_content_item(content) for content in contents]
|
||||
|
||||
@classmethod
|
||||
def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]:
|
||||
return [cls._sanitize_attachment_ref(attachment) for attachment in attachments]
|
||||
|
||||
async def handle_state_updated_event(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Handle state.updated result in event-first mode."""
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
result_run_id = result_dict.get('run_id')
|
||||
if run_id and result_run_id and result_run_id != run_id:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
f'state.updated run_id mismatch: expected {run_id}, got {result_run_id}',
|
||||
)
|
||||
|
||||
scope = data.get('scope')
|
||||
if not scope:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
'state.updated missing required field: scope',
|
||||
)
|
||||
|
||||
key = data.get('key')
|
||||
value = data.get('value')
|
||||
|
||||
if not key:
|
||||
raise RunnerProtocolError(
|
||||
descriptor.id,
|
||||
'state.updated missing required field: key',
|
||||
)
|
||||
|
||||
if self._persistent_state_store is None:
|
||||
self._persistent_state_store = get_persistent_state_store(
|
||||
self.ap.persistence_mgr.get_db_engine()
|
||||
)
|
||||
|
||||
success, error = await self._persistent_state_store.apply_update_from_event(
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
scope=scope,
|
||||
key=key,
|
||||
value=value,
|
||||
logger=self.ap.logger,
|
||||
)
|
||||
|
||||
if success:
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}'
|
||||
)
|
||||
elif error:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} state.updated rejected: {error}'
|
||||
)
|
||||
|
||||
async def write_event_log(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
) -> str:
|
||||
"""Write incoming event to EventLog."""
|
||||
import datetime
|
||||
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
input_summary = None
|
||||
input_json = None
|
||||
if event.input:
|
||||
if event.input.text:
|
||||
input_summary = event.input.text[:1000]
|
||||
input_json = {
|
||||
'text': event.input.text,
|
||||
'contents': self._sanitize_contents(event.input.contents),
|
||||
'attachments': self._sanitize_attachments(event.input.attachments),
|
||||
}
|
||||
|
||||
return await store.append_event(
|
||||
event_id=event.event_id,
|
||||
event_type=event.event_type,
|
||||
source=event.source,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
conversation_id=event.conversation_id,
|
||||
thread_id=event.thread_id,
|
||||
actor_type=event.actor.actor_type if event.actor else None,
|
||||
actor_id=event.actor.actor_id if event.actor else None,
|
||||
actor_name=event.actor.actor_name if event.actor else None,
|
||||
subject_type=event.subject.subject_type if event.subject else None,
|
||||
subject_id=event.subject.subject_id if event.subject else None,
|
||||
input_summary=input_summary,
|
||||
input_json=input_json,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
event_time=datetime.datetime.fromtimestamp(event.event_time) if event.event_time else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def register_input_artifacts(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> None:
|
||||
"""Register current-event attachments referenced by AgentInput."""
|
||||
if not event.input or not event.input.attachments:
|
||||
return
|
||||
|
||||
from .artifact_store import ArtifactStore
|
||||
|
||||
store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
for attachment in event.input.attachments:
|
||||
data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
artifact_id = data.get('artifact_id')
|
||||
artifact_type = data.get('artifact_type') or 'file'
|
||||
if not artifact_id:
|
||||
continue
|
||||
|
||||
content, parsed_mime_type = self.decode_attachment_content(data.get('content'))
|
||||
url = data.get('url')
|
||||
platform_ref_id = data.get('id')
|
||||
storage_key = None
|
||||
storage_type = 'metadata_only'
|
||||
if content is None:
|
||||
if url:
|
||||
storage_key = url
|
||||
storage_type = 'url'
|
||||
elif platform_ref_id:
|
||||
storage_key = platform_ref_id
|
||||
storage_type = 'platform_ref'
|
||||
|
||||
metadata = {
|
||||
'input_attachment': True,
|
||||
'input_source': data.get('source') or 'platform',
|
||||
}
|
||||
if url:
|
||||
metadata['url'] = url
|
||||
if platform_ref_id:
|
||||
metadata['platform_ref_id'] = platform_ref_id
|
||||
|
||||
try:
|
||||
await store.register_artifact(
|
||||
artifact_id=artifact_id,
|
||||
artifact_type=artifact_type,
|
||||
source='platform',
|
||||
storage_key=storage_key,
|
||||
storage_type=storage_type,
|
||||
mime_type=data.get('mime_type') or parsed_mime_type,
|
||||
name=data.get('name'),
|
||||
size_bytes=data.get('size') or (len(content) if content is not None else None),
|
||||
conversation_id=event.conversation_id,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
metadata=metadata,
|
||||
content=content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to register input artifact {artifact_id}: {e}'
|
||||
)
|
||||
|
||||
def decode_attachment_content(
|
||||
self,
|
||||
content: typing.Any,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Decode base64 attachment content, including data URLs."""
|
||||
if not isinstance(content, str) or not content:
|
||||
return None, None
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
mime_type = None
|
||||
payload = content
|
||||
if content.startswith('data:') and ',' in content:
|
||||
header, payload = content.split(',', 1)
|
||||
if ';base64' in header:
|
||||
mime_type = header[5:].split(';', 1)[0] or None
|
||||
|
||||
try:
|
||||
return base64.b64decode(payload, validate=False), mime_type
|
||||
except (binascii.Error, ValueError):
|
||||
return None, mime_type
|
||||
|
||||
async def write_user_transcript(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
event_log_id: str,
|
||||
) -> None:
|
||||
"""Write user message to Transcript."""
|
||||
from .transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
content = event.input.text if event.input else None
|
||||
content_json = None
|
||||
if event.input:
|
||||
content_json = {
|
||||
'role': 'user',
|
||||
'content': self._sanitize_contents(event.input.contents) if event.input.contents else [],
|
||||
}
|
||||
|
||||
artifact_refs = []
|
||||
if event.input and event.input.attachments:
|
||||
for a in event.input.attachments:
|
||||
artifact_refs.append(self._sanitize_attachment_ref(a))
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id=None,
|
||||
event_id=event_log_id,
|
||||
conversation_id=event.conversation_id,
|
||||
role='user',
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
content=content,
|
||||
content_json=content_json,
|
||||
artifact_refs=artifact_refs if artifact_refs else None,
|
||||
thread_id=event.thread_id,
|
||||
item_type='message',
|
||||
metadata={
|
||||
'actor_type': event.actor.actor_type if event.actor else None,
|
||||
'actor_id': event.actor.actor_id if event.actor else None,
|
||||
},
|
||||
)
|
||||
|
||||
async def handle_artifact_created(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Handle artifact.created result, register artifact, and write EventLog."""
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
from .artifact_store import ArtifactStore
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
data = result_dict.get('data', {})
|
||||
|
||||
result_run_id = result_dict.get('run_id')
|
||||
if result_run_id and result_run_id != run_id:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
f'artifact.created run_id mismatch: expected {run_id}, got {result_run_id}',
|
||||
)
|
||||
|
||||
artifact_id = data.get('artifact_id') or str(uuid.uuid4())
|
||||
artifact_type = data.get('artifact_type')
|
||||
if not artifact_type:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
'artifact.created missing required field: artifact_type',
|
||||
)
|
||||
|
||||
mime_type = data.get('mime_type')
|
||||
name = data.get('name')
|
||||
size_bytes = data.get('size_bytes')
|
||||
sha256 = data.get('sha256')
|
||||
metadata = data.get('metadata')
|
||||
content_base64 = data.get('content_base64')
|
||||
|
||||
content: bytes | None = None
|
||||
if content_base64:
|
||||
try:
|
||||
content = base64.b64decode(content_base64, validate=True)
|
||||
except Exception as e:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
f'artifact.created invalid base64 content: {e}',
|
||||
)
|
||||
|
||||
if len(content) > MAX_ARTIFACT_INLINE_BYTES:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
f'artifact.created content size {len(content)} bytes exceeds limit {MAX_ARTIFACT_INLINE_BYTES} bytes',
|
||||
)
|
||||
|
||||
artifact_store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
|
||||
try:
|
||||
registered_id = await artifact_store.register_artifact(
|
||||
artifact_id=artifact_id,
|
||||
artifact_type=artifact_type,
|
||||
source='runner',
|
||||
mime_type=mime_type,
|
||||
name=name,
|
||||
size_bytes=size_bytes,
|
||||
sha256=sha256,
|
||||
conversation_id=event.conversation_id,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
thread_id=event.thread_id,
|
||||
metadata=metadata,
|
||||
content=content,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RunnerProtocolError(
|
||||
runner_id,
|
||||
f'artifact.created failed to register artifact: {e}',
|
||||
)
|
||||
|
||||
event_log_store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
await event_log_store.append_event(
|
||||
event_id=str(uuid.uuid4()),
|
||||
event_type='artifact.created',
|
||||
source='runner',
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
conversation_id=event.conversation_id,
|
||||
thread_id=event.thread_id,
|
||||
actor_type=event.actor.actor_type if event.actor else None,
|
||||
actor_id=event.actor.actor_id if event.actor else None,
|
||||
actor_name=event.actor.actor_name if event.actor else None,
|
||||
input_summary=f'Artifact created: {artifact_type}',
|
||||
input_json={
|
||||
'artifact_id': registered_id,
|
||||
'artifact_type': artifact_type,
|
||||
'mime_type': mime_type,
|
||||
'name': name,
|
||||
'size_bytes': size_bytes,
|
||||
},
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
)
|
||||
|
||||
return {
|
||||
'artifact_id': registered_id,
|
||||
'artifact_type': artifact_type,
|
||||
'mime_type': mime_type,
|
||||
'name': name,
|
||||
}
|
||||
|
||||
def merge_artifact_refs(
|
||||
self,
|
||||
pending_refs: list[dict[str, typing.Any]],
|
||||
result_dict: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Merge pending artifact refs with a message's own refs."""
|
||||
merged = list(pending_refs)
|
||||
seen_ids = {ref.get('artifact_id') for ref in pending_refs if ref.get('artifact_id')}
|
||||
|
||||
data = result_dict.get('data', {})
|
||||
message = data.get('message', {})
|
||||
message_refs = message.get('artifact_refs', [])
|
||||
|
||||
if isinstance(message_refs, list):
|
||||
for ref in message_refs:
|
||||
if isinstance(ref, dict):
|
||||
artifact_id = ref.get('artifact_id')
|
||||
if artifact_id and artifact_id not in seen_ids:
|
||||
merged.append(ref)
|
||||
seen_ids.add(artifact_id)
|
||||
|
||||
return merged
|
||||
|
||||
async def write_steering_dropped_audits(
|
||||
self,
|
||||
items: list[dict[str, typing.Any]],
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
*,
|
||||
reason: str = 'run_ended',
|
||||
) -> None:
|
||||
"""Write terminal audit events for steering items left unconsumed."""
|
||||
if not items:
|
||||
return
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from .event_log_store import EventLogStore
|
||||
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
for item in items:
|
||||
event = item.get('event') if isinstance(item.get('event'), dict) else {}
|
||||
input_data = item.get('input') if isinstance(item.get('input'), dict) else {}
|
||||
conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {}
|
||||
actor = item.get('actor') if isinstance(item.get('actor'), dict) else {}
|
||||
subject = item.get('subject') if isinstance(item.get('subject'), dict) else {}
|
||||
|
||||
text = input_data.get('text')
|
||||
input_summary = text[:1000] if isinstance(text, str) and text else 'Unconsumed steering input dropped'
|
||||
event_time = None
|
||||
raw_event_time = event.get('event_time')
|
||||
if raw_event_time:
|
||||
try:
|
||||
event_time = datetime.datetime.fromtimestamp(raw_event_time)
|
||||
except (TypeError, ValueError, OSError):
|
||||
event_time = None
|
||||
|
||||
await store.append_event(
|
||||
event_id=str(uuid.uuid4()),
|
||||
event_type='steering.dropped',
|
||||
source='host',
|
||||
bot_id=conversation.get('bot_id'),
|
||||
workspace_id=conversation.get('workspace_id'),
|
||||
conversation_id=conversation.get('conversation_id'),
|
||||
thread_id=conversation.get('thread_id'),
|
||||
actor_type=actor.get('actor_type'),
|
||||
actor_id=actor.get('actor_id'),
|
||||
actor_name=actor.get('actor_name'),
|
||||
subject_type=subject.get('subject_type'),
|
||||
subject_id=subject.get('subject_id'),
|
||||
input_summary=input_summary,
|
||||
input_json={
|
||||
'text': text,
|
||||
'contents': self._sanitize_contents(input_data.get('contents') or []),
|
||||
'attachments': self._sanitize_attachments(input_data.get('attachments') or []),
|
||||
},
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
event_time=event_time,
|
||||
metadata={
|
||||
'steering': {
|
||||
'status': 'dropped',
|
||||
'reason': reason,
|
||||
'original_event_id': event.get('event_id'),
|
||||
'claimed_run_id': item.get('claimed_run_id'),
|
||||
'claimed_runner_id': item.get('runner_id'),
|
||||
'claimed_at': item.get('claimed_at'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def write_assistant_transcript(
|
||||
self,
|
||||
result_dict: dict[str, typing.Any],
|
||||
event: AgentEventEnvelope,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
||||
) -> None:
|
||||
"""Write assistant message to Transcript."""
|
||||
import uuid
|
||||
|
||||
from .transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
data = result_dict.get('data', {})
|
||||
message = data.get('message', {})
|
||||
|
||||
content = None
|
||||
content_json = None
|
||||
|
||||
if isinstance(message.get('content'), str):
|
||||
content = message['content']
|
||||
content_json = message
|
||||
elif isinstance(message.get('content'), list):
|
||||
text_parts = []
|
||||
for c in message['content']:
|
||||
if isinstance(c, dict) and c.get('type') == 'text':
|
||||
text_parts.append(c.get('text', ''))
|
||||
content = ' '.join(text_parts) if text_parts else None
|
||||
content_json = {
|
||||
**message,
|
||||
'content': self._sanitize_contents(message['content']),
|
||||
}
|
||||
|
||||
assistant_event_id = str(uuid.uuid4())
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id=str(uuid.uuid4()),
|
||||
event_id=assistant_event_id,
|
||||
conversation_id=event.conversation_id,
|
||||
role='assistant',
|
||||
bot_id=event.bot_id,
|
||||
workspace_id=event.workspace_id,
|
||||
content=content,
|
||||
content_json=content_json,
|
||||
artifact_refs=artifact_refs,
|
||||
thread_id=event.thread_id,
|
||||
item_type='message',
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
metadata={
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
},
|
||||
)
|
||||
428
src/langbot/pkg/agent/runner/session_registry.py
Normal file
428
src/langbot/pkg/agent/runner/session_registry.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""Agent run session registry for proxy action permission validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import typing
|
||||
import time
|
||||
import threading
|
||||
|
||||
from .context_builder import AgentResources
|
||||
|
||||
|
||||
MAX_STEERING_QUEUE_ITEMS = 100
|
||||
|
||||
DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
|
||||
'model': {'invoke', 'stream', 'rerank'},
|
||||
'tool': {'detail', 'call'},
|
||||
'knowledge_base': {'list', 'retrieve'},
|
||||
'file': {'config', 'knowledge'},
|
||||
'skill': {'activate'},
|
||||
}
|
||||
|
||||
|
||||
class AgentRunSessionStatus(typing.TypedDict):
|
||||
"""Status tracking for agent run session."""
|
||||
started_at: int
|
||||
last_activity_at: int
|
||||
|
||||
|
||||
class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
"""Frozen authorization data for one active run.
|
||||
|
||||
ResourceBuilder creates the authorized resource list once before runner
|
||||
execution. Runtime proxy handlers must validate against this run-scoped
|
||||
snapshot instead of recomputing resource policy.
|
||||
"""
|
||||
|
||||
resources: AgentResources
|
||||
available_apis: dict[str, bool]
|
||||
conversation_id: str | None
|
||||
bot_id: str | None
|
||||
workspace_id: str | None
|
||||
thread_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
authorized_ids: dict[str, set[str]]
|
||||
authorized_operations: dict[str, dict[str, set[str]]]
|
||||
|
||||
|
||||
SteeringQueueItem = dict[str, typing.Any]
|
||||
|
||||
|
||||
class AgentRunSession(typing.TypedDict):
|
||||
"""Session for an active agent runner execution.
|
||||
|
||||
Stored in AgentRunSessionRegistry for proxy action permission validation.
|
||||
|
||||
Fields:
|
||||
run_id: Unique run identifier (UUID from AgentRunContext)
|
||||
runner_id: Runner descriptor ID (plugin:author/name/runner)
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
plugin_identity: Plugin identifier (author/name) of the runner
|
||||
authorization: Run-scoped authorization snapshot; runtime auth truth
|
||||
status: Session status tracking
|
||||
"""
|
||||
run_id: str
|
||||
runner_id: str
|
||||
query_id: int | None
|
||||
plugin_identity: str # author/name
|
||||
authorization: RunAuthorizationSnapshot
|
||||
status: AgentRunSessionStatus
|
||||
steering_queue: list[SteeringQueueItem]
|
||||
|
||||
|
||||
class AgentRunSessionRegistry:
|
||||
"""Registry for active agent run sessions.
|
||||
|
||||
Host-owned registry for tracking active AgentRunner executions.
|
||||
Used by proxy actions in handler.py to validate resource access.
|
||||
|
||||
Key: run_id (UUID from AgentRunContext)
|
||||
Value: AgentRunSession with authorized resources
|
||||
|
||||
Thread-safe via asyncio.Lock.
|
||||
"""
|
||||
|
||||
_sessions: dict[str, AgentRunSession]
|
||||
_lock: asyncio.Lock
|
||||
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(
|
||||
self,
|
||||
run_id: str,
|
||||
runner_id: str,
|
||||
query_id: int | None,
|
||||
plugin_identity: str,
|
||||
resources: AgentResources,
|
||||
conversation_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
available_apis: dict[str, bool] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Register a new agent run session.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
runner_id: Runner descriptor ID
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
plugin_identity: Plugin identifier (author/name)
|
||||
resources: Authorized resources for this run
|
||||
conversation_id: Conversation ID for history/event access
|
||||
bot_id: Bot UUID for history/event access
|
||||
workspace_id: Workspace ID for history/event access
|
||||
thread_id: Thread ID for history/event access
|
||||
available_apis: Run-scoped pull APIs exposed in AgentRunContext
|
||||
state_policy: State policy from binding (enable_state, state_scopes)
|
||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
available_apis = copy.deepcopy(available_apis or {})
|
||||
|
||||
# Normalize state_policy to defaults if None
|
||||
if state_policy is None:
|
||||
state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
||||
|
||||
# Normalize state_context to empty dict if None
|
||||
state_context = state_context or {}
|
||||
|
||||
resources_snapshot = copy.deepcopy(resources)
|
||||
authorization: RunAuthorizationSnapshot = {
|
||||
'resources': resources_snapshot,
|
||||
'available_apis': available_apis,
|
||||
'conversation_id': conversation_id,
|
||||
'bot_id': bot_id,
|
||||
'workspace_id': workspace_id,
|
||||
'thread_id': thread_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
||||
}
|
||||
|
||||
session: AgentRunSession = {
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'plugin_identity': plugin_identity,
|
||||
'authorization': authorization,
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'steering_queue': [],
|
||||
}
|
||||
|
||||
async with self._lock:
|
||||
self._sessions[run_id] = session
|
||||
|
||||
def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]:
|
||||
"""Pre-compute authorized resource IDs for O(1) lookup."""
|
||||
return {
|
||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
||||
'skill': {s.get('skill_name') for s in resources.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||
}
|
||||
|
||||
def _build_authorized_operations(
|
||||
self,
|
||||
resources: AgentResources,
|
||||
) -> dict[str, dict[str, set[str]]]:
|
||||
"""Pre-compute resource operations for runtime action validation."""
|
||||
return {
|
||||
'model': {
|
||||
m.get('model_id'): self._resource_operations('model', m)
|
||||
for m in resources.get('models', [])
|
||||
if m.get('model_id')
|
||||
},
|
||||
'tool': {
|
||||
t.get('tool_name'): self._resource_operations('tool', t)
|
||||
for t in resources.get('tools', [])
|
||||
if t.get('tool_name')
|
||||
},
|
||||
'knowledge_base': {
|
||||
kb.get('kb_id'): self._resource_operations('knowledge_base', kb)
|
||||
for kb in resources.get('knowledge_bases', [])
|
||||
if kb.get('kb_id')
|
||||
},
|
||||
'skill': {
|
||||
s.get('skill_name'): self._resource_operations('skill', s)
|
||||
for s in resources.get('skills', [])
|
||||
if s.get('skill_name')
|
||||
},
|
||||
'file': {
|
||||
f.get('file_id'): self._resource_operations('file', f)
|
||||
for f in resources.get('files', [])
|
||||
if f.get('file_id')
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resource_operations(resource_type: str, resource: dict[str, typing.Any]) -> set[str]:
|
||||
"""Return explicit operations or the compatibility default for old resources."""
|
||||
operations = resource.get('operations')
|
||||
if isinstance(operations, list) and operations:
|
||||
return {str(operation) for operation in operations}
|
||||
return set(DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set()))
|
||||
|
||||
async def unregister(self, run_id: str) -> AgentRunSession | None:
|
||||
"""Unregister an agent run session.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
|
||||
Returns:
|
||||
The removed session, if one existed. Callers can inspect any
|
||||
pending in-memory queues before they are discarded.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._sessions.pop(run_id, None)
|
||||
|
||||
async def get(self, run_id: str) -> AgentRunSession | None:
|
||||
"""Get session by run_id.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
|
||||
Returns:
|
||||
AgentRunSession if found, None otherwise
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._sessions.get(run_id)
|
||||
|
||||
async def update_activity(self, run_id: str) -> None:
|
||||
"""Update last activity timestamp for session.
|
||||
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
"""
|
||||
async with self._lock:
|
||||
if run_id in self._sessions:
|
||||
self._sessions[run_id]['status']['last_activity_at'] = int(time.time())
|
||||
|
||||
async def find_steering_target(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
runner_id: str,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Find the oldest active run that can accept steering for a conversation."""
|
||||
async with self._lock:
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for run_id, session in self._sessions.items():
|
||||
authorization = session['authorization']
|
||||
if session.get('runner_id') != runner_id:
|
||||
continue
|
||||
if authorization.get('conversation_id') != conversation_id:
|
||||
continue
|
||||
if authorization.get('bot_id') != bot_id:
|
||||
continue
|
||||
if authorization.get('workspace_id') != workspace_id:
|
||||
continue
|
||||
if authorization.get('thread_id') != thread_id:
|
||||
continue
|
||||
if not authorization.get('available_apis', {}).get('steering_pull', False):
|
||||
continue
|
||||
candidates.append((session['status'].get('started_at', 0), run_id))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda item: item[0])
|
||||
return candidates[0][1]
|
||||
|
||||
async def enqueue_steering(
|
||||
self,
|
||||
run_id: str,
|
||||
item: SteeringQueueItem,
|
||||
) -> bool:
|
||||
"""Append one steering item to an active run queue."""
|
||||
async with self._lock:
|
||||
session = self._sessions.get(run_id)
|
||||
if session is None:
|
||||
return False
|
||||
if len(session['steering_queue']) >= MAX_STEERING_QUEUE_ITEMS:
|
||||
return False
|
||||
session['steering_queue'].append(copy.deepcopy(item))
|
||||
session['status']['last_activity_at'] = int(time.time())
|
||||
return True
|
||||
|
||||
async def pull_steering(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
mode: str = 'all',
|
||||
limit: int | None = None,
|
||||
) -> list[SteeringQueueItem]:
|
||||
"""Pop pending steering items from a run queue."""
|
||||
async with self._lock:
|
||||
session = self._sessions.get(run_id)
|
||||
if session is None:
|
||||
return []
|
||||
|
||||
queue = session['steering_queue']
|
||||
if not queue:
|
||||
return []
|
||||
|
||||
normalized_mode = str(mode or 'all').lower()
|
||||
if normalized_mode in {'one', 'one-at-a-time', 'one_at_a_time'}:
|
||||
count = 1
|
||||
elif isinstance(limit, int) and limit > 0:
|
||||
count = min(limit, len(queue))
|
||||
else:
|
||||
count = len(queue)
|
||||
|
||||
count = max(0, min(count, len(queue), 100))
|
||||
items = [copy.deepcopy(item) for item in queue[:count]]
|
||||
del queue[:count]
|
||||
session['status']['last_activity_at'] = int(time.time())
|
||||
return items
|
||||
|
||||
def is_resource_allowed(
|
||||
self,
|
||||
session: AgentRunSession,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
operation: str | None = None,
|
||||
) -> bool:
|
||||
"""Check if resource access is allowed for this session.
|
||||
|
||||
Uses pre-computed authorized IDs for O(1) lookup.
|
||||
|
||||
Args:
|
||||
session: AgentRunSession to check
|
||||
resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file')
|
||||
resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace', file_key)
|
||||
operation: Optional operation to check within the authorized resource
|
||||
|
||||
Returns:
|
||||
True if resource is authorized, False otherwise
|
||||
"""
|
||||
authorization = session['authorization']
|
||||
authorized_ids = authorization['authorized_ids']
|
||||
resources = authorization['resources']
|
||||
|
||||
if resource_type in ('model', 'tool', 'knowledge_base', 'skill', 'file'):
|
||||
if resource_id not in authorized_ids.get(resource_type, set()):
|
||||
return False
|
||||
if operation is None:
|
||||
return True
|
||||
operation_map = authorization.get('authorized_operations', {})
|
||||
operations = operation_map.get(resource_type, {}).get(resource_id)
|
||||
if not operations:
|
||||
operations = DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set())
|
||||
return operation in operations
|
||||
|
||||
if resource_type == 'storage':
|
||||
storage = resources.get('storage', {})
|
||||
if resource_id == 'plugin':
|
||||
return storage.get('plugin_storage', False)
|
||||
elif resource_id == 'workspace':
|
||||
return storage.get('workspace_storage', False)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def list_active_runs(self) -> list[AgentRunSession]:
|
||||
"""List all active run sessions.
|
||||
|
||||
Returns:
|
||||
List of active AgentRunSession dicts
|
||||
"""
|
||||
async with self._lock:
|
||||
return list(self._sessions.values())
|
||||
|
||||
async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int:
|
||||
"""Cleanup sessions that have been inactive for too long.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum inactivity time in seconds (default 1 hour)
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
now = int(time.time())
|
||||
cleaned = 0
|
||||
|
||||
async with self._lock:
|
||||
stale_run_ids = []
|
||||
for run_id, session in self._sessions.items():
|
||||
last_activity = session['status'].get('last_activity_at', 0)
|
||||
if now - last_activity > max_age_seconds:
|
||||
stale_run_ids.append(run_id)
|
||||
|
||||
for run_id in stale_run_ids:
|
||||
del self._sessions[run_id]
|
||||
cleaned += 1
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
# Global registry instance (singleton)
|
||||
_global_registry: AgentRunSessionRegistry | None = None
|
||||
_global_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_session_registry() -> AgentRunSessionRegistry:
|
||||
"""Get global session registry instance (thread-safe singleton).
|
||||
|
||||
Returns:
|
||||
AgentRunSessionRegistry singleton
|
||||
"""
|
||||
global _global_registry
|
||||
with _global_registry_lock:
|
||||
if _global_registry is None:
|
||||
_global_registry = AgentRunSessionRegistry()
|
||||
return _global_registry
|
||||
136
src/langbot/pkg/agent/runner/state_scope.py
Normal file
136
src/langbot/pkg/agent/runner/state_scope.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""State scope key helpers for AgentRunner host-owned state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
|
||||
from .descriptor import AgentRunnerDescriptor
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
|
||||
|
||||
VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner')
|
||||
|
||||
STATE_KEY_ALIASES = {
|
||||
'conversation_id': 'external.conversation_id',
|
||||
}
|
||||
|
||||
|
||||
def normalize_state_key(key: str) -> str:
|
||||
"""Map accepted public aliases to protocol state keys."""
|
||||
return STATE_KEY_ALIASES.get(key, key)
|
||||
|
||||
|
||||
def get_binding_identity(binding: AgentBinding) -> str:
|
||||
"""Return the stable binding identity used for state isolation."""
|
||||
if binding.binding_id:
|
||||
return binding.binding_id
|
||||
|
||||
scope = binding.scope
|
||||
if scope.scope_type and scope.scope_id:
|
||||
return f'{scope.scope_type}:{scope.scope_id}'
|
||||
|
||||
return 'unknown_binding'
|
||||
|
||||
|
||||
def _scope_hash(scope: str, parts: dict[str, typing.Any]) -> str:
|
||||
"""Encode state scope dimensions without separator ambiguity."""
|
||||
payload = {
|
||||
'version': 2,
|
||||
'scope': scope,
|
||||
**parts,
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
return f'{scope}:v2:{hashlib.sha256(raw.encode("utf-8")).hexdigest()}'
|
||||
|
||||
|
||||
def _base_scope_parts(
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'runner_id': descriptor.id,
|
||||
'binding_identity': get_binding_identity(binding),
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
}
|
||||
|
||||
|
||||
def build_state_scope_key(
|
||||
scope: str,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> str | None:
|
||||
"""Build the storage key for one state scope.
|
||||
|
||||
Returns None when the event lacks the identity required by that scope.
|
||||
"""
|
||||
base_parts = _base_scope_parts(event, binding, descriptor)
|
||||
|
||||
if scope == 'conversation':
|
||||
if not event.conversation_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
})
|
||||
|
||||
if scope == 'actor':
|
||||
if not event.actor or not event.actor.actor_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'actor_type': event.actor.actor_type or 'user',
|
||||
'actor_id': event.actor.actor_id,
|
||||
})
|
||||
|
||||
if scope == 'subject':
|
||||
if not event.subject or not event.subject.subject_id:
|
||||
return None
|
||||
return _scope_hash(scope, {
|
||||
**base_parts,
|
||||
'subject_type': event.subject.subject_type or 'unknown',
|
||||
'subject_id': event.subject.subject_id,
|
||||
})
|
||||
|
||||
if scope == 'runner':
|
||||
return _scope_hash(scope, base_parts)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_state_scope_keys(
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> dict[str, str]:
|
||||
"""Build all available scope keys for an event/binding pair."""
|
||||
scope_keys: dict[str, str] = {}
|
||||
for scope in VALID_STATE_SCOPES:
|
||||
scope_key = build_state_scope_key(scope, event, binding, descriptor)
|
||||
if scope_key:
|
||||
scope_keys[scope] = scope_key
|
||||
return scope_keys
|
||||
|
||||
|
||||
def build_state_context(
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Build the State API context stored in the run session."""
|
||||
return {
|
||||
'scope_keys': build_state_scope_keys(event, binding, descriptor),
|
||||
'binding_identity': get_binding_identity(binding),
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'actor_type': event.actor.actor_type if event.actor else None,
|
||||
'actor_id': event.actor.actor_id if event.actor else None,
|
||||
'subject_type': event.subject.subject_type if event.subject else None,
|
||||
'subject_id': event.subject.subject_id if event.subject else None,
|
||||
}
|
||||
409
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
409
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
content: str | None = None,
|
||||
content_json: dict[str, typing.Any] | None = None,
|
||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
||||
thread_id: str | None = None,
|
||||
item_type: str = "message",
|
||||
run_id: str | None = None,
|
||||
runner_id: str | None = None,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
) -> str:
|
||||
"""Append a transcript item.
|
||||
|
||||
Args:
|
||||
transcript_id: Unique transcript ID (generated if None)
|
||||
event_id: Source event ID
|
||||
conversation_id: Conversation ID
|
||||
role: Message role (user, assistant, system, tool)
|
||||
bot_id: Bot UUID scope
|
||||
workspace_id: Workspace scope
|
||||
content: Text content
|
||||
content_json: Full structured content
|
||||
artifact_refs: Artifact references
|
||||
thread_id: Thread ID
|
||||
item_type: Item type
|
||||
run_id: Run ID that generated this
|
||||
runner_id: Runner ID that generated this
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
The transcript_id
|
||||
"""
|
||||
if transcript_id is None:
|
||||
transcript_id = str(uuid.uuid4())
|
||||
|
||||
# Truncate content if too long
|
||||
if content and len(content) > self.MAX_CONTENT_LENGTH:
|
||||
content = content[:self.MAX_CONTENT_LENGTH - 3] + "..."
|
||||
|
||||
async with self._session_factory() as session:
|
||||
item = Transcript(
|
||||
transcript_id=transcript_id,
|
||||
event_id=event_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
role=role,
|
||||
item_type=item_type,
|
||||
content=content,
|
||||
content_json=json.dumps(content_json) if content_json else None,
|
||||
artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None,
|
||||
seq=0,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
created_at=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,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]:
|
||||
"""Page through transcript items.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
before_seq: Get items before this sequence (backward)
|
||||
after_seq: Get items after this sequence (forward)
|
||||
limit: Maximum items to return (capped at 100)
|
||||
direction: 'backward' (older) or 'forward' (newer)
|
||||
include_artifacts: Include artifact refs
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
Returns:
|
||||
Tuple of (items, next_seq, prev_seq, has_more)
|
||||
"""
|
||||
limit = min(limit, self.HARD_LIMIT)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
query = sqlalchemy.select(Transcript).where(
|
||||
Transcript.conversation_id == conversation_id
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
if direction == "backward" and before_seq is not None:
|
||||
query = query.where(Transcript.seq < before_seq)
|
||||
query = query.order_by(Transcript.seq.desc())
|
||||
elif direction == "forward" and after_seq is not None:
|
||||
query = query.where(Transcript.seq > after_seq)
|
||||
query = query.order_by(Transcript.seq.asc())
|
||||
else:
|
||||
# Default: most recent items first (backward from latest)
|
||||
query = query.order_by(Transcript.seq.desc())
|
||||
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
items = [self._row_to_dict(row, include_artifacts) for row in rows[:limit]]
|
||||
has_more = len(rows) > limit
|
||||
|
||||
# Calculate cursors
|
||||
next_seq = None
|
||||
prev_seq = None
|
||||
|
||||
if direction == "backward":
|
||||
# Items are in descending order
|
||||
if items:
|
||||
next_seq = items[-1].get('seq') if has_more else None
|
||||
prev_seq = items[0].get('seq')
|
||||
else:
|
||||
# Items are in ascending order
|
||||
if items:
|
||||
next_seq = items[-1].get('seq') if has_more else None
|
||||
prev_seq = items[0].get('seq')
|
||||
|
||||
return items, next_seq, prev_seq, has_more
|
||||
|
||||
async def search_transcript(
|
||||
self,
|
||||
conversation_id: str,
|
||||
query_text: str,
|
||||
filters: dict[str, typing.Any] | None = None,
|
||||
top_k: int = 10,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
"""Search transcript items.
|
||||
|
||||
Basic implementation using LIKE filtering.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
query_text: Search query
|
||||
filters: Optional filters
|
||||
top_k: Maximum results
|
||||
bot_id: Optional bot scope filter
|
||||
workspace_id: Optional workspace scope filter
|
||||
thread_id: Optional thread scope filter
|
||||
strict_thread: When true, require thread_id equality including NULL
|
||||
|
||||
Returns:
|
||||
List of matching items
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
query = sqlalchemy.select(Transcript).where(
|
||||
Transcript.conversation_id == conversation_id,
|
||||
Transcript.content.ilike(f"%{query_text}%"),
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
if 'roles' in filters:
|
||||
query = query.where(Transcript.role.in_(filters['roles']))
|
||||
if 'item_types' in filters:
|
||||
query = query.where(Transcript.item_type.in_(filters['item_types']))
|
||||
|
||||
query = query.order_by(Transcript.seq.desc()).limit(top_k)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [self._row_to_dict(row, include_artifacts=True) for row in rows]
|
||||
|
||||
async def get_latest_cursor(
|
||||
self,
|
||||
conversation_id: str,
|
||||
) -> str | None:
|
||||
"""Get the latest cursor for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Cursor string (seq number), or None if no items
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(Transcript.seq)
|
||||
.where(Transcript.conversation_id == conversation_id)
|
||||
.order_by(Transcript.seq.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return str(row)
|
||||
|
||||
async def get_legacy_provider_messages(
|
||||
self,
|
||||
conversation_id: str,
|
||||
limit: int = HARD_LIMIT,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> list[provider_message.Message]:
|
||||
"""Project Transcript rows into the legacy provider Message view.
|
||||
|
||||
AgentRunner history is canonical in Transcript. This view exists for
|
||||
legacy Pipeline readers such as PromptPreProcessing that still expect
|
||||
query.messages.
|
||||
"""
|
||||
items, _, _, _ = await self.page_transcript(
|
||||
conversation_id=conversation_id,
|
||||
limit=limit,
|
||||
direction="backward",
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
strict_thread=strict_thread,
|
||||
)
|
||||
|
||||
messages: list[provider_message.Message] = []
|
||||
for item in reversed(items):
|
||||
message = self._transcript_item_to_provider_message(item)
|
||||
if message is not None:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
async def has_history_before(
|
||||
self,
|
||||
conversation_id: str,
|
||||
seq: int,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
strict_thread: bool = False,
|
||||
) -> bool:
|
||||
"""Check if there is history before a sequence number.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
seq: Sequence number
|
||||
|
||||
Returns:
|
||||
True if there are items before
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
query = (
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(Transcript)
|
||||
.where(Transcript.conversation_id == conversation_id, Transcript.seq < seq)
|
||||
)
|
||||
query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread)
|
||||
result = await session.execute(query)
|
||||
count = result.scalar()
|
||||
return count > 0
|
||||
|
||||
def _apply_scope_filters(
|
||||
self,
|
||||
query: typing.Any,
|
||||
bot_id: str | None,
|
||||
workspace_id: str | None,
|
||||
thread_id: str | None,
|
||||
strict_thread: bool,
|
||||
) -> typing.Any:
|
||||
if bot_id is not None:
|
||||
query = query.where(Transcript.bot_id == bot_id)
|
||||
if workspace_id is not None:
|
||||
query = query.where(Transcript.workspace_id == workspace_id)
|
||||
if strict_thread:
|
||||
if thread_id is None:
|
||||
query = query.where(Transcript.thread_id.is_(None))
|
||||
else:
|
||||
query = query.where(Transcript.thread_id == thread_id)
|
||||
return query
|
||||
|
||||
async def cleanup_transcripts_older_than(
|
||||
self,
|
||||
before: datetime.datetime,
|
||||
) -> int:
|
||||
"""Delete Transcript rows created before the supplied timestamp."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(Transcript).where(Transcript.created_at < before)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
async def _get_next_seq(self, conversation_id: str) -> int:
|
||||
"""Fallback next sequence number for stores that cannot expose autoincrement IDs."""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.select(sqlalchemy.func.max(Transcript.seq))
|
||||
.where(Transcript.conversation_id == conversation_id)
|
||||
)
|
||||
max_seq = result.scalar()
|
||||
return (max_seq or 0) + 1
|
||||
|
||||
def _row_to_dict(
|
||||
self,
|
||||
row: Transcript,
|
||||
include_artifacts: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Convert a Transcript row to dict."""
|
||||
result = {
|
||||
'transcript_id': row.transcript_id,
|
||||
'event_id': row.event_id,
|
||||
'bot_id': row.bot_id,
|
||||
'workspace_id': row.workspace_id,
|
||||
'conversation_id': row.conversation_id,
|
||||
'thread_id': row.thread_id,
|
||||
'role': row.role,
|
||||
'item_type': row.item_type,
|
||||
'content': row.content,
|
||||
'content_json': json.loads(row.content_json) if row.content_json else None,
|
||||
'seq': row.seq,
|
||||
'cursor': str(row.seq),
|
||||
'created_at': 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)
|
||||
@@ -12,7 +12,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器列表"""
|
||||
"""List MCP servers or create a new MCP server."""
|
||||
if quart.request.method == 'GET':
|
||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""获取、更新或删除MCP服务器配置"""
|
||||
"""Get, update, or delete an MCP server configuration."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
server_name = unquote(server_name)
|
||||
@@ -59,7 +59,7 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""测试MCP服务器连接"""
|
||||
"""Test an MCP server connection."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
server_name = unquote(server_name)
|
||||
|
||||
@@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ``system.outbound_ips`` may be a comma-separated string instead of
|
||||
# a list when injected via the SYSTEM__OUTBOUND_IPS env var into a
|
||||
# pre-existing data/config.yaml that lacks the key (env overrides
|
||||
# only coerce to list when the key already holds one).
|
||||
outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', [])
|
||||
if isinstance(outbound_ips, str):
|
||||
outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()]
|
||||
elif isinstance(outbound_ips, list):
|
||||
outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()]
|
||||
else:
|
||||
outbound_ips = []
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service', False
|
||||
),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'wizard_status': wizard_status,
|
||||
'wizard_progress': wizard_progress,
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class MCPService:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||
|
||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||
"""Test an MCP server connection and return the task ID."""
|
||||
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
@@ -152,7 +152,24 @@ class MCPService:
|
||||
coroutine = runtime_mcp_session.refresh()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
coroutine = runtime_mcp_session.start()
|
||||
|
||||
# A transient test owns an isolated Box session. Always tear it down
|
||||
# after the test completes (success or failure) so it does not leak.
|
||||
test_session = runtime_mcp_session
|
||||
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
finally:
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to tear down transient MCP test session '
|
||||
f'{test_session.server_name}: {type(exc).__name__}: {exc}'
|
||||
)
|
||||
|
||||
coroutine = _run_and_cleanup()
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
|
||||
@@ -7,7 +7,6 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....provider.modelmgr import requester as model_requester
|
||||
|
||||
|
||||
@@ -109,23 +108,9 @@ class LLMModelsService:
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
|
||||
if auto_set_to_default_pipeline:
|
||||
# set the default pipeline model to this model
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
if pipeline is not None:
|
||||
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||
if not model_config.get('primary', ''):
|
||||
pipeline_config = pipeline.config
|
||||
pipeline_config['ai']['local-agent']['model'] = {
|
||||
'primary': model_data['uuid'],
|
||||
'fallbacks': [],
|
||||
}
|
||||
pipeline_data = {'config': pipeline_config}
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||
default_config_service = getattr(self.ap, 'agent_runner_default_config_service', None)
|
||||
if default_config_service is not None:
|
||||
await default_config_service.auto_set_default_pipeline_llm_model(model_data['uuid'])
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
@@ -13,7 +14,6 @@ default_stage_order = [
|
||||
'BanSessionCheckStage', # 封禁会话检查
|
||||
'PreContentFilterStage', # 内容过滤前置阶段
|
||||
'PreProcessor', # 预处理器
|
||||
'ConversationMessageTruncator', # 会话消息截断器
|
||||
'RequireRateLimitOccupancy', # 请求速率限制占用
|
||||
'MessageProcessor', # 处理器
|
||||
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
||||
@@ -30,11 +30,100 @@ class PipelineService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _get_default_values_from_schema(self, config_schema: list[dict[str, typing.Any]]) -> dict[str, typing.Any]:
|
||||
"""Build runner config defaults from a DynamicForm schema."""
|
||||
defaults: dict[str, typing.Any] = {}
|
||||
for item in config_schema:
|
||||
name = item.get('name')
|
||||
if not name:
|
||||
continue
|
||||
if 'default' in item:
|
||||
defaults[name] = item['default']
|
||||
return defaults
|
||||
|
||||
async def get_default_pipeline_config(self) -> dict[str, typing.Any]:
|
||||
"""Get the default pipeline config, rendering runner defaults from installed plugins."""
|
||||
from ....utils import paths as path_utils
|
||||
|
||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
agent_runner_registry = getattr(self.ap, 'agent_runner_registry', None)
|
||||
if agent_runner_registry is None:
|
||||
return config
|
||||
|
||||
try:
|
||||
runners = await agent_runner_registry.list_runners(bound_plugins=None)
|
||||
except Exception as e:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger:
|
||||
logger.warning(f'Failed to load plugin agent runners for default pipeline config: {e}')
|
||||
return config
|
||||
|
||||
if not runners:
|
||||
return config
|
||||
|
||||
selected_runner = runners[0]
|
||||
ai_config = config.setdefault('ai', {})
|
||||
runner_config = ai_config.setdefault('runner', {})
|
||||
runner_config['id'] = selected_runner.id
|
||||
runner_config.setdefault('expire-time', 0)
|
||||
|
||||
ai_config['runner_config'] = {
|
||||
selected_runner.id: self._get_default_values_from_schema(selected_runner.config_schema),
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
async def get_pipeline_metadata(self) -> list[dict]:
|
||||
"""Get pipeline metadata with dynamically loaded plugin runners from registry"""
|
||||
import copy
|
||||
|
||||
# Deep copy AI metadata to avoid modifying the original
|
||||
ai_metadata = copy.deepcopy(self.ap.pipeline_config_meta_ai)
|
||||
|
||||
# Find the runner stage
|
||||
runner_stage = None
|
||||
for stage in ai_metadata.get('stages', []):
|
||||
if stage.get('name') == 'runner':
|
||||
runner_stage = stage
|
||||
break
|
||||
|
||||
if runner_stage:
|
||||
# Find the runner select config (now uses 'id' field)
|
||||
for config_item in runner_stage.get('config', []):
|
||||
if config_item.get('name') == 'id':
|
||||
# Get plugin agent runners from registry
|
||||
try:
|
||||
(
|
||||
runner_options,
|
||||
runner_stages,
|
||||
) = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline()
|
||||
|
||||
# Replace options entirely with registry options
|
||||
# Only installed/available runners should be shown
|
||||
config_item['options'] = runner_options
|
||||
|
||||
# Use the registry order as the default order. If no runner is available, leave
|
||||
# the default unset so the UI can recommend installing an AgentRunner plugin.
|
||||
if runner_options and 'default' not in config_item:
|
||||
config_item['default'] = runner_options[0]['name']
|
||||
|
||||
# Add corresponding stage configuration for each runner
|
||||
for stage_config in runner_stages:
|
||||
# Avoid duplicate stages
|
||||
existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])}
|
||||
if stage_config['name'] not in existing_stage_names:
|
||||
ai_metadata['stages'].append(stage_config)
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}')
|
||||
|
||||
return [
|
||||
self.ap.pipeline_config_meta_trigger,
|
||||
self.ap.pipeline_config_meta_safety,
|
||||
self.ap.pipeline_config_meta_ai,
|
||||
ai_metadata,
|
||||
self.ap.pipeline_config_meta_output,
|
||||
]
|
||||
|
||||
@@ -74,8 +163,6 @@ class PipelineService:
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
|
||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||
from ....utils import paths as path_utils
|
||||
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
@@ -89,9 +176,7 @@ class PipelineService:
|
||||
pipeline_data['stages'] = default_stage_order.copy()
|
||||
pipeline_data['is_default'] = default
|
||||
|
||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
pipeline_data['config'] = json.load(f)
|
||||
pipeline_data['config'] = await self.get_default_pipeline_config()
|
||||
|
||||
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
||||
if 'extensions_preferences' not in pipeline_data:
|
||||
@@ -113,10 +198,16 @@ class PipelineService:
|
||||
return pipeline_data['uuid']
|
||||
|
||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||
from ....agent.runner.config_migration import ConfigMigration
|
||||
|
||||
pipeline_data = pipeline_data.copy()
|
||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||
pipeline_data.pop(protected_field, None)
|
||||
|
||||
# Migrate config to new format before saving
|
||||
if 'config' in pipeline_data:
|
||||
pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
|
||||
@@ -120,13 +120,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
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:
|
||||
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
|
||||
|
||||
When this is True the Box runtime lives in a separate process with its
|
||||
own filesystem view (container, pod sidecar, or remote host), so paths
|
||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
||||
LangBot side. When False, Box runs as a stdio child process that shares
|
||||
LangBot's filesystem.
|
||||
"""
|
||||
return bool(
|
||||
self.configured_runtime_endpoint
|
||||
@@ -134,6 +140,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
or platform.use_websocket_to_connect_box_runtime()
|
||||
)
|
||||
|
||||
# Backwards-compatible private alias.
|
||||
def _uses_websocket(self) -> bool:
|
||||
return self.uses_websocket()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._uses_websocket():
|
||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||
|
||||
@@ -12,6 +12,7 @@ import pydantic
|
||||
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from .connector import BoxRuntimeConnector, _get_box_config
|
||||
from ..telemetry import features as telemetry_features
|
||||
from langbot_plugin.box.errors import BoxError, BoxValidationError
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
@@ -67,6 +68,10 @@ class BoxService:
|
||||
self._available = False
|
||||
self._connector_error: str = ''
|
||||
self._reconnecting = False
|
||||
# Optional explicit override for shares_filesystem_with_box. None means
|
||||
# "derive from the connector transport". Set by tests / embedders that
|
||||
# know the real LangBot<->Box filesystem topology.
|
||||
self._shares_filesystem_with_box_override: bool | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -148,6 +153,32 @@ class BoxService:
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def shares_filesystem_with_box(self) -> bool:
|
||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||
|
||||
This is True only when Box runs as a local stdio child process of
|
||||
LangBot (same container/host). In that case paths the Box runtime
|
||||
reports — notably skill ``package_root`` — resolve identically on the
|
||||
LangBot side, so LangBot may validate them against its own filesystem.
|
||||
|
||||
It is False for every separated deployment (Docker Compose, k8s
|
||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||
where the Box runtime owns its own filesystem and LangBot must trust
|
||||
the paths it reports rather than checking them locally.
|
||||
|
||||
When Box is wired up with an injected client (tests, custom embeds)
|
||||
there is no connector to introspect; we conservatively report False so
|
||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
embedder that knows the real topology).
|
||||
"""
|
||||
if self._shares_filesystem_with_box_override is not None:
|
||||
return self._shares_filesystem_with_box_override
|
||||
if self._runtime_connector is None:
|
||||
return False
|
||||
return not self._runtime_connector.uses_websocket()
|
||||
|
||||
async def execute_spec_payload(
|
||||
self,
|
||||
spec_payload: dict,
|
||||
@@ -188,16 +219,35 @@ class BoxService:
|
||||
f'query_id={query.query_id} '
|
||||
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
|
||||
)
|
||||
telemetry_features.increment(query, 'sandbox', 'execs')
|
||||
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}')
|
||||
)
|
||||
"""Resolve the Box session_id from the pipeline's template and query variables.
|
||||
|
||||
When ``system.limitation.force_box_session_id_template`` is set to a
|
||||
non-empty value, that template overrides whatever the pipeline
|
||||
configured. This is the authoritative SaaS guard: it runs on every
|
||||
``exec`` call, so a tenant cannot escape a single shared sandbox even
|
||||
by editing the pipeline config directly through the API (which only
|
||||
gates the web UI).
|
||||
"""
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = '{launcher_type}_{launcher_id}'
|
||||
pipeline_config = query.pipeline_config or {}
|
||||
ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {}
|
||||
runner_selector = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_id = runner_selector.get('id') if isinstance(runner_selector, dict) else None
|
||||
runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {}
|
||||
runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {}
|
||||
configured_template = (
|
||||
runner_config.get('box-session-id-template') if isinstance(runner_config, dict) else None
|
||||
)
|
||||
if isinstance(configured_template, str) and configured_template:
|
||||
template = configured_template
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
@@ -220,14 +270,24 @@ class BoxService:
|
||||
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.
|
||||
Path validation is filesystem-topology dependent. When LangBot and the
|
||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
||||
``package_root`` is missing or no longer a directory is skipped with a
|
||||
warning instead of being passed through to the backend. Without that
|
||||
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.
|
||||
|
||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
@@ -235,13 +295,15 @@ class BoxService:
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
validate_locally = self.shares_filesystem_with_box
|
||||
|
||||
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):
|
||||
if validate_locally and 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. '
|
||||
@@ -564,6 +626,20 @@ class BoxService:
|
||||
raw = str(self._local_config().get('image', '') or '').strip()
|
||||
return raw or None
|
||||
|
||||
def _forced_box_session_id_template(self) -> str:
|
||||
"""Return the SaaS-forced sandbox-scope template, or '' when unset.
|
||||
|
||||
Read from ``system.limitation.force_box_session_id_template``. A
|
||||
non-empty value pins every pipeline to a single sandbox scope
|
||||
(e.g. ``'{global}'``) and cannot be overridden per-pipeline.
|
||||
"""
|
||||
limitation = (
|
||||
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
|
||||
if getattr(self.ap, 'instance_config', None) is not None
|
||||
else {}
|
||||
)
|
||||
return str(limitation.get('force_box_session_id_template', '') or '').strip()
|
||||
|
||||
def _load_workspace_quota_mb(self) -> int | None:
|
||||
raw_value = self._local_config().get('workspace_quota_mb')
|
||||
if raw_value in (None, ''):
|
||||
@@ -717,6 +793,7 @@ class BoxService:
|
||||
# ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
def _record_error(self, exc: Exception, query: pipeline_query.Query):
|
||||
telemetry_features.increment(query, 'sandbox', 'errors')
|
||||
self._recent_errors.append(
|
||||
{
|
||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||
@@ -732,8 +809,8 @@ class BoxService:
|
||||
def get_system_guidance(self) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
||||
All execution-specific prompt text is kept here so that callers stay
|
||||
free of box domain knowledge.
|
||||
"""
|
||||
guidance = (
|
||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
|
||||
@@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
python - <<'PY'
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -198,18 +204,29 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
if [ -d "$_LB_LOCK_DIR" ] && [ ! -f "$_LB_LOCK_DIR/pid" ]; then
|
||||
echo "Clearing stale Python environment lock without owner: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
@@ -225,7 +242,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
python -m venv "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import asyncio
|
||||
import traceback
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..platform import botmgr as im_mgr
|
||||
from ..platform.webhook_pusher import WebhookPusher
|
||||
@@ -46,6 +47,9 @@ from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService
|
||||
|
||||
|
||||
class Application:
|
||||
"""Runtime application object and context"""
|
||||
@@ -165,6 +169,13 @@ class Application:
|
||||
|
||||
maintenance_service: maintenance_service.MaintenanceService = None
|
||||
|
||||
# Agent runner subsystem
|
||||
agent_runner_registry: AgentRunnerRegistry = None
|
||||
|
||||
agent_runner_default_config_service: AgentRunnerDefaultConfigService = None
|
||||
|
||||
agent_run_orchestrator: AgentRunOrchestrator = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@@ -200,6 +211,17 @@ class Application:
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
# Telemetry instance heartbeat (startup + daily); respects
|
||||
# space.disable_telemetry via TelemetryManager.send().
|
||||
if self.telemetry is not None:
|
||||
from ..telemetry import heartbeat as telemetry_heartbeat
|
||||
|
||||
self.task_mgr.create_task(
|
||||
telemetry_heartbeat.heartbeat_loop(self),
|
||||
name='telemetry-heartbeat',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
# Start monitoring data cleanup task if enabled
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -20,6 +21,66 @@ log_colors_config = {
|
||||
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
|
||||
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
|
||||
|
||||
LOG_DIR = 'data/logs'
|
||||
|
||||
|
||||
class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``.
|
||||
|
||||
It combines two rotation triggers:
|
||||
|
||||
* **Size** — within a single day the file is rotated once it exceeds
|
||||
``maxBytes``, producing numbered backups (``langbot-DATE.log.1`` etc.),
|
||||
exactly like :class:`~logging.handlers.RotatingFileHandler`.
|
||||
* **Date** — when the local date changes, logging switches to a fresh
|
||||
``langbot-<new date>.log`` file. This happens even within a single
|
||||
long-running process, so a bot started on day N keeps writing to that
|
||||
day's file and rolls over to day N+1's file at midnight, instead of
|
||||
appending every subsequent day's logs to the start-day file.
|
||||
|
||||
The on-disk naming stays compatible with the log-retention cleanup in
|
||||
``api/http/service/maintenance.py`` (``LOG_FILE_PATTERN``).
|
||||
"""
|
||||
|
||||
def __init__(self, log_dir: str, max_bytes: int, backup_count: int, encoding: str = 'utf-8'):
|
||||
self.log_dir = log_dir
|
||||
self._current_date = self._today()
|
||||
super().__init__(
|
||||
self._build_path(self._current_date),
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _today() -> str:
|
||||
return time.strftime('%Y-%m-%d', time.localtime())
|
||||
|
||||
def _build_path(self, date_str: str) -> str:
|
||||
return os.path.join(self.log_dir, 'langbot-%s.log' % date_str)
|
||||
|
||||
def shouldRollover(self, record):
|
||||
# Roll over when the day changes, regardless of file size.
|
||||
if self._today() != self._current_date:
|
||||
return True
|
||||
return super().shouldRollover(record)
|
||||
|
||||
def doRollover(self):
|
||||
today = self._today()
|
||||
if today != self._current_date:
|
||||
# Date changed: point the handler at the new day's file.
|
||||
# This is a date switch, not a size-based numbered rotation.
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
self._current_date = today
|
||||
self.baseFilename = os.path.abspath(self._build_path(today))
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
else:
|
||||
# Same day, file exceeded maxBytes: numbered rotation.
|
||||
super().doRollover()
|
||||
|
||||
|
||||
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
|
||||
# Remove all existing loggers
|
||||
@@ -31,8 +92,6 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
if constants.debug_mode:
|
||||
level = logging.DEBUG
|
||||
|
||||
log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime())
|
||||
|
||||
qcg_logger = logging.getLogger('langbot')
|
||||
|
||||
qcg_logger.setLevel(level)
|
||||
@@ -48,12 +107,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
|
||||
# Use RotatingFileHandler to prevent unbounded log file growth
|
||||
rotating_file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name,
|
||||
# Rotate by size within a day and switch files when the date changes,
|
||||
# so long-running processes still produce a log file for the current day.
|
||||
rotating_file_handler = DailyGroupedRotatingFileHandler(
|
||||
LOG_DIR,
|
||||
max_bytes=LOG_FILE_MAX_BYTES,
|
||||
backup_count=LOG_FILE_BACKUP_COUNT,
|
||||
encoding='utf-8',
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
)
|
||||
|
||||
log_handlers: list[logging.Handler] = [
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('msg-truncator-cfg-migration', 9)
|
||||
class MsgTruncatorConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'msg-truncate' not in self.ap.pipeline_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.pipeline_cfg.data['msg-truncate'] = {
|
||||
'method': 'round',
|
||||
'round': {'max-round': 10},
|
||||
}
|
||||
|
||||
await self.ap.pipeline_cfg.dump_config()
|
||||
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('weknora-api-config', 42)
|
||||
class WeKnoraAPICfgMigration(migration.Migration):
|
||||
"""WeKnora API 配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'weknora-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['weknora-api'] = {
|
||||
'base-url': 'http://localhost:8080/api/v1',
|
||||
'app-type': 'agent',
|
||||
'api-key': '',
|
||||
'agent-id': 'builtin-smart-reasoning',
|
||||
'knowledge-base-ids': [],
|
||||
'web-search-enabled': False,
|
||||
'timeout': 120,
|
||||
'base-prompt': '请回答用户的问题。',
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('deerflow-api-config', 43)
|
||||
class DeerFlowAPICfgMigration(migration.Migration):
|
||||
"""DeerFlow API 配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'deerflow-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['deerflow-api'] = {
|
||||
'api-base': 'http://127.0.0.1:2026',
|
||||
'api-key': '',
|
||||
'auth-header': '',
|
||||
'assistant-id': 'lead_agent',
|
||||
'model-name': '',
|
||||
'thinking-enabled': False,
|
||||
'plan-mode': False,
|
||||
'subagent-enabled': False,
|
||||
'max-concurrent-subagents': 3,
|
||||
'timeout': 300,
|
||||
'recursion-limit': 1000,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -39,6 +39,7 @@ from ...vector import mgr as vectordb_mgr
|
||||
from .. import taskmgr
|
||||
from ...telemetry import telemetry as telemetry_module
|
||||
from ...survey import manager as survey_module
|
||||
from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -194,5 +195,15 @@ class BuildAppStage(stage.BootingStage):
|
||||
await plugin_connector_inst.initialize()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
|
||||
# Initialize agent runner subsystem
|
||||
agent_runner_registry_inst = AgentRunnerRegistry(ap)
|
||||
ap.agent_runner_registry = agent_runner_registry_inst
|
||||
|
||||
agent_runner_default_config_service_inst = AgentRunnerDefaultConfigService(ap)
|
||||
ap.agent_runner_default_config_service = agent_runner_default_config_service_inst
|
||||
|
||||
agent_run_orchestrator_inst = AgentRunOrchestrator(ap, agent_runner_registry_inst)
|
||||
ap.agent_run_orchestrator = agent_run_orchestrator_inst
|
||||
|
||||
ctrl = controller.Controller(ap)
|
||||
ap.ctrl = ctrl
|
||||
|
||||
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal file
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Agent runner state persistence entity for host-owned state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AgentRunnerState(Base):
|
||||
"""AgentRunnerState stores host-owned state for AgentRunner protocol.
|
||||
|
||||
State is:
|
||||
- Host-owned: Managed by LangBot, not by plugin instances
|
||||
- Scope-isolated: Separated by runner_id + binding_identity + scope
|
||||
- Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes)
|
||||
|
||||
Scope key design:
|
||||
- conversation: runner_id + binding_id + conversation_id [+ thread_id]
|
||||
- actor: runner_id + binding_id + actor_type + actor_id
|
||||
- subject: runner_id + binding_id + subject_type + subject_id
|
||||
- runner: runner_id + binding_id
|
||||
|
||||
This table is the production store for AgentRunner state.
|
||||
"""
|
||||
|
||||
__tablename__ = 'agent_runner_state'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
"""Auto-increment ID for sequencing."""
|
||||
|
||||
# Identity
|
||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""Runner descriptor ID (plugin:author/name/runner)."""
|
||||
|
||||
binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""Binding identity for isolation (binding_id or scope_type:scope_id)."""
|
||||
|
||||
scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
||||
"""State scope: 'conversation', 'actor', 'subject', or 'runner'."""
|
||||
|
||||
scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
|
||||
"""Full scope key for unique lookup (includes all identity parts)."""
|
||||
|
||||
state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
"""State key within scope (should use namespace prefix like external.*)."""
|
||||
|
||||
value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""State value as JSON string (size-limited by host)."""
|
||||
|
||||
# Context fields for querying/filtering
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Bot UUID if applicable."""
|
||||
|
||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Workspace ID for multi-tenant."""
|
||||
|
||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Conversation ID for conversation scope."""
|
||||
|
||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Thread ID for thread-scoped conversation state."""
|
||||
|
||||
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Actor type for actor scope."""
|
||||
|
||||
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Actor ID for actor scope."""
|
||||
|
||||
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Subject type for subject scope."""
|
||||
|
||||
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Subject ID for subject scope."""
|
||||
|
||||
# Lifecycle
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this state entry was created."""
|
||||
|
||||
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
"""When this state entry was last updated."""
|
||||
|
||||
# Unique constraint: scope_key + state_key
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'),
|
||||
sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'),
|
||||
sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'),
|
||||
)
|
||||
77
src/langbot/pkg/entity/persistence/artifact.py
Normal file
77
src/langbot/pkg/entity/persistence/artifact.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Artifact persistence entity for Host-owned artifact store."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AgentArtifact(Base):
|
||||
"""AgentArtifact stores metadata for large files, images, tool results, etc.
|
||||
|
||||
This table only stores metadata. The actual blob content is stored in
|
||||
BinaryStorage or external storage, referenced by storage_key.
|
||||
|
||||
Artifacts are accessed via artifact_metadata and artifact_read APIs
|
||||
with run_id authorization.
|
||||
"""
|
||||
|
||||
__tablename__ = 'agent_artifact'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
"""Auto-increment ID for sequencing."""
|
||||
|
||||
artifact_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
"""Unique artifact identifier."""
|
||||
|
||||
artifact_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||
"""Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc."""
|
||||
|
||||
mime_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""MIME type of the content."""
|
||||
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Original file name (if applicable)."""
|
||||
|
||||
size_bytes = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
|
||||
"""Size in bytes."""
|
||||
|
||||
sha256 = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""SHA256 hash of content (for integrity verification)."""
|
||||
|
||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||
"""Source of artifact: 'platform', 'runner', 'tool', 'system'."""
|
||||
|
||||
# Storage reference (points to BinaryStorage or external storage)
|
||||
storage_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Key in BinaryStorage or external storage reference."""
|
||||
|
||||
storage_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='binary_storage')
|
||||
"""Storage type: 'binary_storage', 'file', 'url', etc."""
|
||||
|
||||
# Context
|
||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Conversation this artifact belongs to."""
|
||||
|
||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Run ID that created this artifact."""
|
||||
|
||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Runner ID that created this artifact."""
|
||||
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Bot UUID that handled this artifact."""
|
||||
|
||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Workspace ID for multi-tenant deployments."""
|
||||
|
||||
# Lifecycle
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this artifact was created."""
|
||||
|
||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
"""When this artifact expires (optional)."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Additional metadata as JSON string."""
|
||||
85
src/langbot/pkg/entity/persistence/event_log.py
Normal file
85
src/langbot/pkg/entity/persistence/event_log.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""EventLog persistence entity for storing auditable event facts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
"""EventLog stores auditable event records for AgentRunner.
|
||||
|
||||
This is the fact source for events - messages, tool calls, system events, etc.
|
||||
Large payloads are stored separately as artifacts; this table stores
|
||||
references and summaries.
|
||||
"""
|
||||
|
||||
__tablename__ = 'event_log'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
"""Auto-increment ID for sequencing."""
|
||||
|
||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
"""Unique event identifier."""
|
||||
|
||||
event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
|
||||
"""Event type (message.received, tool.call.started, etc.)."""
|
||||
|
||||
event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
"""When the event occurred."""
|
||||
|
||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||
"""Event source (platform, webui, api, scheduler, system, pipeline_adapter)."""
|
||||
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Bot UUID that handled this event."""
|
||||
|
||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Workspace ID for multi-tenant deployments."""
|
||||
|
||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Conversation ID this event belongs to."""
|
||||
|
||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Thread ID if platform supports threads."""
|
||||
|
||||
# Actor information
|
||||
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Actor type (user, system, runner)."""
|
||||
|
||||
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Actor identifier."""
|
||||
|
||||
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Actor display name."""
|
||||
|
||||
# Subject information
|
||||
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Subject type (message, tool_call, artifact)."""
|
||||
|
||||
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Subject identifier."""
|
||||
|
||||
# Input information
|
||||
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Brief summary of input (truncated text, max 1000 chars)."""
|
||||
|
||||
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
|
||||
|
||||
# Raw event reference
|
||||
raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Reference to raw event payload in ArtifactStore."""
|
||||
|
||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Run ID that processed this event."""
|
||||
|
||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Runner ID that processed this event."""
|
||||
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this record was created."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Additional metadata as JSON string."""
|
||||
@@ -11,6 +11,10 @@ class MCPServer(Base):
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Markdown documentation captured from LangBot Space at install time so the
|
||||
# detail page can show docs even when the server is offline / has no tools.
|
||||
# Empty string for manually-created servers that have no marketplace README.
|
||||
readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
79
src/langbot/pkg/entity/persistence/transcript.py
Normal file
79
src/langbot/pkg/entity/persistence/transcript.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Transcript persistence entity for conversation history projection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Transcript(Base):
|
||||
"""Transcript stores conversation-oriented message projection for history API.
|
||||
|
||||
This is a projection of EventLog, optimized for agent history retrieval.
|
||||
It includes message content and artifact refs, but not raw platform payloads.
|
||||
"""
|
||||
|
||||
__tablename__ = 'transcript'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
"""Auto-increment ID for sequencing."""
|
||||
|
||||
transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
"""Unique transcript item identifier."""
|
||||
|
||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""Reference to the source event in EventLog."""
|
||||
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Bot UUID this item belongs to."""
|
||||
|
||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Workspace this item belongs to."""
|
||||
|
||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""Conversation this item belongs to."""
|
||||
|
||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Thread ID if platform supports threads."""
|
||||
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||
"""Message role: 'user', 'assistant', 'system', or 'tool'."""
|
||||
|
||||
item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message')
|
||||
"""Item type: 'message', 'tool_call', 'tool_result', 'system'."""
|
||||
|
||||
# Content
|
||||
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
|
||||
|
||||
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Full structured content as JSON string (Message model dump)."""
|
||||
|
||||
# Artifact references
|
||||
artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Artifact references as JSON string (list of ArtifactRef)."""
|
||||
|
||||
# Sequence for cursor-based pagination
|
||||
seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, 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'),
|
||||
sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'),
|
||||
)
|
||||
@@ -13,6 +13,28 @@ from sqlalchemy.engine import Connection
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
# Import all ORM models so they are registered with Base.metadata
|
||||
# This is required for autogenerate to detect model changes
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent_runner_state, # noqa: F401
|
||||
apikey, # noqa: F401
|
||||
artifact, # noqa: F401
|
||||
bot, # noqa: F401
|
||||
bstorage, # noqa: F401
|
||||
event_log, # noqa: F401
|
||||
mcp, # noqa: F401
|
||||
metadata, # noqa: F401
|
||||
model, # noqa: F401
|
||||
monitoring, # noqa: F401
|
||||
pipeline, # noqa: F401
|
||||
plugin, # noqa: F401
|
||||
rag, # noqa: F401
|
||||
transcript, # noqa: F401
|
||||
user, # noqa: F401
|
||||
vector, # noqa: F401
|
||||
webhook, # noqa: F401
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add readme column to mcp_servers
|
||||
|
||||
Revision ID: 0004_add_mcp_readme
|
||||
Revises: 0003_add_rerank_models
|
||||
Create Date: 2026-06-06
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0004_add_mcp_readme'
|
||||
down_revision = '0003_add_rerank_models'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add ``readme`` to mcp_servers if the table exists and the column is missing
|
||||
# (the table may have been created by create_all() with the column already
|
||||
# present on fresh installs, so guard against duplicate-add).
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {col['name'] for col in inspector.get_columns('mcp_servers')}
|
||||
if 'readme' not in columns:
|
||||
op.add_column(
|
||||
'mcp_servers',
|
||||
sa.Column('readme', sa.Text(), nullable=False, server_default=''),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('mcp_servers', 'readme')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Normalize AgentRunner config containers
|
||||
|
||||
Revision ID: 0005_migrate_runner_config
|
||||
Revises: 0004_add_mcp_readme
|
||||
Create Date: 2026-05-10
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
revision = '0005_migrate_runner_config'
|
||||
down_revision = '0004_add_mcp_readme'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def migrate_pipeline_config(config: dict) -> dict:
|
||||
"""Migrate persisted pipeline config to the AgentRunner plugin shape."""
|
||||
return ConfigMigration.migrate_pipeline_config(config)
|
||||
|
||||
|
||||
def _load_config(config_value):
|
||||
if isinstance(config_value, dict):
|
||||
return config_value
|
||||
if isinstance(config_value, str):
|
||||
return json.loads(config_value)
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Normalize existing pipeline config containers."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
table_name = 'legacy_pipelines'
|
||||
|
||||
# Check if pipeline table exists (may not exist in fresh install)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
# Get all pipelines
|
||||
result = conn.execute(sa.text(f'SELECT uuid, config FROM {table_name}'))
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline_uuid, config_json in pipelines:
|
||||
if not config_json:
|
||||
continue
|
||||
|
||||
try:
|
||||
config = _load_config(config_json)
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
migrated_config = migrate_pipeline_config(config)
|
||||
|
||||
# Only update if config changed
|
||||
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE {table_name} SET config = :config WHERE uuid = :uuid'),
|
||||
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
|
||||
)
|
||||
except Exception:
|
||||
# Skip invalid configs
|
||||
continue
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade is not supported for data migration."""
|
||||
# No downgrade - keep configs in new format
|
||||
pass
|
||||
@@ -0,0 +1,148 @@
|
||||
"""add_event_log_and_transcript_tables
|
||||
|
||||
Revision ID: 58846a8d7a81
|
||||
Revises: 0005_migrate_runner_config
|
||||
Create Date: 2026-05-23 15:41:47.030841
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '58846a8d7a81'
|
||||
down_revision = '0005_migrate_runner_config'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||
if not _table_exists(table_name) or _column_exists(table_name, column.name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.add_column(column)
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create event_log table
|
||||
if not _table_exists('event_log'):
|
||||
op.create_table(
|
||||
'event_log',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('event_id', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('event_type', sa.String(100), nullable=False),
|
||||
sa.Column('event_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('source', sa.String(50), nullable=False),
|
||||
sa.Column('bot_id', sa.String(255), nullable=True),
|
||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
||||
sa.Column('conversation_id', sa.String(255), nullable=True),
|
||||
sa.Column('thread_id', sa.String(255), nullable=True),
|
||||
sa.Column('actor_type', sa.String(50), nullable=True),
|
||||
sa.Column('actor_id', sa.String(255), nullable=True),
|
||||
sa.Column('actor_name', sa.String(255), nullable=True),
|
||||
sa.Column('subject_type', sa.String(50), nullable=True),
|
||||
sa.Column('subject_id', sa.String(255), nullable=True),
|
||||
sa.Column('input_summary', sa.Text(), nullable=True),
|
||||
sa.Column('input_json', sa.Text(), nullable=True),
|
||||
sa.Column('raw_ref', sa.String(255), nullable=True),
|
||||
sa.Column('run_id', sa.String(255), nullable=True),
|
||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# Create indexes for event_log
|
||||
_create_index_if_missing('event_log', 'ix_event_log_event_id', ['event_id'], unique=True)
|
||||
_create_index_if_missing('event_log', 'ix_event_log_event_type', ['event_type'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('event_log', 'ix_event_log_run_id', ['run_id'])
|
||||
|
||||
# Create transcript table
|
||||
if not _table_exists('transcript'):
|
||||
op.create_table(
|
||||
'transcript',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('transcript_id', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('event_id', sa.String(255), nullable=False),
|
||||
sa.Column('bot_id', sa.String(255), nullable=True),
|
||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
||||
sa.Column('conversation_id', sa.String(255), nullable=False),
|
||||
sa.Column('thread_id', sa.String(255), nullable=True),
|
||||
sa.Column('role', sa.String(50), nullable=False),
|
||||
sa.Column('item_type', sa.String(50), nullable=False, server_default='message'),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('content_json', sa.Text(), nullable=True),
|
||||
sa.Column('artifact_refs_json', sa.Text(), nullable=True),
|
||||
sa.Column('seq', sa.Integer(), nullable=False),
|
||||
sa.Column('run_id', sa.String(255), nullable=True),
|
||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True))
|
||||
_add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True))
|
||||
|
||||
# Create indexes for transcript
|
||||
_create_index_if_missing('transcript', 'ix_transcript_transcript_id', ['transcript_id'], unique=True)
|
||||
_create_index_if_missing('transcript', 'ix_transcript_event_id', ['event_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_seq', ['conversation_id', 'seq'])
|
||||
_create_index_if_missing('transcript', 'ix_transcript_conversation_created', ['conversation_id', 'created_at'])
|
||||
_create_index_if_missing(
|
||||
'transcript',
|
||||
'ix_transcript_scope_seq',
|
||||
['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'],
|
||||
)
|
||||
_create_index_if_missing('transcript', 'ix_transcript_run_id', ['run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop transcript table
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_run_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_scope_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_created')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_conversation_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_bot_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_event_id')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_transcript_id')
|
||||
|
||||
if _table_exists('transcript'):
|
||||
op.drop_table('transcript')
|
||||
|
||||
# Drop event_log table
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_run_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_conversation_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_bot_id')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_event_type')
|
||||
_drop_index_if_exists('event_log', 'ix_event_log_event_id')
|
||||
|
||||
if _table_exists('event_log'):
|
||||
op.drop_table('event_log')
|
||||
@@ -0,0 +1,94 @@
|
||||
# Alembic script.py.mako — template for auto-generated revisions
|
||||
"""add agent_runner_state table for host-owned persistent state
|
||||
|
||||
Revision ID: 6dfd3dd7f0c7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-05-23 19:49:08.529110
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '6dfd3dd7f0c7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
if not _table_exists('agent_runner_state'):
|
||||
op.create_table('agent_runner_state',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('runner_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('binding_identity', sa.String(length=255), nullable=False),
|
||||
sa.Column('scope', sa.String(length=50), nullable=False),
|
||||
sa.Column('scope_key', sa.String(length=512), nullable=False),
|
||||
sa.Column('state_key', sa.String(length=255), nullable=False),
|
||||
sa.Column('value_json', sa.Text(), nullable=True),
|
||||
sa.Column('bot_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('workspace_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('conversation_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('thread_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('actor_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('actor_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('subject_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('subject_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key')
|
||||
)
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_actor_id', ['actor_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_binding_identity', ['binding_identity'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_bot_id', ['bot_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing(
|
||||
'agent_runner_state',
|
||||
'ix_agent_runner_state_runner_binding',
|
||||
['runner_id', 'binding_identity'],
|
||||
)
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_runner_id', ['runner_id'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope', ['scope'])
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_binding')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_conversation_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_bot_id')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_binding_identity')
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_actor_id')
|
||||
|
||||
if _table_exists('agent_runner_state'):
|
||||
op.drop_table('agent_runner_state')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add transcript scope columns
|
||||
|
||||
Revision ID: 7b2c1d9e4f30
|
||||
Revises: 6dfd3dd7f0c7
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7b2c1d9e4f30'
|
||||
down_revision = '6dfd3dd7f0c7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
|
||||
if not _table_exists(table_name) or _column_exists(table_name, column.name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.add_column(column)
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)}
|
||||
if not set(columns).issubset(existing_columns):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True))
|
||||
_add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True))
|
||||
_create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id'])
|
||||
_create_index_if_missing(
|
||||
'transcript',
|
||||
'ix_transcript_scope_seq',
|
||||
['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'],
|
||||
)
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key')
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup')
|
||||
_create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key', ['scope_key'])
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_scope_seq')
|
||||
_drop_index_if_exists('transcript', 'ix_transcript_bot_id')
|
||||
if not _table_exists('transcript'):
|
||||
return
|
||||
existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns('transcript')}
|
||||
with op.batch_alter_table('transcript', schema=None) as batch_op:
|
||||
if 'workspace_id' in existing_columns:
|
||||
batch_op.drop_column('workspace_id')
|
||||
if 'bot_id' in existing_columns:
|
||||
batch_op.drop_column('bot_id')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""add_agent_artifact_table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 58846a8d7a81
|
||||
Create Date: 2026-05-23 20:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = '58846a8d7a81'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in sa.inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _table_exists(table_name) or _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.create_index(index_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
|
||||
if not _table_exists(table_name) or not _index_exists(table_name, index_name):
|
||||
return
|
||||
with op.batch_alter_table(table_name, schema=None) as batch_op:
|
||||
batch_op.drop_index(index_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create agent_artifact table
|
||||
if not _table_exists('agent_artifact'):
|
||||
op.create_table(
|
||||
'agent_artifact',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('artifact_id', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('artifact_type', sa.String(50), nullable=False),
|
||||
sa.Column('mime_type', sa.String(255), nullable=True),
|
||||
sa.Column('name', sa.String(255), nullable=True),
|
||||
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
|
||||
sa.Column('sha256', sa.String(64), nullable=True),
|
||||
sa.Column('source', sa.String(50), nullable=False),
|
||||
sa.Column('storage_key', sa.String(255), nullable=True),
|
||||
sa.Column('storage_type', sa.String(50), nullable=False, server_default='binary_storage'),
|
||||
sa.Column('conversation_id', sa.String(255), nullable=True),
|
||||
sa.Column('run_id', sa.String(255), nullable=True),
|
||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
||||
sa.Column('bot_id', sa.String(255), nullable=True),
|
||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# Create indexes for agent_artifact
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_artifact_id', ['artifact_id'], unique=True)
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_conversation_id', ['conversation_id'])
|
||||
_create_index_if_missing('agent_artifact', 'ix_agent_artifact_run_id', ['run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop agent_artifact table
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_run_id')
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_conversation_id')
|
||||
_drop_index_if_exists('agent_artifact', 'ix_agent_artifact_artifact_id')
|
||||
|
||||
if _table_exists('agent_artifact'):
|
||||
op.drop_table('agent_artifact')
|
||||
@@ -11,6 +11,7 @@ from ...entity.persistence import (
|
||||
pipeline as persistence_pipeline,
|
||||
bot as persistence_bot,
|
||||
)
|
||||
from ...agent.runner.config_migration import LEGACY_RUNNER_ID_MAP
|
||||
|
||||
|
||||
@migration.migration_class(1)
|
||||
@@ -114,21 +115,28 @@ class DBMigrateV3Config(migration.DBMigration):
|
||||
pipeline_config = default_pipeline['config']
|
||||
|
||||
# ai
|
||||
pipeline_config['ai']['runner'] = {
|
||||
'runner': self.ap.provider_cfg.data['runner'],
|
||||
ai_config = pipeline_config.setdefault('ai', {})
|
||||
runner_name = self.ap.provider_cfg.data['runner']
|
||||
runner_id = LEGACY_RUNNER_ID_MAP.get(runner_name, '')
|
||||
ai_config['runner'] = {
|
||||
'id': runner_id,
|
||||
}
|
||||
pipeline_config['ai']['local-agent']['model'] = model_uuid
|
||||
pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][
|
||||
'max-round'
|
||||
]
|
||||
runner_configs = ai_config.setdefault('runner_config', {})
|
||||
|
||||
pipeline_config['ai']['local-agent']['prompt'] = [
|
||||
local_agent_runner_id = LEGACY_RUNNER_ID_MAP['local-agent']
|
||||
local_agent_config = runner_configs.setdefault(local_agent_runner_id, {})
|
||||
local_agent_config['model'] = {
|
||||
'primary': model_uuid,
|
||||
'fallbacks': [],
|
||||
}
|
||||
|
||||
local_agent_config['prompt'] = [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': self.ap.provider_cfg.data['prompt']['default'],
|
||||
}
|
||||
]
|
||||
pipeline_config['ai']['dify-service-api'] = {
|
||||
runner_configs[LEGACY_RUNNER_ID_MAP['dify-service-api']] = {
|
||||
'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'],
|
||||
'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'],
|
||||
'api-key': self.ap.provider_cfg.data['dify-service-api'][
|
||||
@@ -139,7 +147,7 @@ class DBMigrateV3Config(migration.DBMigration):
|
||||
self.ap.provider_cfg.data['dify-service-api']['app-type']
|
||||
]['timeout'],
|
||||
}
|
||||
pipeline_config['ai']['dashscope-app-api'] = {
|
||||
runner_configs[LEGACY_RUNNER_ID_MAP['dashscope-app-api']] = {
|
||||
'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'],
|
||||
'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'],
|
||||
'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][
|
||||
|
||||
@@ -21,11 +21,45 @@ class Controller:
|
||||
self.ap = ap
|
||||
self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])
|
||||
|
||||
async def _try_claim_steering_before_session_slot(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> bool:
|
||||
"""Claim steering while the normal per-session slot is still busy.
|
||||
|
||||
Follow-up input must be claimed before it waits behind the session
|
||||
semaphore; otherwise the active run can finish before the query reaches
|
||||
ChatMessageHandler.try_claim_steering_from_query.
|
||||
"""
|
||||
try:
|
||||
pipeline_uuid = query.pipeline_uuid
|
||||
if not pipeline_uuid:
|
||||
return False
|
||||
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
if not pipeline:
|
||||
return False
|
||||
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
query.session = session
|
||||
query.pipeline_config = pipeline.pipeline_entity.config
|
||||
query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins
|
||||
query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers
|
||||
|
||||
return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to claim query {query.query_id} as steering input: {exc}',
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
async def consumer(self):
|
||||
"""事件处理循环"""
|
||||
try:
|
||||
while True:
|
||||
selected_query: pipeline_query.Query = None
|
||||
claimed_steering_query: pipeline_query.Query = None
|
||||
|
||||
# 取请求
|
||||
async with self.ap.query_pool:
|
||||
@@ -36,6 +70,13 @@ class Controller:
|
||||
# Debug logging removed from tight loop to prevent excessive log generation
|
||||
# that can cause memory overflow in high-traffic scenarios
|
||||
|
||||
if session._semaphore.locked():
|
||||
if await self._try_claim_steering_before_session_slot(query):
|
||||
claimed_steering_query = query
|
||||
self.ap.logger.debug(f'Claimed query {query.query_id} as steering before session slot')
|
||||
break
|
||||
continue
|
||||
|
||||
if not session._semaphore.locked():
|
||||
selected_query = query
|
||||
await session._semaphore.acquire()
|
||||
@@ -44,7 +85,12 @@ class Controller:
|
||||
|
||||
break
|
||||
|
||||
if selected_query: # 找到了
|
||||
if claimed_steering_query:
|
||||
queries.remove(claimed_steering_query)
|
||||
self.ap.query_pool.cached_queries.pop(claimed_steering_query.query_id, None)
|
||||
self.ap.query_pool.condition.notify_all()
|
||||
continue
|
||||
elif selected_query: # 找到了
|
||||
queries.remove(selected_query)
|
||||
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
||||
await self.ap.query_pool.condition.wait()
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import stage, entities
|
||||
from . import truncator
|
||||
from ...utils import importutil
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from . import truncators
|
||||
|
||||
importutil.import_modules_in_pkg(truncators)
|
||||
|
||||
|
||||
@stage.stage_class('ConversationMessageTruncator')
|
||||
class ConversationMessageTruncator(stage.PipelineStage):
|
||||
"""Conversation message truncator
|
||||
|
||||
Used to truncate the conversation message chain to adapt to the LLM message length limit.
|
||||
"""
|
||||
|
||||
trun: truncator.Truncator
|
||||
|
||||
async def initialize(self, pipeline_config: dict):
|
||||
use_method = 'round'
|
||||
|
||||
for trun in truncator.preregistered_truncators:
|
||||
if trun.name == use_method:
|
||||
self.trun = trun(self.ap)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Unknown truncator: {use_method}')
|
||||
|
||||
async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:
|
||||
"""处理"""
|
||||
query = await self.trun.truncate(query)
|
||||
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
@@ -1,56 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import abc
|
||||
|
||||
from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
preregistered_truncators: list[typing.Type[Truncator]] = []
|
||||
|
||||
|
||||
def truncator_class(
|
||||
name: str,
|
||||
) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]:
|
||||
"""截断器类装饰器
|
||||
|
||||
Args:
|
||||
name (str): 截断器名称
|
||||
|
||||
Returns:
|
||||
typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器
|
||||
"""
|
||||
|
||||
def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]:
|
||||
assert issubclass(cls, Truncator)
|
||||
|
||||
cls.name = name
|
||||
|
||||
preregistered_truncators.append(cls)
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Truncator(abc.ABC):
|
||||
"""消息截断器基类"""
|
||||
|
||||
name: str
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
||||
"""截断
|
||||
|
||||
一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。
|
||||
请勿操作其他字段。
|
||||
"""
|
||||
pass
|
||||
@@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import truncator
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
|
||||
@truncator.truncator_class('round')
|
||||
class RoundTruncator(truncator.Truncator):
|
||||
"""Truncate the conversation message chain to adapt to the LLM message length limit."""
|
||||
|
||||
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
||||
"""截断"""
|
||||
max_round = query.pipeline_config['ai']['local-agent']['max-round']
|
||||
|
||||
temp_messages = []
|
||||
|
||||
current_round = 0
|
||||
|
||||
# Traverse from back to front
|
||||
for msg in query.messages[::-1]:
|
||||
if current_round < max_round:
|
||||
temp_messages.append(msg)
|
||||
if msg.role == 'user':
|
||||
current_round += 1
|
||||
else:
|
||||
break
|
||||
|
||||
query.messages = temp_messages[::-1]
|
||||
|
||||
return query
|
||||
@@ -28,7 +28,6 @@ from . import (
|
||||
wrapper,
|
||||
preproc,
|
||||
ratelimit,
|
||||
msgtrun,
|
||||
)
|
||||
|
||||
importutil.import_modules_in_pkgs(
|
||||
@@ -42,7 +41,6 @@ importutil.import_modules_in_pkgs(
|
||||
wrapper,
|
||||
preproc,
|
||||
ratelimit,
|
||||
msgtrun,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -278,8 +276,10 @@ class RuntimePipeline:
|
||||
|
||||
# Get runner name from pipeline config
|
||||
runner_name = None
|
||||
if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:
|
||||
runner_name = query.pipeline_config['ai']['runner'].get('runner')
|
||||
if query.pipeline_config:
|
||||
from ..agent.runner.config_migration import ConfigMigration
|
||||
|
||||
runner_name = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||
|
||||
# Record query start and store message_id
|
||||
message_id = ''
|
||||
@@ -438,6 +438,9 @@ class PipelineManager:
|
||||
# initialize stage containers according to pipeline_entity.stages
|
||||
stage_containers: list[StageInstContainer] = []
|
||||
for stage_name in pipeline_entity.stages:
|
||||
if stage_name not in self.stage_dict:
|
||||
self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping')
|
||||
continue
|
||||
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
|
||||
|
||||
for stage_container in stage_containers:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user