mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
21 Commits
feat/agent
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b68ff1956c | ||
|
|
7e5d74a1ad | ||
|
|
8a42fd8b21 | ||
|
|
4b9aa20985 | ||
|
|
7328881e6f | ||
|
|
197e117900 | ||
|
|
417b83d3aa | ||
|
|
950da65797 | ||
|
|
3ed35593e9 | ||
|
|
63bdee22b4 | ||
|
|
c55db54fd2 | ||
|
|
57f2e85388 | ||
|
|
503d29ffed | ||
|
|
05f370ca49 | ||
|
|
c7e8eb1214 | ||
|
|
5c182c0f29 | ||
|
|
e4a471af18 | ||
|
|
dfcf9d10e4 | ||
|
|
eb475245ab | ||
|
|
d1b7d56392 | ||
|
|
9f23f4c572 |
109
.github/workflows/run-tests.yml
vendored
109
.github/workflows/run-tests.yml
vendored
@@ -4,29 +4,25 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'src/langbot/**'
|
- 'pkg/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'uv.lock'
|
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
- 'scripts/test-*.sh'
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
paths:
|
paths:
|
||||||
- 'src/langbot/**'
|
- 'pkg/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'uv.lock'
|
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
- 'scripts/test-*.sh'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Run Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -43,13 +39,28 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v4
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: |
|
||||||
|
uv sync --dev
|
||||||
|
|
||||||
- name: Run unit + smoke tests
|
- name: Run unit tests
|
||||||
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
run: |
|
||||||
|
bash run_tests.sh
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
if: matrix.python-version == '3.12'
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
files: ./coverage.xml
|
||||||
|
flags: unit-tests
|
||||||
|
name: unit-tests-coverage
|
||||||
|
fail_ci_if_error: false
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
- name: Test Summary
|
- name: Test Summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -58,79 +69,3 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Fast Integration Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Run fast integration tests
|
|
||||||
run: uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
|
||||||
|
|
||||||
- name: Integration Test Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## Integration Tests Results" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
coverage:
|
|
||||||
name: Coverage Gate
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test, integration]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --dev
|
|
||||||
|
|
||||||
- name: Run coverage (unit + smoke)
|
|
||||||
run: |
|
|
||||||
uv run pytest tests/unit_tests/ tests/smoke/ \
|
|
||||||
--cov=langbot \
|
|
||||||
--cov-report=xml \
|
|
||||||
--cov-report=term-missing \
|
|
||||||
--cov-fail-under=18 \
|
|
||||||
-q --tb=short
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: ./coverage.xml
|
|
||||||
flags: unit-tests
|
|
||||||
name: coverage-report
|
|
||||||
fail_ci_if_error: false
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Coverage Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
109
.github/workflows/test-migrations.yml
vendored
109
.github/workflows/test-migrations.yml
vendored
@@ -9,13 +9,11 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'src/langbot/pkg/persistence/**'
|
- 'src/langbot/pkg/persistence/**'
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
- 'tests/integration/persistence/**'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
paths:
|
paths:
|
||||||
- 'src/langbot/pkg/persistence/**'
|
- 'src/langbot/pkg/persistence/**'
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
- 'tests/integration/persistence/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-migrations-sqlite:
|
test-migrations-sqlite:
|
||||||
@@ -36,8 +34,52 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Run SQLite migration tests
|
- name: Test Alembic upgrade (SQLite)
|
||||||
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
run: |
|
||||||
|
uv run python -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
|
||||||
|
|
||||||
|
# Create all tables (simulates existing DB)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None, 'Expected a revision after upgrade'
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: upgrade from scratch
|
||||||
|
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All SQLite migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|
||||||
test-migrations-postgres:
|
test-migrations-postgres:
|
||||||
name: Migrations (PostgreSQL)
|
name: Migrations (PostgreSQL)
|
||||||
@@ -72,7 +114,58 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Run PostgreSQL migration tests
|
- name: Test Alembic upgrade (PostgreSQL)
|
||||||
env:
|
run: |
|
||||||
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
uv run python -c "
|
||||||
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine(DB_URL)
|
||||||
|
|
||||||
|
# Create all tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: drop all and upgrade from scratch
|
||||||
|
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
|
||||||
|
|
||||||
|
# Create fresh database
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text('COMMIT'))
|
||||||
|
await conn.execute(text('CREATE DATABASE langbot_fresh'))
|
||||||
|
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All PostgreSQL migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|||||||
36
Makefile
36
Makefile
@@ -1,36 +0,0 @@
|
|||||||
# LangBot Makefile
|
|
||||||
# Quick developer commands
|
|
||||||
|
|
||||||
.PHONY: test test-quick test-integration-fast test-coverage test-all-local lint
|
|
||||||
|
|
||||||
# Run all tests (full suite with coverage)
|
|
||||||
test:
|
|
||||||
bash run_tests.sh
|
|
||||||
|
|
||||||
# Quick self-test for developers (lint + unit + smoke, no real credentials needed)
|
|
||||||
test-quick:
|
|
||||||
bash scripts/test-quick.sh
|
|
||||||
|
|
||||||
# Fast integration tests (SQLite/API/Pipeline, no external services)
|
|
||||||
test-integration-fast:
|
|
||||||
bash scripts/test-integration-fast.sh
|
|
||||||
|
|
||||||
# Coverage gate (all tests, enforces minimum threshold)
|
|
||||||
test-coverage:
|
|
||||||
bash scripts/test-coverage.sh
|
|
||||||
|
|
||||||
# Full local quality gate (quick + integration + coverage)
|
|
||||||
test-all-local:
|
|
||||||
bash scripts/test-quick.sh
|
|
||||||
bash scripts/test-integration-fast.sh
|
|
||||||
bash scripts/test-coverage.sh
|
|
||||||
|
|
||||||
# Run linting only
|
|
||||||
lint:
|
|
||||||
ruff check src/langbot/ tests/
|
|
||||||
ruff format --check src/langbot/ tests/
|
|
||||||
|
|
||||||
# Fix linting issues
|
|
||||||
lint-fix:
|
|
||||||
ruff check --fix src/langbot/ tests/
|
|
||||||
ruff format src/langbot/ tests/
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
# Agent-owned Context 协议设计
|
|
||||||
|
|
||||||
本文档描述插件化 AgentRunner 场景下的上下文边界。结论先行:LangBot 不应成为最终 agentic context manager;LangBot 应提供 context substrate,AgentRunner 或其背后的 agent runtime 自己决定如何管理历史、压缩、召回和 KV cache。
|
|
||||||
|
|
||||||
## 当前状态
|
|
||||||
|
|
||||||
**当前分支已落地**:
|
|
||||||
|
|
||||||
- ✅ `AgentRunContext` — event-first context 模型
|
|
||||||
- ✅ `ContextAccess` — cursor、inline policy、available APIs
|
|
||||||
- ✅ `AgentRunAPIProxy.history` — page/search API
|
|
||||||
- ✅ `AgentRunAPIProxy.events` — get/page API
|
|
||||||
- ✅ `AgentRunAPIProxy.artifacts` — metadata/read_range API
|
|
||||||
- ✅ `AgentRunAPIProxy.state` — get/set/delete API
|
|
||||||
- ✅ EventLog / Transcript / ArtifactStore — host 事实源
|
|
||||||
- ✅ PersistentStateStore — 持久化状态存储
|
|
||||||
- ✅ `max-round` / host-side history window 已从 LangBot Host/Pipeline 语义中移除;如某 runner 仍需要类似参数,应由该 runner 自己解释配置
|
|
||||||
- ✅ 外部 harness context projection 已用 Claude Code runner 做 MVP 验证:context 文件、skill 投影、MCP 配置和 host-owned resume state
|
|
||||||
|
|
||||||
## 1. 设计原则
|
|
||||||
|
|
||||||
### 1.1 Agent 拥有上下文策略
|
|
||||||
|
|
||||||
不同 runner 背后的 runtime 差异很大:
|
|
||||||
|
|
||||||
- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。
|
|
||||||
- Claude Code SDK / Codex 类 runtime 可能有自己的 session、transcript、tool loop 和上下文压缩。
|
|
||||||
- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。
|
|
||||||
|
|
||||||
因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:
|
|
||||||
|
|
||||||
- 当前事件的完整结构化信息。
|
|
||||||
- 稳定身份和会话引用。
|
|
||||||
- 可授权读取的 history / event / artifact / state API。
|
|
||||||
- 可投影给外部 harness 的 scoped context、MCP、skill 和 resource refs。
|
|
||||||
- payload hard cap 和权限 guardrail。
|
|
||||||
|
|
||||||
### 1.2 不再把 `max-round` 作为目标设计
|
|
||||||
|
|
||||||
`max-round` 这类历史窗口参数不应继续作为 AgentRunner 协议或 Pipeline adapter 的核心概念。
|
|
||||||
|
|
||||||
如果某个 runner 仍需要“最多读取多少轮历史”这样的策略参数,应由该 runner 在自己的 manifest/config schema 中声明,并作为 binding config 存到 `ctx.config` / `runner_config`。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。
|
|
||||||
|
|
||||||
当前 official local-agent 方向是通过 Host history API 拉取 transcript,并由 runner 自己管理模型上下文。它不依赖 Pipeline adapter 下发历史窗口。
|
|
||||||
|
|
||||||
新协议不应该问“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 bootstrap window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。
|
|
||||||
|
|
||||||
## 2. Event 到来时传什么
|
|
||||||
|
|
||||||
默认 `AgentRunContext` 应尽量小且稳定:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunContext(BaseModel):
|
|
||||||
run_id: str
|
|
||||||
trigger: AgentTrigger
|
|
||||||
event: AgentEventContext
|
|
||||||
conversation: ConversationContext | None
|
|
||||||
actor: ActorContext | None
|
|
||||||
subject: SubjectContext | None
|
|
||||||
input: AgentInput
|
|
||||||
delivery: DeliveryContext
|
|
||||||
resources: AgentResources
|
|
||||||
context: ContextAccess
|
|
||||||
state: AgentRunState
|
|
||||||
runtime: AgentRuntimeContext
|
|
||||||
config: dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
默认规则:
|
|
||||||
|
|
||||||
- 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 APIs when authorized.
|
|
||||||
- Official runners MUST consume Host infrastructure through the same public APIs as third-party runners.
|
|
||||||
|
|
||||||
### 2.1 必须 inline 的内容
|
|
||||||
|
|
||||||
每次 run 必须 inline:
|
|
||||||
|
|
||||||
- 当前 event 的稳定类型、id、时间、source。
|
|
||||||
- 当前输入文本和结构化内容。
|
|
||||||
- 附件 / 文件 / 图片的 metadata 和 artifact ref。
|
|
||||||
- actor、subject、conversation、thread、bot、workspace。
|
|
||||||
- delivery 能力,例如是否支持 streaming、reply target、平台限制。
|
|
||||||
- 已授权资源列表。
|
|
||||||
- context cursors 和可用 API 能力。
|
|
||||||
- runner binding config。
|
|
||||||
|
|
||||||
这些是 agent 决定下一步需要的最低信息。
|
|
||||||
|
|
||||||
### 2.2 默认不 inline 的内容
|
|
||||||
|
|
||||||
默认不要 inline:
|
|
||||||
|
|
||||||
- 完整历史消息。
|
|
||||||
- 大文件全文。
|
|
||||||
- 大工具结果。
|
|
||||||
- 全量知识库内容。
|
|
||||||
- 平台原始 payload 大对象。
|
|
||||||
- 每轮重新生成的大段 summary。
|
|
||||||
|
|
||||||
这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。
|
|
||||||
|
|
||||||
### 2.3 不提供 Host Bootstrap Window
|
|
||||||
|
|
||||||
`AgentRunContext.bootstrap` 可以作为协议里的可选扩展字段保留,但 LangBot Host 默认不填历史窗口,也不通过 Pipeline 配置决定窗口大小。
|
|
||||||
|
|
||||||
如果 runner 需要类似 `recent_tail` 的策略,它应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 `history_page` / `history_search` 读取、裁剪和压缩历史。Host 只负责权限、分页、hard cap 和事实源。
|
|
||||||
|
|
||||||
## 3. ContextAccess
|
|
||||||
|
|
||||||
`ContextAccess` 是 host 交给 agent 的上下文读取入口描述:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ContextAccess(BaseModel):
|
|
||||||
conversation_id: str | None
|
|
||||||
thread_id: str | None
|
|
||||||
latest_cursor: str | None
|
|
||||||
event_seq: int | None
|
|
||||||
transcript_seq: int | None
|
|
||||||
has_history_before: bool
|
|
||||||
inline_policy: InlineContextPolicy
|
|
||||||
available_apis: ContextAPICapabilities
|
|
||||||
```
|
|
||||||
|
|
||||||
它告诉 agent:
|
|
||||||
|
|
||||||
- 当前事件位于哪条 conversation / thread。
|
|
||||||
- 若需要更多历史,从哪个 cursor 开始拉。
|
|
||||||
- host inline 了什么,没 inline 什么。
|
|
||||||
- 当前 run 有哪些 context API 权限。
|
|
||||||
|
|
||||||
## 4. Agent 如何获取更多上下文
|
|
||||||
|
|
||||||
所有 API 都必须走 `AgentRunAPIProxy`,并由 host 用 `run_id` 校验。
|
|
||||||
|
|
||||||
### 4.1 History API
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.history.page(
|
|
||||||
conversation_id=ctx.context.conversation_id,
|
|
||||||
before_cursor=ctx.context.latest_cursor,
|
|
||||||
limit=50,
|
|
||||||
direction="backward",
|
|
||||||
include_artifacts=False,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class HistoryPage(BaseModel):
|
|
||||||
items: list[TranscriptItem]
|
|
||||||
next_cursor: str | None
|
|
||||||
prev_cursor: str | None
|
|
||||||
has_more: bool
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- `limit` 有 host hard cap。
|
|
||||||
- 默认只能读当前 conversation / thread。
|
|
||||||
- 跨会话读取必须有 manifest permission + binding policy。
|
|
||||||
- 返回 artifact ref,不默认返回大文件内容。
|
|
||||||
|
|
||||||
### 4.2 Search API
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.history.search(
|
|
||||||
query="用户之前提到的数据库连接信息",
|
|
||||||
filters={
|
|
||||||
"conversation_id": ctx.context.conversation_id,
|
|
||||||
"event_types": ["message.received"],
|
|
||||||
},
|
|
||||||
top_k=10,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Search 可以先用数据库全文索引,后续再接 embedding recall。它是 host 提供的检索能力,不等于 agent 的长期记忆策略。
|
|
||||||
|
|
||||||
### 4.3 Event API
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.events.get(event_id)
|
|
||||||
await api.events.page(before_cursor=..., limit=...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Event API 用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。
|
|
||||||
|
|
||||||
### 4.4 Artifact API
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.artifacts.metadata(artifact_id)
|
|
||||||
await api.artifacts.read_range(artifact_id, offset=0, length=65536)
|
|
||||||
await api.artifacts.open_stream(artifact_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- 校验 artifact 所属 conversation / run / binding。
|
|
||||||
- 校验 MIME、大小、过期时间和权限。
|
|
||||||
- 大文件按 range/stream 读取。
|
|
||||||
- 工具大结果也应 artifact 化。
|
|
||||||
|
|
||||||
### 4.5 State API
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.state.get(scope="conversation", key="external.session_id")
|
|
||||||
await api.state.set(scope="conversation", key="summary.checkpoint", value=...)
|
|
||||||
```
|
|
||||||
|
|
||||||
State 是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用。
|
|
||||||
|
|
||||||
### 4.6 External harness context projection
|
|
||||||
|
|
||||||
Claude Code、Codex、Kimi Code 这类 runtime 通常已经有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把这类 runner 强行改造成“host prompt assembler”,而应提供可审计的事件和资源投影。
|
|
||||||
|
|
||||||
推荐 projection 形态:
|
|
||||||
|
|
||||||
- `agent-context.json`:结构化 JSON,包含 `run_id`、`event`、`actor`、`subject`、`input`、`delivery`、`resources`、`context`、`state`、`runtime`。
|
|
||||||
- `LANGBOT_CONTEXT.md`:人类可读摘要,用于 code-agent harness 快速理解当前 IM 事件。
|
|
||||||
- `resources`:只包含本次 run 授权后的模型、工具、知识库、artifact、state/storage 句柄,不暴露 Host 内部私有对象。
|
|
||||||
- `skills`:Host 或 binding 把已授权 skill 投影为目标 harness 可读目录,例如 Claude Code 的 `.claude/skills/<name>/SKILL.md`。
|
|
||||||
- `MCP config`:Host 或 binding 提供 scoped MCP 配置,runner adapter 转成目标 harness 的配置文件或 CLI 参数。
|
|
||||||
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存,例如 `external.session_id`、`external.working_directory`。
|
|
||||||
|
|
||||||
当前 Claude Code runner MVP 使用 schema `langbot.agent_runner.external_harness_context.v1`,并已通过 WebUI Debug Chat 验证 context 文件、skill 文件、MCP config 和 resume state 的基本链路。
|
|
||||||
|
|
||||||
这类 projection 是“把 LangBot 事实源和授权资源交给 harness”,不是“由 LangBot 决定最终模型上下文”。外部 harness 可以继续使用自己的 transcript、工具权限和压缩策略。
|
|
||||||
|
|
||||||
## 5. Runner manifest 中的上下文声明
|
|
||||||
|
|
||||||
建议增加:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
context:
|
|
||||||
ownership: self_managed | host_bootstrap | hybrid
|
|
||||||
bootstrap: none | current_event | recent_tail | summary_tail
|
|
||||||
max_inline_events: 0
|
|
||||||
max_inline_bytes: 0
|
|
||||||
supports_history_pull: true
|
|
||||||
supports_history_search: true
|
|
||||||
supports_artifact_pull: true
|
|
||||||
owns_compaction: true
|
|
||||||
wants_static_context_refs: true
|
|
||||||
```
|
|
||||||
|
|
||||||
语义:
|
|
||||||
|
|
||||||
- `self_managed`: Host 不主动 inline 历史,只提供 event 和 handles。
|
|
||||||
- `host_bootstrap`: Host 为简单 runner inline 一个小窗口。
|
|
||||||
- `hybrid`: Host inline summary/tail,runner 仍可按需拉更多。
|
|
||||||
- `owns_compaction`: runner 负责压缩,host 不做语义摘要。
|
|
||||||
- `wants_static_context_refs`: host 用 ref/hash 描述静态内容,减少重复 payload。
|
|
||||||
|
|
||||||
## 6. KV cache 友好的上下文管理
|
|
||||||
|
|
||||||
如果目标是支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime,必须避免每轮由 LangBot 重组大块 prompt。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。
|
|
||||||
- 静态内容使用 `ref + version/hash`:system prompt、resource manifest、tool schema、platform policy。
|
|
||||||
- 每轮只传 delta:当前 event、artifact refs、少量 runtime metadata。
|
|
||||||
- 历史 append-only:不要每轮改写同一段 history 文本。
|
|
||||||
- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint,不要每轮微调。
|
|
||||||
- 大文件和工具结果 artifact 化。
|
|
||||||
- Tool/context API schema 稳定,数据通过 API 拉取,而不是塞入 prompt。
|
|
||||||
- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。
|
|
||||||
|
|
||||||
## 7. Host guardrail
|
|
||||||
|
|
||||||
Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:
|
|
||||||
|
|
||||||
- 每次 run 的 active `run_id`。
|
|
||||||
- runner identity。
|
|
||||||
- 当前 binding 的 resource policy。
|
|
||||||
- conversation / actor / subject scope。
|
|
||||||
- page size、artifact read size、API rate limit。
|
|
||||||
- 跨会话读取权限。
|
|
||||||
- 数据脱敏和敏感变量过滤。
|
|
||||||
- 审计日志。
|
|
||||||
|
|
||||||
Host 不负责“最佳上下文策略”,但负责“不越权、不爆内存、不不可审计”。
|
|
||||||
|
|
||||||
## 8. 官方 runner 与业务编排边界
|
|
||||||
|
|
||||||
官方 runner 插件可以选择把状态寄宿在 LangBot,但它们必须和第三方 runner 一样通过公开 Host APIs 消费这些能力。
|
|
||||||
|
|
||||||
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` 或 `api.storage` 保存。
|
|
||||||
- 图片、文件、工具大结果通过 `api.artifacts` 读取。
|
|
||||||
- 模型、工具、知识库通过 `api.models`、`api.tools`、`api.knowledge` 调用。
|
|
||||||
|
|
||||||
这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。
|
|
||||||
|
|
||||||
## 9. 当前实现需要调整
|
|
||||||
|
|
||||||
**已完成(当前分支)**:
|
|
||||||
|
|
||||||
- ✅ `max-round` 不再是协议字段,也不再是 Host / Pipeline 通用语义
|
|
||||||
- ✅ 新 runner 默认不收到历史窗口
|
|
||||||
- ✅ `AgentRunContext` 增加 `context` / cursor / access capabilities
|
|
||||||
- ✅ `AgentRunAPIProxy` 增加 history / events / artifacts / state API
|
|
||||||
- ✅ Host 增加持久 EventLog / Transcript / ArtifactStore / PersistentStateStore
|
|
||||||
- ✅ `run_from_query()` 委托到 event-first `run(event, binding)`
|
|
||||||
- ✅ Claude Code external harness smoke:context JSON / Markdown、skill、MCP config、`external.session_id` / `external.working_directory`
|
|
||||||
|
|
||||||
这样 LangBot 既能服务依附 host 基础设施的官方 runner,也能服务自带 memory/session/cache 的外部 agent runtime。
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
# Event Based Agent 预留设计
|
|
||||||
|
|
||||||
> **注意**:本文档是 future design note,不是当前分支实现范围。
|
|
||||||
>
|
|
||||||
> EventGateway、EventRouter、Event subscription/notification 由其他分支实现。
|
|
||||||
> 本分支只预留 event-first 入口和 envelope/binding models。
|
|
||||||
> 2026-05-29 的 local-agent / Claude Code runner smoke 只验证本分支的 `run(event, binding)` 调度边界,不表示 EBA 分支已经完成联调。
|
|
||||||
|
|
||||||
本文档描述未来 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。
|
|
||||||
|
|
||||||
本阶段不实现完整 EventBus / EventRouter / Platform API。本阶段要做的是把协议边界设计对,避免当前消息入口继续绑死 Pipeline 和用户文本消息。
|
|
||||||
|
|
||||||
## 1. 设计目标
|
|
||||||
|
|
||||||
- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。
|
|
||||||
- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 AgentBinding。
|
|
||||||
- AgentRunner 通过同一套 orchestrator 被调用。
|
|
||||||
- 非消息事件不伪造成用户文本消息。
|
|
||||||
- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。
|
|
||||||
|
|
||||||
## 2. 事件不是消息
|
|
||||||
|
|
||||||
`message.received` 只是事件的一种。协议不应假设:
|
|
||||||
|
|
||||||
- 一定有用户文本。
|
|
||||||
- 一定有 conversation history。
|
|
||||||
- 一定要返回一条聊天消息。
|
|
||||||
- actor 一定等于 sender。
|
|
||||||
- subject 一定等于当前消息。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
| event_type | actor | subject | input |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 |
|
|
||||||
| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 |
|
|
||||||
| `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 |
|
|
||||||
| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 |
|
|
||||||
| `schedule.triggered` | 系统 | 定时任务 | 任务 payload |
|
|
||||||
| `api.invoked` | API caller | API request | request payload |
|
|
||||||
|
|
||||||
## 3. Event Envelope
|
|
||||||
|
|
||||||
建议事件 envelope:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentEventEnvelope(BaseModel):
|
|
||||||
event_id: str
|
|
||||||
event_type: str
|
|
||||||
event_time: int | None
|
|
||||||
source: EventSource
|
|
||||||
workspace_id: str | None
|
|
||||||
bot_id: str | None
|
|
||||||
conversation_id: str | None
|
|
||||||
thread_id: str | None
|
|
||||||
actor: ActorRef | None
|
|
||||||
subject: SubjectRef | None
|
|
||||||
input: AgentInput
|
|
||||||
delivery: DeliveryContext
|
|
||||||
raw_ref: RawEventRef | None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
顶层字段使用 LangBot 稳定协议名。平台原始事件名和原始 payload 放到 `metadata` 或 `raw_ref`,不直接成为 runner 的稳定依赖。
|
|
||||||
|
|
||||||
## 4. Event Source
|
|
||||||
|
|
||||||
事件来源可以包括:
|
|
||||||
|
|
||||||
- `platform_adapter`: 飞书、QQ、微信、Telegram 等 IM 平台。
|
|
||||||
- `webui`: Debug Chat、控制台操作。
|
|
||||||
- `http_api`: 外部系统调用 LangBot。
|
|
||||||
- `scheduler`: 定时任务。
|
|
||||||
- `system`: runtime、plugin、maintenance 事件。
|
|
||||||
|
|
||||||
同一个 event source 可以产生多个 event type。EventRouter 不应该写死平台 adapter 的类名。
|
|
||||||
|
|
||||||
## 5. Event Binding
|
|
||||||
|
|
||||||
EBA 中,AgentBinding 取代 Pipeline runner 配置成为触发关系:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentBinding(BaseModel):
|
|
||||||
binding_id: str
|
|
||||||
enabled: bool
|
|
||||||
event_types: list[str]
|
|
||||||
scope: BindingScope
|
|
||||||
filters: list[EventFilter]
|
|
||||||
runner_id: str
|
|
||||||
runner_config: dict[str, Any]
|
|
||||||
resource_policy: ResourcePolicy
|
|
||||||
state_policy: StatePolicy
|
|
||||||
delivery_policy: DeliveryPolicy
|
|
||||||
```
|
|
||||||
|
|
||||||
Binding scope 示例:
|
|
||||||
|
|
||||||
- workspace 全局。
|
|
||||||
- bot 级别。
|
|
||||||
- platform channel 级别。
|
|
||||||
- conversation / group / thread 级别。
|
|
||||||
- user / actor 级别。
|
|
||||||
|
|
||||||
旧 Pipeline 可以迁移为 `message.received` 的 binding source,但不是唯一 binding source。
|
|
||||||
|
|
||||||
## 6. EventRouter 调用链
|
|
||||||
|
|
||||||
目标调用链:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Platform Adapter / WebUI / API
|
|
||||||
-> Event Gateway normalize payload
|
|
||||||
-> EventLog append raw event
|
|
||||||
-> EventRouter resolve bindings
|
|
||||||
-> AgentRunOrchestrator.run(event, binding)
|
|
||||||
-> AgentRunContextBuilder.build(event, binding)
|
|
||||||
-> PluginRuntimeConnector.run_agent()
|
|
||||||
-> AgentRunResult stream
|
|
||||||
-> DeliveryController render / platform action
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- `run_from_event()` 必须复用现有 orchestrator 能力。
|
|
||||||
- 不能为 EBA 单独实现另一套 plugin runner 调用协议。
|
|
||||||
- 不能让非消息事件绕过 resource authorization。
|
|
||||||
- Delivery 和 platform action 要走统一权限模型。
|
|
||||||
- 外部 harness runner 也应通过同一套 envelope/binding/context/result 协议接入;EBA 不应为 Claude Code / Codex / Kimi Code 单独发明队列协议。
|
|
||||||
|
|
||||||
## 7. Delivery Context
|
|
||||||
|
|
||||||
Event 不一定回复到当前聊天窗口。需要显式 delivery:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DeliveryContext(BaseModel):
|
|
||||||
surface: str
|
|
||||||
reply_target: ReplyTarget | None
|
|
||||||
supports_streaming: bool
|
|
||||||
supports_edit: bool
|
|
||||||
supports_reaction: bool
|
|
||||||
max_message_size: int | None
|
|
||||||
platform_capabilities: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
消息事件通常带 reply target。系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置。
|
|
||||||
|
|
||||||
## 8. AgentRunResult 与平台动作
|
|
||||||
|
|
||||||
当前消息路径主要消费:
|
|
||||||
|
|
||||||
- `message.delta`
|
|
||||||
- `message.completed`
|
|
||||||
- `run.completed`
|
|
||||||
- `run.failed`
|
|
||||||
|
|
||||||
EBA 后需要预留:
|
|
||||||
|
|
||||||
- `action.requested`: 请求 host 执行平台动作。
|
|
||||||
- `artifact.created`: runner 生成文件或大结果。
|
|
||||||
- `delivery.requested`: 请求投递到某个 surface。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "action.requested",
|
|
||||||
"data": {
|
|
||||||
"action": "friend.request.accept",
|
|
||||||
"target": {"platform": "wechat", "request_id": "..."},
|
|
||||||
"reason": "policy matched"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Host 必须校验:
|
|
||||||
|
|
||||||
- runner manifest 是否声明 platform_api capability。
|
|
||||||
- binding 是否授权该 action。
|
|
||||||
- actor / bot / workspace 是否允许。
|
|
||||||
- 是否需要人工审批。
|
|
||||||
|
|
||||||
本阶段如收到 `action.requested`,可以只记录 telemetry,不执行。
|
|
||||||
|
|
||||||
## 9. 与 Context 协议的关系
|
|
||||||
|
|
||||||
EBA 事件进入 AgentRunner 时仍使用 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的原则:
|
|
||||||
|
|
||||||
- inline 当前事件。
|
|
||||||
- 大 payload 用 raw/artifact ref。
|
|
||||||
- 不默认 inline 完整 history。
|
|
||||||
- agent 按需通过 API 拉 history/event/artifact/state。
|
|
||||||
- Host 保留 EventLog 和权限 guardrail。
|
|
||||||
|
|
||||||
非消息事件可以被投影进 Transcript,但不能强制伪装为 user message。AgentRunner 可以根据 event type 自己决定是否把它纳入模型上下文。
|
|
||||||
|
|
||||||
## 10. 当前实现与目标差距
|
|
||||||
|
|
||||||
**当前分支已落地(Event-first 基础设施)**:
|
|
||||||
|
|
||||||
- ✅ `AgentRunOrchestrator` — event-first `run(event, binding)` 入口
|
|
||||||
- ✅ `AgentRunContextBuilder` — event-first context 构建
|
|
||||||
- ✅ `AgentEventEnvelope` 模型
|
|
||||||
- ✅ `AgentBinding` 模型
|
|
||||||
- ✅ `AgentRunResult` 基础消息流
|
|
||||||
- ✅ `ctx.event` 的最小消息事件封装
|
|
||||||
- ✅ `PipelineAdapter` — Query → Event + Binding 转换
|
|
||||||
- ✅ `run_from_query()` → `run(event, binding)` 委托
|
|
||||||
- ✅ EventLog / Transcript / ArtifactStore
|
|
||||||
- ✅ History / Event / Artifact / State pull APIs
|
|
||||||
- ✅ 当前消息事件 path 已用 `local-agent` 与 Claude Code external harness runner 做本地 smoke
|
|
||||||
|
|
||||||
**其他分支负责(非本分支范围)**:
|
|
||||||
|
|
||||||
- EventGateway 实现
|
|
||||||
- EventRouter 实现
|
|
||||||
- Event subscription / notification
|
|
||||||
- EventLog 持久化管理 UI
|
|
||||||
- AgentBinding 持久化 UI
|
|
||||||
- 平台动作执行 (`action.requested` 执行器)
|
|
||||||
|
|
||||||
**未来 EBA 完整落地需要**:
|
|
||||||
|
|
||||||
- EventGateway 完整实现
|
|
||||||
- EventRouter 与 BindingResolver 集成
|
|
||||||
- AgentBinding 持久模型和 UI
|
|
||||||
- DeliveryContext 完整实现
|
|
||||||
- platform action permission model 和执行器
|
|
||||||
- 真实平台事件接入
|
|
||||||
|
|
||||||
## 11. 落地顺序
|
|
||||||
|
|
||||||
1. 先把当前 Pipeline 消息入口适配成 `message.received` event。
|
|
||||||
2. 增加 `AgentBinding` 抽象,先由 Pipeline config 生成。
|
|
||||||
3. `AgentRunContextBuilder` 改为从 event + binding 构造 context。
|
|
||||||
4. 引入 EventLog / Transcript。
|
|
||||||
5. 增加非消息事件的协议测试,不接真实平台。
|
|
||||||
6. 再接入真实 EventRouter 和 platform action。
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
# LangBot Host 与 SDK 基础设施设计
|
|
||||||
|
|
||||||
本文档描述 LangBot 和 SDK 为插件化 AgentRunner 共同提供的基础设施。它不以 Pipeline 为中心,也不以官方 local-agent 的实现方式为前提。
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
LangBot 要转为 agent host,而不是内置 runner 容器:
|
|
||||||
|
|
||||||
- 接收 IM、WebUI、API 和未来 EventRouter 产生的事件。
|
|
||||||
- 根据事件、bot、workspace、scope 解析应该调用的 agent binding。
|
|
||||||
- 发现、校验和调用插件提供的 AgentRunner。
|
|
||||||
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
|
|
||||||
- 接收 AgentRunner 返回的事件流,并投递到 IM、WebUI 或其他 output surface。
|
|
||||||
|
|
||||||
SDK 要提供稳定协议:
|
|
||||||
|
|
||||||
- `AgentRunner` 组件定义。
|
|
||||||
- runner manifest / capabilities / permissions / config schema。
|
|
||||||
- `AgentRunContext` 输入 envelope。
|
|
||||||
- `AgentRunResult` 输出事件流。
|
|
||||||
- `AgentRunAPIProxy` 运行期受限 API。
|
|
||||||
|
|
||||||
## 2. 非目标
|
|
||||||
|
|
||||||
- 不把 Pipeline 当作长期架构中心。
|
|
||||||
- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。
|
|
||||||
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。
|
|
||||||
- 不在 host 中实现通用 agentic prompt assembler。
|
|
||||||
- 不强制 runner 使用 LangBot state / storage;LangBot 只提供可选、受控的寄宿能力。
|
|
||||||
- **不实现 EventGateway**:EventGateway 是 future integration point,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。
|
|
||||||
|
|
||||||
## 3. 分层架构
|
|
||||||
|
|
||||||
目标结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
IM / WebUI / API / EventRouter (future)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Event Gateway (future - external event branch)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
AgentBindingResolver
|
|
||||||
|
|
|
||||||
v
|
|
||||||
AgentRunOrchestrator
|
|
||||||
|-- AgentRunnerRegistry
|
|
||||||
|-- AgentResourceBuilder
|
|
||||||
|-- AgentContextBuilder
|
|
||||||
|-- AgentRunSessionRegistry
|
|
||||||
|-- PersistentStateStore / EventLogStore / TranscriptStore / ArtifactStore
|
|
||||||
v
|
|
||||||
Plugin Runtime / AgentRunner
|
|
||||||
|
|
|
||||||
v
|
|
||||||
AgentRunResult stream
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Delivery / Renderer / Platform API
|
|
||||||
```
|
|
||||||
|
|
||||||
**当前状态**:
|
|
||||||
- `PipelineAdapter` 作为当前入口 adapter,将 Pipeline Query 转换为 `AgentEventEnvelope` + `AgentBinding`
|
|
||||||
- `run_from_query()` 内部委托到 `run(event, binding)`
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
|
|
||||||
- `local-agent` 与 Claude Code runner 已通过本地 WebUI smoke,验证同一条 `run(event, binding)` path 可服务 host-infra runner 与外部 harness runner
|
|
||||||
- EventGateway 由外部 event branch 实现
|
|
||||||
|
|
||||||
当前 Pipeline 只应接入在 Pipeline adapter 位置。它可以继续产生 `message.received`,但不应继续拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。
|
|
||||||
|
|
||||||
## 4. LangBot 侧能力
|
|
||||||
|
|
||||||
### 4.1 Event Gateway(Future Integration Point)
|
|
||||||
|
|
||||||
> **注意**:EventGateway 由外部 event branch 实现,不在本分支范围。本分支只预留 event-first 入口和 envelope/binding models。
|
|
||||||
|
|
||||||
Event Gateway 将负责把入口统一成 host event:
|
|
||||||
|
|
||||||
- IM 平台消息。
|
|
||||||
- WebUI debug chat 消息。
|
|
||||||
- API 触发。
|
|
||||||
- 后续非消息事件,例如入群、撤回、好友申请。
|
|
||||||
|
|
||||||
输出应是稳定 envelope,而不是 Pipeline Query 私有结构:
|
|
||||||
|
|
||||||
```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
|
|
||||||
delivery: DeliveryContext
|
|
||||||
raw_ref: RawEventRef | None
|
|
||||||
```
|
|
||||||
|
|
||||||
**当前 adapter source**:`PipelineAdapter.query_to_event(query)` 从 Pipeline Query 生成 `AgentEventEnvelope`。
|
|
||||||
|
|
||||||
原始平台 payload 可以存为 raw event 或 artifact ref;不要把平台私有字段直接扩散到 AgentRunner 顶层协议。
|
|
||||||
|
|
||||||
### 4.2 Agent Binding
|
|
||||||
|
|
||||||
Agent binding 是”什么事件调用哪个 runner、带什么绑定配置”的持久配置。它替代长期依赖 Pipeline runner config 的角色。
|
|
||||||
|
|
||||||
建议模型:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentBinding(BaseModel):
|
|
||||||
binding_id: str
|
|
||||||
scope: BindingScope
|
|
||||||
event_types: list[str]
|
|
||||||
runner_id: str
|
|
||||||
runner_config: dict[str, Any]
|
|
||||||
resource_policy: ResourcePolicy
|
|
||||||
state_policy: StatePolicy
|
|
||||||
delivery_policy: DeliveryPolicy
|
|
||||||
enabled: bool
|
|
||||||
```
|
|
||||||
|
|
||||||
**当前 adapter source**:`PipelineAdapter.pipeline_config_to_binding(query, runner_id)` 从 Pipeline config 生成临时 `AgentBinding`。
|
|
||||||
|
|
||||||
Pipeline 当前可以被迁移为一种 binding source:
|
|
||||||
|
|
||||||
- Pipeline AI runner config -> `AgentBinding`
|
|
||||||
- Pipeline extension preference -> `resource_policy`
|
|
||||||
- Pipeline output settings -> `delivery_policy`
|
|
||||||
|
|
||||||
但新设计不应再把这些字段命名为 Pipeline 专属概念。
|
|
||||||
|
|
||||||
### 4.3 AgentRunnerRegistry
|
|
||||||
|
|
||||||
Registry 负责收集 runner descriptor:
|
|
||||||
|
|
||||||
- 插件 runtime 提供的 `AgentRunner`。
|
|
||||||
- 可能存在的 host adapter runner。
|
|
||||||
- 开发期本地插件 runner。
|
|
||||||
|
|
||||||
Descriptor 必须包含:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerDescriptor(BaseModel):
|
|
||||||
id: str
|
|
||||||
source: Literal["plugin", "host_adapter"]
|
|
||||||
label: I18nObject
|
|
||||||
description: I18nObject | None = None
|
|
||||||
capabilities: AgentRunnerCapabilities
|
|
||||||
permissions: AgentRunnerPermissions
|
|
||||||
config_schema: list[DynamicFormItemSchema]
|
|
||||||
plugin: PluginRef | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
`plugin:author/name/runner` 仍可作为稳定 id 格式。多个 binding 指向同一个 runner id 时,不创建多个插件实例。
|
|
||||||
|
|
||||||
### 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_from_query()` 这类 API 可以保留为 Pipeline adapter 入口,但内部应转换成 event + binding 后走统一 `run()`。
|
|
||||||
|
|
||||||
### 4.5 Resource Authorization
|
|
||||||
|
|
||||||
LangBot 在每次 run 前生成 `ctx.resources`。资源来自三层约束:
|
|
||||||
|
|
||||||
- runner manifest 声明的 permissions。
|
|
||||||
- binding/resource policy 允许的资源范围。
|
|
||||||
- 当前 event / actor / bot / workspace 的实际权限。
|
|
||||||
|
|
||||||
资源类型包括:
|
|
||||||
|
|
||||||
- models
|
|
||||||
- tools
|
|
||||||
- knowledge bases
|
|
||||||
- files / artifacts
|
|
||||||
- storage
|
|
||||||
- platform capabilities
|
|
||||||
- history / transcript access
|
|
||||||
|
|
||||||
运行期 action 必须再次通过 `run_id` 校验。SDK 侧本地校验只用于开发体验,host 侧校验才是安全边界。
|
|
||||||
|
|
||||||
### 4.6 State 与 Storage
|
|
||||||
|
|
||||||
LangBot 可以提供 host-owned state,让 AgentRunner 把状态寄宿在 LangBot:
|
|
||||||
|
|
||||||
- conversation state
|
|
||||||
- actor state
|
|
||||||
- subject state
|
|
||||||
- runner/binding state
|
|
||||||
- workspace state
|
|
||||||
|
|
||||||
但这不是强制。外部 agent runtime 可以维护自己的 session 和 memory。LangBot 只需要提供:
|
|
||||||
|
|
||||||
- 授权开关。
|
|
||||||
- scope key。
|
|
||||||
- get/set/list/delete API。
|
|
||||||
- 持久化 backend。
|
|
||||||
- 审计和清理策略。
|
|
||||||
|
|
||||||
当前进程内 state store 只能作为过渡实现,不能作为正式生产语义。
|
|
||||||
|
|
||||||
### 4.7 EventLog / Transcript / Artifact
|
|
||||||
|
|
||||||
LangBot 应提供事实源能力:
|
|
||||||
|
|
||||||
- `EventLog`: 保存原始事件、系统事件、工具调用、投递结果、错误。
|
|
||||||
- `Transcript`: 面向对话 UI / agent history 的消息投影。
|
|
||||||
- `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。
|
|
||||||
|
|
||||||
AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。
|
|
||||||
|
|
||||||
### 4.8 Prompt / Instruction Package(占位)
|
|
||||||
|
|
||||||
旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata,
|
|
||||||
这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个
|
|
||||||
run-scoped instruction package,runner 通过 Host API 拉取:
|
|
||||||
|
|
||||||
- Host 负责记录静态绑定 prompt、host hook / user plugin 产生的 instruction
|
|
||||||
fragment、来源和审计信息。
|
|
||||||
- `ctx.context.available_apis.prompt_get` 只表示拉取能力是否可用。
|
|
||||||
- Runner 拉取 instruction package 后,仍由 runner 自己决定如何与 history、RAG、
|
|
||||||
tool 结果、memory 和当前输入组装最终模型 prompt。
|
|
||||||
- Host 不实现通用 agentic prompt assembler,也不把 Pipeline adapter prompt 作为
|
|
||||||
长期业务输入契约。
|
|
||||||
|
|
||||||
### 4.9 External harness resource projection
|
|
||||||
|
|
||||||
Claude Code、Codex、Kimi Code 等外部 harness runner 可能不会直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源投影到自己的 harness 中执行。Host 侧仍要保持统一边界:
|
|
||||||
|
|
||||||
- Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript/ArtifactStore 和审计。
|
|
||||||
- Host 或 binding policy 负责决定哪些 MCP server、skill、artifact、history/state 句柄可以投影给 runner。
|
|
||||||
- Runner plugin 负责把 scoped projection 转成目标 harness 可消费的形式,例如 context JSON/Markdown、MCP config、skill 目录、环境变量或 CLI 参数。
|
|
||||||
- 外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume 机制。
|
|
||||||
|
|
||||||
当前 Claude Code runner MVP 已验证:
|
|
||||||
|
|
||||||
- LangBot event-first context 可以写入 `agent-context.json` / `LANGBOT_CONTEXT.md`。
|
|
||||||
- binding 中的 skill / MCP 配置可以投影到 Claude Code 原生目录和 CLI 参数。
|
|
||||||
- `external.session_id` 与 `external.working_directory` 可以通过 Host state 保存并用于 resume。
|
|
||||||
|
|
||||||
发布级路径隔离、secret 过滤、MCP allowlist、工具白名单、资源配额和 workspace 清理不属于当前协议闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
|
|
||||||
## 5. SDK 侧协议
|
|
||||||
|
|
||||||
### 5.1 AgentRunner 组件
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunner(BaseComponent):
|
|
||||||
__kind__ = "AgentRunner"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_capabilities(cls) -> AgentRunnerCapabilities:
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_config_schema(cls) -> list[dict]:
|
|
||||||
...
|
|
||||||
|
|
||||||
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Capabilities
|
|
||||||
|
|
||||||
建议能力声明:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
capabilities:
|
|
||||||
streaming: true
|
|
||||||
tool_calling: true
|
|
||||||
knowledge_retrieval: true
|
|
||||||
multimodal_input: true
|
|
||||||
event_context: true
|
|
||||||
platform_api: false
|
|
||||||
interrupt: true
|
|
||||||
stateful_session: true
|
|
||||||
self_managed_context: true
|
|
||||||
host_state: optional
|
|
||||||
```
|
|
||||||
|
|
||||||
`self_managed_context` 表示 runner 或外部 runtime 自己管理上下文。Host 不应给它强塞历史窗口,只提供当前事件和 context handles。
|
|
||||||
|
|
||||||
### 5.3 Permissions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
models: ["invoke", "stream", "rerank"]
|
|
||||||
tools: ["detail", "call"]
|
|
||||||
knowledge_bases: ["list", "retrieve"]
|
|
||||||
history: ["page", "search"]
|
|
||||||
events: ["get", "page"]
|
|
||||||
artifacts: ["metadata", "read"]
|
|
||||||
storage: ["plugin", "workspace", "binding"]
|
|
||||||
files: ["config", "knowledge"]
|
|
||||||
platform_api: []
|
|
||||||
```
|
|
||||||
|
|
||||||
权限声明是 runner 需要的最大能力,实际可用资源仍由 binding 和当前运行上下文裁剪。
|
|
||||||
|
|
||||||
### 5.4 AgentRunContext
|
|
||||||
|
|
||||||
Context 顶层应是 event-first,而不是 Query-first:
|
|
||||||
|
|
||||||
```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
|
|
||||||
resources: AgentResources
|
|
||||||
context: ContextAccess
|
|
||||||
state: AgentRunState
|
|
||||||
runtime: AgentRuntimeContext
|
|
||||||
config: dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
`messages` 可以作为兼容字段或 bootstrap 字段,但不应继续是协议核心。
|
|
||||||
|
|
||||||
### 5.5 AgentRunResult
|
|
||||||
|
|
||||||
输出应是事件流:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunResult(BaseModel):
|
|
||||||
type: Literal[
|
|
||||||
"message.delta",
|
|
||||||
"message.completed",
|
|
||||||
"tool.call.started",
|
|
||||||
"tool.call.completed",
|
|
||||||
"state.updated",
|
|
||||||
"artifact.created",
|
|
||||||
"action.requested",
|
|
||||||
"run.completed",
|
|
||||||
"run.failed",
|
|
||||||
]
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
当前消息回复只消费 `message.delta` / `message.completed` / `run.failed`。平台动作执行等 EBA 和 platform API 权限落地后再启用。
|
|
||||||
|
|
||||||
### 5.6 AgentRunAPIProxy
|
|
||||||
|
|
||||||
Proxy 是 runner 访问 host 能力的唯一入口:
|
|
||||||
|
|
||||||
- model APIs
|
|
||||||
- tool APIs
|
|
||||||
- knowledge APIs
|
|
||||||
- state / storage APIs
|
|
||||||
- history / event APIs
|
|
||||||
- artifact APIs
|
|
||||||
- platform APIs
|
|
||||||
|
|
||||||
所有请求必须带 `run_id`,host 侧按 active run session 验证 runner identity 和 resource ACL。
|
|
||||||
|
|
||||||
## 6. 当前实现与目标差距
|
|
||||||
|
|
||||||
**已落地(当前分支)**:
|
|
||||||
|
|
||||||
- ✅ `AgentRunnerRegistry`
|
|
||||||
- ✅ `AgentRunOrchestrator` — event-first `run(event, binding)`
|
|
||||||
- ✅ `AgentRunContextBuilder` — event-first context
|
|
||||||
- ✅ `AgentResourceBuilder`
|
|
||||||
- ✅ `AgentRunSessionRegistry`
|
|
||||||
- ✅ `AgentRunAPIProxy` — model / tool / knowledge / history / event / artifact / state APIs
|
|
||||||
- ✅ `PipelineAdapter` — Query → Event + Binding
|
|
||||||
- ✅ `AgentBinding` 抽象
|
|
||||||
- ✅ `AgentEventEnvelope` 抽象
|
|
||||||
- ✅ `max-round` 从目标协议中移除;类似历史窗口参数若仍需要,应由具体 runner 的 manifest/config schema 暴露为 binding config
|
|
||||||
- ✅ `PersistentStateStore` — 持久化状态存储
|
|
||||||
- ✅ `EventLogStore` / `TranscriptStore` / `ArtifactStore`
|
|
||||||
- ✅ history / artifact / event 的受限拉取 API
|
|
||||||
- ✅ Claude Code external harness MVP:context/resource projection 与 host-owned resume state smoke
|
|
||||||
|
|
||||||
**其他分支负责(非本分支范围)**:
|
|
||||||
|
|
||||||
- EventGateway 实现
|
|
||||||
- EventRouter 实现
|
|
||||||
- AgentBinding 持久化 UI
|
|
||||||
- platform API 动作执行
|
|
||||||
- 发布级 security hardening
|
|
||||||
|
|
||||||
## 7. 落地顺序
|
|
||||||
|
|
||||||
**已完成**:
|
|
||||||
|
|
||||||
1. ✅ 固化 README 路由和专题文档边界。
|
|
||||||
2. ✅ 在 Host 中抽象 `AgentBinding`,由 Pipeline adapter 生成。
|
|
||||||
3. ✅ 将 `AgentRunContextBuilder` 改为 event-first。
|
|
||||||
4. ✅ 增加持久 transcript/event log/artifact/state 存储模型。
|
|
||||||
5. ✅ 扩展 `AgentRunAPIProxy` 的 history / artifact / state API。
|
|
||||||
6. ✅ 将 Pipeline-only 字段下沉到 Pipeline adapter。
|
|
||||||
7. ✅ 官方 runner 插件迁移完成(7 个插件)。
|
|
||||||
8. ✅ Claude Code runner MVP smoke:外部 harness context 投影和 state handoff。
|
|
||||||
|
|
||||||
**后续工作(其他分支)**:
|
|
||||||
|
|
||||||
- EventGateway 实现
|
|
||||||
- EventRouter 与 BindingResolver 集成
|
|
||||||
- 平台动作执行器
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
# Agent Runner 插件化当前实现与收尾计划
|
|
||||||
|
|
||||||
> 2026-05-29 状态说明:本文档是实现推进计划和历史上下文,不是最新验收结论的唯一来源。当前设计入口见 [README.md](./README.md),协议边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md),进度见 [PROGRESS.md](./PROGRESS.md),下一轮测试入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。
|
|
||||||
|
|
||||||
本文档面向实现 agent,用来把当前 AgentRunner 插件化实现推进到可迁移状态。
|
|
||||||
|
|
||||||
当前代码已经不是从零开始的 PoC。LangBot 已经具备 registry、orchestrator、context/resource builder、result normalizer 和插件 runtime action。本计划重点描述剩余工作:补齐宿主通用能力、对齐旧内置 runner 行为、完成官方 runner 插件迁移验收。
|
|
||||||
|
|
||||||
## 1. 最终状态
|
|
||||||
|
|
||||||
LangBot 最终只保留 Agent Runner 的宿主能力:
|
|
||||||
|
|
||||||
- 发现 runner:`AgentRunnerRegistry`
|
|
||||||
- 选择 runner:Pipeline 配置和未来事件绑定配置
|
|
||||||
- 构造上下文:`AgentRunContext`
|
|
||||||
- 裁剪资源:模型、工具、知识库、文件、存储、平台能力
|
|
||||||
- 调度执行:`AgentRunOrchestrator`
|
|
||||||
- 归一结果:`AgentRunResult` -> 当前 Pipeline 的 `Message` / `MessageChunk`
|
|
||||||
- 隔离错误:插件异常、协议错误、超时、结果过大不能破坏主流程
|
|
||||||
- 迁移旧配置:把旧内置 runner 配置迁到官方 AgentRunner 插件配置
|
|
||||||
- 转发调用:插件 runtime 只维护已安装插件本身的运行实例,Pipeline 不创建插件实例或 runner 实例
|
|
||||||
|
|
||||||
LangBot 不再长期维护内置业务 runner 分支。`local-agent`、Dify、n8n、Coze、DashScope、Langflow、Tbox 等都迁到官方 AgentRunner 插件。
|
|
||||||
|
|
||||||
迁移期间允许旧 `RequestRunner` 文件继续存在,作为行为对齐基准和回退分析材料。它们不影响当前进度;真正的最终条件是主聊天执行路径不再依赖旧 runner。
|
|
||||||
|
|
||||||
## 1.1 当前状态快照
|
|
||||||
|
|
||||||
已完成或基本完成:
|
|
||||||
|
|
||||||
- `AgentRunnerDescriptor`、runner id 解析、registry。
|
|
||||||
- `AgentRunOrchestrator` 替换 `ChatMessageHandler` 内部 runner 调度。
|
|
||||||
- `AgentRunContextBuilder`、`AgentResourceBuilder`、`AgentResultNormalizer`。
|
|
||||||
- `ai.runner.id` + `ai.runner_config[id]` 的读取与旧配置映射。
|
|
||||||
- AgentRunner runtime action:`LIST_AGENT_RUNNERS`、`RUN_AGENT`。
|
|
||||||
- run-scoped proxy authorization:模型、工具、知识库、存储、文件。
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore。
|
|
||||||
- Pipeline adapter 已委托到 event-first `run(event, binding)`。
|
|
||||||
- `local-agent` 与 Claude Code runner 已通过本地 WebUI smoke。
|
|
||||||
|
|
||||||
仍需收尾:
|
|
||||||
|
|
||||||
- Docs final QA 与安装/发布文档整理。
|
|
||||||
- timeout/deadline、取消、插件无输出、协议错误的端到端保护。
|
|
||||||
- 官方 runner 插件安装/预装/迁移缺失处理。
|
|
||||||
- 安全发布级 hardening:路径隔离、权限边界、secret、MCP/skill 投影策略、资源配额、审计。此项不阻塞当前协议闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
- Codex / Kimi runner 全量接入、issue-centric 队列、复杂 workflow engine 和 EBA 分支完整联调。
|
|
||||||
|
|
||||||
## 2. 高层架构
|
|
||||||
|
|
||||||
```text
|
|
||||||
Pipeline MessageProcessor / future EventRouter
|
|
||||||
|
|
|
||||||
v
|
|
||||||
AgentRunOrchestrator
|
|
||||||
|
|
|
||||||
+--> AgentRunnerRegistry
|
|
||||||
| +--> plugin runtime LIST_AGENT_RUNNERS
|
|
||||||
| +--> descriptor cache / validation
|
|
||||||
|
|
|
||||||
+--> AgentRunContextBuilder
|
|
||||||
+--> AgentResourceBuilder
|
|
||||||
+--> AgentResultNormalizer
|
|
||||||
|
|
|
||||||
v
|
|
||||||
PluginRuntimeConnector.run_agent()
|
|
||||||
|
|
|
||||||
v
|
|
||||||
SDK Runtime RUN_AGENT -> plugin AgentRunner.run()
|
|
||||||
```
|
|
||||||
|
|
||||||
关键约束:
|
|
||||||
|
|
||||||
- `ChatMessageHandler` 不解析 `plugin:*`,不实例化 wrapper,不知道 runner 组件细节。
|
|
||||||
- `PipelineService.get_pipeline_metadata()` 不直接访问插件 runtime,而是读取 registry。
|
|
||||||
- 旧 `RequestRunner` 只作为迁移参考,不作为最终运行路径。
|
|
||||||
- `AgentRunOrchestrator` 是 LangBot 侧运行编排层:负责 runner 绑定解析、资源授权、context envelope provisioning、run scope 注册、插件调用和结果归一化;不负责决定 Agent 的最终 prompt/window/压缩策略。
|
|
||||||
- 插件是无状态执行单元:多个 Pipeline 可以绑定同一个 runner id,并分别保存自己的 `ai.runner_config[id]`;运行时 LangBot 只把当前绑定配置放入 `ctx.config` 转发给同一个插件 runner。
|
|
||||||
- 禁止按 Pipeline 或 runner config 创建多个插件实例。需要跨请求持久化的状态必须走明确授权的 plugin storage / workspace storage / 外部服务,不能隐式保存在 per-pipeline 插件对象里。
|
|
||||||
- EBA 只做字段预留,不在本轮实现 EventBus、EventRouter、平台动作执行。
|
|
||||||
|
|
||||||
## 3. 新增 LangBot 模块
|
|
||||||
|
|
||||||
建议新增:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/langbot/pkg/agent/
|
|
||||||
__init__.py
|
|
||||||
runner/
|
|
||||||
__init__.py
|
|
||||||
descriptor.py
|
|
||||||
errors.py
|
|
||||||
id.py
|
|
||||||
registry.py
|
|
||||||
context_builder.py
|
|
||||||
resource_builder.py
|
|
||||||
orchestrator.py
|
|
||||||
result_normalizer.py
|
|
||||||
config_migration.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 descriptor.py
|
|
||||||
|
|
||||||
定义 LangBot 内部使用的 descriptor:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerDescriptor(BaseModel):
|
|
||||||
id: str
|
|
||||||
source: Literal["plugin"]
|
|
||||||
label: dict[str, str]
|
|
||||||
description: dict[str, str] | None = None
|
|
||||||
plugin_author: str
|
|
||||||
plugin_name: str
|
|
||||||
runner_name: str
|
|
||||||
plugin_version: str | None = None
|
|
||||||
protocol_version: str = "1"
|
|
||||||
config_schema: list[dict[str, Any]] = []
|
|
||||||
capabilities: dict[str, bool] = {}
|
|
||||||
permissions: dict[str, list[str]] = {}
|
|
||||||
raw_manifest: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`source == "builtin"` 不作为最终目标。如果实现阶段需要临时 adapter,必须标记为测试过渡代码,并在官方插件跑通后删除。
|
|
||||||
|
|
||||||
### 3.2 id.py
|
|
||||||
|
|
||||||
统一 runner id 解析和生成:
|
|
||||||
|
|
||||||
- 插件 runner id:`plugin:{author}/{plugin_name}/{runner_name}`
|
|
||||||
- `parse_runner_id(id)` 返回结构化对象
|
|
||||||
- 禁止业务代码手写字符串 split
|
|
||||||
- PoC 已存在的 `plugin:author/name/runner` 继续作为合法 id
|
|
||||||
|
|
||||||
### 3.3 registry.py
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 调用 `ap.plugin_connector.list_agent_runners(bound_plugins=None)` 拉取插件 runner
|
|
||||||
- 校验 manifest:
|
|
||||||
- `kind == AgentRunner`
|
|
||||||
- `metadata.name` 存在
|
|
||||||
- `metadata.label` 存在
|
|
||||||
- `spec.protocol_version` 兼容,默认 `1`
|
|
||||||
- `spec.config` 是 list,默认空
|
|
||||||
- `spec.capabilities` 是 dict,默认空
|
|
||||||
- `spec.permissions` 是 dict,默认空
|
|
||||||
- 输出 `AgentRunnerDescriptor`
|
|
||||||
- 缓存 discovery 结果,提供 `refresh()`
|
|
||||||
- 单个插件 manifest 失败只记录 warning,不影响其它 runner
|
|
||||||
|
|
||||||
刷新触发点:
|
|
||||||
|
|
||||||
- 插件安装、卸载、升级、重启后
|
|
||||||
- Pipeline metadata 请求时发现缓存为空
|
|
||||||
- 可选 TTL,优先保证正确性
|
|
||||||
|
|
||||||
### 3.4 context_builder.py / pipeline_adapter.py
|
|
||||||
|
|
||||||
`context_builder.py` 只负责从 `AgentEventEnvelope + AgentBinding` 构造 SDK v1 `AgentRunContext`。Pipeline Query 的读取、参数过滤和 prompt 提取属于 `PipelineAdapter`,但 PipelineAdapter 不再做历史窗口裁剪或 bootstrap 打包。
|
|
||||||
|
|
||||||
当前消息 Pipeline 进入 agent runner 的路径:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Query
|
|
||||||
-> PipelineAdapter.query_to_event(query)
|
|
||||||
-> PipelineAdapter.pipeline_config_to_binding(query, runner_id)
|
|
||||||
-> PipelineAdapter.build_adapter_context(query, binding)
|
|
||||||
-> AgentRunOrchestrator.run(event, binding, adapter_context=...)
|
|
||||||
-> AgentRunContextBuilder.build_context_from_event(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Protocol v1 context 的稳定字段:
|
|
||||||
|
|
||||||
- `run_id`: 新 UUID,不使用 query id 作为全局 run id
|
|
||||||
- `trigger.type`: 事件触发类型,例如 `message.received`
|
|
||||||
- `conversation`: conversation/thread/launcher/sender/bot/pipeline 投影
|
|
||||||
- `event`: 稳定事件上下文
|
|
||||||
- `actor`: 触发者
|
|
||||||
- `subject`: 当前消息、群、频道或其它事件主体
|
|
||||||
- `input`: 当前事件输入,不是历史消息窗口
|
|
||||||
- `delivery`: 输出 surface 和平台投递能力
|
|
||||||
- `resources`: 由 `resource_builder` 基于 binding policy 注入
|
|
||||||
- `state`: `PersistentStateStore` 读取的 host-managed scoped state snapshot
|
|
||||||
- `runtime`: host/version/workspace/bot/query/trace/deadline
|
|
||||||
- `config`: 当前 binding 对该 runner id 的配置,即 `runner_config`
|
|
||||||
- `bootstrap`: 可选扩展字段;LangBot Host 默认不填历史窗口
|
|
||||||
- `adapter`: Pipeline 或其它入口 adapter 的元数据
|
|
||||||
|
|
||||||
Pipeline adapter 的 `prompt` 和公开业务变量不进入顶层协议字段:
|
|
||||||
|
|
||||||
- filtered params -> `ctx.adapter.extra["params"]`
|
|
||||||
- legacy/effective prompt 可以暂存到 `ctx.adapter.extra["prompt"]`,但 official
|
|
||||||
runner 不应把它当作行为契约
|
|
||||||
- LangBot Host 不生成 `bootstrap.messages`、`adapter_messages` 或 context packaging 元数据
|
|
||||||
|
|
||||||
现阶段不要把新的压缩或 token-budget 裁剪塞回 Pipeline stage。Pipeline 只负责入口适配;完整历史和长期上下文由 EventLog / Transcript / pull APIs / future ContextCompressor 支撑。
|
|
||||||
|
|
||||||
### 3.4.1 Agentic context plan
|
|
||||||
|
|
||||||
EventLog / Transcript / Host pull APIs 已落地,`ContextCompressor` 仍是设计预留。
|
|
||||||
目标是让 Pipeline 逐步退化为入口 adapter,让 AgentRunner 层拥有上下文打包职责。
|
|
||||||
|
|
||||||
建议 Host 保持三类事实源和受限 API:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ConversationStore / EventLog
|
|
||||||
-> durable append-only raw messages, events, tool results, artifact refs
|
|
||||||
ConversationProjection
|
|
||||||
-> converts events into agent-readable conversation history
|
|
||||||
ContextCompressor
|
|
||||||
-> future optional service for summaries/checkpoints, requested and consumed by runners
|
|
||||||
```
|
|
||||||
|
|
||||||
关键原则:
|
|
||||||
|
|
||||||
- 完整历史属于 LangBot host,不属于插件实例。插件仍是 singleton/stateless。
|
|
||||||
- `ctx.bootstrap.messages` 不是 Host 默认下发的 working context。
|
|
||||||
- 每轮不能全量复制/序列化完整历史给插件 runtime;否则长会话会产生 O(n) 成本和跨进程 payload 膨胀。
|
|
||||||
- `max-round` 或类似窗口规则不属于 LangBot Host / Pipeline 语义。
|
|
||||||
- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。
|
|
||||||
- `ContextCompressor` 生成的是派生 summary/checkpoint,不能覆盖或删除 raw history。
|
|
||||||
- 重启恢复依赖持久化 store 和 summary checkpoint,不依赖 `SessionManager` 里的进程内 conversation list。
|
|
||||||
|
|
||||||
未来需要的受限 API:
|
|
||||||
|
|
||||||
```python
|
|
||||||
api.get_conversation_messages(cursor: str | None, limit: int) -> HistoryPage
|
|
||||||
api.get_context_summary(scope: str = "conversation") -> ContextSummary | None
|
|
||||||
api.request_context_compaction(policy: dict) -> CompactionResult
|
|
||||||
```
|
|
||||||
|
|
||||||
这些 API 必须绑定 `run_id`、runner id、actor/subject scope 和资源权限;Host 需要限制
|
|
||||||
page size、总字节数、deadline 和可访问 conversation。
|
|
||||||
|
|
||||||
### 3.4.2 Large artifacts and tool collaboration
|
|
||||||
|
|
||||||
大文件、多模态输入和工具产物不要内联进 prompt、bootstrap 或 tool result。后续统一用
|
|
||||||
artifact/resource ref 协作:
|
|
||||||
|
|
||||||
- message/content 里只放小文本和必要摘要。
|
|
||||||
- 大文件、图片、音频、长工具输出返回 `artifact_id`、`mime_type`、`size`、`digest`、
|
|
||||||
`summary`、`expires_at`、`permissions`。
|
|
||||||
- `/tmp` 只能作为单次 run 的临时 staging,用于插件或工具短时间读写;它不是 durable store,
|
|
||||||
也不能作为重启恢复依据。
|
|
||||||
- box/object storage 是长期 artifact 的目标位置。当前分支尚未合并 box 能力,因此本轮只写文档预留,不实现 API。
|
|
||||||
- 工具之间传递大结果时应传 artifact ref,不传完整 blob。Agent 需要读取时走受限 proxy。
|
|
||||||
|
|
||||||
未来建议 API:
|
|
||||||
|
|
||||||
```python
|
|
||||||
api.get_artifact_metadata(artifact_id: str) -> ArtifactMetadata
|
|
||||||
api.open_artifact_stream(artifact_id: str) -> AsyncIterator[bytes]
|
|
||||||
api.read_artifact_range(artifact_id: str, offset: int, length: int) -> bytes
|
|
||||||
api.create_temp_artifact(name: str, content_type: str, ttl_seconds: int) -> ArtifactWriter
|
|
||||||
```
|
|
||||||
|
|
||||||
安全约束:
|
|
||||||
|
|
||||||
- Host 校验 artifact 是否属于当前 run、conversation、actor/subject scope 或授权资源。
|
|
||||||
- 默认不允许插件直接读任意本地路径,包括 `/tmp` 任意路径。
|
|
||||||
- 临时文件应有 TTL 和清理机制;box artifact 应有 retention policy。
|
|
||||||
- 多模态文件进入模型前,由 runner/context packager 决定传引用、摘要、缩略图还是实际 bytes。
|
|
||||||
|
|
||||||
### 3.5 resource_builder.py
|
|
||||||
|
|
||||||
执行前做三层裁剪:
|
|
||||||
|
|
||||||
1. runner manifest 声明的 `spec.permissions`
|
|
||||||
2. Pipeline 的 `extensions_preferences`
|
|
||||||
3. 当前 Pipeline runner 绑定配置中选择的资源范围
|
|
||||||
|
|
||||||
输出写入 `ctx.resources`,至少覆盖:
|
|
||||||
|
|
||||||
- models:可调用模型 UUID、类型、能力摘要。包括 LLM、fallback LLM、rerank 等 runner config schema 中选择的模型类资源。
|
|
||||||
- tools:可见工具 manifest,使用当前 bound plugins / MCP server 范围
|
|
||||||
- knowledge_bases:可检索知识库列表
|
|
||||||
- storage:plugin storage / workspace storage 权限摘要
|
|
||||||
- files:允许读取的配置文件、知识文件摘要
|
|
||||||
- platform_capabilities:本阶段只声明,不执行平台动作
|
|
||||||
|
|
||||||
注意:旧的 unrestricted proxy action 必须二次校验,不能只靠 context 声明。AgentRunner 可用资源应来自 `ctx.resources`,不是插件 runtime 的全局能力。
|
|
||||||
|
|
||||||
本阶段不接入 sandbox/skills,也不预留 runner 可见字段。后续相关分支合并后,
|
|
||||||
执行、文件、skill、MCP 等能力应先由 Host 侧封装成普通 tool,再通过
|
|
||||||
`ctx.resources.tools` 进入 runner;runner 不应识别或硬编码执行环境 provider。
|
|
||||||
|
|
||||||
资源裁剪要尽量通用,不应只写死 local-agent:
|
|
||||||
|
|
||||||
- `model-fallback-selector` 授权 primary/fallback LLM。
|
|
||||||
- `llm-model-selector` 授权 LLM。
|
|
||||||
- `rerank-model-selector` 授权 rerank 模型。
|
|
||||||
- `knowledge-base-multi-selector` 授权知识库。
|
|
||||||
- 后续新增 selector 时应在 resource builder 中统一扩展。
|
|
||||||
|
|
||||||
### 3.5.1 future EventRouter 预留
|
|
||||||
|
|
||||||
当前分支不实现 EBA EventRouter,但 AgentRunner 协议必须从现在开始兼容非消息事件。未来不要为消息撤回、群成员加入、好友申请各写一套 runner wrapper;统一入口应是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
EventRouter -> AgentRunOrchestrator.run_from_event(event_request)
|
|
||||||
```
|
|
||||||
|
|
||||||
EBA 落地后,`ConversationStore` 不应只保存聊天消息,而应从 `EventLog` 投影生成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Platform Adapter
|
|
||||||
-> EventLog append raw event
|
|
||||||
-> ConversationProjection update message/history view when applicable
|
|
||||||
-> EventRouter resolve binding
|
|
||||||
-> AgentRunOrchestrator.run_from_event(event_request)
|
|
||||||
-> Context packager builds working context from projection + state + artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
这样消息事件、工具事件、群成员事件、好友申请事件可以共用同一套 run/session/state/resource
|
|
||||||
边界;非消息事件也不需要伪造成一条用户文本消息。
|
|
||||||
|
|
||||||
`event_request` 至少需要包含:
|
|
||||||
|
|
||||||
- `event_type`: 稳定协议名,例如 `message.recalled`、`group.member_joined`、`friend.request_received`
|
|
||||||
- `event_id` / `event_timestamp`
|
|
||||||
- `event_data`: 平台原始 payload 摘要和 source event type
|
|
||||||
- `actor`: 触发者,例如撤回操作者、新成员、好友申请人
|
|
||||||
- `subject`: 事件作用对象,例如被撤回消息、群/成员关系、好友申请
|
|
||||||
- `conversation`: 可选。群事件有 launcher 语义,好友申请可能还没有 conversation
|
|
||||||
- `input`: 可选结构化输入。非消息事件允许 `text=None`、`contents=[]`
|
|
||||||
- `binding`: 事件绑定解析出的 runner id、runner config、资源范围
|
|
||||||
|
|
||||||
先保留的稳定事件名:
|
|
||||||
|
|
||||||
- `message.received`
|
|
||||||
- `message.recalled`
|
|
||||||
- `group.member_joined`
|
|
||||||
- `friend.request_received`
|
|
||||||
|
|
||||||
这些事件名应作为插件协议的一部分保持稳定。平台原始事件名只能进入 `event_data`,不能成为 `ctx.event.event_type` 的公共契约。
|
|
||||||
|
|
||||||
### 3.6 result_normalizer.py
|
|
||||||
|
|
||||||
只接受 SDK v1 result:
|
|
||||||
|
|
||||||
- `message.delta`
|
|
||||||
- `message.completed`
|
|
||||||
- `tool.call.started`
|
|
||||||
- `tool.call.completed`
|
|
||||||
- `state.updated`
|
|
||||||
- `run.completed`
|
|
||||||
- `run.failed`
|
|
||||||
- `action.requested` 允许实验性返回,但本阶段只记录 telemetry,不执行
|
|
||||||
|
|
||||||
映射:
|
|
||||||
|
|
||||||
- `message.delta.data.chunk` -> `provider_message.MessageChunk`
|
|
||||||
- `message.completed.data.message` -> `provider_message.Message`
|
|
||||||
- `run.completed.data.message` -> `provider_message.Message`
|
|
||||||
- `run.failed` -> 抛出受控异常,让 `ChatMessageHandler` 使用现有错误策略
|
|
||||||
- 工具和状态事件默认不 yield 到 Pipeline,只记录 debug/telemetry
|
|
||||||
|
|
||||||
防护:
|
|
||||||
|
|
||||||
- 未知 type warning 后忽略
|
|
||||||
- 单 result 序列化大小限制
|
|
||||||
- provider message schema 校验失败转 `run.failed`
|
|
||||||
- 插件没有输出任何消息时,按 runner failed 处理
|
|
||||||
|
|
||||||
### 3.7 orchestrator.py
|
|
||||||
|
|
||||||
核心入口:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def run_from_query(query: pipeline_query.Query) -> AsyncGenerator[Message | MessageChunk, None]:
|
|
||||||
runner_id = resolve_runner_id(query.pipeline_config)
|
|
||||||
descriptor = await registry.get(runner_id, bound_plugins=query.variables.get("_pipeline_bound_plugins"))
|
|
||||||
ctx = await context_builder.from_query(query, descriptor)
|
|
||||||
async for raw in plugin_connector.run_agent(...):
|
|
||||||
async for message in result_normalizer.normalize(raw):
|
|
||||||
yield message
|
|
||||||
```
|
|
||||||
|
|
||||||
必须覆盖:
|
|
||||||
|
|
||||||
- runner id 不存在
|
|
||||||
- 插件系统关闭
|
|
||||||
- runner 不在 bound plugins 范围内
|
|
||||||
- 插件 runtime 断连
|
|
||||||
- runner 协议版本不兼容
|
|
||||||
- run 超时
|
|
||||||
- task cancellation
|
|
||||||
|
|
||||||
## 4. 配置模型直接切换
|
|
||||||
|
|
||||||
配置模型表达的是 Pipeline 到 runner id 的绑定,不表达插件实例。插件安装后由 plugin runtime 管理单个插件运行实例;不同 Pipeline 选择同一个 runner id 时,只是保存不同的 `runner_config[id]`,调用时随 `AgentRunContext.config` 传入。
|
|
||||||
|
|
||||||
目标格式:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ai": {
|
|
||||||
"runner": {
|
|
||||||
"id": "plugin:langbot/local-agent/default",
|
|
||||||
"expire-time": 0
|
|
||||||
},
|
|
||||||
"runner_config": {
|
|
||||||
"plugin:langbot/local-agent/default": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
兼容读取:
|
|
||||||
|
|
||||||
- 优先读 `ai.runner.id`
|
|
||||||
- 没有 `id` 时读旧 `ai.runner.runner`
|
|
||||||
- 旧内置 runner 名通过迁移表映射:
|
|
||||||
- `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`
|
|
||||||
|
|
||||||
写入策略:
|
|
||||||
|
|
||||||
- 新 UI 只写 `ai.runner.id` 和 `ai.runner_config`
|
|
||||||
- 后端 update 接口接受旧字段,但保存时归一成新格式
|
|
||||||
- migration 最后统一落库
|
|
||||||
|
|
||||||
## 5. 需要修改的 LangBot 范围
|
|
||||||
|
|
||||||
必须修改:
|
|
||||||
|
|
||||||
- `src/langbot/pkg/core/app.py`
|
|
||||||
- 增加 `agent_runner_registry` / `agent_run_orchestrator` 属性
|
|
||||||
- `src/langbot/pkg/core/stages/build_app.py`
|
|
||||||
- 初始化 Agent 子系统
|
|
||||||
- `src/langbot/pkg/pipeline/process/handlers/chat.py`
|
|
||||||
- 删除 `PluginAgentRunnerWrapper`
|
|
||||||
- 删除内置 runner 查找逻辑
|
|
||||||
- 调用 orchestrator
|
|
||||||
- `src/langbot/pkg/api/http/service/pipeline.py`
|
|
||||||
- metadata 从 registry 生成
|
|
||||||
- `src/langbot/pkg/plugin/connector.py`
|
|
||||||
- `list_agent_runners()` / `run_agent()` 增加协议校验和 bound plugin 参数
|
|
||||||
- `src/langbot/pkg/plugin/handler.py`
|
|
||||||
- proxy action 二次权限校验
|
|
||||||
- `src/langbot/pkg/pipeline/preproc/preproc.py`
|
|
||||||
- 不再只为 `local-agent` 构造工具、知识库、模型
|
|
||||||
- 对所有 agent runner 保留 multimodal input
|
|
||||||
- `src/langbot/pkg/pipeline/pipelinemgr.py`
|
|
||||||
- runner name 监控改读 `runner.id`
|
|
||||||
- `src/langbot/templates/metadata/pipeline/ai.yaml`
|
|
||||||
- runner 字段从 `runner` 迁到 `id`
|
|
||||||
- `src/langbot/templates/default-pipeline-config.json`
|
|
||||||
- 默认 runner 改为官方 local-agent 插件 id
|
|
||||||
- `web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx`
|
|
||||||
- 当前 runner 改读 `ai.runner.id`
|
|
||||||
- runner 配置区改写入 `ai.runner_config[id]`
|
|
||||||
|
|
||||||
最终删除或停用:
|
|
||||||
|
|
||||||
- `src/langbot/pkg/provider/runner.py` 的业务注册路径
|
|
||||||
- `src/langbot/pkg/provider/runners/*` 的运行入口
|
|
||||||
|
|
||||||
可以暂时保留文件作为官方插件迁移参考,但不应被运行时引用。
|
|
||||||
|
|
||||||
## 6. 收尾实现顺序
|
|
||||||
|
|
||||||
### Step 1:补齐宿主上下文
|
|
||||||
|
|
||||||
- SDK `AgentRunContext` 保持 event-first:`event/input/delivery/resources/context/state/runtime/config/bootstrap/adapter`。
|
|
||||||
- LangBot context builder 只从 `AgentEventEnvelope + AgentBinding` 写入稳定协议字段。
|
|
||||||
- Pipeline adapter 可以把公开业务变量写入 `ctx.adapter.extra["params"]`;legacy/effective prompt 若保留在 `ctx.adapter.extra["prompt"]`,也只属于 adapter metadata。
|
|
||||||
- 保持 `ctx.config` 只表达静态绑定配置。
|
|
||||||
|
|
||||||
### Step 2:增强宿主 AgentRun proxy action
|
|
||||||
|
|
||||||
- `invoke_llm` / `invoke_llm_stream` 通过 `run_id/query_id` 找回当前 Query。
|
|
||||||
- 自动合并 model persisted `extra_args` 与 action-level override。
|
|
||||||
- 自动应用 pipeline `remove-think`,并允许 action 显式 override。
|
|
||||||
- `call_tool` 传回当前 Query,恢复旧工具调用上下文。
|
|
||||||
- `retrieve_knowledge` 保持 `bot_uuid`、`sender_id`、`session_name` 等 settings。
|
|
||||||
- `invoke_rerank` 使用 run-scoped model authorization。
|
|
||||||
|
|
||||||
### Step 3:泛化资源构建
|
|
||||||
|
|
||||||
- 按 manifest permissions + bound plugins/MCP + runner config schema 构造资源。
|
|
||||||
- 支持 primary/fallback LLM、rerank model、KB selector。
|
|
||||||
- 不把 local-agent 特例扩散到通用资源层。
|
|
||||||
|
|
||||||
### Step 4:local-agent parity
|
|
||||||
|
|
||||||
- 使用静态绑定配置 `ctx.config["prompt"]`,不读取 `ctx.adapter.extra["prompt"]`。
|
|
||||||
- 通过 Host history API 拉取 transcript,不读取 `ctx.bootstrap.messages` 或 adapter window 字段。
|
|
||||||
- 当前 user message 从 `ctx.input.contents` 构造,保留多模态内容。
|
|
||||||
- RAG 只替换/插入文本部分,不丢图片/文件。
|
|
||||||
- streaming/non-streaming 默认跟随 `runtime.metadata.streaming_supported`。
|
|
||||||
- 首轮 fallback 成功后,tool loop 固定使用 committed model。
|
|
||||||
- tool loop 继续传可用 tools,支持多步工具调用。
|
|
||||||
- rerank 通过授权模型资源调用。
|
|
||||||
|
|
||||||
### Step 5:端到端保护和测试
|
|
||||||
|
|
||||||
- 插件无输出时按 runner failed 处理。
|
|
||||||
- timeout/deadline 覆盖 plugin runtime、模型调用和外部 runner 调用。
|
|
||||||
- runner 协议错误转受控错误。
|
|
||||||
- 覆盖 local-agent 用户可见行为:普通回复、流式、工具、多步工具、KB、rerank、多模态、绑定 prompt、history API。
|
|
||||||
|
|
||||||
### Step 6:官方 runner 迁移
|
|
||||||
|
|
||||||
- 官方插件 ready 后移除内置 runner registry
|
|
||||||
- 删除或隔离 provider runners 的运行引用
|
|
||||||
- 测试旧 runner 名只能通过 migration 映射到插件 id
|
|
||||||
|
|
||||||
### Step 7:历史配置迁移
|
|
||||||
|
|
||||||
- 写 persistence migration
|
|
||||||
- 更新 default pipeline config
|
|
||||||
- 对已存在 Pipeline 执行旧字段到新字段迁移
|
|
||||||
- 对监控/日志里的 runner 字段改用新 id
|
|
||||||
|
|
||||||
## 7. 测试要求
|
|
||||||
|
|
||||||
单测:
|
|
||||||
|
|
||||||
- runner id parse / format
|
|
||||||
- registry manifest 校验、失败隔离、bound plugins 过滤
|
|
||||||
- context builder 从 query 生成完整 v1 context
|
|
||||||
- resource builder 三层裁剪
|
|
||||||
- result normalizer 对每种 result type 的映射
|
|
||||||
- 旧配置 resolve 和 migration
|
|
||||||
|
|
||||||
集成测试:
|
|
||||||
|
|
||||||
- fake AgentRunner 插件可被 Pipeline 选择
|
|
||||||
- streaming 输出仍能更新 message card
|
|
||||||
- 插件异常返回用户可理解错误,不中断 runtime
|
|
||||||
- runner 不在 bound plugins 时不可执行
|
|
||||||
- 未授权工具 / 知识库 / 模型 proxy 调用被拒绝
|
|
||||||
- 旧 `local-agent` Pipeline 配置迁到官方插件 id
|
|
||||||
|
|
||||||
## 8. 验收标准
|
|
||||||
|
|
||||||
- LangBot Pipeline 可以选择插件 AgentRunner 并完成非流式和流式回复。
|
|
||||||
- `ChatMessageHandler` 不包含插件 runner 解析和 wrapper。
|
|
||||||
- `PipelineService` 不直接拼插件 runner metadata。
|
|
||||||
- 所有 runner 配置使用 `ai.runner.id` + `ai.runner_config`。
|
|
||||||
- 插件 runtime 不为每个 Pipeline 或 runner 配置创建插件实例;`runner_config` 只作为绑定配置随 `ctx.config` 传入。
|
|
||||||
- 主聊天路径不再通过旧内置 runner 执行业务 runner。迁移期间旧文件可以保留。
|
|
||||||
- 插件只能访问 `ctx.resources` 授权的模型、工具、知识库和文件。
|
|
||||||
- 宿主 action 能为 AgentRunner 调用恢复必要 Query 语义,插件不需要拿裸 Query。
|
|
||||||
- 官方 `local-agent` 插件对外行为与旧内置 local-agent 对齐。
|
|
||||||
- EBA 相关字段只作为 context/result 预留,不执行平台动作。
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
# 官方 AgentRunner 插件迁移计划
|
|
||||||
|
|
||||||
本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。
|
|
||||||
它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和
|
|
||||||
[AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot
|
|
||||||
宿主协议的设计前提。
|
|
||||||
|
|
||||||
官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,
|
|
||||||
而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot 的
|
|
||||||
host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管
|
|
||||||
context/runtime 的 runner,不能被官方插件的实现细节绑死。
|
|
||||||
|
|
||||||
当前实现已经进入过渡阶段:
|
|
||||||
|
|
||||||
- LangBot 主聊天路径通过 `AgentRunOrchestrator` 调用插件化 `AgentRunner`。
|
|
||||||
- 旧 `src/langbot/pkg/provider/runners/*` 仍保留,作为迁移参考和回退分析材料;在官方插件迁移完成前不要求删除。
|
|
||||||
- 官方 runner 当前以独立插件目录/仓库推进,例如 `langbot-local-agent/` 和 `langbot-agent-runner/*-agent/`。不再要求先落地单一 monorepo。
|
|
||||||
- `claude-code-agent` 与 `codex-agent` 已作为外部 harness runner MVP 接入,用来验证 Claude Code / Codex / Kimi Code 这类自管 runtime 的边界。
|
|
||||||
|
|
||||||
## 1. 为什么新仓库
|
|
||||||
|
|
||||||
官方 runner 插件会和 LangBot 主仓库、SDK 仓库以不同节奏迭代:
|
|
||||||
|
|
||||||
- LangBot 主仓库只维护宿主协议和调度。
|
|
||||||
- SDK 仓库维护 AgentRunner 组件和 runtime 协议。
|
|
||||||
- 官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。
|
|
||||||
|
|
||||||
不要把官方 runner 插件重新绑死在 LangBot 主仓库内。允许开发期使用本地路径插件,但运行边界必须保持为:
|
|
||||||
|
|
||||||
- LangBot 提供通用宿主能力:当前事件、context handles、资源授权、状态/存储、历史、artifact、模型/工具/知识库调用代理、结果归一。
|
|
||||||
- 插件消费这些公开能力,实现具体 runner 行为。
|
|
||||||
- LangBot 默认不把全量历史消息 inline 给 runner;runner 按需通过授权 API 拉取历史和 artifact。
|
|
||||||
- 旧内置 runner 只作为行为对齐的基准,不作为长期运行路径。
|
|
||||||
|
|
||||||
## 2. 仓库结构
|
|
||||||
|
|
||||||
当前推荐策略是“官方插件可独立发布,必要时共享 SDK helper”。开发期可以采用本地多目录布局:
|
|
||||||
|
|
||||||
```text
|
|
||||||
langbot-app/
|
|
||||||
langbot-local-agent/
|
|
||||||
manifest.yaml
|
|
||||||
components/agent_runner/default.yaml
|
|
||||||
components/agent_runner/default.py
|
|
||||||
pkg/
|
|
||||||
tests/
|
|
||||||
langbot-agent-runner/
|
|
||||||
claude-code-agent/
|
|
||||||
codex-agent/
|
|
||||||
n8n-agent/
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
后续可以把多个官方 runner 聚合进 monorepo,也可以继续独立发布。这个选择不影响协议设计;协议边界由 SDK 和 LangBot 宿主保证。
|
|
||||||
|
|
||||||
如果多个 runner 出现重复逻辑,优先沉淀到 SDK 或一个明确的共享 helper 包,不要把宿主私有结构泄漏给插件。
|
|
||||||
|
|
||||||
## 3. 插件命名和 runner id
|
|
||||||
|
|
||||||
固定映射:
|
|
||||||
|
|
||||||
| 旧 runner | 官方插件 | runner id |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` |
|
|
||||||
| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` |
|
|
||||||
| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` |
|
|
||||||
| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` |
|
|
||||||
| - | `langbot/claude-code-agent` | `plugin:langbot/claude-code-agent/default` |
|
|
||||||
| - | `langbot/codex-agent` | `plugin:langbot/codex-agent/default` |
|
|
||||||
| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` |
|
|
||||||
| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` |
|
|
||||||
| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` |
|
|
||||||
|
|
||||||
每个插件可以后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`。
|
|
||||||
|
|
||||||
## 4. 迁移优先级
|
|
||||||
|
|
||||||
### Batch 1:打通协议
|
|
||||||
|
|
||||||
1. `local-agent`
|
|
||||||
2. `claude-code-agent`
|
|
||||||
3. `codex-agent`
|
|
||||||
4. `dify-agent`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- `local-agent` 覆盖模型、工具、知识库、流式、会话历史,是能力最完整的基准。
|
|
||||||
- `claude-code-agent` / `codex-agent` 代表 Claude Code / Codex / Kimi Code 这类本地或外部 code-agent harness:它们通常自带 session、tool loop、上下文压缩和权限模型,LangBot 主要提供 IM 事件、资源投影、审计和状态指针。
|
|
||||||
- `dify-agent` 代表外部 Agent 平台调用,配置和错误处理能验证传统 service API runner 的迁移方式。
|
|
||||||
|
|
||||||
### Batch 2:迁移外部 workflow runner
|
|
||||||
|
|
||||||
1. `n8n-agent`
|
|
||||||
2. `langflow-agent`
|
|
||||||
|
|
||||||
这批主要验证 webhook/workflow 输入输出、timeout、外部 conversation id。
|
|
||||||
|
|
||||||
### Batch 3:迁移平台 Agent API
|
|
||||||
|
|
||||||
1. `coze-agent`
|
|
||||||
2. `dashscope-agent`
|
|
||||||
3. `tbox-agent`
|
|
||||||
|
|
||||||
这批主要验证平台特有响应格式、引用资料、文件/图片输入。
|
|
||||||
|
|
||||||
## 5. 每个官方插件的组件要求
|
|
||||||
|
|
||||||
每个插件至少包含:
|
|
||||||
|
|
||||||
```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:
|
|
||||||
streaming: true
|
|
||||||
tool_calling: false
|
|
||||||
knowledge_retrieval: false
|
|
||||||
multimodal_input: false
|
|
||||||
event_context: true
|
|
||||||
platform_api: false
|
|
||||||
interrupt: false
|
|
||||||
stateful_session: true
|
|
||||||
permissions:
|
|
||||||
models: []
|
|
||||||
tools: []
|
|
||||||
knowledge_bases: []
|
|
||||||
storage: ["plugin"]
|
|
||||||
files: []
|
|
||||||
platform_api: []
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./main.py
|
|
||||||
attr: DefaultAgentRunner
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. local-agent 插件方向
|
|
||||||
|
|
||||||
`local-agent` 是官方插件中的重要消费者,但不是宿主协议的设计中心。它可以选择复用
|
|
||||||
旧实现,也可以完全重写。它需要证明:一个主要依附 LangBot host 能力的 agent runner
|
|
||||||
可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。
|
|
||||||
|
|
||||||
LangBot core 不应为了 local-agent 保留业务编排逻辑。local-agent 的 prompt 组装、history
|
|
||||||
拉取、summary/checkpoint、tool loop、RAG 编排、fallback、多模态处理都应在插件内完成。
|
|
||||||
|
|
||||||
迁移或重写时需要覆盖旧内置 runner 的用户可见能力:
|
|
||||||
|
|
||||||
- model primary/fallback 选择
|
|
||||||
- prompt
|
|
||||||
- knowledge-bases
|
|
||||||
- rerank-model
|
|
||||||
- rerank-top-k
|
|
||||||
- function calling
|
|
||||||
- streaming
|
|
||||||
- multimodal input
|
|
||||||
- conversation history
|
|
||||||
- monitoring metadata
|
|
||||||
|
|
||||||
与 LangBot 主仓库的责任边界:
|
|
||||||
|
|
||||||
- LangBot 构造当前事件、结构化输入、资源授权、context handles、state/storage 能力和 delivery 能力
|
|
||||||
- LangBot 不默认 inline 全量历史,不替插件组装最终模型上下文
|
|
||||||
- 插件负责选择模型、拼请求、调用 LLM、处理 tool call loop、输出 result stream
|
|
||||||
- 插件不能绕过 `ctx.resources` 调用未授权模型、工具或知识库
|
|
||||||
|
|
||||||
为了保持旧内置 runner 的用户可见行为,`local-agent` 插件应消费宿主处理后的有效输入和
|
|
||||||
受限 API,而不是读取宿主内部私有结构:
|
|
||||||
|
|
||||||
- `ctx.event` / `ctx.input`:当前结构化输入,必须保留图片、文件等多模态内容。
|
|
||||||
- `ctx.context`:history cursor、inline policy、可用 context API。
|
|
||||||
- `AgentRunAPIProxy.history`:按需读取 transcript,而不是依赖 host 每轮强塞历史窗口。
|
|
||||||
- `AgentRunAPIProxy.artifacts`:按需读取图片、文件、工具大结果。
|
|
||||||
- `AgentRunAPIProxy.state` / storage:保存 summary、外部 conversation id、用户偏好等可选状态。
|
|
||||||
- `ctx.resources`:已授权模型、工具、知识库、文件和 storage。
|
|
||||||
- `ctx.runtime.metadata.streaming_supported`:当前 adapter 是否能消费流式输出。
|
|
||||||
- 宿主代理 action:模型、工具、知识库、rerank 调用必须通过 `run_id` 校验资源权限。
|
|
||||||
|
|
||||||
`local-agent` 不应消费 Pipeline adapter 生成的历史窗口,也不应读取
|
|
||||||
`ctx.adapter.extra.prompt`。它应从绑定配置读取静态 `prompt`,并通过 Host
|
|
||||||
history API 拉取 transcript。Pipeline adapter 不保留 Host-side window 兼容逻辑。
|
|
||||||
|
|
||||||
建议 local-agent manifest 使用 hybrid 或 self-managed context:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
context:
|
|
||||||
ownership: hybrid
|
|
||||||
bootstrap: current_event
|
|
||||||
max_inline_events: 0
|
|
||||||
max_inline_bytes: 0
|
|
||||||
supports_history_pull: true
|
|
||||||
supports_history_search: true
|
|
||||||
supports_artifact_pull: true
|
|
||||||
owns_compaction: true
|
|
||||||
wants_static_context_refs: true
|
|
||||||
```
|
|
||||||
|
|
||||||
这表示:LangBot 只给当前事件和 context handles;local-agent 自己决定是否拉取历史、是否搜索、
|
|
||||||
何时摘要、如何构造最终 prompt。
|
|
||||||
|
|
||||||
### 6.1 Native Execution / Skills 后续接入
|
|
||||||
|
|
||||||
本阶段不把 sandbox/skills 做成 AgentRunner 协议字段,也不预留 runner 可见字段。
|
|
||||||
后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process
|
|
||||||
等能力应先由 LangBot Host 封装成 scoped tools,再通过 `ctx.resources.tools`
|
|
||||||
暴露给 runner。
|
|
||||||
|
|
||||||
这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。
|
|
||||||
Claude Code / Codex 这类外部 harness runner 仍可先保留自己的执行模型,但要在文档和
|
|
||||||
配置中明确它们是否使用 LangBot 提供的工具投影。
|
|
||||||
|
|
||||||
## 7. 外部 runner 插件要求
|
|
||||||
|
|
||||||
外部平台 runner 迁移时遵循:
|
|
||||||
|
|
||||||
- 旧配置字段尽量保持同名,便于 migration 复制
|
|
||||||
- 输出统一转换为 `AgentRunResult`
|
|
||||||
- 外部 API timeout 从 runner config 读取
|
|
||||||
- 平台 conversation id 存 plugin storage 或 context runtime state,不能依赖 LangBot 内置 conversation uuid 私有结构
|
|
||||||
- 流式支持按平台能力声明,没有流式就只发 `message.completed`
|
|
||||||
|
|
||||||
### 7.1 Code-agent harness runner 要求
|
|
||||||
|
|
||||||
Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行。它们可以依赖自己的 harness,但仍必须遵守 LangBot 的宿主边界:
|
|
||||||
|
|
||||||
- 输入来自 `ctx.event` / `ctx.input`,不能直接依赖 Pipeline 私有 `Query`。
|
|
||||||
- LangBot 授权后的资源应被投影为 harness 可读的 context 文件、MCP 配置、skill 目录、环境变量或 CLI 参数。
|
|
||||||
- 外部 session id、workspace、checkpoint 等跨轮次指针应写入 Host state 或 plugin storage;插件实例本身保持无状态。
|
|
||||||
- CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。
|
|
||||||
- 如果外部 harness 选择使用 LangBot 托管执行能力,它应通过 scoped MCP/tool
|
|
||||||
投影消费 Host 授权资源;否则它属于 external harness mode,不能声称具备
|
|
||||||
LangBot-managed 执行隔离。
|
|
||||||
- 外部 harness 的 permission mode、allowed/disallowed tools、MCP 配置只是一层执行约束;LangBot 仍负责调用前的资源授权、路径策略、secret 过滤和审计。发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
|
|
||||||
### 7.2 SDK-owned LangBot MCP bridge
|
|
||||||
|
|
||||||
Claude Code / Codex 这类外部 harness 不能直接持有 Python 进程内的
|
|
||||||
`plugin_runtime_handler`,因此不能像 `local-agent` 一样直接调用
|
|
||||||
`AgentRunAPIProxy`。当前轻量方案是由 SDK 提供一层 per-run MCP bridge:
|
|
||||||
|
|
||||||
- `AgentRunner.create_external_mcp_bridge(ctx)` 是 runner 父类入口。
|
|
||||||
- Bridge 由 `AgentRunAPIProxy` 和 `AgentRunContext` 构造,生命周期只覆盖当前 run。
|
|
||||||
- Bridge 暴露 SDK 中显式注解的 `AgentRunExternalTools`,而不是扫描或导出全部 SDK action。
|
|
||||||
- MCP tool schema 由注解和 Pydantic args model 生成;runner 插件不各自手写 LangBot tool schema。
|
|
||||||
- stdio MCP proxy 只把外部 harness 的 MCP 调用转发回当前 run 的本地 bridge。
|
|
||||||
- run 结束后 bridge 关闭;这不是 LangBot 主程序全局 MCP server。
|
|
||||||
|
|
||||||
第一批工具保持很小:当前事件快照、history page、knowledge retrieve、authorized tool call。后续新增工具必须先进入 SDK-owned annotated surface,再由 MCP adapter 自动投影。
|
|
||||||
|
|
||||||
## 8. Claude Code runner 当前形态
|
|
||||||
|
|
||||||
当前 `claude-code-agent` 是最小可运行 MVP,用来证明外部 harness runner 可以接入同一套 AgentRunner 协议。
|
|
||||||
|
|
||||||
### 8.1 基本行为
|
|
||||||
|
|
||||||
- Runner ID:`plugin:langbot/claude-code-agent/default`
|
|
||||||
- 执行方式:本地 Claude Code CLI print mode,默认命令为 `claude -p`
|
|
||||||
- 默认输出:`message.completed` + `run.completed`
|
|
||||||
- 默认权限:`permission-mode=plan`、`max-turns=1`、`disallowedTools=AskUserQuestion`
|
|
||||||
- 默认状态:如果 Claude Code 返回 `session_id`,runner 通过 `state.updated` 写回 `external.session_id`
|
|
||||||
- 工作目录:优先使用 binding config 的 `working-directory`,其次使用 Host state 中的 `external.working_directory`
|
|
||||||
|
|
||||||
### 8.2 Context / skill / MCP 投影
|
|
||||||
|
|
||||||
Claude Code runner 当前把 LangBot event-first context 投影给外部 harness:
|
|
||||||
|
|
||||||
- 写入 `agent-context.json`,schema 为 `langbot.agent_runner.external_harness_context.v1`
|
|
||||||
- 写入 `LANGBOT_CONTEXT.md`,作为人类可读摘要
|
|
||||||
- 将 prompt prefix 指向 context 文件路径
|
|
||||||
- 可把 binding 提供的 `skills-json` 写入 Claude Code 原生 `.claude/skills/<name>/SKILL.md`
|
|
||||||
- 可把 binding 提供的 `mcp-config-json` 写成每次 run 的 MCP config,并通过 `--mcp-config` / `--strict-mcp-config` 传给 Claude Code
|
|
||||||
- 可通过 `enable-langbot-mcp=true` 启用 SDK-owned per-run LangBot MCP bridge,使 Claude Code 通过 MCP 调用受限的 `AgentRunAPIProxy` 能力
|
|
||||||
|
|
||||||
这些投影目前由 runner adapter 完成;长期更理想的形态是 LangBot Host 负责生成 scoped resource projection,runner 只负责适配 Claude Code 的原生目录和 CLI 参数。
|
|
||||||
|
|
||||||
### 8.3 已验证能力
|
|
||||||
|
|
||||||
2026-05-29 本地验证:
|
|
||||||
|
|
||||||
- WebUI Debug Chat 能通过 Pipeline adapter 调用 `claude-code-agent`
|
|
||||||
- Claude Code 能读取 LangBot context 文件并按指令输出 sentinel
|
|
||||||
- Skill 文件可以投影到 `.claude/skills/`
|
|
||||||
- MCP config 可以通过 binding config 投影为 Claude Code CLI 参数
|
|
||||||
- SDK-owned per-run LangBot MCP bridge 可以被真实 Claude Code CLI 调用,并通过 `langbot_get_current_event` 读取当前 run_id
|
|
||||||
- `external.session_id` 与 `external.working_directory` 可以写入 host-owned state,用于后续 resume
|
|
||||||
- `codex-agent` 可通过 WebUI Debug Chat 调用本机 Codex CLI,读取 LangBot event context,并把 Codex `thread_id` 写入 host-owned state
|
|
||||||
- SDK-owned per-run LangBot MCP bridge 可以被真实 Codex CLI 调用,并通过 `langbot_get_current_event` 读取当前 run_id
|
|
||||||
- 对需要代理的本地运行环境,`codex-agent` 可通过 binding config 的 `environment-json` 显式传递非 secret 环境变量
|
|
||||||
|
|
||||||
下一轮测试入口见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md)。
|
|
||||||
|
|
||||||
### 8.4 当前限制
|
|
||||||
|
|
||||||
- 不是发布级安全边界实现。
|
|
||||||
- 默认只做本地 CLI 调用,不实现完整执行隔离或 workspace 生命周期。
|
|
||||||
- 不实现 issue-centric 队列、复杂 workflow engine 或长期任务调度。
|
|
||||||
- 不代表 Codex 发布级能力或 Kimi runner 已完成;当前只验证外部 harness runner 的协议形态。
|
|
||||||
|
|
||||||
## 9. 发布和安装策略
|
|
||||||
|
|
||||||
最终 LangBot 安装或升级时需要保证官方 runner 插件可用。可选方案:
|
|
||||||
|
|
||||||
1. 首次启动检测缺失官方 runner 插件并提示安装。
|
|
||||||
2. 打包发行版时预装官方 runner 插件。
|
|
||||||
3. 在 migration 前检查对应插件是否存在,不存在则自动安装或阻止迁移。
|
|
||||||
|
|
||||||
建议实现顺序:
|
|
||||||
|
|
||||||
- 开发阶段使用本地路径插件。
|
|
||||||
- 发布前支持 marketplace 安装。
|
|
||||||
- 历史配置 migration 只在官方插件可用时执行。
|
|
||||||
- 迁移期间保留旧内置 runner 文件,直到对应官方插件通过 parity 验收。
|
|
||||||
|
|
||||||
## 10. 验收标准
|
|
||||||
|
|
||||||
- 每个旧 runner 都有对应官方 AgentRunner 插件。
|
|
||||||
- 旧 runner 配置能无损复制到新 `runner_config[id]`。
|
|
||||||
- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。
|
|
||||||
- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。
|
|
||||||
- `local-agent` 插件能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。
|
|
||||||
- `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。
|
|
||||||
- 对外行为与旧内置 local-agent runner 保持一致;代码结构不需要相同。
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
# Agent Runner QA 指南
|
|
||||||
|
|
||||||
本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。
|
|
||||||
|
|
||||||
目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。
|
|
||||||
|
|
||||||
## 1. 测试边界
|
|
||||||
|
|
||||||
当前主线验证的是 AgentRunner Protocol v1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event -> binding -> runner.run(ctx) -> result stream
|
|
||||||
```
|
|
||||||
|
|
||||||
本指南验证:
|
|
||||||
|
|
||||||
- Host 能通过当前 Pipeline adapter 进入 event-first `run(event, binding)` 主链路。
|
|
||||||
- Runner 来自插件 registry,而不是旧内置 runner 分支。
|
|
||||||
- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。
|
|
||||||
- 外部 harness runner(Claude Code / Codex)能消费 event-first context,并把 session / working directory 等指针写回 host-owned state。
|
|
||||||
- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。
|
|
||||||
|
|
||||||
本指南不验证:
|
|
||||||
|
|
||||||
- Runtime Control Plane v2。
|
|
||||||
- EventGateway / EventRouter 完整落地。
|
|
||||||
- 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。
|
|
||||||
- 所有外部服务 runner 的真实凭据联调。
|
|
||||||
|
|
||||||
这些属于后续能力或发布门槛,分别见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 与 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
|
|
||||||
## 2. 状态定义
|
|
||||||
|
|
||||||
测试报告只使用以下状态:
|
|
||||||
|
|
||||||
| 状态 | 含义 |
|
|
||||||
| --- | --- |
|
|
||||||
| PASS | 按步骤执行,用户可见行为和日志证据都满足通过条件。 |
|
|
||||||
| FAIL | 环境可用,但行为不满足通过条件。 |
|
|
||||||
| BLOCKED | 凭据、CLI、外部服务、测试数据或本地配置缺失导致无法执行。必须写清阻塞原因。 |
|
|
||||||
| N/A | 当前 runner 或平台明确不支持该能力。必须引用 manifest、文档或配置说明。 |
|
|
||||||
|
|
||||||
不能使用“看起来正常”“大概通过”“基本没问题”等模糊状态。
|
|
||||||
|
|
||||||
## 3. 执行顺序
|
|
||||||
|
|
||||||
推荐按以下顺序执行,前一层失败时不要继续扩大测试面:
|
|
||||||
|
|
||||||
1. Host / SDK / runner 单测。
|
|
||||||
2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。
|
|
||||||
3. `local-agent` 高价值场景。
|
|
||||||
4. Claude Code / Codex 外部 harness smoke。
|
|
||||||
5. 权限和错误路径补充检查。
|
|
||||||
6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。
|
|
||||||
|
|
||||||
用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。
|
|
||||||
|
|
||||||
## 4. 必跑基线
|
|
||||||
|
|
||||||
### 4.1 单测基线
|
|
||||||
|
|
||||||
在 LangBot 仓库运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --frozen pytest tests/unit_tests/agent
|
|
||||||
```
|
|
||||||
|
|
||||||
如果本次改动只触及默认配置或 API service,也至少补跑相关目标测试,例如:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py
|
|
||||||
```
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- agent 单测全 PASS,或失败项已确认与本次 agent-runner 路径无关。
|
|
||||||
- 若失败来自 `context_builder`、`orchestrator`、`session_registry`、`resource_builder`、`plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。
|
|
||||||
|
|
||||||
### 4.2 环境基线
|
|
||||||
|
|
||||||
用 `langbot-skills` 做环境检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd "$LANGBOT_SKILLS_REPO"
|
|
||||||
bin/lbs env doctor
|
|
||||||
bin/lbs case list
|
|
||||||
```
|
|
||||||
|
|
||||||
`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case,而不是临时发明测试路径。
|
|
||||||
|
|
||||||
推荐首批 case:
|
|
||||||
|
|
||||||
- `webui-login-state`
|
|
||||||
- `pipeline-debug-chat`
|
|
||||||
- `local-agent-basic-debug-chat`
|
|
||||||
- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge)
|
|
||||||
- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy)
|
|
||||||
|
|
||||||
## 5. WebUI 主链路 Smoke
|
|
||||||
|
|
||||||
### 5.1 Runner registry
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 打开 WebUI Pipeline 配置页。
|
|
||||||
2. 查看 AI runner 下拉列表。
|
|
||||||
3. 选择 `plugin:langbot/local-agent/default`。
|
|
||||||
4. 保存并刷新页面。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- runner 选项来自插件 registry。
|
|
||||||
- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。
|
|
||||||
- `runner_config` 表示 binding config,不表示插件实例状态。
|
|
||||||
- 插件没有循环重启或 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 或自管上下文读取历史,不依赖 bootstrap window。 |
|
|
||||||
| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 |
|
|
||||||
| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 |
|
|
||||||
| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 |
|
|
||||||
| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 |
|
|
||||||
| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 |
|
|
||||||
| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 |
|
|
||||||
|
|
||||||
Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。
|
|
||||||
|
|
||||||
## 7. 外部 Harness Runner Smoke
|
|
||||||
|
|
||||||
这些测试用于验证 Claude Code / Codex 这类自管 runtime 能走同一条 Host 协议路径。若本机没有 CLI、登录态或代理配置,标记 BLOCKED,不要伪造 PASS。
|
|
||||||
|
|
||||||
### 7.1 Claude Code runner
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 确认 `claude` CLI 在 LangBot runtime host 上可执行。
|
|
||||||
2. 绑定 `plugin:langbot/claude-code-agent/default`。
|
|
||||||
3. 使用保守权限模式和确定性 prompt。
|
|
||||||
4. 在 Debug Chat 执行一次真实 smoke。
|
|
||||||
5. 检查 context / skill / MCP projection 和 host-owned state。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- WebUI 可见回复包含预期 sentinel。
|
|
||||||
- context JSON schema 为 `langbot.agent_runner.external_harness_context.v1` 或当前文档声明的等价 schema。
|
|
||||||
- context 包含 event、input、delivery、resources、context、state。
|
|
||||||
- 如启用 skills / MCP,投影路径和配置可被 Claude Code 读取。
|
|
||||||
- `external.session_id` / `external.working_directory` 写入 host-owned state。
|
|
||||||
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`。
|
|
||||||
|
|
||||||
### 7.2 Codex runner
|
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 确认 `codex` CLI 在 LangBot runtime host 上可执行。
|
|
||||||
2. 绑定 `plugin:langbot/codex-agent/default`。
|
|
||||||
3. 如需要代理,使用 binding config 的 `environment-json` 显式传入。
|
|
||||||
4. 在 Debug Chat 执行一次真实 smoke。
|
|
||||||
5. 检查 JSONL 事件、last message、host-owned state。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- WebUI 可见回复包含预期 sentinel。
|
|
||||||
- Codex JSONL 至少包含 thread/session 起始事件、agent message、turn completed。
|
|
||||||
- `external.session_id` / `external.working_directory` 写入 host-owned state。
|
|
||||||
- timeout/cancel 不遗留 orphan CLI 子进程。
|
|
||||||
- CLI missing、nonzero exit、timeout、empty output 都转成受控 `run.failed`。
|
|
||||||
|
|
||||||
### 7.3 API 型外部 runner
|
|
||||||
|
|
||||||
Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。
|
|
||||||
|
|
||||||
通过条件:
|
|
||||||
|
|
||||||
- runner 可选,配置可保存。
|
|
||||||
- 请求成功,或外部服务错误被清晰返回。
|
|
||||||
- 外部服务凭据缺失时标记 BLOCKED,并记录缺失项。
|
|
||||||
|
|
||||||
## 8. 权限与隔离补充
|
|
||||||
|
|
||||||
以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。
|
|
||||||
|
|
||||||
| 场景 | 推荐证据 |
|
|
||||||
| --- | --- |
|
|
||||||
| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 |
|
|
||||||
| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 |
|
|
||||||
| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 |
|
|
||||||
| run_id 结束后复用被拒绝 | session registry 注销测试。 |
|
|
||||||
| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 |
|
|
||||||
| storage/state scope 越权被拒绝 | state/storage proxy 单测。 |
|
|
||||||
|
|
||||||
如果这些单测失败,不能用 WebUI 正常回复替代。
|
|
||||||
|
|
||||||
## 9. 证据要求
|
|
||||||
|
|
||||||
每轮测试报告至少记录:
|
|
||||||
|
|
||||||
- LangBot commit、SDK commit、相关 runner 插件 commit。
|
|
||||||
- Pipeline UUID/name、runner id、关键 runner config 摘要。
|
|
||||||
- WebUI 截图或 Playwright 操作记录。
|
|
||||||
- 后端日志中对应 query id / run id 的关键行。
|
|
||||||
- `langbot-skills` case/report 路径。
|
|
||||||
- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。
|
|
||||||
- FAIL/BLOCKED 的复现步骤和归属仓库建议。
|
|
||||||
|
|
||||||
报告结论必须回答:
|
|
||||||
|
|
||||||
- 是否建议继续进入下一阶段测试。
|
|
||||||
- 是否存在主聊天路径阻塞。
|
|
||||||
- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。
|
|
||||||
- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。
|
|
||||||
|
|
||||||
## 10. 历史高价值记录
|
|
||||||
|
|
||||||
历史报告已合并为本指南,不再保留单独文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。
|
|
||||||
|
|
||||||
截至 2026-05-29,已有本地 smoke 证明:
|
|
||||||
|
|
||||||
- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。
|
|
||||||
- Claude Code runner 可以通过同一条 `run(event, binding)` 路径执行。
|
|
||||||
- Claude Code runner 可以读取 LangBot event-first context / skill / MCP 投影,并写回 `external.session_id` / `external.working_directory`。
|
|
||||||
- Codex runner 可以通过同一条路径执行,并把 Codex `thread_id` 写回 host-owned state。
|
|
||||||
|
|
||||||
这些记录只证明本地协议闭环可用,不代表发布级 security hardening 已完成。
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Agent Runner 插件化实现进度
|
|
||||||
|
|
||||||
本文档跟踪 Agent Runner 插件化的实现状态,便于快速了解当前进度。
|
|
||||||
|
|
||||||
## 总体进度
|
|
||||||
|
|
||||||
**当前阶段**: Phase 3.5 已完成,Event-first 基础设施已完成;2026-05-29 已通过本地 `local-agent` 与 Claude Code runner smoke。
|
|
||||||
|
|
||||||
| Phase | 描述 | 状态 |
|
|
||||||
|-------|------|------|
|
|
||||||
| Phase 0 | PoC 验证 | ✅ 完成 |
|
|
||||||
| Phase 1 | 核心架构(Registry、Orchestrator、上下文模型) | ✅ 完成 |
|
|
||||||
| Phase 2 | 权限、能力声明、资源注入 | ✅ 完成 |
|
|
||||||
| Phase 3 | 内置 runner 迁移到插件 | ✅ 完成(7/7) |
|
|
||||||
| Phase 3.5 | Event-first 基础设施 | ✅ 完成 |
|
|
||||||
| Phase 3.6 | 外部 harness runner 协议 smoke | ✅ 完成(Claude Code MVP) |
|
|
||||||
| Phase 4 | EBA 事件支持 | 🔲 未开始(已预留 event-first 入口,EventGateway 由其他分支实现) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 详细状态
|
|
||||||
|
|
||||||
### SDK 侧 (`langbot-plugin-sdk`)
|
|
||||||
|
|
||||||
| 组件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AgentRunner` 组件 | ✅ | `api/definition/components/agent_runner/runner.py` |
|
|
||||||
| `AgentRunContext` | ✅ | `api/entities/builtin/agent_runner/context.py` |
|
|
||||||
| `AgentRunResult` | ✅ | `api/entities/builtin/agent_runner/result.py` |
|
|
||||||
| `AgentRunnerCapabilities` | ✅ | `api/entities/builtin/agent_runner/capabilities.py` |
|
|
||||||
| `AgentRunnerPermissions` | ✅ | `api/entities/builtin/agent_runner/permissions.py` |
|
|
||||||
| EBA 事件模型 (Event/Actor/Subject) | ✅ | `api/entities/builtin/agent_runner/event.py` |
|
|
||||||
| `LIST_AGENT_RUNNERS` action | ✅ | `runtime/io/handlers/control.py` |
|
|
||||||
| `RUN_AGENT` action | ✅ | `runtime/io/handlers/control.py` |
|
|
||||||
| `AgentRunAPIProxy` | ✅ | `api/proxies/agent_run_api.py` |
|
|
||||||
| Pull API handlers (State/History/Event/Artifact) | ✅ | `runtime/io/handlers/plugin.py` |
|
|
||||||
| `caller_plugin_identity` injection | ✅ | Pull API handlers inject caller identity |
|
|
||||||
|
|
||||||
### LangBot 侧
|
|
||||||
|
|
||||||
| 组件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AgentRunnerRegistry` | ✅ | `pkg/agent/runner/registry.py` |
|
|
||||||
| `AgentRunOrchestrator` | ✅ | `pkg/agent/runner/orchestrator.py` - event-first `run(event, binding)` |
|
|
||||||
| `AgentRunnerDescriptor` | ✅ | `pkg/agent/runner/descriptor.py` |
|
|
||||||
| `AgentResourceBuilder` | ✅ | `pkg/agent/runner/resource_builder.py` |
|
|
||||||
| `AgentRunContextBuilder` | ✅ | `pkg/agent/runner/context_builder.py` - event-first context |
|
|
||||||
| `AgentResultNormalizer` | ✅ | `pkg/agent/runner/result_normalizer.py` |
|
|
||||||
| `ConfigMigration` | ✅ | `pkg/agent/runner/config_migration.py` |
|
|
||||||
| `PipelineAdapter` | ✅ | `pkg/agent/runner/pipeline_adapter.py` - Query → Event + Binding |
|
|
||||||
| `run_from_query()` → `run(event, binding)` | ✅ | Pipeline 路径委托到 event-first path |
|
|
||||||
| `ChatMessageHandler` 集成 | ✅ | 使用 orchestrator 替代 wrapper |
|
|
||||||
| `PipelineService` 集成 | ✅ | 从 registry 获取 runner metadata |
|
|
||||||
| Plugin connector | ✅ | `list_agent_runners()` / `run_agent()` |
|
|
||||||
| `EventLogStore` | ✅ | `pkg/agent/runner/event_log_store.py` |
|
|
||||||
| `TranscriptStore` | ✅ | `pkg/agent/runner/transcript_store.py` |
|
|
||||||
| `ArtifactStore` | ✅ | `pkg/agent/runner/artifact_store.py` |
|
|
||||||
| `PersistentStateStore` | ✅ | `pkg/agent/runner/persistent_state_store.py` |
|
|
||||||
| History / Event pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| Artifact pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| State pull APIs | ✅ | Orchestrator + APIProxy |
|
|
||||||
| `artifact.created` / `state.updated` handling | ✅ | Event-first handlers in orchestrator |
|
|
||||||
| Pipeline path host capability coverage | ✅ | EventLog/Transcript/ArtifactStore/PersistentStateStore |
|
|
||||||
| External harness state handoff | ✅ | `external.session_id` / `external.working_directory` 写入 PersistentStateStore |
|
|
||||||
|
|
||||||
### 官方插件
|
|
||||||
|
|
||||||
> 外部服务插件仓库:`/home/glwuy/langbot-app/langbot-agent-runner/`
|
|
||||||
> 本地 Local Agent 插件仓库:`/home/glwuy/langbot-app/langbot-local-agent/`
|
|
||||||
|
|
||||||
| 插件 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `local-agent` | ✅ 已完成 | 核心功能:模型、工具、知识库、流式、会话 |
|
|
||||||
| `dify-agent` | ✅ 已完成 | 支持 chat/agent/workflow 三种应用类型 |
|
|
||||||
| `n8n-agent` | ✅ 已完成 | Webhook 调用,支持 basic/jwt/header 认证 |
|
|
||||||
| `coze-agent` | ✅ 已完成 | 多模态输入,思维链处理 |
|
|
||||||
| `claude-code-agent` | ✅ MVP smoke 通过 | 本地 Claude Code CLI;context / skill / MCP 投影;host-owned resume state |
|
|
||||||
| `dashscope-agent` | ✅ 已完成 | 阿里云百炼,支持 agent/workflow 两种模式 |
|
|
||||||
| `langflow-agent` | ✅ 已完成 | SSE 流式,tweaks 配置支持 |
|
|
||||||
| `tbox-agent` | ✅ 已完成 | 蚂蚁百宝箱,多模态输入 |
|
|
||||||
|
|
||||||
**注意**: LangBot 内置 runner(`pkg/provider/runners/`)已停用,文件顶部添加了 DEPRECATED 注释。
|
|
||||||
|
|
||||||
### 本地验收
|
|
||||||
|
|
||||||
| 日期 | 范围 | 状态 | 证据 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| 2026-05-29 | `local-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-17-59-00-462-08-00-pipeline-debug-chat.md` |
|
|
||||||
| 2026-05-29 | `claude-code-agent` Pipeline Debug Chat | ✅ PASS | `langbot-skills/reports/2026-05-29-18-03-31-169-08-00-pipeline-debug-chat.md` |
|
|
||||||
| 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` |
|
|
||||||
| 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未完成但仍属本分支收尾
|
|
||||||
|
|
||||||
以下项目属于本分支收尾工作:
|
|
||||||
|
|
||||||
- [x] Smoke / manual validation — `local-agent`、Claude Code MVP、Codex MVP 已通过本地 WebUI smoke
|
|
||||||
- [ ] Docs final QA
|
|
||||||
- [ ] Claude Code runner 文档、安装和 marketplace 发布准备
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 非本分支范围
|
|
||||||
|
|
||||||
以下能力由其他分支负责:
|
|
||||||
|
|
||||||
| 能力 | 负责分支 | 备注 |
|
|
||||||
|------|----------|------|
|
|
||||||
| EventGateway implementation | event branch | 完整事件网关、事件路由、持久化管理 |
|
|
||||||
| Event subscription / notification | event branch | 事件订阅、推送通知 |
|
|
||||||
| BindingResolver persistence UI | 其他模块 | 绑定配置的持久化 UI |
|
|
||||||
| Event router integration | event branch | 与 BindingResolver 集成 |
|
|
||||||
| Scheduler / background event source | 其他模块 | 定时任务、后台事件源 |
|
|
||||||
| Security release hardening | 后续 release gate | 路径隔离、权限边界、secret、MCP/skill 投影策略、资源配额、审计 |
|
|
||||||
| Codex / Kimi runner 全量接入 | 后续 runner 插件工作 | Codex MVP 已打通;Codex 发布级能力、Kimi runner 和全量 hardening 仍不扩大到当前协议闭环 |
|
|
||||||
| Issue-centric 产品模型 / 异步队列 / workflow engine | 后续产品架构 | 不属于当前 agent-runner plugin 协议闭环 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 待办事项
|
|
||||||
|
|
||||||
### 高优先级
|
|
||||||
|
|
||||||
- [x] 工具详情 API — SDK `GET_TOOL_DETAIL` action、`AgentRunAPIProxy.get_tool_detail()` 与 Host 侧授权校验已接通
|
|
||||||
- [x] Pipeline `run_from_query()` → `run(event, binding)` — 已完成
|
|
||||||
- [x] EventLog / Transcript / ArtifactStore / PersistentStateStore — 已完成
|
|
||||||
- [x] History / Event / Artifact / State pull APIs — 已完成
|
|
||||||
- [x] `caller_plugin_identity` 验证路径 — 已完成
|
|
||||||
|
|
||||||
### 低优先级 / 未来
|
|
||||||
|
|
||||||
- [ ] EBA 完整集成 — EventGateway、event subscription、event notification 由其他分支实现
|
|
||||||
- [ ] 平台 API 动作执行 — `action.requested` 结果类型存在但未执行
|
|
||||||
- [ ] 安全发布级 hardening — 作为生产默认启用前的 release gate,不阻塞当前协议闭环
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键决策记录
|
|
||||||
|
|
||||||
| 日期 | 决策 |
|
|
||||||
|------|------|
|
|
||||||
| 2026-05-10 | Phase 0 集成测试通过,SDK v1 协议验证成功 |
|
|
||||||
| 2026-05-13 | Phase 3 完成:所有 7 个官方 runner 插件迁移完成 |
|
|
||||||
| 2026-05-23 | Phase 3.5 完成:`run_from_query()` 委托到 event-first `run(event, binding)`,Pipeline path 获得 host capabilities |
|
|
||||||
| 2026-05-29 | 本地 `local-agent` 与 `claude-code-agent` 通过 WebUI smoke;Claude Code runner 验证 external harness context 投影和 host-owned resume state |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 相关文档
|
|
||||||
|
|
||||||
- [README.md](./README.md) — 总体设计
|
|
||||||
- [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) — Agent Runner QA 指南和下一轮测试入口
|
|
||||||
- [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) — 官方插件仓库计划
|
|
||||||
- [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) — 安全发布级 hardening 后续门槛
|
|
||||||
- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) — 具体实施细节
|
|
||||||
@@ -1,702 +0,0 @@
|
|||||||
# LangBot AgentRunner Protocol v1
|
|
||||||
|
|
||||||
本文档定义 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间的协议合同。它优先描述”稳定接口应是什么”,不描述具体落地任务。
|
|
||||||
|
|
||||||
## 当前状态
|
|
||||||
|
|
||||||
**Protocol v1 已在当前分支落地**:
|
|
||||||
|
|
||||||
- ✅ SDK 定义 `AgentRunnerManifest`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy`
|
|
||||||
- ✅ Runtime 支持 `LIST_AGENT_RUNNERS` 和 `RUN_AGENT`
|
|
||||||
- ✅ Host 支持 `run_id` session authorization
|
|
||||||
- ✅ Host 能从当前 Pipeline 入口生成 event-first context
|
|
||||||
- ✅ `messages` 降级为 optional bootstrap
|
|
||||||
- ✅ `max-round` 不出现在协议实体中,也不属于 Host / Pipeline 语义;类似参数若存在,由 runner 自己解释 `ctx.config`
|
|
||||||
- ✅ Proxy 覆盖 model、tool、knowledge、state/storage
|
|
||||||
- ✅ History / Event / Artifact / State API 已落地
|
|
||||||
- ✅ EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
|
|
||||||
- ✅ `local-agent` 与 Claude Code runner 已通过本地 WebUI smoke,验证 host-infra runner 与外部 harness runner 共享同一协议路径
|
|
||||||
|
|
||||||
## 1. 协议目标
|
|
||||||
|
|
||||||
Protocol v1 要解决四件事:
|
|
||||||
|
|
||||||
- LangBot 如何发现插件提供的 AgentRunner。
|
|
||||||
- LangBot 如何把一次事件调用封装成 `AgentRunContext`。
|
|
||||||
- AgentRunner 如何以事件流形式返回运行结果。
|
|
||||||
- AgentRunner 如何通过受限 API 访问 LangBot host 能力。
|
|
||||||
|
|
||||||
Protocol v1 不定义:
|
|
||||||
|
|
||||||
- LangBot 内部如何持久化 AgentBinding。
|
|
||||||
- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory。
|
|
||||||
- 官方 local-agent 的具体实现。
|
|
||||||
- Pipeline 的长期配置模型。
|
|
||||||
- 发布级安全 hardening 的完整实现;当前只定义 Host 侧资源、权限、状态和审计边界,release gate 见 [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。 |
|
|
||||||
|
|
||||||
`AgentBinding` 只影响 Host 构造出的 `ctx.config`、`ctx.resources`、`ctx.context` 和 `ctx.delivery`。SDK 不需要知道 binding 的持久化形态。
|
|
||||||
|
|
||||||
外部 harness runner(Claude Code、Codex、Kimi Code 等)仍然是 `AgentRunner`。Protocol v1 只要求它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage/artifact APIs 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。
|
|
||||||
|
|
||||||
## 3. Discovery 协议
|
|
||||||
|
|
||||||
### 3.1 LIST_AGENT_RUNNERS
|
|
||||||
|
|
||||||
Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表。该请求不需要额外 payload。
|
|
||||||
|
|
||||||
Runtime 返回:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ListAgentRunnersResponse(BaseModel):
|
|
||||||
runners: list[AgentRunnerManifest]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 AgentRunnerManifest
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerManifest(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
label: I18nObject
|
|
||||||
description: I18nObject | None = None
|
|
||||||
capabilities: AgentRunnerCapabilities
|
|
||||||
permissions: AgentRunnerPermissions
|
|
||||||
context: AgentRunnerContextPolicy
|
|
||||||
config_schema: list[DynamicFormItemSchema] = []
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
字段要求:
|
|
||||||
|
|
||||||
- `id` 必须稳定,推荐 `plugin:author/name/runner`。
|
|
||||||
- `name` 是插件内 runner 名称,例如 `default`。
|
|
||||||
- `config_schema` 只描述绑定配置表单,不代表插件实例状态。
|
|
||||||
- `metadata` 只能放展示、诊断、非稳定扩展信息。
|
|
||||||
|
|
||||||
### 3.3 Capabilities
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerCapabilities(BaseModel):
|
|
||||||
streaming: bool = False
|
|
||||||
tool_calling: bool = False
|
|
||||||
knowledge_retrieval: bool = False
|
|
||||||
multimodal_input: bool = False
|
|
||||||
event_context: bool = True
|
|
||||||
platform_api: bool = False
|
|
||||||
interrupt: bool = False
|
|
||||||
stateful_session: bool = False
|
|
||||||
self_managed_context: bool = True
|
|
||||||
```
|
|
||||||
|
|
||||||
语义:
|
|
||||||
|
|
||||||
- `streaming`: runner 可以返回 `message.delta`。
|
|
||||||
- `tool_calling`: runner 可能调用 Host tool APIs。
|
|
||||||
- `knowledge_retrieval`: runner 可能调用 Host knowledge APIs。
|
|
||||||
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
|
|
||||||
- `event_context`: runner 理解 event-first 输入。
|
|
||||||
- `platform_api`: runner 可能请求平台动作。
|
|
||||||
- `interrupt`: runner 支持取消或中断。
|
|
||||||
- `stateful_session`: runner 可能维护跨 run 会话状态。
|
|
||||||
- `self_managed_context`: runner 自己管理 working context,Host 不应默认 inline 历史。
|
|
||||||
|
|
||||||
### 3.4 Permissions
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerPermissions(BaseModel):
|
|
||||||
models: list[Literal["invoke", "stream", "rerank"]] = []
|
|
||||||
tools: list[Literal["detail", "call"]] = []
|
|
||||||
knowledge_bases: list[Literal["list", "retrieve"]] = []
|
|
||||||
history: list[Literal["page", "search"]] = []
|
|
||||||
events: list[Literal["get", "page"]] = []
|
|
||||||
artifacts: list[Literal["metadata", "read"]] = []
|
|
||||||
storage: list[Literal["plugin", "workspace", "binding"]] = []
|
|
||||||
files: list[Literal["config", "knowledge"]] = []
|
|
||||||
platform_api: list[str] = []
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest permissions 是 runner 需要的最大能力。实际可用资源还要经过 Host binding policy 和当前 run scope 裁剪。
|
|
||||||
|
|
||||||
### 3.5 Context Policy
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunnerContextPolicy(BaseModel):
|
|
||||||
ownership: Literal["self_managed", "host_bootstrap", "hybrid"] = "self_managed"
|
|
||||||
bootstrap: Literal["none", "current_event", "recent_tail", "summary_tail"] = "current_event"
|
|
||||||
max_inline_events: int = 0
|
|
||||||
max_inline_bytes: int = 0
|
|
||||||
supports_history_pull: bool = True
|
|
||||||
supports_history_search: bool = False
|
|
||||||
supports_artifact_pull: bool = True
|
|
||||||
owns_compaction: bool = True
|
|
||||||
wants_static_context_refs: bool = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Host 不使用该声明给 runner inline 历史窗口。默认原则:
|
|
||||||
|
|
||||||
- Host 不得默认 inline 全量历史。
|
|
||||||
- Host 只 inline 当前 event / input 和 context handles。
|
|
||||||
- Runner 拥有 working context assembly。
|
|
||||||
- Runner 可在授权后通过 Host history / event / artifact / state APIs 拉取更多上下文。
|
|
||||||
- `max-round` 或类似窗口参数不属于 Protocol v1 字段,也不属于 Pipeline / Host 通用语义;如果某个 runner 需要,应由 runner 自己解释 `ctx.config`。
|
|
||||||
|
|
||||||
## 4. Run 协议
|
|
||||||
|
|
||||||
### 4.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` 为准。
|
|
||||||
|
|
||||||
### 4.2 AgentRunContext
|
|
||||||
|
|
||||||
```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] = {}
|
|
||||||
bootstrap: BootstrapContext | None = None
|
|
||||||
adapter: AdapterContext | None = None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
核心约束:
|
|
||||||
|
|
||||||
- `event` 是必选字段,Protocol v1 是 event-first。
|
|
||||||
- `input` 表示当前事件的主输入,不等于历史消息。
|
|
||||||
- `bootstrap` 是可选字段;LangBot Host 默认不填历史窗口。
|
|
||||||
- `adapter` 只放 Pipeline adapter 字段,runner 不应依赖它做长期能力。
|
|
||||||
- `config` 是 Host binding config,不是插件实例状态。
|
|
||||||
|
|
||||||
### 4.3 AgentTrigger
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentTrigger(BaseModel):
|
|
||||||
type: str
|
|
||||||
source: Literal["platform", "webui", "api", "scheduler", "system", "pipeline_adapter"]
|
|
||||||
timestamp: int | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如 Pipeline 兼容入口触发消息时:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "message.received",
|
|
||||||
"source": "pipeline_adapter"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.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`。
|
|
||||||
- 平台原始事件名放入 `source_event_type`。
|
|
||||||
- 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`。
|
|
||||||
|
|
||||||
### 4.5 Actor / Subject / Conversation
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ConversationContext(BaseModel):
|
|
||||||
conversation_id: str | None = None
|
|
||||||
thread_id: str | None = None
|
|
||||||
launcher_type: str | None = None
|
|
||||||
launcher_id: str | None = None
|
|
||||||
bot_id: str | None = None
|
|
||||||
workspace_id: str | None = None
|
|
||||||
|
|
||||||
class ActorContext(BaseModel):
|
|
||||||
actor_type: str
|
|
||||||
actor_id: str | None = None
|
|
||||||
actor_name: str | None = None
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
|
|
||||||
class SubjectContext(BaseModel):
|
|
||||||
subject_type: str
|
|
||||||
subject_id: str | None = None
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
- 消息事件:actor 是发消息的人,subject 是当前消息。
|
|
||||||
- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。
|
|
||||||
- 定时事件:actor 可以是 system,subject 是 schedule。
|
|
||||||
|
|
||||||
### 4.6 AgentInput
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentInput(BaseModel):
|
|
||||||
text: str | None = None
|
|
||||||
contents: list[ContentElement] = []
|
|
||||||
attachments: list[ArtifactRef] = []
|
|
||||||
message_chain: dict[str, Any] | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
- 文本、多模态、附件都属于当前 event input。
|
|
||||||
- 大文件、图片、音频、工具大结果应以 artifact ref 传递。
|
|
||||||
- `message_chain` 是平台兼容字段,不应成为长期稳定依赖。
|
|
||||||
|
|
||||||
### 4.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`。
|
|
||||||
|
|
||||||
### 4.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
|
|
||||||
```
|
|
||||||
|
|
||||||
`ContextAccess` 告诉 runner:Host inline 了什么、没有 inline 什么、如果需要更多上下文应该通过哪些 API 拉取。
|
|
||||||
它不是 Host 的业务上下文编排策略,而是 runner 按需读取上下文的入口说明。
|
|
||||||
|
|
||||||
```python
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.9 BootstrapContext
|
|
||||||
|
|
||||||
```python
|
|
||||||
class BootstrapContext(BaseModel):
|
|
||||||
messages: list[Message] = []
|
|
||||||
summary: str | None = None
|
|
||||||
artifacts: list[ArtifactRef] = []
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- `bootstrap.messages` 不是 LangBot Host 的默认行为。
|
|
||||||
- 自管 context runner 默认应收到空 bootstrap。
|
|
||||||
- Host 不应为了”帮 agent 更聪明”而自动拼接完整 transcript。
|
|
||||||
- 类似历史窗口策略应由具体 runner 自己解释 binding config,并通过 Host history API 拉取历史;new/official runners 不应依赖 Pipeline adapter 下发历史窗口。
|
|
||||||
|
|
||||||
### 4.10 RuntimeContext
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRuntimeContext(BaseModel):
|
|
||||||
host: str = "langbot"
|
|
||||||
langbot_version: str | None = None
|
|
||||||
trace_id: str
|
|
||||||
deadline_at: float | None = None
|
|
||||||
locale: str | None = None
|
|
||||||
timezone: str | None = None
|
|
||||||
static_refs: dict[str, StaticContextRef] = {}
|
|
||||||
metadata: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`static_refs` 用于 KV cache 友好的静态上下文引用,例如 system policy、tool schema、resource manifest 的 hash/version。
|
|
||||||
|
|
||||||
### 4.11 State
|
|
||||||
|
|
||||||
```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 也可以完全自管状态。
|
|
||||||
|
|
||||||
## 5. Resources
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentResources(BaseModel):
|
|
||||||
models: list[ModelResource] = []
|
|
||||||
tools: list[ToolResource] = []
|
|
||||||
knowledge_bases: list[KnowledgeBaseResource] = []
|
|
||||||
files: list[FileResource] = []
|
|
||||||
storage: StorageResource = StorageResource()
|
|
||||||
platform_capabilities: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
|
|
||||||
|
|
||||||
## 6. Result Stream
|
|
||||||
|
|
||||||
### 6.1 AgentRunResult
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentRunResult(BaseModel):
|
|
||||||
run_id: str
|
|
||||||
type: str
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
sequence: int | None = None
|
|
||||||
timestamp: int | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 稳定 result types
|
|
||||||
|
|
||||||
| type | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `message.delta` | 流式消息片段。 |
|
|
||||||
| `message.completed` | 完整消息。 |
|
|
||||||
| `tool.call.started` | runner 开始工具调用的可观测事件。 |
|
|
||||||
| `tool.call.completed` | runner 完成工具调用的可观测事件。 |
|
|
||||||
| `artifact.created` | runner 生成 artifact。 |
|
|
||||||
| `state.updated` | runner 请求更新 host-owned state。 |
|
|
||||||
| `action.requested` | runner 请求 Host 执行平台动作。 |
|
|
||||||
| `run.completed` | run 正常结束。 |
|
|
||||||
| `run.failed` | run 失败。 |
|
|
||||||
|
|
||||||
Host 必须忽略未知 result type 并记录 warning,除非该 type 明确要求强校验。
|
|
||||||
|
|
||||||
### 6.3 message.delta
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "message.delta",
|
|
||||||
"data": {
|
|
||||||
"chunk": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "hel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 message.completed
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "message.completed",
|
|
||||||
"data": {
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "hello"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.5 state.updated
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "state.updated",
|
|
||||||
"data": {
|
|
||||||
"scope": "conversation",
|
|
||||||
"key": "external.session_id",
|
|
||||||
"value": "abc"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Host 必须校验 scope、key、value 大小和 JSON 可序列化性。
|
|
||||||
|
|
||||||
### 6.6 action.requested
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "action.requested",
|
|
||||||
"data": {
|
|
||||||
"action": "message.edit",
|
|
||||||
"target": {"message_id": "..."},
|
|
||||||
"payload": {"text": "..."}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Protocol v1 只定义表达方式。Host 是否执行 action 取决于 platform API 能力、binding policy、审批策略和实现阶段。
|
|
||||||
|
|
||||||
## 7. AgentRunAPIProxy
|
|
||||||
|
|
||||||
所有 proxy action 必须携带 `run_id`。Host 必须校验:
|
|
||||||
|
|
||||||
- active run session 存在。
|
|
||||||
- caller plugin identity 匹配。
|
|
||||||
- resource 在本次 `ctx.resources` 中授权。
|
|
||||||
- scope 不越界。
|
|
||||||
- payload size / rate limit / deadline 合法。
|
|
||||||
|
|
||||||
### 7.1 Model APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.models.invoke(model_id, messages, tools=None, extra_args=None)
|
|
||||||
await api.models.stream(model_id, messages, tools=None, extra_args=None)
|
|
||||||
await api.models.rerank(model_id, query, documents, top_k=None)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Tool APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.tools.get_detail(tool_name)
|
|
||||||
await api.tools.call(tool_name, parameters)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Knowledge APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.knowledge.retrieve(kb_id, query_text, top_k=5, filters=None)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 History APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
History API 返回 Transcript projection,不返回原始平台 payload。
|
|
||||||
|
|
||||||
### 7.5 Event APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.events.get(event_id)
|
|
||||||
await api.events.page(before_cursor=None, limit=50)
|
|
||||||
```
|
|
||||||
|
|
||||||
Event API 返回稳定 event envelope 或受限 raw ref,不默认返回大 payload。
|
|
||||||
|
|
||||||
### 7.6 Artifact APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.artifacts.metadata(artifact_id)
|
|
||||||
await api.artifacts.read_range(artifact_id, offset=0, length=65536)
|
|
||||||
await api.artifacts.open_stream(artifact_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Artifact API 必须支持大小限制、MIME 校验、过期时间和授权范围。
|
|
||||||
|
|
||||||
### 7.7 State / Storage APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.state.get(scope, key)
|
|
||||||
await api.state.set(scope, key, value)
|
|
||||||
await api.state.delete(scope, key)
|
|
||||||
|
|
||||||
await api.storage.get(area, key)
|
|
||||||
await api.storage.set(area, key, value)
|
|
||||||
await api.storage.delete(area, key)
|
|
||||||
await api.storage.list(area, prefix=None)
|
|
||||||
```
|
|
||||||
|
|
||||||
建议区分:
|
|
||||||
|
|
||||||
- `state`: 小型 JSON 状态,适合 conversation / actor / runner / binding。
|
|
||||||
- `storage`: blob 或较大数据,适合插件私有数据、workspace 数据、checkpoint。
|
|
||||||
|
|
||||||
### 7.8 Platform APIs
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.platform.request_action(action, target, payload)
|
|
||||||
```
|
|
||||||
|
|
||||||
平台 API 是受限能力。默认不开放。需要 runner manifest、binding policy、用户审批策略同时允许。
|
|
||||||
|
|
||||||
## 8. 错误模型
|
|
||||||
|
|
||||||
Host API 错误统一返回:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AgentAPIError(BaseModel):
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
retryable: bool = False
|
|
||||||
details: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
建议 code:
|
|
||||||
|
|
||||||
| code | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `unauthorized` | 未授权访问资源或 scope。 |
|
|
||||||
| `not_found` | 资源不存在或对当前 runner 不可见。 |
|
|
||||||
| `deadline_exceeded` | 超过 run deadline。 |
|
|
||||||
| `payload_too_large` | 请求或响应过大。 |
|
|
||||||
| `rate_limited` | Host 限流。 |
|
|
||||||
| `invalid_argument` | 参数错误。 |
|
|
||||||
| `runtime_error` | Host 或下游能力错误。 |
|
|
||||||
|
|
||||||
Runner 失败使用 `run.failed`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "run.failed",
|
|
||||||
"data": {
|
|
||||||
"code": "runner.error",
|
|
||||||
"message": "failed to call external agent",
|
|
||||||
"retryable": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Timeout 与 Cancellation
|
|
||||||
|
|
||||||
Host 在 `ctx.runtime.deadline_at` 中下发总 deadline。SDK proxy 必须用该 deadline 限制单次 action timeout。
|
|
||||||
|
|
||||||
取消语义:
|
|
||||||
|
|
||||||
- Host 可以取消 active run。
|
|
||||||
- Runtime 应尽力中断 runner。
|
|
||||||
- Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。
|
|
||||||
- Host 必须 unregister active run session。
|
|
||||||
|
|
||||||
## 10. Security 与 Guardrail
|
|
||||||
|
|
||||||
Protocol v1 的安全边界在 Host:
|
|
||||||
|
|
||||||
- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。
|
|
||||||
- SDK 本地校验只提升开发体验,不能替代 Host 校验。
|
|
||||||
- 所有 resource id 对 runner 来说都是 opaque。
|
|
||||||
- 默认只能访问当前 conversation / thread 的 history。
|
|
||||||
- 跨会话、workspace 级 history 或 storage 必须额外授权。
|
|
||||||
- 大 payload 必须 artifact 化。
|
|
||||||
- Host 必须记录 run_id、runner_id、action、resource、scope、result。
|
|
||||||
|
|
||||||
对外部 harness runner,边界进一步拆分为:
|
|
||||||
|
|
||||||
- Host 在调用前完成 binding/resource policy 裁剪、路径策略、secret 过滤和审计记录。
|
|
||||||
- Runner plugin 把授权后的 context/resource projection 适配为目标 harness 的 context 文件、MCP 配置、skill 目录、环境变量或 CLI 参数。
|
|
||||||
- Claude Code / Codex / Kimi Code 等外部 harness 的 native permission mode、allowed/disallowed tools 和执行隔离策略只是额外执行约束,不能替代 Host 侧授权。
|
|
||||||
- 外部 session id、working directory、checkpoint 等跨轮次指针应作为小型 JSON state 保存,例如 `external.session_id`、`external.working_directory`。
|
|
||||||
|
|
||||||
完整路径隔离、MCP allowlist、secret redaction、配额、workspace 清理和发布级安全测试不属于当前 Protocol v1 smoke 闭环,详见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。
|
|
||||||
|
|
||||||
Host 不负责业务编排:
|
|
||||||
|
|
||||||
- 不拼接全量历史。
|
|
||||||
- 不替 runner 做业务 prompt assembly。
|
|
||||||
- 不内置 agent memory 策略。
|
|
||||||
- 不内置 tool loop 业务流程。
|
|
||||||
- 不内置上下文压缩策略。
|
|
||||||
|
|
||||||
这些能力可以由官方或第三方 AgentRunner 插件实现,并通过公开 Host APIs 消费 LangBot 的状态、历史、存储、artifact、模型、工具和知识库能力。
|
|
||||||
|
|
||||||
## 11. Pipeline Adapter
|
|
||||||
|
|
||||||
Pipeline 是当前入口 adapter,不是协议中心。
|
|
||||||
|
|
||||||
**当前分支已实现**:
|
|
||||||
|
|
||||||
- ✅ `PipelineAdapter.query_to_event(query)` — 从 `Query` 构造 `AgentEventEnvelope`
|
|
||||||
- ✅ `PipelineAdapter.pipeline_config_to_binding(query, runner_id)` — 从 Pipeline config 构造临时 AgentBinding
|
|
||||||
- ✅ `run_from_query()` 委托到 `run(event, binding)`
|
|
||||||
- ✅ runner-specific config 从 Pipeline 当前绑定配置透传到 `AgentBinding.runner_config` / `ctx.config`
|
|
||||||
- ✅ Query-only 字段放入 `adapter` context
|
|
||||||
|
|
||||||
Pipeline adapter 负责:
|
|
||||||
|
|
||||||
- 从 `Query` 构造 `AgentEventContext`。
|
|
||||||
- 从 Pipeline config 构造临时 AgentBinding。
|
|
||||||
- 从当前 runner binding config 构造 `ctx.config`。
|
|
||||||
- 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略。
|
|
||||||
- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction
|
|
||||||
package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`。
|
|
||||||
- 将 Query-only 字段放入 `adapter`。
|
|
||||||
|
|
||||||
Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。
|
|
||||||
|
|
||||||
## 12. 最小 v1 完成标准
|
|
||||||
|
|
||||||
Protocol v1 已在当前分支完成:
|
|
||||||
|
|
||||||
- ✅ SDK 定义 `AgentRunnerManifest`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy`
|
|
||||||
- ✅ Runtime 支持 `LIST_AGENT_RUNNERS` 和 `RUN_AGENT`
|
|
||||||
- ✅ Host 支持 `run_id` session authorization
|
|
||||||
- ✅ Host 能从当前 Pipeline 入口生成 event-first context
|
|
||||||
- ✅ `messages` 降级为 optional bootstrap
|
|
||||||
- ✅ `max-round` 不出现在协议实体中,也不属于 Host / Pipeline 语义
|
|
||||||
- ✅ Proxy 至少覆盖 model、tool、knowledge、state/storage
|
|
||||||
- ✅ History / event / artifact API 已落地
|
|
||||||
- ✅ EventLog / Transcript / ArtifactStore / PersistentStateStore 已落地
|
|
||||||
- ✅ 外部 harness runner 最小 smoke 已落地:Claude Code runner 能消费 event-first context、返回消息、写回 `external.session_id` / `external.working_directory`
|
|
||||||
|
|
||||||
## 13. 开放问题
|
|
||||||
|
|
||||||
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
|
|
||||||
- `TranscriptItem` 的最小字段集如何定义。
|
|
||||||
- ArtifactStore 是否复用现有 BinaryStorage backend,还是引入独立实体。
|
|
||||||
- State 与 Storage 的边界是否需要更强类型。
|
|
||||||
- `platform_api` action 的审批模型如何表达。
|
|
||||||
- 多 runner 并发处理同一 event 时,result delivery 的冲突策略如何定义。
|
|
||||||
- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Agent Runner 插件化文档入口
|
|
||||||
|
|
||||||
本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 预留和官方 runner 迁移混在同一份 README 里。
|
|
||||||
|
|
||||||
## 本分支目标
|
|
||||||
|
|
||||||
**本分支目标:AgentRunner 外化 / 插件化基础设施**
|
|
||||||
|
|
||||||
本分支只做 LangBot 作为 Agent Host 的基础能力建设:
|
|
||||||
|
|
||||||
- LangBot 与 SDK 的稳定协议合同(Protocol v1)
|
|
||||||
- Host-side `AgentEventEnvelope` / `AgentBinding` 模型
|
|
||||||
- `run(event, binding)` event-first 入口
|
|
||||||
- `PipelineAdapter`:Pipeline Query → AgentEventEnvelope + AgentBinding
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore
|
|
||||||
- History / Event / Artifact / State pull APIs
|
|
||||||
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
|
|
||||||
|
|
||||||
## 本分支不实现
|
|
||||||
|
|
||||||
以下能力由其他分支负责,本分支只预留 integration point:
|
|
||||||
|
|
||||||
- **EventGateway**:完整事件网关实现、事件路由、事件持久化管理
|
|
||||||
- **Event subscription / Event notification**:事件订阅、推送通知
|
|
||||||
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
|
|
||||||
- **Scheduler / Background event source**:定时任务、后台事件源
|
|
||||||
- **Runtime control plane v2**:runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit
|
|
||||||
|
|
||||||
EventGateway 在本文档中描述为 **future integration point**,由外部 event branch 提供。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。
|
|
||||||
|
|
||||||
## 当前状态
|
|
||||||
|
|
||||||
**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。**
|
|
||||||
|
|
||||||
当前主入口仍可由 Pipeline 触发,但内部已转换成 event-first path:
|
|
||||||
|
|
||||||
1. `run_from_query()` 使用 `PipelineAdapter.query_to_event(query)` 转换为 `AgentEventEnvelope`
|
|
||||||
2. `run_from_query()` 使用 `PipelineAdapter.pipeline_config_to_binding(query, runner_id)` 转换为 `AgentBinding`
|
|
||||||
3. `run_from_query()` 委托到 `run(event, binding, bound_plugins, adapter_context)`
|
|
||||||
|
|
||||||
Pipeline path 已获得 event-first host capabilities:
|
|
||||||
- EventLog / Transcript 写入
|
|
||||||
- ArtifactStore 注册
|
|
||||||
- PersistentStateStore 状态持久化
|
|
||||||
- History / Event / Artifact / State pull APIs 可用
|
|
||||||
|
|
||||||
## 设计文档
|
|
||||||
|
|
||||||
| 文档 | 关注点 |
|
|
||||||
| --- | --- |
|
|
||||||
| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:run context、result stream、proxy actions、错误和 adapter 边界。 |
|
|
||||||
| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力、SDK 协议、runner 发现、绑定、权限、状态、存储、生命周期和调用链。 |
|
|
||||||
| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 |
|
|
||||||
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 预留:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度。**标注为 future design note**。 |
|
|
||||||
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面预留:Host 新增 runtime registry、heartbeat、task queue、daemon 执行和 audit;管理插件构建在这些 Host 能力之上。**标注为 future design note**。 |
|
|
||||||
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
|
|
||||||
| [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
|
|
||||||
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
|
|
||||||
| [PROGRESS.md](./PROGRESS.md) | 当前实现进度、已验收能力、未完成收尾和非本分支范围。 |
|
|
||||||
|
|
||||||
## 工作拆分
|
|
||||||
|
|
||||||
### 1. LangBot + SDK 基础设施
|
|
||||||
|
|
||||||
目标是把 LangBot 从内置 runner 执行器变成 agent host:
|
|
||||||
|
|
||||||
- LangBot 与 SDK 的稳定协议合同
|
|
||||||
- runner manifest / descriptor / registry
|
|
||||||
- agent binding 与配置解析
|
|
||||||
- run orchestration 和生命周期管理
|
|
||||||
- resource authorization 与 `run_id` 级权限校验
|
|
||||||
- host-owned state / storage / event log / transcript / artifact 能力
|
|
||||||
- SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy`
|
|
||||||
|
|
||||||
协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。
|
|
||||||
|
|
||||||
详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。
|
|
||||||
|
|
||||||
### 2. Agent-owned context
|
|
||||||
|
|
||||||
LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。
|
|
||||||
|
|
||||||
`max-round` 这类历史窗口参数不应作为目标协议继续扩展;如果某个 runner 仍需要类似策略,应由该 runner 的 manifest/config schema 暴露为 binding config。
|
|
||||||
|
|
||||||
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
|
||||||
|
|
||||||
### 3. Event Based Agent(Future)
|
|
||||||
|
|
||||||
消息只是事件的一种。后续 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。
|
|
||||||
|
|
||||||
**本分支不实现 EBA 完整能力,只预留:**
|
|
||||||
- event-first envelope (`AgentEventEnvelope`)
|
|
||||||
- AgentBinding model
|
|
||||||
- `run(event, binding)` 入口
|
|
||||||
- PipelineAdapter(当前 AgentEventEnvelope / AgentBinding 的 Pipeline adapter source)
|
|
||||||
|
|
||||||
详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。
|
|
||||||
|
|
||||||
### 4. 官方 runner 插件
|
|
||||||
|
|
||||||
官方 `local-agent` 和外部 runner 迁移是下游工作。它们需要依附 LangBot 提供的宿主能力,但不应反过来决定宿主协议。
|
|
||||||
|
|
||||||
`local-agent` 可以外移,也可以重写。验收重点是它能完整消费 LangBot 的模型、工具、知识库、存储、事件、history API 和 result stream,而不是保留旧内置 runner 的内部结构。
|
|
||||||
|
|
||||||
详见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。
|
|
||||||
|
|
||||||
### 5. Runtime Control Plane v2(Future)
|
|
||||||
|
|
||||||
当前 AgentRunner v1 主线只负责 `event -> binding -> runner.run(ctx) -> result stream`。
|
|
||||||
后续 Agent Platform v2 可以在 Host 侧新增 runtime registry、heartbeat、task queue、daemon claim、progress/cancel 和 runtime audit。
|
|
||||||
|
|
||||||
在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验,runtime/task 的事实源仍由 Host 持有。
|
|
||||||
|
|
||||||
详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。
|
|
||||||
|
|
||||||
## 已确认决策
|
|
||||||
|
|
||||||
- 一个插件可以声明多个 `AgentRunner` 组件,每个组件独立暴露 manifest、配置 schema、能力和权限。
|
|
||||||
- 插件本身按单实例、无状态执行单元理解;不同绑定不创建多个插件实例。
|
|
||||||
- 绑定只保存 runner id 和绑定配置,不代表插件实例状态。
|
|
||||||
- LangBot 可以提供 host-owned state / storage 能力,让 runner 把状态寄宿在 LangBot;但这应该是授权能力,不是强制要求。
|
|
||||||
- 官方 runner 插件是协议消费者,不是协议设计的优先约束。
|
|
||||||
- Pipeline 是当前入口 adapter,不是未来架构中心。
|
|
||||||
- EventGateway 是 future integration point,由外部 event branch 提供。
|
|
||||||
- Runtime control plane 是 v2 Host capability layer,不阻塞当前 AgentRunner v1 主线;agent 管控面插件应构建在该 Host 能力层之上。
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
# Agent Runtime Control Plane V2
|
|
||||||
|
|
||||||
本文档记录后续 Agent Platform / runtime 管控面的设计方向。它是当前讨论中的 **v2 文档**,但这里的 v2 指 Host capability layer / runtime control plane,不是 `AgentRunner Protocol v2`,也不属于当前 AgentRunner Protocol v1 插件化主线的交付范围。
|
|
||||||
|
|
||||||
## 1. 结论
|
|
||||||
|
|
||||||
当前主线应继续收口 AgentRunner v1:
|
|
||||||
|
|
||||||
```text
|
|
||||||
message/event -> binding -> runner.run(ctx) -> result stream
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime Control Plane v2 在 Host 侧新增 runtime control plane:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event -> task -> runtime selection -> daemon claim -> execute -> progress/audit/result
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Runtime Control Plane v2 之上,可以构建独立的 agent 管控面插件。插件负责 UI、策略和编排体验;runtime、task、heartbeat、audit 的事实源必须属于 LangBot Host,而不是插件私有 storage。
|
|
||||||
|
|
||||||
## 2. 不影响 v1 主线
|
|
||||||
|
|
||||||
v2 不应改变 AgentRunner v1 的基本契约:
|
|
||||||
|
|
||||||
- 现有 `local-agent`、Dify、n8n、Coze 等 runner 仍可按 v1 直接执行。
|
|
||||||
- 当前 Claude Code / Codex MVP runner 可以继续作为本机 subprocess 开发路径。
|
|
||||||
- Host v1 已有的 event-first context、resource authorization、history / event / artifact / state / storage pull APIs 继续保留。
|
|
||||||
- Pipeline 仍只是当前入口 adapter,不参与 v2 runtime 管控面的设计中心。
|
|
||||||
|
|
||||||
v2 只是在 Host 上新增一层可选能力。需要管控面的 runner 或管理插件可以声明使用它;不需要的 runner 不受影响。
|
|
||||||
|
|
||||||
## 3. 当前 Host 能力与缺口
|
|
||||||
|
|
||||||
当前 Host 已经具备 v2 的基础设施底座:
|
|
||||||
|
|
||||||
- `AgentEventEnvelope` / `AgentBinding`
|
|
||||||
- run-scoped resource authorization
|
|
||||||
- EventLog / Transcript / ArtifactStore / PersistentStateStore
|
|
||||||
- History / Event / Artifact / State / Storage pull APIs
|
|
||||||
- AgentRunner result stream 和受控错误回流
|
|
||||||
- binding config 与 host-owned state
|
|
||||||
|
|
||||||
这些能力足够支持一次 `runner.run(ctx)` 内的安全执行,但不足以承担完整 runtime 管控面。
|
|
||||||
|
|
||||||
v2 还需要 Host 新增:
|
|
||||||
|
|
||||||
- runtime registry:runtime id、所属 workspace、所在机器、provider 能力、状态。
|
|
||||||
- capability discovery:`claude` / `codex` / 其它 CLI 是否存在、版本、登录状态、执行隔离能力。
|
|
||||||
- heartbeat / liveness:runtime 在线、忙闲、最后心跳、可用 slot。
|
|
||||||
- task queue:enqueue、claim、start、progress、complete、fail、cancel。
|
|
||||||
- workspace mapping:LangBot workspace / project 如何映射到 runtime 上的真实目录、仓库或挂载。
|
|
||||||
- secret / env projection:按授权向 runtime 投影 token、代理、MCP 配置、技能和环境变量。
|
|
||||||
- runtime audit:stdout、stderr、事件流、产物、失败原因、执行耗时、使用量。
|
|
||||||
- control API / UI:选择 runtime、测试 runtime、查看状态、下线、取消任务、重试任务。
|
|
||||||
|
|
||||||
## 4. 角色边界
|
|
||||||
|
|
||||||
### 4.1 LangBot Host
|
|
||||||
|
|
||||||
Host 是事实源和控制面内核:
|
|
||||||
|
|
||||||
- 保存 runtime / task / heartbeat / audit 状态。
|
|
||||||
- 做权限校验、资源裁剪、workspace 绑定和审计。
|
|
||||||
- 决定任务是否可被某 runtime claim。
|
|
||||||
- 将执行结果统一回写到 event / transcript / artifact / state。
|
|
||||||
|
|
||||||
Host 不应内置具体 agent CLI 的复杂业务逻辑,也不应把某个官方 runner 的特殊行为提升为通用协议。
|
|
||||||
|
|
||||||
### 4.2 Agent 管控面插件
|
|
||||||
|
|
||||||
管理插件是 v2 control plane 的产品化管理层:
|
|
||||||
|
|
||||||
- 展示 runtime、agent、task、进度、失败、审计。
|
|
||||||
- 提供策略配置,例如默认 runtime、provider 偏好、并发限制、重试策略。
|
|
||||||
- 触发 runtime 测试、任务取消、任务重试、手动分配。
|
|
||||||
|
|
||||||
管理插件不应把 runtime/task 的事实源放进自己的 plugin storage。它应该调用 Host v2 API。
|
|
||||||
|
|
||||||
### 4.3 Runtime daemon / worker
|
|
||||||
|
|
||||||
Runtime daemon 负责真实执行:
|
|
||||||
|
|
||||||
- 在所在机器上检测 CLI 和版本。
|
|
||||||
- 管理工作目录、仓库、挂载、临时文件和进程。
|
|
||||||
- 从 Host claim 任务,执行后上报 progress / complete / fail。
|
|
||||||
- 将 stdout / stderr / artifacts / session id 回流 Host。
|
|
||||||
|
|
||||||
Claude Code、Codex、OpenCode、Gemini CLI 等 provider 适配逻辑应主要落在 daemon / worker 或 provider adapter 中。
|
|
||||||
|
|
||||||
## 5. 部署形态
|
|
||||||
|
|
||||||
### 5.1 uv / local embedded
|
|
||||||
|
|
||||||
用户用 `uv` 或源码直接启动 LangBot 时,LangBot 进程所在机器就是 runtime host。
|
|
||||||
|
|
||||||
这种模式下可以直接检测用户主机上的 `claude`、`codex` 等 CLI,也可以直接 subprocess 执行。它适合个人开发和本地 smoke,但不应作为团队级管控面的唯一形态。
|
|
||||||
|
|
||||||
### 5.2 Docker embedded
|
|
||||||
|
|
||||||
用户用 Docker 启动 LangBot 时,runtime host 是容器,不是宿主机。
|
|
||||||
|
|
||||||
因此:
|
|
||||||
|
|
||||||
- 只能检测容器内的 `claude`、`codex`。
|
|
||||||
- 只能使用容器内的 HOME、PATH、凭据和挂载目录。
|
|
||||||
- 如果镜像未安装 CLI,或未挂载认证文件 / workspace,CLI runner 会不可用。
|
|
||||||
|
|
||||||
Docker embedded 可以作为高级部署选项,但需要用户显式安装 CLI、挂载工作区和凭据。Host 不应假设 Docker 容器能自动访问宿主机 CLI。
|
|
||||||
|
|
||||||
### 5.3 Sidecar daemon
|
|
||||||
|
|
||||||
推荐的 v2 形态是 sidecar daemon:
|
|
||||||
|
|
||||||
```text
|
|
||||||
LangBot Host (Docker or server)
|
|
||||||
<-> Runtime daemon on user host / worker host
|
|
||||||
-> claude / codex / other CLI
|
|
||||||
```
|
|
||||||
|
|
||||||
这种模式下,LangBot 可以跑在 Docker 内,runtime daemon 跑在宿主机或独立 worker 机器上。daemon 负责检测本机 CLI、持有本机凭据和工作区访问能力。
|
|
||||||
|
|
||||||
### 5.4 Remote runtime
|
|
||||||
|
|
||||||
团队场景可以使用远端 runtime:
|
|
||||||
|
|
||||||
- 开发机、构建机、云主机或专用 worker。
|
|
||||||
- 多个 workspace 可绑定不同 runtime。
|
|
||||||
- Host 只通过 registry / task queue / heartbeat / audit 进行管理。
|
|
||||||
|
|
||||||
### 5.5 API-only agent
|
|
||||||
|
|
||||||
Dify、n8n、Coze、DashScope 等 API 型 runner 不依赖本地 CLI。它们可以继续按 v1 直接执行,也可以在未来按需要接入 v2 task/audit。
|
|
||||||
|
|
||||||
## 6. 与 Claude Code / Codex MVP runner 的关系
|
|
||||||
|
|
||||||
当前 Claude Code / Codex runner 是 v1 runner:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runner.run(ctx) -> subprocess("claude" / "codex")
|
|
||||||
```
|
|
||||||
|
|
||||||
它们适合验证 Host context 投影、state resume、result stream 和基础 CLI 调用,但有明确限制:
|
|
||||||
|
|
||||||
- 命令只在 LangBot runtime host 上执行。
|
|
||||||
- Docker 环境只能看到容器内 CLI。
|
|
||||||
- 没有 runtime registry、heartbeat、task queue、cancel、workspace lifecycle。
|
|
||||||
- 不提供发布级执行隔离、secret projection、团队级 audit。
|
|
||||||
|
|
||||||
v2 不需要删除这些 runner。它们可以继续作为 dev / MVP 路径存在。未来若接入管控面,可以增加 runtime-managed 执行模式:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runner binding -> Host task -> runtime daemon -> provider CLI -> Host result
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 最小 v2 API 草案
|
|
||||||
|
|
||||||
以下仅记录能力边界,不代表最终 API 命名。
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- `runtime.register`
|
|
||||||
- `runtime.heartbeat`
|
|
||||||
- `runtime.list`
|
|
||||||
- `runtime.get`
|
|
||||||
- `runtime.disable`
|
|
||||||
- `runtime.capabilities.report`
|
|
||||||
- `runtime.capabilities.probe`
|
|
||||||
|
|
||||||
Task:
|
|
||||||
|
|
||||||
- `task.enqueue`
|
|
||||||
- `task.claim`
|
|
||||||
- `task.start`
|
|
||||||
- `task.progress`
|
|
||||||
- `task.complete`
|
|
||||||
- `task.fail`
|
|
||||||
- `task.cancel`
|
|
||||||
- `task.retry`
|
|
||||||
|
|
||||||
Workspace:
|
|
||||||
|
|
||||||
- `runtime.workspace.bind`
|
|
||||||
- `runtime.workspace.unbind`
|
|
||||||
- `runtime.workspace.resolve`
|
|
||||||
|
|
||||||
Audit / artifacts:
|
|
||||||
|
|
||||||
- `task.log.append`
|
|
||||||
- `task.artifact.create`
|
|
||||||
- `task.events.page`
|
|
||||||
|
|
||||||
这些 API 应由 Host 提供,并受 workspace、runtime、binding、actor 和 plugin identity 约束。
|
|
||||||
|
|
||||||
## 8. 管控面插件可以构建的能力
|
|
||||||
|
|
||||||
基于 v2 Host 能力,可以实现一个类似 Multica 的 agent 管控面插件:
|
|
||||||
|
|
||||||
- runtime 列表、在线状态、CLI 能力、版本、认证状态。
|
|
||||||
- agent profile 与 runtime/provider 绑定。
|
|
||||||
- 任务看板、任务详情、进度流、失败原因、重试和取消。
|
|
||||||
- workspace 到 runtime 目录 / 仓库的映射管理。
|
|
||||||
- provider capability 测试,例如 Claude Code / Codex 是否可执行。
|
|
||||||
- 审计视图:输入、输出、工具、artifact、stdout/stderr、session id。
|
|
||||||
- 策略配置:并发、队列、默认 runtime、fallback runtime、权限模式。
|
|
||||||
|
|
||||||
该插件应该是 Host v2 的消费者,而不是 Host v2 的替代品。
|
|
||||||
|
|
||||||
## 9. 设计原则
|
|
||||||
|
|
||||||
- v1 先稳定,v2 可选叠加。
|
|
||||||
- Host 保存事实源,插件提供管理体验。
|
|
||||||
- Runtime daemon 执行具体 CLI 和本机资源访问。
|
|
||||||
- Docker 不假设拥有宿主机 CLI;需要 sidecar 或显式挂载。
|
|
||||||
- Pipeline 不进入 v2 控制面中心。
|
|
||||||
- 直接 subprocess runner 可保留,但只作为 local/dev/MVP 路径。
|
|
||||||
- 发布级能力必须经过 Host 权限、审计和资源边界。
|
|
||||||
|
|
||||||
## 10. 待定问题
|
|
||||||
|
|
||||||
- runtime daemon 与 Host 的认证模型:workspace token、device token、还是 scoped PAT。
|
|
||||||
- task 与 AgentRunner binding 的映射关系:由 binding 直接 enqueue,还是由独立 task policy 决定。
|
|
||||||
- runtime capability schema 的稳定字段:provider、version、login status、execution isolation、workspace access、slot。
|
|
||||||
- secret projection 的边界:Host 存储、用户本机存储、或外部 secret manager。
|
|
||||||
- Docker compose 是否提供官方 sidecar daemon 示例。
|
|
||||||
- v2 UI 是核心前端的一部分,还是完全由管理插件提供。
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Agent Runner Security Hardening
|
|
||||||
|
|
||||||
本文档记录 agent-runner 插件化进入生产发布前需要补齐的安全与稳定加固项。
|
|
||||||
|
|
||||||
## 状态
|
|
||||||
|
|
||||||
**当前结论:暂不塞进本阶段 agent-runner plugin 协议闭环。**
|
|
||||||
|
|
||||||
本阶段目标是验证 LangBot 可以通过统一的 `run(event, binding)` 协议接入 `local-agent` 与外部 harness runner(如 Claude Code runner),并能传递事件、上下文、资源句柄、状态和结果流。
|
|
||||||
|
|
||||||
安全发布级 hardening 是后续 release gate,不应阻塞当前协议闭环,但必须作为进入生产默认启用前的验收条件。
|
|
||||||
|
|
||||||
## 责任边界
|
|
||||||
|
|
||||||
### LangBot Host 负责
|
|
||||||
|
|
||||||
- 资源授权:决定某个 `run_id` / binding 可以访问哪些模型、RAG、MCP、skill、artifact、history、state。
|
|
||||||
- 资源投影:只把授权后的资源句柄、配置片段或上下文文件传给 runner。
|
|
||||||
- 路径策略:限制 workspace / context file / artifact 的允许路径和清理策略。
|
|
||||||
- Secret 策略:过滤环境变量、配置、日志和 transcript 中的 secret。
|
|
||||||
- 运行约束:配置超时、轮次、并发、配额、输出大小和取消路径。
|
|
||||||
- 审计记录:记录事件、绑定、资源授权、runner 调用、外部 harness session id、关键错误和结果摘要。
|
|
||||||
|
|
||||||
### Runner Plugin 负责
|
|
||||||
|
|
||||||
- 遵守 LangBot 下发的 binding config、授权资源和运行约束。
|
|
||||||
- 将 LangBot 资源投影成目标 runner 可消费的形式,例如 context 文件、MCP 配置、环境变量或 CLI 参数。
|
|
||||||
- 不把长期状态保存在插件实例内;需要跨轮次保存的外部 session id / working directory 等状态应写入 host-owned state。
|
|
||||||
- 对外部进程做最小必要封装,包括命令参数构造、超时、取消、输出解析和错误映射。
|
|
||||||
|
|
||||||
### 外部 Harness 负责
|
|
||||||
|
|
||||||
Claude Code、Codex、Kimi Code 等外部 harness 可以继续使用自身的权限模型、工具 allow / deny 规则、MCP 加载策略、session/resume 机制和沙箱能力。
|
|
||||||
|
|
||||||
但外部 harness 不是 LangBot 的唯一安全边界。LangBot 仍必须在调用前完成资源授权、路径限制、secret 过滤和审计记录。
|
|
||||||
|
|
||||||
## 当前 MVP 可接受边界
|
|
||||||
|
|
||||||
当前阶段可以接受以下前提:
|
|
||||||
|
|
||||||
- 由可信管理员配置 runner binding。
|
|
||||||
- 工作目录和 context 输出目录为显式配置或 host 生成路径。
|
|
||||||
- 外部 runner 默认使用保守权限,例如 plan / no-write 模式或禁用高风险工具。
|
|
||||||
- 通过 timeout、max turns、输出长度和进程取消降低失控风险。
|
|
||||||
- 通过 host-owned state 保存 `external.session_id`、`external.working_directory` 等 resume 所需指针。
|
|
||||||
|
|
||||||
这些前提足够做本地 E2E 与协议验收,不等同于生产发布完成。
|
|
||||||
|
|
||||||
## Release Gate Checklist
|
|
||||||
|
|
||||||
进入生产默认启用前,需要补齐:
|
|
||||||
|
|
||||||
- Path isolation:workspace allowlist、路径规范化、防止 `..` 逃逸、context / artifact 清理。
|
|
||||||
- Permission boundary:runner 能力声明、binding 级资源授权、run 级权限校验。
|
|
||||||
- Secret handling:环境变量白名单、配置脱敏、日志和 transcript redaction。
|
|
||||||
- MCP policy:MCP server allowlist、scoped token、tool allow / deny、危险工具审计。
|
|
||||||
- Skill projection policy:skill 来源验证、只读投影、版本和摘要记录。
|
|
||||||
- Process isolation:进程组管理、取消、超时、CPU / 内存 / 输出配额。
|
|
||||||
- State lifecycle:session id、workspace、artifact 的过期、清理、迁移和审计。
|
|
||||||
- Audit first-class:事件、资源授权、外部命令、session id、结果摘要可追踪。
|
|
||||||
- UI / Admin control:管理员能看到 runner 权限、风险提示、资源绑定和禁用入口。
|
|
||||||
- Test matrix:路径逃逸、secret 泄漏、权限拒绝、timeout、取消、MCP deny、resume、cleanup、audit 完整性。
|
|
||||||
|
|
||||||
## 非当前范围
|
|
||||||
|
|
||||||
以下内容不属于本阶段协议闭环:
|
|
||||||
|
|
||||||
- 完整异步队列与 issue-centric 产品模型。
|
|
||||||
- 复杂 workflow engine。
|
|
||||||
- Codex / Kimi runner 全量接入。
|
|
||||||
- EBA 分支完整迁移和联调。
|
|
||||||
- 发布级安全 hardening 的完整实现。
|
|
||||||
|
|
||||||
197
docs/event-based-agents/00-overview.md
Normal file
197
docs/event-based-agents/00-overview.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Event Based Agents 架构设计总览
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
### 当前架构的局限性
|
||||||
|
|
||||||
|
LangBot 当前的平台适配器架构围绕**消息事件**单一场景设计:
|
||||||
|
|
||||||
|
- **事件层面**:只监听 `FriendMessage`(私聊消息)和 `GroupMessage`(群消息)两种事件
|
||||||
|
- **API 层面**:只暴露 `send_message` 和 `reply_message` 两个平台 API
|
||||||
|
- **处理层面**:所有消息统一进入 Pipeline 流水线处理,无法为不同事件类型配置不同处理逻辑
|
||||||
|
- **适配器结构**:每个适配器是单个 Python 文件(200-800 行),随着功能增加难以维护
|
||||||
|
|
||||||
|
这导致以下问题:
|
||||||
|
|
||||||
|
1. **无法处理非消息事件**:新成员入群、好友请求、消息撤回、消息编辑等大部分平台都支持的事件被完全忽略
|
||||||
|
2. **平台能力未充分利用**:编辑消息、撤回消息、获取群成员列表、管理群组等 API 无法使用
|
||||||
|
3. **插件能力受限**:插件只能监听消息事件、只能发送/回复消息,无法实现更丰富的交互
|
||||||
|
4. **处理逻辑不灵活**:所有消息走同一条 Pipeline,无法为入群欢迎、好友自动通过等场景配置独立的处理流程
|
||||||
|
|
||||||
|
### 设计目标
|
||||||
|
|
||||||
|
Event Based Agents(EBA)架构旨在将 LangBot 从"消息处理平台"升级为"事件驱动的智能代理平台":
|
||||||
|
|
||||||
|
- **丰富事件**:支持消息、群组、好友、Bot 状态等多种事件类型
|
||||||
|
- **丰富 API**:支持消息编辑/撤回、群组管理、用户信息查询等通用 API,以及适配器特有 API 的透传调用
|
||||||
|
- **灵活编排**:用户可在 WebUI 上为每个 Bot 的每种事件类型配置不同的处理器
|
||||||
|
- **可扩展**:适配器可声明自己支持的事件和 API,平台特有能力通过标准机制暴露
|
||||||
|
- **向后兼容**:现有插件无需修改即可在新架构下运行
|
||||||
|
|
||||||
|
## 2. 架构对比
|
||||||
|
|
||||||
|
### 现有架构
|
||||||
|
|
||||||
|
```
|
||||||
|
消息平台 (Telegram/Discord/...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
平台适配器 (单文件, 只处理消息)
|
||||||
|
│ FriendMessage / GroupMessage
|
||||||
|
▼
|
||||||
|
RuntimeBot (注册 on_friend_message / on_group_message 回调)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MessageAggregator (消息聚合)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
QueryPool → Controller → Pipeline (固定阶段链)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ RequestRunner (local-agent / dify / n8n / ...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
adapter.reply_message() / adapter.send_message()
|
||||||
|
```
|
||||||
|
|
||||||
|
关键代码路径:
|
||||||
|
- 适配器基类:`langbot-plugin-sdk/.../abstract/platform/adapter.py` — `AbstractMessagePlatformAdapter`
|
||||||
|
- 事件定义:`langbot-plugin-sdk/.../builtin/platform/events.py` — 仅 `FriendMessage` / `GroupMessage`
|
||||||
|
- Bot 管理:`LangBot/src/langbot/pkg/platform/botmgr.py` — `RuntimeBot` 只注册两个消息回调
|
||||||
|
- 流水线控制:`LangBot/src/langbot/pkg/pipeline/controller.py` — 从 QueryPool 消费并执行 Pipeline
|
||||||
|
|
||||||
|
### 新架构(Event Based Agents)
|
||||||
|
|
||||||
|
```
|
||||||
|
消息平台 (Telegram/Discord/...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
平台适配器 (独立目录, 监听所有事件, 实现丰富 API)
|
||||||
|
│ MessageReceived / MemberJoined / FriendRequest / ...
|
||||||
|
▼
|
||||||
|
EventBus (统一事件总线)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventRouter (事件路由引擎, 读取 Bot 的 event_handlers 配置)
|
||||||
|
│
|
||||||
|
├─→ PipelineHandler — 现有流水线(完整 Stage 链)
|
||||||
|
├─→ AgentHandler — 直接调用 RequestRunner(轻量 AI 处理)
|
||||||
|
├─→ WebhookHandler — POST 到外部服务(Dify/n8n webhook 等)
|
||||||
|
└─→ PluginHandler — 分发给插件 EventListener
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
统一平台 API
|
||||||
|
send / reply / edit / delete / getGroupInfo / getUserInfo / callPlatformApi / ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 核心概念
|
||||||
|
|
||||||
|
### 3.1 统一事件体系
|
||||||
|
|
||||||
|
所有平台事件统一为命名空间式的事件类型:
|
||||||
|
|
||||||
|
| 命名空间 | 事件 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `message.*` | `message.received`, `message.edited`, `message.deleted`, `message.reaction` | 消息相关 |
|
||||||
|
| `feedback.*` | `feedback.received` | 用户对 Bot 回复的点赞、点踩、取消反馈等评价事件 |
|
||||||
|
| `group.*` | `group.member_joined`, `group.member_left`, `group.member_banned`, `group.info_updated` | 群组相关 |
|
||||||
|
| `friend.*` | `friend.request_received`, `friend.added`, `friend.removed` | 好友相关 |
|
||||||
|
| `bot.*` | `bot.invited_to_group`, `bot.removed_from_group`, `bot.muted`, `bot.unmuted` | Bot 状态 |
|
||||||
|
| `platform.*` | `platform.{adapter}.{action}` | 适配器特有事件 |
|
||||||
|
|
||||||
|
详见 [01-event-system.md](./01-event-system.md)。
|
||||||
|
|
||||||
|
### 3.2 统一平台 API
|
||||||
|
|
||||||
|
扩展适配器基类,提供通用 API + 透传机制:
|
||||||
|
|
||||||
|
| 类别 | API | 必需/可选 |
|
||||||
|
|------|-----|----------|
|
||||||
|
| 消息 | `send_message`, `reply_message`, `edit_message`, `delete_message`, `forward_message` | send/reply 必需,其余可选 |
|
||||||
|
| 群组 | `get_group_info`, `get_group_member_list`, `get_group_member_info`, `mute_member`, `kick_member` | 全部可选 |
|
||||||
|
| 用户 | `get_user_info`, `get_friend_list` | 全部可选 |
|
||||||
|
| 媒体 | `upload_file`, `get_file_url` | 全部可选 |
|
||||||
|
| 透传 | `call_platform_api(action, params)` | 可选 |
|
||||||
|
|
||||||
|
详见 [02-platform-api.md](./02-platform-api.md)。
|
||||||
|
|
||||||
|
### 3.3 适配器新结构
|
||||||
|
|
||||||
|
每个适配器从单文件迁移到独立目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/platform/adapters/
|
||||||
|
├── _base/ # 基类和通用定义
|
||||||
|
│ ├── adapter.py
|
||||||
|
│ ├── events.py
|
||||||
|
│ ├── entities.py
|
||||||
|
│ └── api.py
|
||||||
|
├── telegram/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── adapter.py # 主适配器类
|
||||||
|
│ ├── event_converter.py # 事件转换(多种事件类型)
|
||||||
|
│ ├── message_converter.py # 消息链转换
|
||||||
|
│ ├── api_impl.py # 通用 API 实现
|
||||||
|
│ ├── platform_api.py # 平台特有 API
|
||||||
|
│ ├── types.py # 平台特有类型
|
||||||
|
│ └── manifest.yaml
|
||||||
|
├── discord/
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [03-adapter-structure.md](./03-adapter-structure.md)。
|
||||||
|
|
||||||
|
### 3.4 事件处理器(Event Handler)
|
||||||
|
|
||||||
|
四种处理器类型,用户在 WebUI 的 Bot 管理页面配置:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **pipeline** | 现有流水线机制,完整的多 Stage 处理链(PreProcessor → MessageProcessor → PostProcessor 等) | 复杂消息处理,需要完整的预处理/后处理流程 |
|
||||||
|
| **agent** | 直接调用 RequestRunner(local-agent / dify / n8n / coze / dashscope / langflow / tbox),从 Pipeline 中解耦 | 轻量级 AI 处理、直接对接外部 LLMOps 平台处理各类事件 |
|
||||||
|
| **webhook** | 将事件 POST 到外部 URL,根据响应执行动作 | 对接自建服务、Dify/n8n 的 Webhook 触发器、自定义后端 |
|
||||||
|
| **plugin** | 分发给插件 EventListener 处理 | 插件自定义逻辑 |
|
||||||
|
|
||||||
|
配置存储在 Bot 表的 `event_handlers` JSON 字段中,通过 WebUI 编排面板管理。
|
||||||
|
|
||||||
|
详见 [04-event-routing.md](./04-event-routing.md)。
|
||||||
|
|
||||||
|
### 3.5 插件 SDK 改造
|
||||||
|
|
||||||
|
- 新事件类型全部暴露给插件
|
||||||
|
- 新 API 全部通过 `LangBotAPIProxy` 暴露
|
||||||
|
- 兼容层保证现有插件零修改运行
|
||||||
|
|
||||||
|
详见 [05-plugin-sdk.md](./05-plugin-sdk.md)。
|
||||||
|
|
||||||
|
## 4. 关键设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 选择 | 理由 |
|
||||||
|
|---|--------|------|------|
|
||||||
|
| 1 | 事件处理器配置粒度 | 每个 Bot 独立配置 | Bot 是用户操作的核心单元,不同 Bot 可能对接不同业务场景 |
|
||||||
|
| 2 | 适配器特有 API | 统一抽象 + `call_platform_api` 透传 | 通用 API 覆盖大部分场景,透传机制保证灵活性,避免每个适配器导出独立的类型化 API 包 |
|
||||||
|
| 3 | 向后兼容策略 | 兼容层适配 | 保留旧事件类型和 API 作为新系统的 alias/wrapper,现有插件无需修改 |
|
||||||
|
| 4 | 处理器配置存储 | Bot 表新增 `event_handlers` JSON 字段 | 简单直接,避免新增关联表;替代现有 `use_pipeline_uuid` |
|
||||||
|
| 5 | Agent 处理器定位 | 从 Pipeline 中解耦 RequestRunner | 不是所有事件都需要完整 Pipeline Stage 链;Agent 处理器提供轻量级 AI 处理路径,支持所有现有 Runner |
|
||||||
|
| 6 | 事件命名方式 | 命名空间式(`message.received`) | 清晰的分类层级,便于通配匹配(`message.*`),与 WebUI 配置天然对应 |
|
||||||
|
|
||||||
|
## 5. 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [01-event-system.md](./01-event-system.md) | 统一事件体系:事件分类、定义、生命周期 |
|
||||||
|
| [02-platform-api.md](./02-platform-api.md) | 统一平台 API:通用 API、透传 API、实体定义 |
|
||||||
|
| [03-adapter-structure.md](./03-adapter-structure.md) | 适配器新结构:目录布局、基类、注册机制 |
|
||||||
|
| [04-event-routing.md](./04-event-routing.md) | 事件路由与编排:路由引擎、处理器类型、WebUI 数据模型 |
|
||||||
|
| [05-plugin-sdk.md](./05-plugin-sdk.md) | 插件 SDK 改造:新事件/API、兼容层 |
|
||||||
|
| [06-migration-plan.md](./06-migration-plan.md) | 分阶段迁移计划 |
|
||||||
|
|
||||||
|
## 6. 涉及的代码仓库
|
||||||
|
|
||||||
|
| 仓库 | 改动范围 |
|
||||||
|
|------|----------|
|
||||||
|
| **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 |
|
||||||
|
| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot 实体扩展、数据库迁移、RequestRunner 解耦 |
|
||||||
|
| **LangBot**(前端) | Bot 事件处理器编排面板 |
|
||||||
|
| **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 |
|
||||||
|
| **langbot-plugin-demo** | 示例更新(使用新事件和 API) |
|
||||||
561
docs/event-based-agents/01-event-system.md
Normal file
561
docs/event-based-agents/01-event-system.md
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
# 统一事件体系
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
- **命名空间分类**:事件类型采用 `{namespace}.{action}` 格式,如 `message.received`
|
||||||
|
- **通用优先**:大部分平台都支持的事件抽象为通用事件,定义统一的字段格式
|
||||||
|
- **平台特有事件标准化**:各适配器的独有事件通过 `PlatformSpecificEvent` 承载,保留原始数据
|
||||||
|
- **向后兼容**:现有 `FriendMessage` / `GroupMessage` 通过兼容层映射到新的 `message.received` 事件
|
||||||
|
|
||||||
|
## 2. 事件基类层次
|
||||||
|
|
||||||
|
```
|
||||||
|
Event (事件基类)
|
||||||
|
├── MessageEvent (消息相关事件)
|
||||||
|
│ ├── MessageReceivedEvent # message.received
|
||||||
|
│ ├── MessageEditedEvent # message.edited
|
||||||
|
│ ├── MessageDeletedEvent # message.deleted
|
||||||
|
│ └── MessageReactionEvent # message.reaction
|
||||||
|
├── FeedbackEvent (用户反馈事件)
|
||||||
|
│ └── FeedbackReceivedEvent # feedback.received
|
||||||
|
├── GroupEvent (群组相关事件)
|
||||||
|
│ ├── MemberJoinedEvent # group.member_joined
|
||||||
|
│ ├── MemberLeftEvent # group.member_left
|
||||||
|
│ ├── MemberBannedEvent # group.member_banned
|
||||||
|
│ ├── MemberUnbannedEvent # group.member_unbanned
|
||||||
|
│ └── GroupInfoUpdatedEvent # group.info_updated
|
||||||
|
├── FriendEvent (好友相关事件)
|
||||||
|
│ ├── FriendRequestReceivedEvent # friend.request_received
|
||||||
|
│ ├── FriendAddedEvent # friend.added
|
||||||
|
│ └── FriendRemovedEvent # friend.removed
|
||||||
|
├── BotEvent (Bot 状态事件)
|
||||||
|
│ ├── BotInvitedToGroupEvent # bot.invited_to_group
|
||||||
|
│ ├── BotRemovedFromGroupEvent # bot.removed_from_group
|
||||||
|
│ ├── BotMutedEvent # bot.muted
|
||||||
|
│ └── BotUnmutedEvent # bot.unmuted
|
||||||
|
└── PlatformSpecificEvent # platform.{adapter}.{action}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 通用事件定义
|
||||||
|
|
||||||
|
### 3.1 事件基类
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Event(pydantic.BaseModel):
|
||||||
|
"""事件基类"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
"""事件类型标识,如 'message.received'"""
|
||||||
|
|
||||||
|
timestamp: float
|
||||||
|
"""事件发生的时间戳"""
|
||||||
|
|
||||||
|
bot_uuid: str
|
||||||
|
"""接收到此事件的 Bot UUID"""
|
||||||
|
|
||||||
|
adapter_name: str
|
||||||
|
"""产生此事件的适配器名称"""
|
||||||
|
|
||||||
|
source_platform_object: typing.Optional[typing.Any] = None
|
||||||
|
"""原始平台事件对象,供适配器内部使用"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 消息事件
|
||||||
|
|
||||||
|
#### MessageReceivedEvent (`message.received`)
|
||||||
|
|
||||||
|
收到新消息。这是最核心的事件,替代现有的 `FriendMessage` / `GroupMessage`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageReceivedEvent(Event):
|
||||||
|
"""收到新消息"""
|
||||||
|
|
||||||
|
type: str = "message.received"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""消息 ID"""
|
||||||
|
|
||||||
|
message_chain: MessageChain
|
||||||
|
"""消息内容"""
|
||||||
|
|
||||||
|
sender: User
|
||||||
|
"""发送者"""
|
||||||
|
|
||||||
|
chat_type: ChatType # "private" | "group"
|
||||||
|
"""会话类型"""
|
||||||
|
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
"""会话 ID(私聊为对方用户 ID,群聊为群 ID)"""
|
||||||
|
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
"""群信息(仅群聊时存在)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
与现有类型的映射关系:
|
||||||
|
- `chat_type == "private"` → 等价于现有 `FriendMessage`
|
||||||
|
- `chat_type == "group"` → 等价于现有 `GroupMessage`
|
||||||
|
|
||||||
|
`ChatType` 枚举:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatType(str, Enum):
|
||||||
|
PRIVATE = "private"
|
||||||
|
GROUP = "group"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageEditedEvent (`message.edited`)
|
||||||
|
|
||||||
|
消息被编辑。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageEditedEvent(Event):
|
||||||
|
"""消息被编辑"""
|
||||||
|
|
||||||
|
type: str = "message.edited"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被编辑的消息 ID"""
|
||||||
|
|
||||||
|
new_content: MessageChain
|
||||||
|
"""编辑后的新内容"""
|
||||||
|
|
||||||
|
editor: User
|
||||||
|
"""编辑者"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageDeletedEvent (`message.deleted`)
|
||||||
|
|
||||||
|
消息被删除/撤回。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageDeletedEvent(Event):
|
||||||
|
"""消息被删除/撤回"""
|
||||||
|
|
||||||
|
type: str = "message.deleted"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被删除的消息 ID"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者(可能是发送者自己撤回,也可能是管理员删除)"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageReactionEvent (`message.reaction`)
|
||||||
|
|
||||||
|
消息收到表情回应。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageReactionEvent(Event):
|
||||||
|
"""消息收到表情回应"""
|
||||||
|
|
||||||
|
type: str = "message.reaction"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被回应的消息 ID"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""回应者"""
|
||||||
|
|
||||||
|
reaction: str
|
||||||
|
"""回应的表情标识(emoji 或平台特定表情 ID)"""
|
||||||
|
|
||||||
|
is_add: bool
|
||||||
|
"""True 为添加回应,False 为移除回应"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 用户反馈事件
|
||||||
|
|
||||||
|
#### FeedbackReceivedEvent (`feedback.received`)
|
||||||
|
|
||||||
|
用户对 Bot 回复提交反馈。该事件用于承载平台提供的点赞、点踩、取消反馈以及点踩原因等评价信息;典型来源包括企业微信 AI Bot 的 `feedback_event`、飞书卡片按钮回调、Web Embed 的反馈入口等。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeedbackReceivedEvent(Event):
|
||||||
|
"""收到用户反馈"""
|
||||||
|
|
||||||
|
type: str = "feedback.received"
|
||||||
|
|
||||||
|
feedback_id: str
|
||||||
|
"""平台侧反馈 ID,用于幂等记录或取消反馈"""
|
||||||
|
|
||||||
|
feedback_type: int
|
||||||
|
"""1 = like, 2 = dislike, 3 = cancel/remove feedback"""
|
||||||
|
|
||||||
|
feedback_content: typing.Optional[str] = None
|
||||||
|
"""用户填写的自由文本反馈"""
|
||||||
|
|
||||||
|
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||||
|
"""点踩时平台提供的预设不准确原因"""
|
||||||
|
|
||||||
|
user_id: typing.Optional[str] = None
|
||||||
|
"""提交反馈的用户 ID"""
|
||||||
|
|
||||||
|
session_id: typing.Optional[str] = None
|
||||||
|
"""会话 ID,例如 person_xxx 或 group_xxx"""
|
||||||
|
|
||||||
|
message_id: typing.Optional[str] = None
|
||||||
|
"""被评价的 Bot 回复消息 ID"""
|
||||||
|
|
||||||
|
stream_id: typing.Optional[str] = None
|
||||||
|
"""流式回复 ID,用于关联 streaming response"""
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约定:
|
||||||
|
|
||||||
|
- `feedback_id` 是幂等键;同一个 `feedback_id` 的后续事件应更新已有记录。
|
||||||
|
- `feedback_type == 3` 表示用户取消/移除反馈,处理器可删除对应记录或标记为取消。
|
||||||
|
- 如果平台只能给出原始回调 payload,差异字段保留在 `source_platform_object` 或 `PlatformSpecificEvent.data` 中;通用字段仍优先映射到 `FeedbackReceivedEvent`。
|
||||||
|
- 该事件保留向后兼容映射:EBA 事件可转换为旧的 `FeedbackEvent`,字段语义保持一致。
|
||||||
|
|
||||||
|
### 3.4 群组事件
|
||||||
|
|
||||||
|
#### MemberJoinedEvent (`group.member_joined`)
|
||||||
|
|
||||||
|
新成员加入群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberJoinedEvent(Event):
|
||||||
|
"""新成员加入群组"""
|
||||||
|
|
||||||
|
type: str = "group.member_joined"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
"""群组"""
|
||||||
|
|
||||||
|
member: User
|
||||||
|
"""加入的成员"""
|
||||||
|
|
||||||
|
inviter: typing.Optional[User] = None
|
||||||
|
"""邀请者(如有)"""
|
||||||
|
|
||||||
|
join_type: typing.Optional[str] = None
|
||||||
|
"""加入方式:'invite' / 'request' / 'direct' / None"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberLeftEvent (`group.member_left`)
|
||||||
|
|
||||||
|
成员离开群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberLeftEvent(Event):
|
||||||
|
"""成员离开群组"""
|
||||||
|
|
||||||
|
type: str = "group.member_left"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
|
||||||
|
is_kicked: bool = False
|
||||||
|
"""是否被踢出"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者(踢出时为管理员)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberBannedEvent (`group.member_banned`)
|
||||||
|
|
||||||
|
成员被禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberBannedEvent(Event):
|
||||||
|
"""成员被禁言"""
|
||||||
|
|
||||||
|
type: str = "group.member_banned"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
"""禁言时长(秒),None 表示永久"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberUnbannedEvent (`group.member_unbanned`)
|
||||||
|
|
||||||
|
成员被解除禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberUnbannedEvent(Event):
|
||||||
|
"""成员被解除禁言"""
|
||||||
|
|
||||||
|
type: str = "group.member_unbanned"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GroupInfoUpdatedEvent (`group.info_updated`)
|
||||||
|
|
||||||
|
群组信息被修改。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GroupInfoUpdatedEvent(Event):
|
||||||
|
"""群组信息被修改"""
|
||||||
|
|
||||||
|
type: str = "group.info_updated"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
"""更新后的群组信息"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者"""
|
||||||
|
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
"""发生变更的字段名列表,如 ['name', 'description']"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 好友事件
|
||||||
|
|
||||||
|
#### FriendRequestReceivedEvent (`friend.request_received`)
|
||||||
|
|
||||||
|
收到好友请求。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendRequestReceivedEvent(Event):
|
||||||
|
"""收到好友请求"""
|
||||||
|
|
||||||
|
type: str = "friend.request_received"
|
||||||
|
|
||||||
|
request_id: typing.Union[int, str]
|
||||||
|
"""请求 ID,用于后续 approve/reject 操作"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""请求者"""
|
||||||
|
|
||||||
|
message: typing.Optional[str] = None
|
||||||
|
"""验证消息"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FriendAddedEvent (`friend.added`)
|
||||||
|
|
||||||
|
成功添加好友。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendAddedEvent(Event):
|
||||||
|
"""成功添加好友"""
|
||||||
|
|
||||||
|
type: str = "friend.added"
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""新好友"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FriendRemovedEvent (`friend.removed`)
|
||||||
|
|
||||||
|
好友被移除。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendRemovedEvent(Event):
|
||||||
|
"""好友被移除"""
|
||||||
|
|
||||||
|
type: str = "friend.removed"
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""被移除的好友"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Bot 状态事件
|
||||||
|
|
||||||
|
#### BotInvitedToGroupEvent (`bot.invited_to_group`)
|
||||||
|
|
||||||
|
Bot 被邀请加入群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotInvitedToGroupEvent(Event):
|
||||||
|
"""Bot 被邀请加入群组"""
|
||||||
|
|
||||||
|
type: str = "bot.invited_to_group"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
inviter: typing.Optional[User] = None
|
||||||
|
|
||||||
|
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""邀请请求 ID,某些平台需要 Bot 确认才加入"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BotRemovedFromGroupEvent (`bot.removed_from_group`)
|
||||||
|
|
||||||
|
Bot 被移出群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotRemovedFromGroupEvent(Event):
|
||||||
|
"""Bot 被移出群组"""
|
||||||
|
|
||||||
|
type: str = "bot.removed_from_group"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BotMutedEvent / BotUnmutedEvent (`bot.muted` / `bot.unmuted`)
|
||||||
|
|
||||||
|
Bot 被禁言/解除禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotMutedEvent(Event):
|
||||||
|
"""Bot 被禁言"""
|
||||||
|
|
||||||
|
type: str = "bot.muted"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotUnmutedEvent(Event):
|
||||||
|
"""Bot 被解除禁言"""
|
||||||
|
|
||||||
|
type: str = "bot.unmuted"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 平台特有事件
|
||||||
|
|
||||||
|
对于无法抽象为通用事件的平台特有事件,使用统一的 `PlatformSpecificEvent` 承载:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlatformSpecificEvent(Event):
|
||||||
|
"""平台特有事件
|
||||||
|
|
||||||
|
适配器无法映射到通用事件类型时,使用此类型承载。
|
||||||
|
插件可以通过 adapter_name + action 来识别和处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str = "platform.specific"
|
||||||
|
|
||||||
|
action: str
|
||||||
|
"""平台特有的事件动作标识,如 'channel_created', 'pin_message'"""
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
"""事件数据,结构由具体适配器定义"""
|
||||||
|
```
|
||||||
|
|
||||||
|
事件类型字符串格式为 `platform.{adapter_name}.{action}`,例如:
|
||||||
|
- `platform.telegram.chat_member_updated` — Telegram 的群成员信息更新
|
||||||
|
- `platform.discord.channel_created` — Discord 的频道创建
|
||||||
|
- `platform.discord.voice_state_update` — Discord 的语音状态变更
|
||||||
|
- `platform.slack.app_home_opened` — Slack 的 App Home 打开
|
||||||
|
|
||||||
|
## 4. 各平台事件支持矩阵
|
||||||
|
|
||||||
|
下表标注各通用事件在主要平台上的支持情况:
|
||||||
|
|
||||||
|
| 事件 | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||||
|
|------|----------|---------|-----------|------|------|-------|------|------|------|
|
||||||
|
| `message.received` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `message.edited` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||||
|
| `message.deleted` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||||
|
| `message.reaction` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||||
|
| `feedback.received` | N | N | N | Y | N | N | Y | N | N |
|
||||||
|
| `group.member_joined` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `group.member_left` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `group.member_banned` | Y | Y | Y | N | N | N | N | N | N |
|
||||||
|
| `group.info_updated` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||||
|
| `friend.request_received` | N | Y | Y | N | N | N | Y | Y | Y |
|
||||||
|
| `friend.added` | N | Y | Y | N | N | N | Y | Y | N |
|
||||||
|
| `bot.invited_to_group` | Y | Y | Y | Y | Y | Y | Y | N | Y |
|
||||||
|
| `bot.removed_from_group` | Y | Y | Y | Y | N | N | Y | N | Y |
|
||||||
|
| `bot.muted` | Y | N | Y | N | N | N | N | N | N |
|
||||||
|
| `bot.unmuted` | Y | N | Y | N | N | N | N | N | N |
|
||||||
|
| `platform.specific` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
|
||||||
|
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准,实施时逐个确认。
|
||||||
|
|
||||||
|
## 5. 事件生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 平台 SDK 回调触发
|
||||||
|
│
|
||||||
|
2. 适配器 EventConverter.target2yiri(raw_event)
|
||||||
|
│ 将平台原生事件转换为统一 Event 对象
|
||||||
|
│ 无法映射的事件 → PlatformSpecificEvent
|
||||||
|
│
|
||||||
|
3. 适配器回调注册的 listener(event, adapter)
|
||||||
|
│
|
||||||
|
4. RuntimeBot 接收事件
|
||||||
|
│
|
||||||
|
5. EventBus 分发
|
||||||
|
│
|
||||||
|
6. EventRouter 查询 Bot 的 event_handlers 配置
|
||||||
|
│ 匹配事件类型 → 找到对应的 Handler
|
||||||
|
│ 支持通配符:'message.*' 匹配所有消息事件
|
||||||
|
│ 未匹配到 → 走默认 Handler(plugin,保持向后兼容)
|
||||||
|
│
|
||||||
|
7. Handler 处理事件
|
||||||
|
│ PipelineHandler → 进入 Pipeline 流水线
|
||||||
|
│ AgentHandler → 调用 RequestRunner
|
||||||
|
│ WebhookHandler → POST 到外部 URL
|
||||||
|
│ PluginHandler → 分发给插件 EventListener
|
||||||
|
│
|
||||||
|
8. Handler 执行完毕,可能通过 API 执行响应动作
|
||||||
|
(发消息、编辑消息、踢人、同意好友请求等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 与现有事件类型的兼容映射
|
||||||
|
|
||||||
|
为保证现有插件不受影响,建立以下映射关系:
|
||||||
|
|
||||||
|
| 新事件 | 条件 | 旧事件 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `MessageReceivedEvent` (chat_type=private) | — | `FriendMessage` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group) | — | `GroupMessage` |
|
||||||
|
|
||||||
|
在插件 SDK 层面:
|
||||||
|
|
||||||
|
| 新事件 | 旧插件事件 |
|
||||||
|
|--------|-----------|
|
||||||
|
| `MessageReceivedEvent` (chat_type=private, 非命令) | `PersonNormalMessageReceived` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group, 非命令) | `GroupNormalMessageReceived` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=private, 命令) | `PersonCommandSent` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group, 命令) | `GroupCommandSent` |
|
||||||
|
| `MessageReceivedEvent` (处理完毕后) | `NormalMessageResponded` |
|
||||||
|
|
||||||
|
兼容层在事件分发给插件 EventListener 时自动生成旧格式事件,确保监听旧事件类型的插件仍能正常工作。
|
||||||
|
|
||||||
|
## 7. 事件类型注册表
|
||||||
|
|
||||||
|
适配器在 manifest.yaml 中声明自己支持的事件类型:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
spec:
|
||||||
|
supported_events:
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
platform_specific_events:
|
||||||
|
- chat_member_updated
|
||||||
|
- chat_join_request
|
||||||
|
```
|
||||||
|
|
||||||
|
这份声明用于:
|
||||||
|
1. WebUI 在配置事件处理器时,只显示当前 Bot 的适配器支持的事件类型
|
||||||
|
2. EventRouter 在路由时校验事件类型有效性
|
||||||
|
3. 文档自动生成
|
||||||
546
docs/event-based-agents/02-platform-api.md
Normal file
546
docs/event-based-agents/02-platform-api.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# 统一平台 API 与实体定义
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
- **通用 API 抽象**:大部分平台都支持的操作(发消息、获取群信息等)定义为通用 API 方法
|
||||||
|
- **required / optional 标记**:每个 API 标记为必需或可选,适配器未实现可选 API 时抛出 `NotSupportedError`
|
||||||
|
- **透传机制**:适配器特有的操作通过 `call_platform_api(action, params)` 统一入口透传调用
|
||||||
|
- **能力声明**:适配器在 manifest 中声明自己支持的 API 列表,供 WebUI 和插件查询
|
||||||
|
- **实体统一**:通用实体(User、Group 等)在 SDK 层面统一定义,适配器负责转换
|
||||||
|
|
||||||
|
## 2. 通用实体定义
|
||||||
|
|
||||||
|
### 2.1 现有实体回顾
|
||||||
|
|
||||||
|
当前 SDK 已有以下实体(`langbot_plugin/api/entities/builtin/platform/entities.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
Entity(id)
|
||||||
|
├── Friend(id, nickname, remark)
|
||||||
|
├── Group(id, name, permission)
|
||||||
|
└── GroupMember(id, member_name, permission, group, special_title)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 新实体设计
|
||||||
|
|
||||||
|
扩展实体体系,保持向后兼容:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(pydantic.BaseModel):
|
||||||
|
"""用户实体(统一表示)"""
|
||||||
|
|
||||||
|
id: typing.Union[int, str]
|
||||||
|
"""用户 ID"""
|
||||||
|
|
||||||
|
nickname: str = ""
|
||||||
|
"""昵称"""
|
||||||
|
|
||||||
|
avatar_url: typing.Optional[str] = None
|
||||||
|
"""头像 URL"""
|
||||||
|
|
||||||
|
is_bot: bool = False
|
||||||
|
"""是否为 Bot"""
|
||||||
|
|
||||||
|
# 以下为可选的扩展信息,不同平台可能部分为空
|
||||||
|
username: typing.Optional[str] = None
|
||||||
|
"""用户名(如 Telegram 的 @username)"""
|
||||||
|
|
||||||
|
remark: typing.Optional[str] = None
|
||||||
|
"""备注名"""
|
||||||
|
|
||||||
|
|
||||||
|
class Group(pydantic.BaseModel):
|
||||||
|
"""群组实体"""
|
||||||
|
|
||||||
|
id: typing.Union[int, str]
|
||||||
|
"""群组 ID"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
"""群组名称"""
|
||||||
|
|
||||||
|
description: typing.Optional[str] = None
|
||||||
|
"""群组描述"""
|
||||||
|
|
||||||
|
member_count: typing.Optional[int] = None
|
||||||
|
"""成员数量"""
|
||||||
|
|
||||||
|
avatar_url: typing.Optional[str] = None
|
||||||
|
"""群组头像 URL"""
|
||||||
|
|
||||||
|
owner_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""群主 ID"""
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(pydantic.BaseModel):
|
||||||
|
"""群成员实体"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""用户信息"""
|
||||||
|
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
"""所属群组 ID"""
|
||||||
|
|
||||||
|
role: MemberRole
|
||||||
|
"""群内角色"""
|
||||||
|
|
||||||
|
display_name: typing.Optional[str] = None
|
||||||
|
"""群内显示名"""
|
||||||
|
|
||||||
|
joined_at: typing.Optional[float] = None
|
||||||
|
"""加入群组的时间戳"""
|
||||||
|
|
||||||
|
title: typing.Optional[str] = None
|
||||||
|
"""群头衔/特殊称号"""
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRole(str, Enum):
|
||||||
|
"""群成员角色"""
|
||||||
|
OWNER = "owner"
|
||||||
|
ADMIN = "admin"
|
||||||
|
MEMBER = "member"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 与现有实体的兼容映射
|
||||||
|
|
||||||
|
| 新实体 | 旧实体 | 映射方式 |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| `User` | `Friend` | `User(id=friend.id, nickname=friend.nickname, remark=friend.remark)` |
|
||||||
|
| `Group` | `Group`(旧) | `Group(id=old.id, name=old.name)` + `permission` 字段弃用 |
|
||||||
|
| `GroupMember` | `GroupMember`(旧) | `GroupMember(user=User(...), role=..., display_name=old.member_name)` |
|
||||||
|
| `MemberRole` | `Permission` | `OWNER↔Owner`, `ADMIN↔Administrator`, `MEMBER↔Member` |
|
||||||
|
|
||||||
|
旧实体类保留,标记为 `@deprecated`,内部通过转换方法桥接到新实体。
|
||||||
|
|
||||||
|
## 3. 通用 API 定义
|
||||||
|
|
||||||
|
### 3.1 API 方法一览
|
||||||
|
|
||||||
|
#### 消息 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `send_message(target_type, target_id, message)` | **必需** | 主动发送消息 |
|
||||||
|
| `reply_message(event, message, quote_origin)` | **必需** | 回复一个消息事件 |
|
||||||
|
| `edit_message(chat_type, chat_id, message_id, new_content)` | 可选 | 编辑已发送的消息 |
|
||||||
|
| `delete_message(chat_type, chat_id, message_id)` | 可选 | 删除/撤回消息 |
|
||||||
|
| `forward_message(from_chat, message_id, to_chat_type, to_chat_id)` | 可选 | 转发消息到另一个会话 |
|
||||||
|
| `get_message(chat_type, chat_id, message_id)` | 可选 | 获取指定消息的内容 |
|
||||||
|
|
||||||
|
#### 群组 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `get_group_info(group_id)` | 可选 | 获取群组信息 |
|
||||||
|
| `get_group_list()` | 可选 | 获取 Bot 加入的群组列表 |
|
||||||
|
| `get_group_member_list(group_id)` | 可选 | 获取群成员列表 |
|
||||||
|
| `get_group_member_info(group_id, user_id)` | 可选 | 获取指定群成员信息 |
|
||||||
|
| `set_group_name(group_id, name)` | 可选 | 修改群名称 |
|
||||||
|
| `mute_member(group_id, user_id, duration)` | 可选 | 禁言群成员 |
|
||||||
|
| `unmute_member(group_id, user_id)` | 可选 | 解除禁言 |
|
||||||
|
| `kick_member(group_id, user_id)` | 可选 | 踢出群成员 |
|
||||||
|
| `leave_group(group_id)` | 可选 | Bot 退出群组 |
|
||||||
|
|
||||||
|
#### 用户 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `get_user_info(user_id)` | 可选 | 获取用户信息 |
|
||||||
|
| `get_friend_list()` | 可选 | 获取好友列表 |
|
||||||
|
| `approve_friend_request(request_id, approve, remark)` | 可选 | 处理好友请求 |
|
||||||
|
| `approve_group_invite(request_id, approve)` | 可选 | 处理入群邀请 |
|
||||||
|
|
||||||
|
#### 媒体 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `upload_file(file_data, filename)` | 可选 | 上传文件,返回可引用的文件 ID 或 URL |
|
||||||
|
| `get_file_url(file_id)` | 可选 | 获取文件下载 URL |
|
||||||
|
|
||||||
|
#### 透传 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `call_platform_api(action, params)` | 可选 | 调用适配器特有 API |
|
||||||
|
|
||||||
|
### 3.2 API 方法签名详解
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||||
|
"""平台适配器基类(新版)"""
|
||||||
|
|
||||||
|
# ======== 必需方法 ========
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str, # "private" | "group"
|
||||||
|
target_id: typing.Union[int, str],
|
||||||
|
message: MessageChain,
|
||||||
|
) -> MessageResult:
|
||||||
|
"""主动发送消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageResult: 包含 message_id 等发送结果
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
event: MessageReceivedEvent,
|
||||||
|
message: MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
) -> MessageResult:
|
||||||
|
"""回复一个消息事件"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ======== 可选消息方法 ========
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""编辑已发送的消息"""
|
||||||
|
raise NotSupportedError("edit_message")
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""删除/撤回消息"""
|
||||||
|
raise NotSupportedError("delete_message")
|
||||||
|
|
||||||
|
async def forward_message(
|
||||||
|
self,
|
||||||
|
from_chat_type: str,
|
||||||
|
from_chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
to_chat_type: str,
|
||||||
|
to_chat_id: typing.Union[int, str],
|
||||||
|
) -> MessageResult:
|
||||||
|
"""转发消息"""
|
||||||
|
raise NotSupportedError("forward_message")
|
||||||
|
|
||||||
|
async def get_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> MessageReceivedEvent:
|
||||||
|
"""获取指定消息"""
|
||||||
|
raise NotSupportedError("get_message")
|
||||||
|
|
||||||
|
# ======== 可选群组方法 ========
|
||||||
|
|
||||||
|
async def get_group_info(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> Group:
|
||||||
|
"""获取群组信息"""
|
||||||
|
raise NotSupportedError("get_group_info")
|
||||||
|
|
||||||
|
async def get_group_list(self) -> list[Group]:
|
||||||
|
"""获取 Bot 加入的群组列表"""
|
||||||
|
raise NotSupportedError("get_group_list")
|
||||||
|
|
||||||
|
async def get_group_member_list(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> list[GroupMember]:
|
||||||
|
"""获取群成员列表"""
|
||||||
|
raise NotSupportedError("get_group_member_list")
|
||||||
|
|
||||||
|
async def get_group_member_info(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> GroupMember:
|
||||||
|
"""获取指定群成员信息"""
|
||||||
|
raise NotSupportedError("get_group_member_info")
|
||||||
|
|
||||||
|
async def set_group_name(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""修改群名称"""
|
||||||
|
raise NotSupportedError("set_group_name")
|
||||||
|
|
||||||
|
async def mute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""禁言群成员,duration 为秒数,0 表示永久"""
|
||||||
|
raise NotSupportedError("mute_member")
|
||||||
|
|
||||||
|
async def unmute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""解除禁言"""
|
||||||
|
raise NotSupportedError("unmute_member")
|
||||||
|
|
||||||
|
async def kick_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""踢出群成员"""
|
||||||
|
raise NotSupportedError("kick_member")
|
||||||
|
|
||||||
|
async def leave_group(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""Bot 退出群组"""
|
||||||
|
raise NotSupportedError("leave_group")
|
||||||
|
|
||||||
|
# ======== 可选用户方法 ========
|
||||||
|
|
||||||
|
async def get_user_info(
|
||||||
|
self,
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> User:
|
||||||
|
"""获取用户信息"""
|
||||||
|
raise NotSupportedError("get_user_info")
|
||||||
|
|
||||||
|
async def get_friend_list(self) -> list[User]:
|
||||||
|
"""获取好友列表"""
|
||||||
|
raise NotSupportedError("get_friend_list")
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求"""
|
||||||
|
raise NotSupportedError("approve_friend_request")
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
raise NotSupportedError("approve_group_invite")
|
||||||
|
|
||||||
|
# ======== 可选媒体方法 ========
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
file_data: bytes,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""上传文件,返回文件 ID 或 URL"""
|
||||||
|
raise NotSupportedError("upload_file")
|
||||||
|
|
||||||
|
async def get_file_url(
|
||||||
|
self,
|
||||||
|
file_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""获取文件下载 URL"""
|
||||||
|
raise NotSupportedError("get_file_url")
|
||||||
|
|
||||||
|
# ======== 透传 API ========
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用适配器特有 API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: 平台特有的 API 动作标识
|
||||||
|
params: 参数字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 返回结果
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Telegram: pin 消息
|
||||||
|
await adapter.call_platform_api("pin_message", {
|
||||||
|
"chat_id": 123456,
|
||||||
|
"message_id": 789
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discord: 创建频道
|
||||||
|
await adapter.call_platform_api("create_channel", {
|
||||||
|
"guild_id": "...",
|
||||||
|
"name": "new-channel",
|
||||||
|
"type": "text"
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
raise NotSupportedError("call_platform_api")
|
||||||
|
|
||||||
|
# ======== 流式输出(保留现有机制) ========
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
event: MessageReceivedEvent,
|
||||||
|
bot_message: dict,
|
||||||
|
message: MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
):
|
||||||
|
"""流式回复消息"""
|
||||||
|
raise NotSupportedError("reply_message_chunk")
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""是否支持流式输出"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ======== 生命周期方法(保留现有) ========
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def run_async(self):
|
||||||
|
"""启动适配器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
"""停止适配器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def register_listener(self, event_type, callback):
|
||||||
|
"""注册事件监听器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def unregister_listener(self, event_type, callback):
|
||||||
|
"""注销事件监听器"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 返回值类型
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageResult(pydantic.BaseModel):
|
||||||
|
"""消息发送结果"""
|
||||||
|
|
||||||
|
message_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""发送成功后的消息 ID"""
|
||||||
|
|
||||||
|
raw: typing.Optional[dict] = None
|
||||||
|
"""平台原始返回数据"""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSupportedError(Exception):
|
||||||
|
"""适配器未实现此 API"""
|
||||||
|
|
||||||
|
def __init__(self, api_name: str):
|
||||||
|
self.api_name = api_name
|
||||||
|
super().__init__(f"API not supported by this adapter: {api_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. API 能力声明
|
||||||
|
|
||||||
|
适配器在 manifest.yaml 中声明支持的 API:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
spec:
|
||||||
|
supported_apis:
|
||||||
|
required:
|
||||||
|
- send_message
|
||||||
|
- reply_message
|
||||||
|
optional:
|
||||||
|
- edit_message
|
||||||
|
- delete_message
|
||||||
|
- get_group_info
|
||||||
|
- get_group_member_list
|
||||||
|
- get_user_info
|
||||||
|
- upload_file
|
||||||
|
- get_file_url
|
||||||
|
- call_platform_api
|
||||||
|
platform_specific_apis:
|
||||||
|
- action: pin_message
|
||||||
|
description: "Pin a message in a chat"
|
||||||
|
params_schema:
|
||||||
|
chat_id: { type: "string", required: true }
|
||||||
|
message_id: { type: "string", required: true }
|
||||||
|
- action: unpin_message
|
||||||
|
description: "Unpin a message"
|
||||||
|
params_schema:
|
||||||
|
chat_id: { type: "string", required: true }
|
||||||
|
message_id: { type: "string", required: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:
|
||||||
|
1. **WebUI**:在配置界面展示当前 Bot 可用的 API 能力
|
||||||
|
2. **插件**:插件可查询某个 Bot 是否支持特定 API,据此决定行为
|
||||||
|
3. **文档**:自动生成各适配器的 API 支持矩阵
|
||||||
|
|
||||||
|
## 5. 各平台 API 支持矩阵
|
||||||
|
|
||||||
|
| API | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||||
|
|-----|----------|---------|-----------|------|------|-------|------|------|------|
|
||||||
|
| `send_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `reply_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `edit_message` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||||
|
| `delete_message` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||||
|
| `forward_message` | Y | N | Y | Y | N | N | Y | N | N |
|
||||||
|
| `get_group_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_group_member_list` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_user_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_friend_list` | N | Y | Y | N | N | N | Y | N | N |
|
||||||
|
| `mute_member` | Y | Y | Y | N | N | N | N | N | N |
|
||||||
|
| `kick_member` | Y | Y | Y | N | N | N | N | N | Y |
|
||||||
|
| `upload_file` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `call_platform_api` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
|
||||||
|
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准。
|
||||||
|
|
||||||
|
## 6. MessageChain 扩展
|
||||||
|
|
||||||
|
### 6.1 保留的通用组件
|
||||||
|
|
||||||
|
以下 MessageComponent 类型保持不变,继续作为通用消息元素:
|
||||||
|
|
||||||
|
- `Source` — 消息元信息
|
||||||
|
- `Plain` — 纯文本
|
||||||
|
- `Quote` — 引用回复
|
||||||
|
- `At` / `AtAll` — @提及
|
||||||
|
- `Image` — 图片
|
||||||
|
- `Voice` — 语音
|
||||||
|
- `File` — 文件
|
||||||
|
- `Forward` — 合并转发
|
||||||
|
- `Face` — 表情
|
||||||
|
- `Unknown` — 未知类型
|
||||||
|
|
||||||
|
### 6.2 平台特有组件处理
|
||||||
|
|
||||||
|
当前 MessageChain 中存在大量微信特有的组件类型(`WeChatMiniPrograms`, `WeChatEmoji`, `WeChatLink` 等)。在新架构下:
|
||||||
|
|
||||||
|
- 这些类型**继续保留**在 SDK 中以保持兼容
|
||||||
|
- 新增的平台特有消息组件统一使用 `PlatformComponent` 基类:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlatformComponent(MessageComponent):
|
||||||
|
"""平台特有的消息组件"""
|
||||||
|
|
||||||
|
type: str = "Platform"
|
||||||
|
|
||||||
|
platform: str
|
||||||
|
"""平台标识"""
|
||||||
|
|
||||||
|
component_type: str
|
||||||
|
"""组件类型"""
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
"""组件数据"""
|
||||||
|
```
|
||||||
|
|
||||||
|
适配器在转换消息链时,对于无法映射到通用组件的平台特有内容,使用 `PlatformComponent` 承载。
|
||||||
483
docs/event-based-agents/03-adapter-structure.md
Normal file
483
docs/event-based-agents/03-adapter-structure.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# 适配器新目录结构
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
- **模块化**:每个适配器从单文件拆分到独立目录,各模块职责清晰
|
||||||
|
- **可维护**:随着事件和 API 的增加,代码量会显著增长,目录结构有助于管理复杂度
|
||||||
|
- **一致性**:所有适配器遵循相同的目录布局和文件命名约定
|
||||||
|
- **兼容现有发现机制**:保持 YAML manifest + ComponentDiscoveryEngine 的注册体系
|
||||||
|
|
||||||
|
## 2. 新目录布局
|
||||||
|
|
||||||
|
### 2.1 整体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/platform/
|
||||||
|
├── __init__.py
|
||||||
|
├── botmgr.py # PlatformManager + RuntimeBot(重构)
|
||||||
|
├── event_bus.py # EventBus(新增)
|
||||||
|
├── event_router.py # EventRouter(新增)
|
||||||
|
├── logger.py # EventLogger(保留)
|
||||||
|
├── webhook_pusher.py # WebhookPusher(重构为 WebhookHandler)
|
||||||
|
│
|
||||||
|
├── adapters/ # 适配器(新目录)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ │
|
||||||
|
│ ├── telegram/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py # TelegramAdapter 主类
|
||||||
|
│ │ ├── event_converter.py # 平台事件 → 统一事件
|
||||||
|
│ │ ├── message_converter.py # MessageChain 互转
|
||||||
|
│ │ ├── api_impl.py # 通用 API 实现
|
||||||
|
│ │ ├── platform_api.py # call_platform_api 的动作映射
|
||||||
|
│ │ ├── types.py # 平台特有类型定义
|
||||||
|
│ │ └── manifest.yaml # 适配器清单
|
||||||
|
│ │
|
||||||
|
│ ├── discord/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py
|
||||||
|
│ │ ├── event_converter.py
|
||||||
|
│ │ ├── message_converter.py
|
||||||
|
│ │ ├── api_impl.py
|
||||||
|
│ │ ├── platform_api.py
|
||||||
|
│ │ ├── types.py
|
||||||
|
│ │ ├── voice.py # Discord 语音连接管理(特有)
|
||||||
|
│ │ └── manifest.yaml
|
||||||
|
│ │
|
||||||
|
│ ├── aiocqhttp/ # OneBot v11 (QQ)
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── qqofficial/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── lark/ # 飞书
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── dingtalk/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── slack/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wechatpad/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── officialaccount/ # 微信公众号
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecom/ # 企业微信
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecombot/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecomcs/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── kook/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── line/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── satori/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── websocket/ # 内置 WebSocket 适配器
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py
|
||||||
|
│ │ ├── manager.py # WebSocket 连接管理
|
||||||
|
│ │ └── manifest.yaml
|
||||||
|
│ │
|
||||||
|
│ └── legacy/ # 旧版适配器(保留一段时间后移除)
|
||||||
|
│ ├── gewechat/
|
||||||
|
│ ├── nakuru/
|
||||||
|
│ └── qqbotpy/
|
||||||
|
│
|
||||||
|
└── handlers/ # 事件处理器实现(新增)
|
||||||
|
├── __init__.py
|
||||||
|
├── base.py # AbstractEventHandler 基类
|
||||||
|
├── pipeline_handler.py # PipelineHandler
|
||||||
|
├── agent_handler.py # AgentHandler
|
||||||
|
├── webhook_handler.py # WebhookHandler
|
||||||
|
└── plugin_handler.py # PluginHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 适配器目录内各文件职责
|
||||||
|
|
||||||
|
以 Telegram 为例:
|
||||||
|
|
||||||
|
| 文件 | 职责 | 关键类/函数 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `adapter.py` | 主入口,继承 `AbstractPlatformAdapter`,组装其他模块 | `TelegramAdapter` |
|
||||||
|
| `event_converter.py` | 将 Telegram 原生事件转换为统一事件类型 | `TelegramEventConverter` — 支持 Message/Edit/Delete/Reaction/MemberJoin 等所有事件 |
|
||||||
|
| `message_converter.py` | `MessageChain` 与 Telegram 消息格式互转 | `TelegramMessageConverter.yiri2target()` / `target2yiri()` |
|
||||||
|
| `api_impl.py` | 实现通用 API 方法(edit_message, delete_message, get_group_info 等) | 各 API 方法的 Telegram 实现 |
|
||||||
|
| `platform_api.py` | 实现 `call_platform_api` 的动作分发表 | `PLATFORM_API_MAP = {"pin_message": ..., "unpin_message": ...}` |
|
||||||
|
| `types.py` | 平台特有的类型定义 | Telegram 特有的枚举、配置结构等 |
|
||||||
|
| `manifest.yaml` | 适配器清单:名称、配置 schema、支持的事件和 API 列表 | — |
|
||||||
|
|
||||||
|
## 3. 新基类设计
|
||||||
|
|
||||||
|
### 3.1 AbstractPlatformAdapter
|
||||||
|
|
||||||
|
新基类继承自现有 `AbstractMessagePlatformAdapter` 并扩展,位于 `langbot-plugin-sdk` 中:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# langbot_plugin/api/definition/abstract/platform/adapter.py
|
||||||
|
|
||||||
|
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||||
|
"""平台适配器基类(EBA 版本)
|
||||||
|
|
||||||
|
相比旧版 AbstractMessagePlatformAdapter:
|
||||||
|
- 新增通用 API 方法(edit_message, delete_message, get_group_info 等)
|
||||||
|
- 新增透传 API(call_platform_api)
|
||||||
|
- 新增能力声明(get_supported_events, get_supported_apis)
|
||||||
|
- 事件监听器支持所有事件类型,不仅限于消息事件
|
||||||
|
"""
|
||||||
|
|
||||||
|
bot_account_id: str = ""
|
||||||
|
config: dict
|
||||||
|
logger: AbstractEventLogger = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
# ---- 能力声明 ----
|
||||||
|
|
||||||
|
def get_supported_events(self) -> list[str]:
|
||||||
|
"""返回此适配器支持的事件类型列表
|
||||||
|
|
||||||
|
默认实现从 manifest.yaml 读取。
|
||||||
|
适配器也可以 override 此方法动态声明。
|
||||||
|
"""
|
||||||
|
return ["message.received"]
|
||||||
|
|
||||||
|
def get_supported_apis(self) -> list[str]:
|
||||||
|
"""返回此适配器支持的 API 列表
|
||||||
|
|
||||||
|
默认实现从 manifest.yaml 读取。
|
||||||
|
"""
|
||||||
|
return ["send_message", "reply_message"]
|
||||||
|
|
||||||
|
# ---- 必需方法(抽象) ----
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def send_message(self, target_type, target_id, message) -> MessageResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def reply_message(self, event, message, quote_origin=False) -> MessageResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def run_async(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def register_listener(self, event_type, callback):
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def unregister_listener(self, event_type, callback):
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 可选方法(默认抛 NotSupportedError) ----
|
||||||
|
# edit_message, delete_message, forward_message,
|
||||||
|
# get_group_info, get_group_member_list, ...
|
||||||
|
# call_platform_api, ...
|
||||||
|
# (完整签名见 02-platform-api.md)
|
||||||
|
|
||||||
|
# ---- 流式输出(保留) ----
|
||||||
|
|
||||||
|
async def reply_message_chunk(self, event, bot_message, message,
|
||||||
|
quote_origin=False, is_final=False):
|
||||||
|
raise NotSupportedError("reply_message_chunk")
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- 消息卡片(保留) ----
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id, event) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def is_muted(self, group_id) -> bool:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 AbstractMessagePlatformAdapter 兼容
|
||||||
|
|
||||||
|
旧的 `AbstractMessagePlatformAdapter` 保留为 `AbstractPlatformAdapter` 的类型别名:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 向后兼容
|
||||||
|
AbstractMessagePlatformAdapter = AbstractPlatformAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
现有适配器代码中的 `AbstractMessagePlatformAdapter` 引用不需要立即修改。
|
||||||
|
|
||||||
|
### 3.3 EventConverter 新设计
|
||||||
|
|
||||||
|
现有 `AbstractEventConverter` 只有 `target2yiri` 和 `yiri2target` 两个静态方法,且只处理消息事件。
|
||||||
|
|
||||||
|
新设计支持多种事件类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractEventConverter:
|
||||||
|
"""事件转换器基类(EBA 版本)
|
||||||
|
|
||||||
|
适配器需要实现此转换器,将平台原生事件转换为统一事件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def target2yiri(raw_event: typing.Any) -> typing.Optional[Event]:
|
||||||
|
"""将平台原生事件转换为统一事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_event: 平台 SDK 回调传入的原始事件对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一 Event 对象,如果无法转换或不需要处理则返回 None
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def yiri2target(event: Event) -> typing.Any:
|
||||||
|
"""将统一事件转换为平台原生事件(一般不需要)"""
|
||||||
|
raise NotImplementedError
|
||||||
|
```
|
||||||
|
|
||||||
|
具体适配器的 EventConverter 实现会是一个分发式的结构:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TelegramEventConverter(AbstractEventConverter):
|
||||||
|
"""Telegram 事件转换器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def target2yiri(update: telegram.Update) -> typing.Optional[Event]:
|
||||||
|
# 消息事件
|
||||||
|
if update.message:
|
||||||
|
return TelegramEventConverter._convert_message(update)
|
||||||
|
# 消息编辑
|
||||||
|
if update.edited_message:
|
||||||
|
return TelegramEventConverter._convert_edited_message(update)
|
||||||
|
# 成员变动
|
||||||
|
if update.chat_member:
|
||||||
|
return TelegramEventConverter._convert_chat_member(update)
|
||||||
|
# 回调查询(按钮点击等)
|
||||||
|
if update.callback_query:
|
||||||
|
return TelegramEventConverter._convert_callback_query(update)
|
||||||
|
# 其他 → PlatformSpecificEvent
|
||||||
|
return TelegramEventConverter._convert_platform_specific(update)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_message(update) -> MessageReceivedEvent:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_edited_message(update) -> MessageEditedEvent:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_chat_member(update) -> typing.Union[
|
||||||
|
MemberJoinedEvent, MemberLeftEvent, ...
|
||||||
|
]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_platform_specific(update) -> PlatformSpecificEvent:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Manifest 文件格式扩展
|
||||||
|
|
||||||
|
现有 manifest.yaml 只声明 `kind`, `metadata`, `spec.config`, `execution`。
|
||||||
|
|
||||||
|
新增 `spec.supported_events` 和 `spec.supported_apis`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
label:
|
||||||
|
en_US: Telegram
|
||||||
|
zh_Hans: Telegram
|
||||||
|
icon: telegram.svg
|
||||||
|
description:
|
||||||
|
en_US: Telegram Bot adapter
|
||||||
|
zh_Hans: Telegram Bot 适配器
|
||||||
|
|
||||||
|
spec:
|
||||||
|
config:
|
||||||
|
# 现有配置 schema(保持不变)
|
||||||
|
- key: token
|
||||||
|
label: { en_US: "Bot Token", zh_Hans: "Bot Token" }
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
sensitive: true
|
||||||
|
# ...
|
||||||
|
|
||||||
|
supported_events:
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
|
||||||
|
supported_apis:
|
||||||
|
required:
|
||||||
|
- send_message
|
||||||
|
- reply_message
|
||||||
|
optional:
|
||||||
|
- edit_message
|
||||||
|
- delete_message
|
||||||
|
- get_group_info
|
||||||
|
- get_group_member_list
|
||||||
|
- get_group_member_info
|
||||||
|
- get_user_info
|
||||||
|
- upload_file
|
||||||
|
- get_file_url
|
||||||
|
- call_platform_api
|
||||||
|
|
||||||
|
platform_specific_apis:
|
||||||
|
- action: pin_message
|
||||||
|
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
|
||||||
|
- action: unpin_message
|
||||||
|
description: { en_US: "Unpin a message", zh_Hans: "取消置顶" }
|
||||||
|
- action: get_chat_administrators
|
||||||
|
description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" }
|
||||||
|
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: pkg/platform/adapters/telegram/adapter.py
|
||||||
|
attr: TelegramAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 适配器注册与发现
|
||||||
|
|
||||||
|
### 5.1 Blueprint 更新
|
||||||
|
|
||||||
|
`templates/components.yaml` 中更新扫描路径:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: Blueprint
|
||||||
|
spec:
|
||||||
|
components:
|
||||||
|
MessagePlatformAdapter:
|
||||||
|
fromDirs:
|
||||||
|
- path: pkg/platform/adapters/ # 新路径
|
||||||
|
```
|
||||||
|
|
||||||
|
`ComponentDiscoveryEngine` 的递归扫描逻辑不变——它会扫描所有子目录中的 `.yaml` 文件。因此每个适配器目录下的 `manifest.yaml` 会被自动发现。
|
||||||
|
|
||||||
|
### 5.2 PlatformManager 适配
|
||||||
|
|
||||||
|
`PlatformManager.initialize()` 的核心逻辑基本不变:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def initialize(self):
|
||||||
|
# 1. 发现适配器组件(自动扫描新目录结构)
|
||||||
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
|
|
||||||
|
# 2. 动态导入适配器类
|
||||||
|
for component in self.adapter_components:
|
||||||
|
self.adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
|
|
||||||
|
# 3. 从数据库加载 Bot 并实例化适配器(不变)
|
||||||
|
await self.load_bots_from_db()
|
||||||
|
```
|
||||||
|
|
||||||
|
变更点:
|
||||||
|
- `execution.python.path` 从 `pkg/platform/sources/telegram.py` 变为 `pkg/platform/adapters/telegram/adapter.py`
|
||||||
|
- `get_python_component_class()` 正常工作,因为它按路径动态导入
|
||||||
|
|
||||||
|
## 6. RuntimeBot 重构
|
||||||
|
|
||||||
|
### 6.1 现有问题
|
||||||
|
|
||||||
|
当前 `RuntimeBot.initialize()` 硬编码注册了两个回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 现有代码
|
||||||
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 新设计
|
||||||
|
|
||||||
|
`RuntimeBot` 改为注册一个通用的事件回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RuntimeBot:
|
||||||
|
async def initialize(self):
|
||||||
|
# 注册通用事件回调,接收所有事件类型
|
||||||
|
self.adapter.register_listener(Event, self._on_event)
|
||||||
|
|
||||||
|
async def _on_event(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""统一事件入口"""
|
||||||
|
|
||||||
|
# 1. 设置事件的 bot_uuid 和 adapter_name
|
||||||
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
|
event.adapter_name = self.bot_entity.adapter
|
||||||
|
|
||||||
|
# 2. 日志记录
|
||||||
|
await self._log_event(event)
|
||||||
|
|
||||||
|
# 3. 提交给 EventBus
|
||||||
|
await self.ap.event_bus.emit(event, adapter)
|
||||||
|
```
|
||||||
|
|
||||||
|
适配器侧的 `register_listener` 实现也需调整:
|
||||||
|
- 当 `event_type` 为 `Event`(基类)时,注册为"接收所有事件"的通配回调
|
||||||
|
- 适配器在收到平台原生事件时,通过 `EventConverter.target2yiri()` 转换后,调用所有匹配的回调
|
||||||
|
|
||||||
|
## 7. 从现有单文件适配器迁移
|
||||||
|
|
||||||
|
### 7.1 迁移模式
|
||||||
|
|
||||||
|
以 Telegram 为例,从 `sources/telegram.py`(445 行)拆分:
|
||||||
|
|
||||||
|
| 原代码位置 | → 新文件 |
|
||||||
|
|-----------|----------|
|
||||||
|
| `TelegramMessageConverter` 类 | `telegram/message_converter.py` |
|
||||||
|
| `TelegramEventConverter` 类 | `telegram/event_converter.py`(扩展,支持更多事件) |
|
||||||
|
| `TelegramAdapter.__init__` / `run_async` / `kill` / `register_listener` | `telegram/adapter.py` |
|
||||||
|
| `TelegramAdapter.send_message` / `reply_message` / `reply_message_chunk` | `telegram/adapter.py`(消息方法保留在主类)+ `telegram/api_impl.py`(新增 API) |
|
||||||
|
| 新增代码 | `telegram/api_impl.py`(edit_message, delete_message, get_group_info 等) |
|
||||||
|
| 新增代码 | `telegram/platform_api.py`(pin_message, unpin_message 等的映射) |
|
||||||
|
| `telegram.yaml` | `telegram/manifest.yaml`(扩展 supported_events/apis) |
|
||||||
|
|
||||||
|
### 7.2 迁移顺序建议
|
||||||
|
|
||||||
|
1. **Telegram** — 功能最完整的适配器之一,适合作为模板
|
||||||
|
2. **Discord** — 第二个迁移,验证模式的通用性
|
||||||
|
3. **AioCQHTTP (OneBot)** — 国内最常用,确保兼容
|
||||||
|
4. **其他适配器** — 按使用频率排序
|
||||||
|
|
||||||
|
### 7.3 渐进式迁移
|
||||||
|
|
||||||
|
不需要一次性迁移所有适配器。可以采用渐进策略:
|
||||||
|
|
||||||
|
1. 先在 `adapters/` 下建立新适配器
|
||||||
|
2. `Blueprint` 同时扫描 `sources/` 和 `adapters/` 两个目录
|
||||||
|
3. 旧适配器在 `sources/` 中继续工作
|
||||||
|
4. 逐个迁移到新结构
|
||||||
|
5. 全部迁移完成后移除 `sources/` 目录
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 过渡期的 Blueprint
|
||||||
|
kind: Blueprint
|
||||||
|
spec:
|
||||||
|
components:
|
||||||
|
MessagePlatformAdapter:
|
||||||
|
fromDirs:
|
||||||
|
- path: pkg/platform/sources/ # 旧路径(尚未迁移的适配器)
|
||||||
|
- path: pkg/platform/adapters/ # 新路径(已迁移的适配器)
|
||||||
|
```
|
||||||
743
docs/event-based-agents/04-event-routing.md
Normal file
743
docs/event-based-agents/04-event-routing.md
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
# 事件路由与编排
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
事件路由是 EBA 架构的核心机制:事件从适配器产生后,经由 EventBus 进入 EventRouter,由 EventRouter 根据 Bot 的配置将事件分发到对应的处理器(Handler)。
|
||||||
|
|
||||||
|
**配置方式**:用户在 WebUI 的 Bot 管理页面通过可视化编排面板管理事件处理器配置,配置数据存储在数据库的 Bot 表 `event_handlers` JSON 字段中。
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 Bot 实体扩展
|
||||||
|
|
||||||
|
在 `bots` 表新增 `event_handlers` 字段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Bot(Base):
|
||||||
|
__tablename__ = "bots"
|
||||||
|
|
||||||
|
uuid: str # 主键
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
adapter: str
|
||||||
|
adapter_config: dict # JSON
|
||||||
|
enable: bool
|
||||||
|
|
||||||
|
# 新增
|
||||||
|
event_handlers: list # JSON — 事件处理器配置列表
|
||||||
|
|
||||||
|
# 保留(过渡期后弃用)
|
||||||
|
use_pipeline_name: str # deprecated
|
||||||
|
use_pipeline_uuid: str # deprecated
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 EventHandler 配置结构
|
||||||
|
|
||||||
|
`event_handlers` 字段存储一个 JSON 数组,每个元素定义一条事件路由规则:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventHandlerConfig(pydantic.BaseModel):
|
||||||
|
"""单条事件处理器配置"""
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
"""匹配的事件类型
|
||||||
|
|
||||||
|
支持精确匹配和通配符:
|
||||||
|
- "message.received" — 精确匹配
|
||||||
|
- "message.*" — 匹配 message 命名空间下所有事件
|
||||||
|
- "group.*" — 匹配 group 命名空间下所有事件
|
||||||
|
- "*" — 匹配所有事件(兜底)
|
||||||
|
"""
|
||||||
|
|
||||||
|
handler_type: str
|
||||||
|
"""处理器类型: "pipeline" | "agent" | "webhook" | "plugin" """
|
||||||
|
|
||||||
|
handler_config: dict = {}
|
||||||
|
"""处理器的具体配置,结构取决于 handler_type"""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
"""是否启用此规则"""
|
||||||
|
|
||||||
|
priority: int = 0
|
||||||
|
"""优先级,数字越大越先匹配(同一事件类型有多条规则时)"""
|
||||||
|
|
||||||
|
description: str = ""
|
||||||
|
"""规则描述(供 WebUI 显示)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 各 Handler 类型的 handler_config 结构
|
||||||
|
|
||||||
|
#### pipeline
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件作为消息事件传入现有 Pipeline 流水线。仅适用于 `message.received` 事件。
|
||||||
|
|
||||||
|
#### agent
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "local-agent",
|
||||||
|
"runner_config": {
|
||||||
|
"model_uuid": "...",
|
||||||
|
"prompt": "你是一个群组助理,请处理以下事件:{event_summary}",
|
||||||
|
"tools_enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "dify-service-api",
|
||||||
|
"runner_config": {
|
||||||
|
"base_url": "https://api.dify.ai/v1",
|
||||||
|
"api_key": "...",
|
||||||
|
"app_type": "agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
直接调用 RequestRunner 处理事件。可用的 runner 包括:
|
||||||
|
- `local-agent` — 内置 LLM Agent
|
||||||
|
- `dify-service-api` — Dify 平台
|
||||||
|
- `n8n-service-api` — n8n 工作流
|
||||||
|
- `coze-api` — Coze (扣子)
|
||||||
|
- `dashscope-app-api` — 阿里百炼
|
||||||
|
- `langflow-api` — Langflow
|
||||||
|
- `tbox-app-api` — 蚂蚁 Tbox
|
||||||
|
|
||||||
|
Agent 处理器不经过 Pipeline 的多 Stage 流程,而是直接构建上下文并调用 Runner。适用于所有事件类型。
|
||||||
|
|
||||||
|
**Agent Handler 与 Pipeline 的关系**:
|
||||||
|
- Pipeline 是完整的多 Stage 处理链(PreProcessor → MessageProcessor(内含Runner) → PostProcessor → ...),适合复杂消息处理
|
||||||
|
- Agent Handler 是轻量级的,直接调用 Runner,跳过 PreProcessor/PostProcessor 等阶段
|
||||||
|
- Pipeline 内部的 AI Stage 仍然使用 Runner,所以 Runner 本身被两种 Handler 共享
|
||||||
|
- 用户可以根据场景选择:消息处理用 Pipeline(更多控制),其他事件用 Agent(更直接)
|
||||||
|
|
||||||
|
#### webhook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "webhook",
|
||||||
|
"handler_config": {
|
||||||
|
"url": "https://example.com/webhook/langbot-events",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer xxx"
|
||||||
|
},
|
||||||
|
"timeout": 30,
|
||||||
|
"retry_count": 3,
|
||||||
|
"retry_interval": 5,
|
||||||
|
"response_actions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件序列化为 JSON POST 到外部 URL。支持的特性:
|
||||||
|
- **认证**:通过 headers 配置(Bearer Token、API Key 等)
|
||||||
|
- **重试**:配置重试次数和间隔
|
||||||
|
- **响应动作**:如果 `response_actions` 为 true,解析响应 JSON 中的 `actions` 字段并执行(如发送消息、同意好友请求等)
|
||||||
|
|
||||||
|
Webhook 请求体格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"type": "group.member_joined",
|
||||||
|
"timestamp": 1700000000.0,
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"adapter_name": "telegram",
|
||||||
|
"group": { "id": "...", "name": "..." },
|
||||||
|
"member": { "id": "...", "nickname": "..." }
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "...",
|
||||||
|
"adapter": "telegram"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应体格式(当 `response_actions` 为 true 时):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "send_message",
|
||||||
|
"params": {
|
||||||
|
"target_type": "group",
|
||||||
|
"target_id": "123456",
|
||||||
|
"message": [{ "type": "Plain", "text": "欢迎新成员!" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "call_platform_api",
|
||||||
|
"params": {
|
||||||
|
"action": "pin_message",
|
||||||
|
"params": { "chat_id": "123456", "message_id": "789" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### plugin
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {
|
||||||
|
"plugin_filter": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件分发给插件的 EventListener 处理。
|
||||||
|
|
||||||
|
- `plugin_filter`:可选的插件名过滤列表,为空表示分发给所有插件
|
||||||
|
- 沿用现有的插件事件分发机制(按优先级遍历插件,支持 `prevent_postorder`)
|
||||||
|
|
||||||
|
### 2.4 完整配置示例
|
||||||
|
|
||||||
|
一个 Bot 的 `event_handlers` 配置示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": "default-pipeline-uuid"
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"description": "消息事件使用默认流水线处理"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "group.member_joined",
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "local-agent",
|
||||||
|
"runner_config": {
|
||||||
|
"model_uuid": "gpt-4o-mini",
|
||||||
|
"prompt": "有新成员 {member_name} 加入了群组 {group_name},请生成一条欢迎消息。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 0,
|
||||||
|
"description": "新成员入群时用 AI 生成欢迎消息"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "friend.request_received",
|
||||||
|
"handler_type": "webhook",
|
||||||
|
"handler_config": {
|
||||||
|
"url": "https://my-server.com/api/friend-request",
|
||||||
|
"response_actions": true
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 0,
|
||||||
|
"description": "好友请求转发到自建服务处理"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "*",
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": -100,
|
||||||
|
"description": "所有事件兜底发给插件处理"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. EventBus 设计
|
||||||
|
|
||||||
|
EventBus 是事件的中转站,接收来自各个 RuntimeBot 的事件,交由 EventRouter 处理。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventBus:
|
||||||
|
"""事件总线"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
self.event_router = EventRouter(ap)
|
||||||
|
|
||||||
|
async def emit(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""接收并分发事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: 统一事件对象
|
||||||
|
adapter: 产生此事件的适配器实例
|
||||||
|
"""
|
||||||
|
# 1. 全局事件日志
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f"EventBus: {event.type} from bot {event.bot_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 交由 EventRouter 路由处理
|
||||||
|
await self.event_router.route(event, adapter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. EventRouter 设计
|
||||||
|
|
||||||
|
EventRouter 是事件路由引擎,根据 Bot 的 `event_handlers` 配置决定事件的处理方式。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventRouter:
|
||||||
|
"""事件路由引擎"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
self.handlers: dict[str, AbstractEventHandler] = {
|
||||||
|
"pipeline": PipelineHandler(ap),
|
||||||
|
"agent": AgentHandler(ap),
|
||||||
|
"webhook": WebhookHandler(ap),
|
||||||
|
"plugin": PluginHandler(ap),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def route(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""路由事件到对应处理器"""
|
||||||
|
|
||||||
|
# 1. 获取 Bot 配置
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||||
|
if not bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 获取事件处理器配置
|
||||||
|
event_handlers = bot.bot_entity.event_handlers or []
|
||||||
|
|
||||||
|
# 3. 匹配规则(按 priority 降序排列)
|
||||||
|
matched_handlers = self._match_handlers(event.type, event_handlers)
|
||||||
|
|
||||||
|
if not matched_handlers:
|
||||||
|
# 未匹配到任何规则 → 默认交给插件处理(向后兼容)
|
||||||
|
await self.handlers["plugin"].handle(event, adapter, {})
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. 执行第一个匹配的 Handler
|
||||||
|
# (未来可扩展为多个 Handler 串行/并行执行)
|
||||||
|
handler_config = matched_handlers[0]
|
||||||
|
handler = self.handlers.get(handler_config.handler_type)
|
||||||
|
|
||||||
|
if handler:
|
||||||
|
await handler.handle(event, adapter, handler_config.handler_config)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f"Unknown handler type: {handler_config.handler_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _match_handlers(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
handlers: list[EventHandlerConfig],
|
||||||
|
) -> list[EventHandlerConfig]:
|
||||||
|
"""匹配事件类型到处理器配置
|
||||||
|
|
||||||
|
匹配规则:
|
||||||
|
1. 精确匹配:event_type == handler.event_type
|
||||||
|
2. 命名空间通配:handler.event_type 为 "message.*" 时匹配所有 "message.xxx"
|
||||||
|
3. 全局通配:handler.event_type 为 "*" 时匹配所有事件
|
||||||
|
4. 按 priority 降序排列
|
||||||
|
5. 只返回 enabled=True 的规则
|
||||||
|
"""
|
||||||
|
matched = []
|
||||||
|
for handler in handlers:
|
||||||
|
if not handler.enabled:
|
||||||
|
continue
|
||||||
|
if self._event_type_matches(event_type, handler.event_type):
|
||||||
|
matched.append(handler)
|
||||||
|
|
||||||
|
matched.sort(key=lambda h: h.priority, reverse=True)
|
||||||
|
return matched
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_type_matches(event_type: str, pattern: str) -> bool:
|
||||||
|
"""判断事件类型是否匹配模式"""
|
||||||
|
if pattern == "*":
|
||||||
|
return True
|
||||||
|
if pattern == event_type:
|
||||||
|
return True
|
||||||
|
if pattern.endswith(".*"):
|
||||||
|
namespace = pattern[:-2]
|
||||||
|
return event_type.startswith(namespace + ".")
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 事件处理器(Handler)实现
|
||||||
|
|
||||||
|
### 5.1 Handler 基类
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractEventHandler(abc.ABC):
|
||||||
|
"""事件处理器基类"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
config: dict,
|
||||||
|
) -> None:
|
||||||
|
"""处理事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: 统一事件对象
|
||||||
|
adapter: 适配器实例(用于调用平台 API 发送响应)
|
||||||
|
config: handler_config 配置
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 PipelineHandler
|
||||||
|
|
||||||
|
将消息事件注入现有 Pipeline 流水线处理。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PipelineHandler(AbstractEventHandler):
|
||||||
|
"""Pipeline 处理器 — 将事件送入现有 Pipeline 流水线"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
pipeline_uuid = config.get("pipeline_uuid")
|
||||||
|
|
||||||
|
if not isinstance(event, MessageReceivedEvent):
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f"PipelineHandler only supports MessageReceivedEvent, "
|
||||||
|
f"got {event.type}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 将 MessageReceivedEvent 转换为现有的 Query 并投入 QueryPool
|
||||||
|
# 复用现有的 MessageAggregator + QueryPool + Pipeline 机制
|
||||||
|
launcher_type = (
|
||||||
|
LauncherTypes.PERSON
|
||||||
|
if event.chat_type == ChatType.PRIVATE
|
||||||
|
else LauncherTypes.GROUP
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.msg_aggregator.add_message(
|
||||||
|
bot_uuid=event.bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_event(), # 转换为 FriendMessage/GroupMessage
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
adapter=adapter,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 AgentHandler
|
||||||
|
|
||||||
|
直接调用 RequestRunner 处理事件,不经过 Pipeline Stage 链。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AgentHandler(AbstractEventHandler):
|
||||||
|
"""Agent 处理器 — 直接调用 RequestRunner 处理事件"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
runner_name = config.get("runner", "local-agent")
|
||||||
|
runner_config = config.get("runner_config", {})
|
||||||
|
|
||||||
|
# 1. 查找 Runner 类
|
||||||
|
runner_cls = None
|
||||||
|
for r in preregistered_runners:
|
||||||
|
if r.name == runner_name:
|
||||||
|
runner_cls = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not runner_cls:
|
||||||
|
self.ap.logger.error(f"Runner not found: {runner_name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 构建事件上下文(将事件信息整理为 Runner 可处理的格式)
|
||||||
|
event_context = self._build_event_context(event, runner_config)
|
||||||
|
|
||||||
|
# 3. 实例化并调用 Runner
|
||||||
|
runner = runner_cls(self.ap, self._build_runner_pipeline_config(config))
|
||||||
|
|
||||||
|
response_messages = []
|
||||||
|
async for result in runner.run(event_context):
|
||||||
|
response_messages.append(result)
|
||||||
|
|
||||||
|
# 4. 发送响应(如果 Runner 产生了回复)
|
||||||
|
if response_messages and isinstance(event, MessageReceivedEvent):
|
||||||
|
# 将 Runner 输出转换为 MessageChain 并回复
|
||||||
|
reply_chain = self._build_reply_chain(response_messages)
|
||||||
|
await adapter.reply_message(event, reply_chain)
|
||||||
|
|
||||||
|
def _build_event_context(self, event, runner_config):
|
||||||
|
"""将事件构建为 Runner 可处理的上下文
|
||||||
|
|
||||||
|
对于消息事件,直接使用消息内容。
|
||||||
|
对于其他事件,根据 runner_config 中的 prompt 模板生成描述文本。
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _build_runner_pipeline_config(self, config):
|
||||||
|
"""将 handler_config 转换为 Runner 需要的 pipeline_config 格式"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 WebhookHandler
|
||||||
|
|
||||||
|
将事件 POST 到外部 URL。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WebhookHandler(AbstractEventHandler):
|
||||||
|
"""Webhook 处理器 — 将事件 POST 到外部 URL"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
url = config.get("url")
|
||||||
|
method = config.get("method", "POST")
|
||||||
|
headers = config.get("headers", {})
|
||||||
|
timeout = config.get("timeout", 30)
|
||||||
|
retry_count = config.get("retry_count", 3)
|
||||||
|
response_actions = config.get("response_actions", False)
|
||||||
|
|
||||||
|
# 1. 构建请求体
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||||
|
payload = {
|
||||||
|
"event": event.model_dump(),
|
||||||
|
"bot": {
|
||||||
|
"uuid": bot.bot_entity.uuid,
|
||||||
|
"name": bot.bot_entity.name,
|
||||||
|
"adapter": bot.bot_entity.adapter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 发送请求(带重试)
|
||||||
|
response = await self._send_with_retry(
|
||||||
|
url, method, headers, payload, timeout, retry_count
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 处理响应动作
|
||||||
|
if response_actions and response:
|
||||||
|
await self._execute_response_actions(
|
||||||
|
response, adapter, event
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_response_actions(self, response, adapter, event):
|
||||||
|
"""执行响应中的动作列表"""
|
||||||
|
actions = response.get("actions", [])
|
||||||
|
for action in actions:
|
||||||
|
action_type = action.get("type")
|
||||||
|
params = action.get("params", {})
|
||||||
|
|
||||||
|
if action_type == "send_message":
|
||||||
|
chain = MessageChain.model_validate(params.get("message", []))
|
||||||
|
await adapter.send_message(
|
||||||
|
params["target_type"],
|
||||||
|
params["target_id"],
|
||||||
|
chain,
|
||||||
|
)
|
||||||
|
elif action_type == "reply":
|
||||||
|
chain = MessageChain.model_validate(params.get("message", []))
|
||||||
|
await adapter.reply_message(event, chain)
|
||||||
|
elif action_type == "call_platform_api":
|
||||||
|
await adapter.call_platform_api(
|
||||||
|
params["action"],
|
||||||
|
params.get("params", {}),
|
||||||
|
)
|
||||||
|
elif action_type == "approve_friend_request":
|
||||||
|
await adapter.approve_friend_request(
|
||||||
|
params["request_id"],
|
||||||
|
params.get("approve", True),
|
||||||
|
)
|
||||||
|
# ... 更多动作类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 PluginHandler
|
||||||
|
|
||||||
|
将事件分发给插件的 EventListener。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginHandler(AbstractEventHandler):
|
||||||
|
"""Plugin 处理器 — 分发给插件 EventListener"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
plugin_filter = config.get("plugin_filter", [])
|
||||||
|
|
||||||
|
# 复用现有的插件事件分发机制
|
||||||
|
# 通过 plugin_connector 将事件发送给 Plugin Runtime
|
||||||
|
await self.ap.plugin_connector.emit_event(
|
||||||
|
event=event,
|
||||||
|
adapter=adapter,
|
||||||
|
plugin_filter=plugin_filter,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. use_pipeline_uuid 迁移
|
||||||
|
|
||||||
|
### 6.1 自动迁移
|
||||||
|
|
||||||
|
数据库迁移脚本将现有的 `use_pipeline_uuid` 自动转换为 `event_handlers`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 迁移逻辑
|
||||||
|
for bot in all_bots:
|
||||||
|
if bot.use_pipeline_uuid and not bot.event_handlers:
|
||||||
|
bot.event_handlers = [
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": bot.use_pipeline_uuid
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 10,
|
||||||
|
"description": "Auto-migrated from use_pipeline_uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "*",
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {},
|
||||||
|
"enabled": True,
|
||||||
|
"priority": -100,
|
||||||
|
"description": "Default plugin handler"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 过渡期兼容
|
||||||
|
|
||||||
|
在过渡期内,如果 `event_handlers` 为空且 `use_pipeline_uuid` 非空,EventRouter 自动回退到旧行为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# EventRouter.route() 中的兼容逻辑
|
||||||
|
if not event_handlers and bot.bot_entity.use_pipeline_uuid:
|
||||||
|
# 回退:消息事件走 Pipeline,其他事件走 Plugin
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
await self.handlers["pipeline"].handle(
|
||||||
|
event, adapter,
|
||||||
|
{"pipeline_uuid": bot.bot_entity.use_pipeline_uuid}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.handlers["plugin"].handle(event, adapter, {})
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. WebUI 编排面板数据模型
|
||||||
|
|
||||||
|
### 7.1 交互设计概要
|
||||||
|
|
||||||
|
在 WebUI 的 Bot 管理页面,新增"事件处理器"标签页(或区域),呈现为一个**规则列表**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 事件处理器 [+ 添加规则] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 1 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [message.received ▾] │ │
|
||||||
|
│ │ 处理器: [Pipeline ▾] │ │
|
||||||
|
│ │ Pipeline: [默认流水线 ▾] │ │
|
||||||
|
│ │ 优先级: [10] │ │
|
||||||
|
│ │ 描述: 消息事件使用默认流水线处理 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 2 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [group.member_joined ▾] │ │
|
||||||
|
│ │ 处理器: [Agent ▾] │ │
|
||||||
|
│ │ Runner: [local-agent ▾] │ │
|
||||||
|
│ │ 模型: [gpt-4o-mini ▾] │ │
|
||||||
|
│ │ Prompt: [有新成员加入...] │ │
|
||||||
|
│ │ 优先级: [0] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 3 (兜底) ──────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [* ▾] │ │
|
||||||
|
│ │ 处理器: [Plugin ▾] │ │
|
||||||
|
│ │ 优先级: [-100] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 前端数据结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EventHandlerRule {
|
||||||
|
event_type: string; // 下拉选择,选项从适配器 manifest 的 supported_events 获取
|
||||||
|
handler_type: string; // "pipeline" | "agent" | "webhook" | "plugin"
|
||||||
|
handler_config: Record<string, any>; // 根据 handler_type 动态渲染不同的配置表单
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot 编辑接口扩展
|
||||||
|
interface BotConfig {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
adapter: string;
|
||||||
|
adapter_config: Record<string, any>;
|
||||||
|
enable: boolean;
|
||||||
|
event_handlers: EventHandlerRule[]; // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 事件类型下拉选项
|
||||||
|
|
||||||
|
从 Bot 关联的适配器 manifest 中获取 `supported_events`,加上通配符选项:
|
||||||
|
|
||||||
|
```
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- friend.request_received
|
||||||
|
- friend.added
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
─────────────────
|
||||||
|
- message.* (所有消息事件)
|
||||||
|
- feedback.* (所有反馈事件)
|
||||||
|
- group.* (所有群组事件)
|
||||||
|
- friend.* (所有好友事件)
|
||||||
|
- bot.* (所有 Bot 事件)
|
||||||
|
- * (所有事件)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 HTTP API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/bots/{uuid}/event-handlers 获取 Bot 的事件处理器配置
|
||||||
|
PUT /api/v1/bots/{uuid}/event-handlers 更新 Bot 的事件处理器配置
|
||||||
|
GET /api/v1/adapters/{name}/supported-events 获取适配器支持的事件类型
|
||||||
|
GET /api/v1/adapters/{name}/supported-apis 获取适配器支持的 API
|
||||||
|
```
|
||||||
738
docs/event-based-agents/05-plugin-sdk.md
Normal file
738
docs/event-based-agents/05-plugin-sdk.md
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
# 插件 SDK 改造
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
插件 SDK 需要配合 EBA 架构进行以下改造:
|
||||||
|
|
||||||
|
1. **新事件类型**:将所有通用事件暴露给插件
|
||||||
|
2. **新 API**:将新增的平台 API 通过 `LangBotAPIProxy` 暴露给插件
|
||||||
|
3. **兼容层**:保证现有插件零修改运行
|
||||||
|
4. **通信协议扩展**:新增 action 枚举支持新 API
|
||||||
|
|
||||||
|
## 2. 新事件类型暴露
|
||||||
|
|
||||||
|
### 2.1 插件事件模型扩展
|
||||||
|
|
||||||
|
当前插件 SDK 的事件模型(`api/entities/events.py`)只有消息相关事件。需要新增所有通用事件的插件级包装:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# api/entities/events.py — 新增事件
|
||||||
|
|
||||||
|
# ---- 消息事件(扩展) ----
|
||||||
|
|
||||||
|
class MessageEditedReceived(BaseEventModel):
|
||||||
|
"""消息被编辑事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
editor_id: typing.Union[int, str]
|
||||||
|
new_content: MessageChain
|
||||||
|
chat_type: str # "private" | "group"
|
||||||
|
|
||||||
|
class MessageDeletedReceived(BaseEventModel):
|
||||||
|
"""消息被删除/撤回事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
chat_type: str
|
||||||
|
|
||||||
|
class MessageReactionReceived(BaseEventModel):
|
||||||
|
"""消息表情回应事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
reaction: str
|
||||||
|
is_add: bool
|
||||||
|
|
||||||
|
# ---- 用户反馈事件 ----
|
||||||
|
|
||||||
|
class FeedbackReceived(BaseEventModel):
|
||||||
|
"""用户对 Bot 回复提交反馈"""
|
||||||
|
feedback_id: str
|
||||||
|
feedback_type: int # 1=like, 2=dislike, 3=cancel/remove feedback
|
||||||
|
feedback_content: typing.Optional[str] = None
|
||||||
|
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||||
|
user_id: typing.Optional[str] = None
|
||||||
|
session_id: typing.Optional[str] = None
|
||||||
|
message_id: typing.Optional[str] = None
|
||||||
|
stream_id: typing.Optional[str] = None
|
||||||
|
|
||||||
|
# ---- 群组事件 ----
|
||||||
|
|
||||||
|
class GroupMemberJoined(BaseEventModel):
|
||||||
|
"""新成员加入群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
member_name: str
|
||||||
|
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
join_type: typing.Optional[str] = None
|
||||||
|
|
||||||
|
class GroupMemberLeft(BaseEventModel):
|
||||||
|
"""成员离开群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
member_name: str
|
||||||
|
is_kicked: bool = False
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class GroupMemberBanned(BaseEventModel):
|
||||||
|
"""成员被禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
class GroupMemberUnbanned(BaseEventModel):
|
||||||
|
"""成员被解除禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class GroupInfoUpdated(BaseEventModel):
|
||||||
|
"""群组信息被修改"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
|
||||||
|
# ---- 好友事件 ----
|
||||||
|
|
||||||
|
class FriendRequestReceived(BaseEventModel):
|
||||||
|
"""收到好友请求"""
|
||||||
|
request_id: typing.Union[int, str]
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
message: typing.Optional[str] = None
|
||||||
|
|
||||||
|
class FriendAdded(BaseEventModel):
|
||||||
|
"""成功添加好友"""
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
|
||||||
|
class FriendRemoved(BaseEventModel):
|
||||||
|
"""好友被移除"""
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
|
||||||
|
# ---- Bot 状态事件 ----
|
||||||
|
|
||||||
|
class BotInvitedToGroup(BaseEventModel):
|
||||||
|
"""Bot 被邀请加入群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class BotRemovedFromGroup(BaseEventModel):
|
||||||
|
"""Bot 被移出群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class BotMuted(BaseEventModel):
|
||||||
|
"""Bot 被禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
class BotUnmuted(BaseEventModel):
|
||||||
|
"""Bot 被解除禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
# ---- 平台特有事件 ----
|
||||||
|
|
||||||
|
class PlatformSpecificEventReceived(BaseEventModel):
|
||||||
|
"""平台特有事件"""
|
||||||
|
adapter_name: str
|
||||||
|
action: str
|
||||||
|
data: dict = {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 EventListener 注册方式
|
||||||
|
|
||||||
|
插件的 EventListener 继续使用 `@self.handler(EventType)` 装饰器注册,只是可以注册的事件类型大幅增加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyEventListener(EventListener):
|
||||||
|
def __init__(self, host):
|
||||||
|
super().__init__(host)
|
||||||
|
|
||||||
|
# 现有方式(继续工作)
|
||||||
|
@self.handler(PersonNormalMessageReceived)
|
||||||
|
async def on_person_message(ctx: EventContext):
|
||||||
|
...
|
||||||
|
|
||||||
|
# 新事件类型
|
||||||
|
@self.handler(GroupMemberJoined)
|
||||||
|
async def on_member_joined(ctx: EventContext):
|
||||||
|
group_name = ctx.event.group_name
|
||||||
|
member_name = ctx.event.member_name
|
||||||
|
await ctx.reply(MessageChain([
|
||||||
|
Plain(f"欢迎 {member_name} 加入 {group_name}!")
|
||||||
|
]))
|
||||||
|
|
||||||
|
@self.handler(FriendRequestReceived)
|
||||||
|
async def on_friend_request(ctx: EventContext):
|
||||||
|
# 自动通过好友请求
|
||||||
|
await ctx.approve_friend_request(
|
||||||
|
ctx.event.request_id, approve=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.handler(FeedbackReceived)
|
||||||
|
async def on_feedback(ctx: EventContext):
|
||||||
|
if ctx.event.feedback_type == 2:
|
||||||
|
await self.log_warning(
|
||||||
|
f"用户点踩了回复: {ctx.event.feedback_content or ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.handler(PlatformSpecificEventReceived)
|
||||||
|
async def on_platform_event(ctx: EventContext):
|
||||||
|
if ctx.event.adapter_name == "telegram" and ctx.event.action == "chat_join_request":
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 新 API 暴露
|
||||||
|
|
||||||
|
### 3.1 LangBotAPIProxy 扩展
|
||||||
|
|
||||||
|
在 `LangBotAPIProxy` 中新增以下方法,插件通过 `self.xxx()` 调用(在 BasePlugin 中继承):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LangBotAPIProxy:
|
||||||
|
# ---- 现有方法(保留) ----
|
||||||
|
# get_langbot_version, get_bots, get_bot_info,
|
||||||
|
# send_message, invoke_llm, get/set/delete_plugin_storage, ...
|
||||||
|
|
||||||
|
# ---- 新增消息 API ----
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""编辑已发送的消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""删除/撤回消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def forward_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
from_chat_type: str,
|
||||||
|
from_chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
to_chat_type: str,
|
||||||
|
to_chat_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""转发消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取指定消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增群组 API ----
|
||||||
|
|
||||||
|
async def get_group_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取群组信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取 Bot 加入的群组列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取群成员列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取指定群成员信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def mute_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""禁言群成员"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def unmute_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""解除禁言"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def kick_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""踢出群成员"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增用户 API ----
|
||||||
|
|
||||||
|
async def get_user_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取用户信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_friend_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取好友列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增透传 API ----
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用适配器特有 API
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Telegram: pin 消息
|
||||||
|
result = await self.call_platform_api(
|
||||||
|
bot_uuid, "pin_message",
|
||||||
|
{"chat_id": 123456, "message_id": 789}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discord: 创建频道
|
||||||
|
result = await self.call_platform_api(
|
||||||
|
bot_uuid, "create_channel",
|
||||||
|
{"guild_id": "...", "name": "new-channel"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增能力查询 API ----
|
||||||
|
|
||||||
|
async def get_supported_events(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""获取指定 Bot 的适配器支持的事件类型"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_supported_apis(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""获取指定 Bot 的适配器支持的 API"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 QueryBasedAPIProxy 扩展
|
||||||
|
|
||||||
|
在事件处理上下文中(EventContext),通过 `QueryBasedAPIProxy` 新增便捷方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QueryBasedAPIProxy:
|
||||||
|
# ---- 现有方法(保留) ----
|
||||||
|
# reply, get_bot_uuid, set_query_var, get_query_var,
|
||||||
|
# create_new_conversation, ...
|
||||||
|
|
||||||
|
# ---- 新增便捷方法 ----
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""在当前会话中编辑消息(自动使用当前 bot_uuid 和 chat 信息)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""在当前会话中删除消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求(上下文中自动获取 bot_uuid)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_info(self) -> dict:
|
||||||
|
"""获取当前群组信息(仅群聊事件中可用)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_list(self) -> list[dict]:
|
||||||
|
"""获取当前群组成员列表(仅群聊事件中可用)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用平台特有 API(自动使用当前 bot_uuid)"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 兼容层设计
|
||||||
|
|
||||||
|
### 4.1 事件兼容层
|
||||||
|
|
||||||
|
当 PluginHandler 将新的 `MessageReceivedEvent` 分发给插件时,需要同时生成旧格式事件:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginEventCompatLayer:
|
||||||
|
"""插件事件兼容层
|
||||||
|
|
||||||
|
将新的统一事件转换为旧的插件事件格式,
|
||||||
|
确保监听旧事件类型的插件仍能正常工作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert_to_legacy_events(
|
||||||
|
event: Event,
|
||||||
|
) -> list[BaseEventModel]:
|
||||||
|
"""将统一事件转换为旧插件事件列表
|
||||||
|
|
||||||
|
一个统一事件可能生成多个旧插件事件。
|
||||||
|
例如 MessageReceivedEvent 会同时生成:
|
||||||
|
- PersonMessageReceived / GroupMessageReceived(总是生成)
|
||||||
|
- PersonNormalMessageReceived / GroupNormalMessageReceived(非命令时)
|
||||||
|
- PersonCommandSent / GroupCommandSent(命令时)
|
||||||
|
"""
|
||||||
|
legacy_events = []
|
||||||
|
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
if event.chat_type == ChatType.PRIVATE:
|
||||||
|
legacy_events.append(
|
||||||
|
PersonMessageReceived(
|
||||||
|
launcher_type="person",
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_friend_message(),
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 命令检测后还会生成 PersonNormalMessageReceived
|
||||||
|
# 或 PersonCommandSent,在 Pipeline 阶段处理
|
||||||
|
elif event.chat_type == ChatType.GROUP:
|
||||||
|
legacy_events.append(
|
||||||
|
GroupMessageReceived(
|
||||||
|
launcher_type="group",
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_group_message(),
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 新事件类型没有旧的对应物,不生成兼容事件
|
||||||
|
# 只有监听了新事件类型的插件才会收到
|
||||||
|
|
||||||
|
return legacy_events
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 分发流程
|
||||||
|
|
||||||
|
```
|
||||||
|
统一事件 (MessageReceivedEvent)
|
||||||
|
│
|
||||||
|
├─→ 转换为旧格式 (PersonMessageReceived / GroupMessageReceived)
|
||||||
|
│ └─→ 分发给监听旧事件类型的插件 EventListener
|
||||||
|
│
|
||||||
|
└─→ 直接分发为新格式 (MessageReceivedEvent → 对应的插件事件)
|
||||||
|
└─→ 分发给监听新事件类型的插件 EventListener
|
||||||
|
```
|
||||||
|
|
||||||
|
插件 Runtime 在分发事件时检查每个 EventListener 注册的事件类型:
|
||||||
|
- 如果注册的是旧类型(`PersonMessageReceived` 等),发送兼容层生成的旧格式事件
|
||||||
|
- 如果注册的是新类型(`GroupMemberJoined` 等),发送新格式事件
|
||||||
|
- 两者可以共存,同一个插件可以同时监听新旧类型
|
||||||
|
|
||||||
|
### 4.3 API 兼容层
|
||||||
|
|
||||||
|
现有插件使用的 API 不受影响:
|
||||||
|
|
||||||
|
| 现有 API | 新架构行为 |
|
||||||
|
|---------|----------|
|
||||||
|
| `self.send_message(bot_uuid, target_type, target_id, message_chain)` | 不变,直接调用适配器的 `send_message` |
|
||||||
|
| `ctx.reply(message_chain, quote_origin)` | 不变,在 MessageReceivedEvent 上下文中调用适配器的 `reply_message` |
|
||||||
|
| `self.get_bots()` | 不变 |
|
||||||
|
| `self.get_bot_info(bot_uuid)` | 不变 |
|
||||||
|
|
||||||
|
新 API 只是额外新增的方法,不影响现有方法。
|
||||||
|
|
||||||
|
## 5. 通信协议扩展
|
||||||
|
|
||||||
|
### 5.1 新增 Action 枚举
|
||||||
|
|
||||||
|
在 `entities/io/actions/enums.py` 中新增 action:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginToRuntimeAction(str, Enum):
|
||||||
|
# ---- 现有 actions(保留) ----
|
||||||
|
REGISTER_PLUGIN = "register_plugin"
|
||||||
|
REPLY = "reply"
|
||||||
|
SEND_MESSAGE = "send_message"
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# ---- 新增消息 API ----
|
||||||
|
EDIT_MESSAGE = "edit_message"
|
||||||
|
DELETE_MESSAGE = "delete_message"
|
||||||
|
FORWARD_MESSAGE = "forward_message"
|
||||||
|
GET_MESSAGE = "get_message"
|
||||||
|
|
||||||
|
# ---- 新增群组 API ----
|
||||||
|
GET_GROUP_INFO = "get_group_info"
|
||||||
|
GET_GROUP_LIST = "get_group_list"
|
||||||
|
GET_GROUP_MEMBER_LIST = "get_group_member_list"
|
||||||
|
GET_GROUP_MEMBER_INFO = "get_group_member_info"
|
||||||
|
MUTE_MEMBER = "mute_member"
|
||||||
|
UNMUTE_MEMBER = "unmute_member"
|
||||||
|
KICK_MEMBER = "kick_member"
|
||||||
|
|
||||||
|
# ---- 新增用户 API ----
|
||||||
|
GET_USER_INFO = "get_user_info"
|
||||||
|
GET_FRIEND_LIST = "get_friend_list"
|
||||||
|
APPROVE_FRIEND_REQUEST = "approve_friend_request"
|
||||||
|
APPROVE_GROUP_INVITE = "approve_group_invite"
|
||||||
|
|
||||||
|
# ---- 新增透传 API ----
|
||||||
|
CALL_PLATFORM_API = "call_platform_api"
|
||||||
|
|
||||||
|
# ---- 新增能力查询 ----
|
||||||
|
GET_SUPPORTED_EVENTS = "get_supported_events"
|
||||||
|
GET_SUPPORTED_APIS = "get_supported_apis"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeToPluginAction(str, Enum):
|
||||||
|
# ---- 现有 actions(保留) ----
|
||||||
|
EMIT_EVENT = "emit_event"
|
||||||
|
# ...
|
||||||
|
# EMIT_EVENT 的 data 结构扩展以支持新事件类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 新增 Action 的请求/响应格式
|
||||||
|
|
||||||
|
以 `EDIT_MESSAGE` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 (Plugin → Runtime)
|
||||||
|
{
|
||||||
|
"action": "edit_message",
|
||||||
|
"seq_id": 12345,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"chat_type": "group",
|
||||||
|
"chat_id": "123456",
|
||||||
|
"message_id": "789",
|
||||||
|
"new_content": [
|
||||||
|
{ "type": "Plain", "text": "edited message" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应 (Runtime → Plugin)
|
||||||
|
{
|
||||||
|
"seq_id": 12345,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以 `GET_GROUP_MEMBER_LIST` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"action": "get_group_member_list",
|
||||||
|
"seq_id": 12346,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"group_id": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{
|
||||||
|
"seq_id": 12346,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"user": { "id": "111", "nickname": "Alice" },
|
||||||
|
"group_id": "123456",
|
||||||
|
"role": "admin",
|
||||||
|
"display_name": "管理员Alice"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以 `CALL_PLATFORM_API` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"action": "call_platform_api",
|
||||||
|
"seq_id": 12347,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"action": "pin_message",
|
||||||
|
"params": {
|
||||||
|
"chat_id": "123456",
|
||||||
|
"message_id": "789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{
|
||||||
|
"seq_id": 12347,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"result": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 LangBot 侧 Handler 实现
|
||||||
|
|
||||||
|
在 `ControlConnectionHandler`(LangBot → Runtime 侧)和 `PluginConnectionHandler`(Runtime → Plugin 侧)中新增对应的 action 处理逻辑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PluginConnectionHandler 中新增
|
||||||
|
async def _handle_edit_message(self, data):
|
||||||
|
bot_uuid = data["bot_uuid"]
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
await bot.adapter.edit_message(
|
||||||
|
chat_type=data["chat_type"],
|
||||||
|
chat_id=data["chat_id"],
|
||||||
|
message_id=data["message_id"],
|
||||||
|
new_content=MessageChain.model_validate(data["new_content"]),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _handle_call_platform_api(self, data):
|
||||||
|
bot_uuid = data["bot_uuid"]
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
result = await bot.adapter.call_platform_api(
|
||||||
|
action=data["action"],
|
||||||
|
params=data.get("params", {}),
|
||||||
|
)
|
||||||
|
return {"result": result}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 插件开发者迁移指南
|
||||||
|
|
||||||
|
### 6.1 无需迁移(零修改运行)
|
||||||
|
|
||||||
|
以下场景的现有插件**不需要任何修改**:
|
||||||
|
|
||||||
|
- 使用 `PersonNormalMessageReceived` / `GroupNormalMessageReceived` 监听消息
|
||||||
|
- 使用 `PersonCommandSent` / `GroupCommandSent` 处理命令
|
||||||
|
- 使用 `ctx.reply()` 回复消息
|
||||||
|
- 使用 `self.send_message()` 主动发消息
|
||||||
|
- 使用 LLM / 存储 / RAG 等现有 API
|
||||||
|
|
||||||
|
### 6.2 推荐迁移(获得新能力)
|
||||||
|
|
||||||
|
如果插件希望利用新功能,可以:
|
||||||
|
|
||||||
|
1. **监听新事件类型**:在 EventListener 中注册新事件类型的 handler
|
||||||
|
2. **使用新 API**:调用 `self.edit_message()`, `self.get_group_info()` 等
|
||||||
|
3. **使用透传 API**:调用 `self.call_platform_api()` 使用平台特有功能
|
||||||
|
|
||||||
|
### 6.3 SDK 版本号
|
||||||
|
|
||||||
|
新功能通过提升 SDK minor 版本发布:
|
||||||
|
|
||||||
|
- 现有版本:`langbot-plugin-sdk >= x.y.z`
|
||||||
|
- 新版本:`langbot-plugin-sdk >= x.(y+1).0`
|
||||||
|
|
||||||
|
插件的 `manifest.yaml` 中的 `min_sdk_version` 决定是否能使用新 API。使用旧 SDK 版本的插件在新 LangBot 上正常运行(兼容层保证),只是无法调用新 API。
|
||||||
429
docs/event-based-agents/06-migration-plan.md
Normal file
429
docs/event-based-agents/06-migration-plan.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# 分阶段迁移计划
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
EBA 架构涉及 langbot-plugin-sdk、LangBot 后端、LangBot 前端、文档和示例插件等多个仓库的改动。为降低风险、保证系统稳定性,采用分阶段渐进式迁移策略。
|
||||||
|
|
||||||
|
### 1.1 阶段总览
|
||||||
|
|
||||||
|
| 阶段 | 名称 | 范围 | 依赖 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Phase 1 | SDK 实体层 | langbot-plugin-sdk | 无 |
|
||||||
|
| Phase 2 | 适配器重构 | LangBot 后端 | Phase 1 |
|
||||||
|
| Phase 3 | 核心系统 | LangBot 后端 | Phase 2 |
|
||||||
|
| Phase 4 | 插件 SDK 集成 | langbot-plugin-sdk + LangBot | Phase 3 |
|
||||||
|
| Phase 5 | WebUI 编排面板 | LangBot 前端 | Phase 3 |
|
||||||
|
| Phase 6 | 文档与示例 | langbot-wiki + langbot-plugin-demo | Phase 4, 5 |
|
||||||
|
|
||||||
|
### 1.2 核心原则
|
||||||
|
|
||||||
|
- **每个阶段结束后系统可运行**:任何阶段完成后,现有功能不受影响
|
||||||
|
- **向后兼容贯穿全程**:旧接口在整个迁移期间保持可用
|
||||||
|
- **先 SDK 后实现**:先定义好接口和模型,再做具体实现
|
||||||
|
- **先核心适配器后边缘**:优先迁移用户量大的适配器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 1:SDK 实体层
|
||||||
|
|
||||||
|
**目标**:在 langbot-plugin-sdk 中定义新的事件体系、通用实体、API 接口和适配器基类。
|
||||||
|
|
||||||
|
**仓库**:`langbot-plugin-sdk`
|
||||||
|
|
||||||
|
### 2.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 文件/模块 | 说明 |
|
||||||
|
|---|------|----------|------|
|
||||||
|
| 1.1 | 定义通用事件基类层次 | `api/entities/builtin/platform/events.py` | 新增 `MessageReceivedEvent`, `MessageEditedEvent`, `GroupMemberJoinedEvent` 等,保留现有 `FriendMessage`/`GroupMessage` |
|
||||||
|
| 1.2 | 定义平台特有事件基类 | `api/entities/builtin/platform/events.py` | 新增 `PlatformSpecificEvent` |
|
||||||
|
| 1.3 | 扩展通用实体 | `api/entities/builtin/platform/entities.py` | 新增 `User`(统一 Friend/GroupMember 的基础)、`Channel` 等,保留现有实体 |
|
||||||
|
| 1.4 | 清理消息组件 | `api/entities/builtin/platform/message.py` | 将 `WeChatMiniPrograms` 等 WeChat 特有组件标记为 platform-specific,不再作为通用组件 |
|
||||||
|
| 1.5 | 定义新适配器基类 | `api/definition/abstract/platform/adapter.py` | 新增 `AbstractPlatformAdapter`(继承现有 `AbstractMessagePlatformAdapter` 并扩展通用 API 方法),保留旧基类 |
|
||||||
|
| 1.6 | 定义 API 能力声明 | `api/definition/abstract/platform/capabilities.py`(新文件) | `AdapterCapabilities` 数据类,声明适配器支持的事件和 API |
|
||||||
|
| 1.7 | 定义 `NotSupportedError` | `api/entities/builtin/platform/errors.py`(新文件) | 可选 API 未实现时抛出的异常 |
|
||||||
|
|
||||||
|
### 2.2 关键设计约束
|
||||||
|
|
||||||
|
- 所有新增定义以**新增文件或新增类**的方式引入,**不修改**现有类的字段和方法签名
|
||||||
|
- 现有 `AbstractMessagePlatformAdapter` 保留不动,新基类 `AbstractPlatformAdapter` 继承它
|
||||||
|
- 新事件类与旧事件类并存,通过 `event_type` 字段(命名空间字符串)区分
|
||||||
|
|
||||||
|
### 2.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 所有新增类可正常 import 且通过类型检查
|
||||||
|
- [ ] 现有 `FriendMessage`, `GroupMessage`, `AbstractMessagePlatformAdapter` 等类行为不变
|
||||||
|
- [ ] 新增单元测试覆盖事件序列化/反序列化、实体构造
|
||||||
|
- [ ] SDK 版本号 minor bump(如 `0.x.0` → `0.x+1.0`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 2:适配器重构
|
||||||
|
|
||||||
|
**目标**:将现有单文件适配器迁移到独立目录结构,实现新事件监听和通用 API。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(后端)
|
||||||
|
|
||||||
|
### 3.1 适配器迁移优先级
|
||||||
|
|
||||||
|
根据用户量和代表性,建议按以下顺序迁移:
|
||||||
|
|
||||||
|
| 优先级 | 适配器 | 理由 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| P0 | **Telegram** | 用户量大,API 最完善,适合作为参考实现 |
|
||||||
|
| P0 | **Discord** | 国际用户主要平台,事件类型丰富 |
|
||||||
|
| P1 | **aiocqhttp**(OneBot v11) | 国内 QQ 用户主要适配器 |
|
||||||
|
| P1 | **Satori** | 通用协议适配器,覆盖多个平台 |
|
||||||
|
| P2 | **Lark** / **DingTalk** / **Slack** | 企业平台,用户量中等 |
|
||||||
|
| P2 | **qqofficial** / **WeChat 系列** | 国内用户 |
|
||||||
|
| P3 | **Kook** / **LINE** / **WeCom 系列** | 用户量较小 |
|
||||||
|
| P3 | **WebSocket** | 内置适配器,相对简单 |
|
||||||
|
| P4 | **legacy/*** | 遗留适配器,按需决定是否迁移或废弃 |
|
||||||
|
|
||||||
|
### 3.2 单个适配器迁移步骤(以 Telegram 为例)
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2.1 | 创建目录结构 | `pkg/platform/adapters/telegram/` 下创建 `__init__.py`, `adapter.py`, `event_converter.py`, `message_converter.py`, `api_impl.py`, `types.py`, `manifest.yaml` |
|
||||||
|
| 2.2 | 迁移消息转换器 | 将 `TelegramMessageConverter` 从 `sources/telegram.py` 搬到 `adapters/telegram/message_converter.py`,逻辑不变 |
|
||||||
|
| 2.3 | 重写事件转换器 | 新的 `TelegramEventConverter` 支持将 Telegram Update 转换为所有通用事件类型(不只是消息),不支持的事件转为 `PlatformSpecificEvent` |
|
||||||
|
| 2.4 | 实现通用 API | 在 `api_impl.py` 中实现 `edit_message`, `delete_message`, `get_group_info` 等 Telegram 支持的通用 API |
|
||||||
|
| 2.5 | 实现透传 API | 在 `adapter.py` 中实现 `call_platform_api`,将 action 映射到 Telegram Bot API 调用 |
|
||||||
|
| 2.6 | 声明能力 | 在 `manifest.yaml` 或适配器类中声明支持的事件和 API 列表 |
|
||||||
|
| 2.7 | 新建 Adapter 主类 | `TelegramAdapter` 继承 `AbstractPlatformAdapter`(新基类),委托各模块实现 |
|
||||||
|
| 2.8 | 更新 manifest.yaml | 更新 `execution.python.path` 指向新位置 |
|
||||||
|
| 2.9 | 验证 | 确保新适配器通过现有消息收发流程的测试 |
|
||||||
|
|
||||||
|
### 3.3 基础设施任务
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2.A | 创建 `adapters/_base/` | 将 SDK 中新基类的运行时辅助代码放在此处(如事件分发辅助函数) |
|
||||||
|
| 2.B | 更新 ComponentDiscovery | 使 `discover_blueprint` 支持扫描 `adapters/` 子目录中的 YAML |
|
||||||
|
| 2.C | 更新 `templates/components.yaml` | 将 `fromDirs` 从 `pkg/platform/sources/` 改为 `pkg/platform/adapters/`(过渡期两个都扫描) |
|
||||||
|
| 2.D | 保留旧 sources/ | 过渡期不删除旧文件,通过 manifest 的 `deprecated: true` 标记 |
|
||||||
|
|
||||||
|
### 3.4 验收标准
|
||||||
|
|
||||||
|
- [ ] 已迁移的适配器在新目录结构下正常启动和收发消息
|
||||||
|
- [ ] 新事件(如 `message.edited`)在支持的平台上正确触发
|
||||||
|
- [ ] 通用 API(如 `edit_message`)在支持的平台上正确执行
|
||||||
|
- [ ] 未迁移的适配器(仍在 `sources/`)继续正常工作
|
||||||
|
- [ ] ComponentDiscovery 同时扫描新旧目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 3:核心系统
|
||||||
|
|
||||||
|
**目标**:实现 EventBus、EventRouter 和事件处理器框架,将事件从适配器分发到不同的处理器。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(后端)
|
||||||
|
|
||||||
|
### 4.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 文件/模块 | 说明 |
|
||||||
|
|---|------|----------|------|
|
||||||
|
| 3.1 | 实现 EventBus | `pkg/platform/event_bus.py`(新文件) | 事件总线:接收适配器事件,进行日志记录,分发给 EventRouter |
|
||||||
|
| 3.2 | 实现 EventRouter | `pkg/platform/event_router.py`(新文件) | 事件路由引擎:读取 Bot 的 `event_handlers` 配置,匹配事件类型,分发到对应 Handler |
|
||||||
|
| 3.3 | 实现 PipelineHandler | `pkg/platform/handlers/pipeline_handler.py` | 将 `message.received` 事件转为现有 Query,进入 Pipeline 流水线 |
|
||||||
|
| 3.4 | 实现 AgentHandler | `pkg/platform/handlers/agent_handler.py` | 直接调用 RequestRunner 处理事件,不经过 Pipeline 多 Stage 流程 |
|
||||||
|
| 3.5 | 实现 WebhookHandler | `pkg/platform/handlers/webhook_handler.py` | 将事件 POST 到外部 URL,解析响应执行动作(重构现有 WebhookPusher) |
|
||||||
|
| 3.6 | 实现 PluginHandler | `pkg/platform/handlers/plugin_handler.py` | 将事件分发给插件 EventListener(复用现有 plugin_connector 机制) |
|
||||||
|
| 3.7 | Bot 实体扩展 | `pkg/entity/persistence/bot.py` | 新增 `event_handlers` JSON 字段 |
|
||||||
|
| 3.8 | 数据库迁移 | `pkg/persistence/migrations/` | 新增迁移脚本:添加 `event_handlers` 列,将现有 `use_pipeline_uuid` 数据迁移为 `event_handlers` 格式 |
|
||||||
|
| 3.9 | 重构 RuntimeBot | `pkg/platform/botmgr.py` | 将 `initialize()` 中硬编码的 `on_friend_message`/`on_group_message` 回调替换为通过 EventBus 分发所有事件 |
|
||||||
|
| 3.10 | 重构 MessageAggregator | `pkg/pipeline/aggregator.py` | 从 RuntimeBot 解耦,作为 PipelineHandler 的内部机制(只对 `message.received` 事件生效) |
|
||||||
|
| 3.11 | Agent Handler 中 RequestRunner 解耦 | `pkg/provider/runner.py` + handlers | RequestRunner 需要能独立于 Pipeline Stage 运行,为 Agent Handler 提供轻量调用路径 |
|
||||||
|
| 3.12 | HTTP API 扩展 | `pkg/api/http/controller/` | 新增/更新 Bot API 端点以支持 `event_handlers` 的 CRUD |
|
||||||
|
|
||||||
|
### 4.2 数据迁移策略
|
||||||
|
|
||||||
|
现有 Bot 表有 `use_pipeline_uuid` 字段,需要自动迁移为 `event_handlers`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 迁移逻辑伪代码
|
||||||
|
for bot in all_bots:
|
||||||
|
if bot.use_pipeline_uuid:
|
||||||
|
bot.event_handlers = [
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": bot.use_pipeline_uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
bot.event_handlers = []
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 RuntimeBot 重构要点
|
||||||
|
|
||||||
|
当前 `RuntimeBot.initialize()` 硬编码注册两个回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 现有代码 (botmgr.py)
|
||||||
|
self.adapter.register_listener(FriendMessage, on_friend_message)
|
||||||
|
self.adapter.register_listener(GroupMessage, on_group_message)
|
||||||
|
```
|
||||||
|
|
||||||
|
重构后改为注册通用事件回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新代码
|
||||||
|
async def on_event(event: Event, adapter: AbstractPlatformAdapter):
|
||||||
|
await self.event_bus.emit(
|
||||||
|
bot_uuid=self.bot_entity.uuid,
|
||||||
|
event=event,
|
||||||
|
adapter=adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册所有事件类型的统一回调
|
||||||
|
self.adapter.register_listener(Event, on_event)
|
||||||
|
```
|
||||||
|
|
||||||
|
EventBus 接收事件后,调用 EventRouter 按配置分发。
|
||||||
|
|
||||||
|
### 4.4 事件处理器执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
EventBus.emit(bot_uuid, event, adapter)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventRouter.route(bot_uuid, event)
|
||||||
|
│ 查询 bot.event_handlers 配置
|
||||||
|
│ 匹配 event_type(精确匹配 > 通配符 *)
|
||||||
|
▼
|
||||||
|
匹配到的 Handler(s)
|
||||||
|
│
|
||||||
|
├── PipelineHandler.handle(event, adapter)
|
||||||
|
│ │ 仅支持 message.received
|
||||||
|
│ │ 构造 Query → MessageAggregator → QueryPool → Pipeline
|
||||||
|
│ └── 沿用现有完整流水线机制
|
||||||
|
│
|
||||||
|
├── AgentHandler.handle(event, adapter)
|
||||||
|
│ │ 根据 handler_config 选择 RequestRunner
|
||||||
|
│ │ 直接调用 runner.run() 处理事件
|
||||||
|
│ └── 将结果通过 adapter API 回复
|
||||||
|
│
|
||||||
|
├── WebhookHandler.handle(event, adapter)
|
||||||
|
│ │ 序列化事件为 JSON
|
||||||
|
│ │ POST 到 handler_config.url
|
||||||
|
│ └── 解析响应,执行动作(回复消息、调用 API 等)
|
||||||
|
│
|
||||||
|
└── PluginHandler.handle(event, adapter)
|
||||||
|
│ 通过 plugin_connector 分发给插件
|
||||||
|
└── 插件 EventListener 处理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 验收标准
|
||||||
|
|
||||||
|
- [ ] `message.received` 事件通过 PipelineHandler 正确进入现有 Pipeline(与旧行为一致)
|
||||||
|
- [ ] 新增事件(如 `group.member_joined`)能通过 PluginHandler 分发给插件
|
||||||
|
- [ ] AgentHandler 能直接调用 RequestRunner(至少 `local-agent`)处理事件并回复
|
||||||
|
- [ ] WebhookHandler 能将事件 POST 到外部 URL
|
||||||
|
- [ ] 数据库迁移正确执行,`use_pipeline_uuid` 数据迁移到 `event_handlers`
|
||||||
|
- [ ] 现有 Bot 在不修改配置的情况下行为不变(自动迁移保证)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 4:插件 SDK 集成
|
||||||
|
|
||||||
|
**目标**:将新事件和 API 通过插件 SDK 暴露给插件开发者,同时实现兼容层。
|
||||||
|
|
||||||
|
**仓库**:`langbot-plugin-sdk` + `LangBot`
|
||||||
|
|
||||||
|
### 5.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 4.1 | 新增插件事件包装 | 在 `api/entities/events.py` 中为每个通用事件新增插件级事件类(如 `MessageEditedReceived`, `MemberJoinedReceived`) |
|
||||||
|
| 4.2 | 兼容层实现 | `PersonMessageReceived` / `GroupMessageReceived` 由新的 `MessageReceivedEvent` 自动生成,旧事件作为新事件的 alias |
|
||||||
|
| 4.3 | 新 API 暴露 | 在 `LangBotAPIProxy` 中新增方法:`edit_message`, `delete_message`, `get_group_info`, `get_user_info`, `call_platform_api` 等 |
|
||||||
|
| 4.4 | 通信协议扩展 | 在 `entities/io/actions/enums.py` 中新增 action 枚举(如 `EDIT_MESSAGE`, `DELETE_MESSAGE`, `GET_GROUP_INFO`, `CALL_PLATFORM_API`) |
|
||||||
|
| 4.5 | Runtime Handler 扩展 | 在 PluginConnectionHandler / ControlConnectionHandler 中添加新 action 的处理逻辑 |
|
||||||
|
| 4.6 | EventListener 扩展 | 确保 `@handler()` 装饰器支持注册新事件类型 |
|
||||||
|
| 4.7 | QueryBasedAPI 扩展 | 在 `QueryBasedAPIProxy` 中新增事件上下文相关的 API(如 `get_event_source_adapter`) |
|
||||||
|
|
||||||
|
### 5.2 兼容层详细设计
|
||||||
|
|
||||||
|
```
|
||||||
|
新事件系统 旧事件系统(兼容层)
|
||||||
|
───────────── ─────────────────
|
||||||
|
MessageReceivedEvent ┌→ PersonMessageReceived (chat_type == "private")
|
||||||
|
(chat_type: "private"|"group") ┤
|
||||||
|
└→ GroupMessageReceived (chat_type == "group")
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现方式**:在 RuntimeEventDispatcher 中,当分发 `MessageReceivedEvent` 给插件时,同时生成对应的旧事件类实例。插件可以用新事件类或旧事件类注册 handler,都能收到。
|
||||||
|
|
||||||
|
### 5.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 现有插件(使用旧事件和 API)无需修改即可运行
|
||||||
|
- [ ] 新插件可以使用新事件类型(如 `MemberJoinedReceived`)注册 handler
|
||||||
|
- [ ] 新 API(如 `edit_message`)可通过 `self.edit_message()` 或 `event_context.edit_message()` 调用
|
||||||
|
- [ ] 透传 API `call_platform_api` 可正常调用适配器特有功能
|
||||||
|
- [ ] 所有新 action 的通信协议正确工作(stdio / WebSocket)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phase 5:WebUI 编排面板
|
||||||
|
|
||||||
|
**目标**:在 WebUI 的 Bot 管理页面实现事件处理器的可视化编排。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(前端 `web/`)
|
||||||
|
|
||||||
|
### 6.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 5.1 | Bot 编辑页面扩展 | 在 Bot 编辑页面新增「事件处理」面板 |
|
||||||
|
| 5.2 | 事件处理器列表组件 | 可视化展示当前 Bot 的 `event_handlers` 列表,支持增删改排序 |
|
||||||
|
| 5.3 | 事件类型选择器 | 下拉选择事件类型(命名空间分组展示),支持通配符 `*` |
|
||||||
|
| 5.4 | Handler 类型选择与配置 | 选择 handler 类型后展示对应的配置表单(Pipeline 选择器、Runner 选择器、Webhook URL 等) |
|
||||||
|
| 5.5 | Pipeline Handler 配置 | 复用现有的 Pipeline 选择 UI(从现有 `use_pipeline_uuid` 选择器迁移) |
|
||||||
|
| 5.6 | Agent Handler 配置 | Runner 选择器(local-agent / dify / n8n / coze 等)+ Runner 参数配置表单 |
|
||||||
|
| 5.7 | Webhook Handler 配置 | URL 输入、认证方式选择、Header 配置 |
|
||||||
|
| 5.8 | Plugin Handler 配置 | 通常无需额外配置,分发给所有匹配的插件 EventListener |
|
||||||
|
| 5.9 | HTTP API 对接 | 前端调用后端 API 保存/读取 `event_handlers` 配置 |
|
||||||
|
| 5.10 | 迁移提示 | 对于从旧版本升级的用户,如果检测到 `use_pipeline_uuid` 已自动迁移,展示提示说明 |
|
||||||
|
|
||||||
|
### 6.2 UI 交互设计概要
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Bot 编辑页面 ─────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 基本信息 │ 适配器配置 │ ★ 事件处理 │ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 事件处理器列表 ────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ① message.received → Pipeline: "主流水线" │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ② group.member_joined → Agent: local-agent │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ③ * (默认) → Plugin │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [+ 添加事件处理器] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [保存] [取消] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 用户可以在 WebUI 上为 Bot 添加/编辑/删除事件处理器
|
||||||
|
- [ ] 四种 Handler 类型均有对应的配置表单
|
||||||
|
- [ ] 配置保存后正确写入数据库 `event_handlers` 字段
|
||||||
|
- [ ] 旧版本升级后,自动迁移的配置在 UI 上正确展示
|
||||||
|
- [ ] Pipeline Handler 的行为与旧的 `use_pipeline_uuid` 完全一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 6:文档与示例
|
||||||
|
|
||||||
|
**目标**:更新所有面向开发者的文档和示例。
|
||||||
|
|
||||||
|
**仓库**:`langbot-wiki`, `langbot-plugin-demo`
|
||||||
|
|
||||||
|
### 7.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 仓库 | 说明 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 6.1 | EBA 架构概览文档 | langbot-wiki | 面向用户的新架构说明 |
|
||||||
|
| 6.2 | 适配器开发指南更新 | langbot-wiki | 如何开发一个新的适配器(新目录结构、新基类、事件转换等) |
|
||||||
|
| 6.3 | 插件开发指南更新 | langbot-wiki | 新事件类型、新 API 的使用说明 |
|
||||||
|
| 6.4 | 插件迁移指南 | langbot-wiki | 现有插件如何迁移到新事件/API(如果需要使用新能力) |
|
||||||
|
| 6.5 | 事件处理器配置指南 | langbot-wiki | WebUI 上如何配置事件处理器 |
|
||||||
|
| 6.6 | 示例插件更新 | langbot-plugin-demo | HelloPlugin 增加新事件监听示例、新 API 调用示例 |
|
||||||
|
| 6.7 | 新示例插件 | langbot-plugin-demo | 新建一个示例展示非消息事件处理(如入群欢迎) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险评估与缓解
|
||||||
|
|
||||||
|
### 8.1 技术风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 适配器迁移中断现有功能 | 高 | 中 | 新旧目录并存,ComponentDiscovery 同时扫描两个目录,逐个适配器迁移验证 |
|
||||||
|
| 事件模型不兼容导致插件崩溃 | 高 | 低 | 兼容层保证旧事件类型继续工作,新增类不修改旧类 |
|
||||||
|
| 数据库迁移失败 | 高 | 低 | 迁移脚本做前置校验,`use_pipeline_uuid` 在过渡期保留不删除 |
|
||||||
|
| RequestRunner 解耦破坏 Pipeline | 高 | 中 | Agent Handler 调用 Runner 的路径独立于 Pipeline,不修改现有 Pipeline Stage 中的 Runner 调用逻辑 |
|
||||||
|
| 性能回退(EventBus 额外开销) | 中 | 低 | EventBus 在进程内同步分发,无额外序列化/网络开销 |
|
||||||
|
| 各平台事件差异大难以统一 | 中 | 中 | 通用事件只抽象最大公约数字段,差异部分保留在 `source_platform_object`;不支持的事件走 `PlatformSpecificEvent` |
|
||||||
|
|
||||||
|
### 8.2 兼容性风险
|
||||||
|
|
||||||
|
| 风险 | 缓解措施 |
|
||||||
|
|------|----------|
|
||||||
|
| 现有插件使用旧事件类 | 兼容层自动将新事件转为旧事件分发,两种事件类都能注册 handler |
|
||||||
|
| 现有插件调用 `reply()` / `send_message()` | 这两个 API 保持不变,只是底层实现可能微调 |
|
||||||
|
| 第三方基于 `AbstractMessagePlatformAdapter` 开发的适配器 | 旧基类保留,新基类继承旧基类,第三方适配器无需立即迁移 |
|
||||||
|
| 用户自定义 Pipeline 配置 | Pipeline 机制完整保留,PipelineHandler 只是入口变了(从 RuntimeBot 硬编码变为 EventRouter 配置) |
|
||||||
|
|
||||||
|
### 8.3 回滚策略
|
||||||
|
|
||||||
|
每个 Phase 独立可回滚:
|
||||||
|
|
||||||
|
- **Phase 1**(SDK 新增类):删除新增文件,回退 SDK 版本号
|
||||||
|
- **Phase 2**(适配器目录):恢复 `components.yaml` 的 `fromDirs` 指向旧目录,旧 sources/ 未删除
|
||||||
|
- **Phase 3**(核心系统):回退数据库迁移,恢复 RuntimeBot 旧的硬编码回调
|
||||||
|
- **Phase 4**(插件集成):回退 SDK 版本,插件使用旧版 SDK
|
||||||
|
- **Phase 5**(WebUI):前端回退,Bot 编辑页面隐藏事件处理面板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 里程碑与时间线建议
|
||||||
|
|
||||||
|
| 里程碑 | 阶段 | 预期产出 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| M1 | Phase 1 完成 | SDK 新版本发布,包含新事件/实体/基类定义 |
|
||||||
|
| M2 | Phase 2 首批适配器(Telegram + Discord) | 两个参考实现,验证目录结构和事件/API 体系 |
|
||||||
|
| M3 | Phase 3 核心系统 | EventBus + EventRouter + 四种 Handler 可用 |
|
||||||
|
| M4 | Phase 2 剩余适配器 | 所有活跃适配器迁移完成 |
|
||||||
|
| M5 | Phase 4 插件集成 | 新 SDK 发布,插件可使用新事件和 API |
|
||||||
|
| M6 | Phase 5 WebUI | 事件处理器编排面板上线 |
|
||||||
|
| M7 | Phase 6 文档 | 开发者文档和示例更新完毕 |
|
||||||
|
|
||||||
|
建议 M1-M3 作为第一个大版本发布(如 v5.0),M4-M7 在后续小版本迭代中完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 开发指引
|
||||||
|
|
||||||
|
### 10.1 分支策略
|
||||||
|
|
||||||
|
建议在主仓库创建 `feature/eba` 长期特性分支,各 Phase 在子分支上开发后合入特性分支:
|
||||||
|
|
||||||
|
```
|
||||||
|
main
|
||||||
|
└── feature/eba
|
||||||
|
├── feature/eba-sdk-entities (Phase 1)
|
||||||
|
├── feature/eba-adapter-telegram (Phase 2)
|
||||||
|
├── feature/eba-adapter-discord (Phase 2)
|
||||||
|
├── feature/eba-core-system (Phase 3)
|
||||||
|
├── feature/eba-plugin-sdk (Phase 4)
|
||||||
|
└── feature/eba-webui (Phase 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 测试策略
|
||||||
|
|
||||||
|
| 层次 | 测试内容 | 工具 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 单元测试 | 事件序列化/反序列化、实体构造、API 调用 mock | pytest |
|
||||||
|
| 集成测试 | EventBus → EventRouter → Handler 全链路 | pytest + asyncio |
|
||||||
|
| 适配器测试 | 各适配器的事件转换、消息转换、API 调用 | pytest + mock SDK |
|
||||||
|
| 端到端测试 | 从模拟平台事件到完整处理流程 | staging 环境 |
|
||||||
|
| 插件兼容性测试 | 旧插件在新系统下的行为 | langbot-plugin-demo |
|
||||||
|
|
||||||
|
### 10.3 代码审查关注点
|
||||||
|
|
||||||
|
- 新增代码是否影响现有行为
|
||||||
|
- 兼容层是否正确映射所有旧事件/API 场景
|
||||||
|
- 数据库迁移是否可逆
|
||||||
|
- 新 API 的错误处理(`NotSupportedError`)是否一致
|
||||||
|
- 事件模型的序列化在 stdio/WebSocket 通信中是否正确
|
||||||
39
docs/event-based-agents/adapters/00-index.md
Normal file
39
docs/event-based-agents/adapters/00-index.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# EBA Adapter Migration Records
|
||||||
|
|
||||||
|
This directory records adapter-level migration details for the Event-Based Agents architecture. Each adapter document should be kept close to the implementation and must answer four questions:
|
||||||
|
|
||||||
|
1. What changed in the adapter structure.
|
||||||
|
2. Which configuration fields are required.
|
||||||
|
3. Which events and APIs are supported.
|
||||||
|
4. What has been verified end to end.
|
||||||
|
|
||||||
|
## Adapter Documents
|
||||||
|
|
||||||
|
General acceptance checklist: [EBA Adapter Acceptance Checklist](./acceptance-checklist.md)
|
||||||
|
|
||||||
|
Current acceptance report: [EBA Adapter Acceptance Report](./acceptance-report.md)
|
||||||
|
|
||||||
|
| Adapter | Status | Document |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Telegram | Migrated; partial plugin E2E, real UI inbound image/file verified | [Telegram](./telegram.md) |
|
||||||
|
| Discord | Migrated; partial plugin E2E, media-inbound gaps remain | [Discord](./discord.md) |
|
||||||
|
| OneBot v11 / aiocqhttp | Migrated; Matcha UI plus protocol-level multi-component coverage | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
|
||||||
|
| DingTalk | Migrated; partial plugin E2E, real UI inbound image/file verified; group gap remains | [DingTalk](./dingtalk.md) |
|
||||||
|
| Lark / Feishu | Migrated; partial live text E2E, media-inbound gap remains | [Lark / Feishu](./lark.md) |
|
||||||
|
| WeCom | Migrated; private text plugin E2E verified, media/group gaps remain | [WeCom](./wecom.md) |
|
||||||
|
| WeComBot | Migrated; private text and outbound/API plugin E2E verified, feedback/group gaps remain | [WeComBot](./wecombot.md) |
|
||||||
|
| Official Account | Migrated; private text plugin E2E verified, proactive outbound not supported | [Official Account](./officialaccount.md) |
|
||||||
|
| QQ Official API | Migrated; WebSocket inbound reached LangBot, model config blocked reply | [QQ Official API](./qqofficial.md) |
|
||||||
|
| Slack | Migrated; private text and outbound/API plugin E2E verified | [Slack](./slack.md) |
|
||||||
|
|
||||||
|
## Documentation Checklist
|
||||||
|
|
||||||
|
When migrating a new adapter, add one document here with:
|
||||||
|
|
||||||
|
- Configuration table matching the adapter manifest.
|
||||||
|
- Supported event list.
|
||||||
|
- Supported common API list.
|
||||||
|
- Supported `call_platform_api` action list.
|
||||||
|
- Known unsupported APIs and the reason.
|
||||||
|
- Live test notes, including platform, channel type, destructive operations, and residual risks.
|
||||||
|
- A clear distinction between real UI inbound media, protocol-level injected inbound media, and bot outbound media.
|
||||||
208
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
208
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# EBA Adapter Acceptance Checklist
|
||||||
|
|
||||||
|
This checklist is the architecture-level acceptance standard for every Event-Based Agents platform adapter. It is not platform-specific. Adapter migration is not complete until the adapter has a written result against this checklist.
|
||||||
|
|
||||||
|
## Evidence Levels
|
||||||
|
|
||||||
|
Use these evidence levels consistently in adapter records:
|
||||||
|
|
||||||
|
| Level | Meaning | Can Mark Complete |
|
||||||
|
|-------|---------|-------------------|
|
||||||
|
| `plugin-e2e-ui` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real platform/simulator UI action. | Yes |
|
||||||
|
| `plugin-e2e-protocol` | Real SDK plugin running through standalone runtime, LangBot core, and the migrated adapter from a protocol-boundary event injection, such as a OneBot reverse WebSocket event. | Partial; must not be claimed as UI coverage |
|
||||||
|
| `plugin-e2e-outbound` | Real SDK plugin calls an API and the bot output is visible in the real platform/simulator UI. | Yes for send/API coverage only |
|
||||||
|
| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only |
|
||||||
|
| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only |
|
||||||
|
| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported |
|
||||||
|
| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No |
|
||||||
|
|
||||||
|
The primary acceptance path must be `plugin-e2e-ui` for inbound UI-triggered behavior and `plugin-e2e-outbound` for bot send/API behavior. `adapter-live`, `plugin-e2e-protocol`, and `unit` tests are useful, but they must be labelled precisely.
|
||||||
|
|
||||||
|
## Required Architecture Path
|
||||||
|
|
||||||
|
Every adapter must prove this full path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Real platform / simulator UI
|
||||||
|
-> platform SDK native event
|
||||||
|
-> adapter event converter
|
||||||
|
-> unified EBA event/entity/message types
|
||||||
|
-> LangBot core event dispatch
|
||||||
|
-> standalone SDK runtime
|
||||||
|
-> real test plugin listener
|
||||||
|
-> plugin calls platform APIs through SDK
|
||||||
|
-> LangBot core API dispatch
|
||||||
|
-> adapter API implementation
|
||||||
|
-> real platform / simulator UI
|
||||||
|
```
|
||||||
|
|
||||||
|
The test plugin must record JSONL evidence containing:
|
||||||
|
|
||||||
|
- event class and `event.type`
|
||||||
|
- `bot_uuid` and `adapter_name` as received by the plugin
|
||||||
|
- adapter name
|
||||||
|
- chat type and chat ID
|
||||||
|
- sender/user/group IDs with secrets redacted
|
||||||
|
- message component list for received messages
|
||||||
|
- API action name, input summary, result or error
|
||||||
|
- raw unsupported/blocked reason when an item is skipped
|
||||||
|
|
||||||
|
## Required Message Receive Tests
|
||||||
|
|
||||||
|
For every adapter, inbound message conversion must be tested through `plugin-e2e-ui` for each component the platform can receive. If a protocol-level injection is used, label it `plugin-e2e-protocol`; it proves the adapter/core/plugin path, but it does not prove that the user-facing platform UI can send that component. If the platform UI/simulator cannot create a component, record it as `blocked` with the endpoint limitation.
|
||||||
|
|
||||||
|
| Component | Required Receive Assertion |
|
||||||
|
|-----------|----------------------------|
|
||||||
|
| `Source` | Message ID and timestamp are present and stable enough for reply/get/delete APIs. |
|
||||||
|
| `Plain` | Text is preserved exactly, including spaces and multi-line content. |
|
||||||
|
| `At` | Mentioned user ID is converted to common `At.target`. |
|
||||||
|
| `AtAll` | Broadcast mention is converted to common `AtAll`, if platform supports it. |
|
||||||
|
| `Image` | Image ID, URL, path, or base64 is represented without leaking platform-native segment shape. |
|
||||||
|
| `Voice` | Voice/audio component is represented as `Voice` when the platform exposes it. |
|
||||||
|
| `File` | File name, ID/URL, and size are represented as `File` when available. |
|
||||||
|
| `Quote` | Reply/quote source ID and origin content are represented when the platform exposes it. |
|
||||||
|
| `Face` | Native emoji/sticker/dice/rps-like components are represented as `Face` or documented as platform-specific. |
|
||||||
|
| `Forward` | Merged/forwarded messages are represented as `Forward` when the platform exposes structured content. |
|
||||||
|
| `Unknown` | Unsupported native segments become `Unknown` or `PlatformSpecificEvent` data, not crashes. |
|
||||||
|
| Mixed chain | A message containing multiple component types preserves order. |
|
||||||
|
|
||||||
|
The plugin must subscribe to `MessageReceivedEvent` and assert that `message_chain` contains common `langbot_plugin.api.entities.builtin.platform.message` components, not platform-native SDK objects.
|
||||||
|
|
||||||
|
## Required Message Send Tests
|
||||||
|
|
||||||
|
For every adapter, outbound message conversion must be tested through `plugin-e2e-outbound` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
|
||||||
|
|
||||||
|
| Component | Required Send Assertion |
|
||||||
|
|-----------|-------------------------|
|
||||||
|
| `Plain` | Text appears exactly on the platform. |
|
||||||
|
| `At` | User mention renders as a mention or platform equivalent. |
|
||||||
|
| `AtAll` | Broadcast mention renders or is explicitly unsupported. |
|
||||||
|
| `Image` | URL, path, or base64 image sends and renders/downloads correctly. |
|
||||||
|
| `Voice` | Voice/audio sends when supported. |
|
||||||
|
| `File` | File sends with name and content/link when supported. |
|
||||||
|
| `Quote` | Quoted reply points to the original message when supported. |
|
||||||
|
| `Face` | Native emoji/sticker/dice/rps sends or is explicitly unsupported. |
|
||||||
|
| `Forward` | Forward/merged-forward sends when supported; otherwise fallback behavior is documented. |
|
||||||
|
| Mixed chain | A mixed chain preserves component order as closely as the platform allows. |
|
||||||
|
|
||||||
|
If a platform supports a component only in one direction, the adapter record must say so explicitly.
|
||||||
|
|
||||||
|
## Required Event Tests
|
||||||
|
|
||||||
|
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e-ui`, `plugin-e2e-protocol`, `not-supported`, or `blocked`.
|
||||||
|
|
||||||
|
| Event | Required Assertion |
|
||||||
|
|-------|--------------------|
|
||||||
|
| `message.received` | Real message reaches plugin as `MessageReceivedEvent`. |
|
||||||
|
| `message.edited` | Edited message reaches plugin with message ID and new content, if declared. |
|
||||||
|
| `message.deleted` | Deleted/recalled message reaches plugin with message ID and operator when available, if declared. |
|
||||||
|
| `message.reaction` | Reaction add/remove reaches plugin with message ID, user, reaction, and direction, if declared. |
|
||||||
|
| `feedback.received` | Feedback payload reaches plugin with feedback type and message/session IDs, if declared. |
|
||||||
|
| `group.member_joined` | Join event reaches plugin with group and member. |
|
||||||
|
| `group.member_left` | Leave/kick event reaches plugin with group, member, and kick flag. |
|
||||||
|
| `group.member_banned` | Mute/ban event reaches plugin with group, member, operator, and duration. |
|
||||||
|
| `group.info_updated` | Group metadata update reaches plugin with changed fields, if declared. |
|
||||||
|
| `friend.request_received` | Friend request reaches plugin with request ID and message. |
|
||||||
|
| `friend.added` | Friend-added event reaches plugin. |
|
||||||
|
| `friend.removed` | Friend-removed event reaches plugin, if declared. |
|
||||||
|
| `bot.invited_to_group` | Bot invite/join request reaches plugin with group and inviter/request ID. |
|
||||||
|
| `bot.removed_from_group` | Bot removal reaches plugin with group and operator when available. |
|
||||||
|
| `bot.muted` | Bot mute reaches plugin with duration. |
|
||||||
|
| `bot.unmuted` | Bot unmute reaches plugin. |
|
||||||
|
| `platform.specific` | At least one unmapped native event is delivered as structured platform-specific data, if declared. |
|
||||||
|
|
||||||
|
Do not declare an event in the manifest unless there is an implementation path and an acceptance entry.
|
||||||
|
|
||||||
|
## Required Common API Tests
|
||||||
|
|
||||||
|
The plugin must call every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. Each call must be recorded with input summary and result.
|
||||||
|
|
||||||
|
| API | Required Assertion |
|
||||||
|
|-----|--------------------|
|
||||||
|
| `send_message` | Plugin sends to private and group/channel targets where supported. |
|
||||||
|
| `reply_message` | Plugin replies to the triggering message, with quoted mode tested when supported. |
|
||||||
|
| `edit_message` | Plugin edits a bot-sent message, if declared. |
|
||||||
|
| `delete_message` | Plugin deletes/recalls a bot-sent message, if declared and permissions allow. |
|
||||||
|
| `forward_message` | Plugin forwards or emulates forwarding a real message, if declared. |
|
||||||
|
| `get_message` | Plugin retrieves a real message and receives common `MessageReceivedEvent` shape. |
|
||||||
|
| `get_group_info` | Plugin receives `UserGroup` with ID/name/count where available. |
|
||||||
|
| `get_group_list` | Plugin receives joined groups/channels list where supported. |
|
||||||
|
| `get_group_member_list` | Plugin receives list of `UserGroupMember` where supported. |
|
||||||
|
| `get_group_member_info` | Plugin receives one member with role/display name where available. |
|
||||||
|
| `set_group_name` | Plugin changes and restores a disposable group name, if declared. |
|
||||||
|
| `mute_member` | Plugin mutes a disposable target, if declared. |
|
||||||
|
| `unmute_member` | Plugin unmutes the same target, if declared. |
|
||||||
|
| `kick_member` | Plugin kicks a disposable target only in destructive test mode, if declared. |
|
||||||
|
| `leave_group` | Plugin leaves only in destructive test mode and only at the end, if declared. |
|
||||||
|
| `get_user_info` | Plugin receives common `User` shape. |
|
||||||
|
| `get_friend_list` | Plugin receives friend/contact list where supported. |
|
||||||
|
| `approve_friend_request` | Plugin accepts/rejects a disposable friend request, if declared. |
|
||||||
|
| `approve_group_invite` | Plugin accepts/rejects a disposable group invite, if declared. |
|
||||||
|
| `upload_file` | Plugin uploads a real small file, if declared. |
|
||||||
|
| `get_file_url` | Plugin resolves a real file ID to a URL, if declared. |
|
||||||
|
| `call_platform_api` | Plugin calls every declared platform-specific action with safe parameters. |
|
||||||
|
|
||||||
|
Destructive APIs must be opt-in and documented with the exact target used.
|
||||||
|
|
||||||
|
The SDK must expose a plugin-side platform API escape hatch for adapter-specific actions. The acceptance plugin should call it from the same EBA event handler that received the real platform event, so the evidence proves both directions of the path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugin -> SDK call_platform_api -> LangBot core -> adapter call_platform_api -> platform SDK/API
|
||||||
|
```
|
||||||
|
|
||||||
|
The result must be serialized into JSON-safe values before it is returned to the plugin runtime.
|
||||||
|
|
||||||
|
## Platform-Specific API Tests
|
||||||
|
|
||||||
|
Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui` or `plugin-e2e-outbound`: called by the plugin against the live/simulator endpoint.
|
||||||
|
- `plugin-e2e-protocol`: called by the plugin after a protocol-boundary injected event; useful for endpoint-specific simulators but must be labelled.
|
||||||
|
- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not.
|
||||||
|
- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable.
|
||||||
|
|
||||||
|
Do not leave a platform-specific API in the manifest without a corresponding test record.
|
||||||
|
|
||||||
|
## Required Compatibility Tests
|
||||||
|
|
||||||
|
Each migrated adapter must also prove:
|
||||||
|
|
||||||
|
- Manifest supported events match `adapter.get_supported_events()`.
|
||||||
|
- Manifest supported APIs match `adapter.get_supported_apis()`.
|
||||||
|
- Manifest platform-specific actions match `PLATFORM_API_MAP`.
|
||||||
|
- Legacy `FriendMessage` / `GroupMessage` listeners still work when the core registers them.
|
||||||
|
- EBA listener dispatch prefers the most specific event class, then `EBAEvent`, then base `Event`.
|
||||||
|
- Self-message filtering prevents bot echo loops without dropping edit/delete/moderation events needed for API tests.
|
||||||
|
- `source_platform_object` is present for reply/debug but not required by plugins for common behavior.
|
||||||
|
|
||||||
|
## Required Documentation Per Adapter
|
||||||
|
|
||||||
|
Each adapter document must include:
|
||||||
|
|
||||||
|
- adapter directory and manifest name
|
||||||
|
- config table
|
||||||
|
- supported event table with evidence level per event
|
||||||
|
- supported common API table with evidence level per API
|
||||||
|
- platform-specific API table with evidence level per action
|
||||||
|
- receive component table with evidence level per component
|
||||||
|
- send component table with evidence level per component
|
||||||
|
- exact test date
|
||||||
|
- exact platform endpoint or simulator used
|
||||||
|
- standalone runtime command
|
||||||
|
- plugin path/name used for testing
|
||||||
|
- evidence JSONL path
|
||||||
|
- destructive operations performed or explicitly skipped
|
||||||
|
- blocked items and reasons
|
||||||
|
|
||||||
|
## Acceptance Rule
|
||||||
|
|
||||||
|
An adapter can be marked migrated only when:
|
||||||
|
|
||||||
|
1. All declared events have `plugin-e2e-ui`, justified `plugin-e2e-protocol`, or `not-supported` evidence.
|
||||||
|
2. All declared APIs have `plugin-e2e-outbound` or `not-supported` evidence.
|
||||||
|
3. All platform-supported receive components have `plugin-e2e-ui` evidence; protocol-only receive coverage keeps the status partial.
|
||||||
|
4. All platform-supported send components have `plugin-e2e-outbound` evidence.
|
||||||
|
5. Unit tests cover conversion and API-shape boundaries.
|
||||||
|
6. The adapter document lists every blocked or skipped item honestly.
|
||||||
|
|
||||||
|
If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial.
|
||||||
171
docs/event-based-agents/adapters/acceptance-report.md
Normal file
171
docs/event-based-agents/adapters/acceptance-report.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# EBA Adapter Acceptance Report
|
||||||
|
|
||||||
|
Date: May 10, 2026
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- `telegram-eba`
|
||||||
|
- `discord-eba`
|
||||||
|
- `aiocqhttp-eba`
|
||||||
|
- `dingtalk-eba`
|
||||||
|
- `lark-eba`
|
||||||
|
- `wecom-eba`
|
||||||
|
- `wecombot-eba`
|
||||||
|
- `wecomcs-eba`
|
||||||
|
- `officialaccount-eba`
|
||||||
|
- `qqofficial-eba`
|
||||||
|
- `slack-eba`
|
||||||
|
|
||||||
|
This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui`: real platform or simulator UI event reached LangBot, standalone runtime, and `EBAEventProbe`.
|
||||||
|
- `plugin-e2e-protocol`: real adapter endpoint event reached LangBot, standalone runtime, and `EBAEventProbe`, but the event was injected at the platform protocol boundary rather than sent through the UI.
|
||||||
|
- `plugin-e2e-outbound`: the plugin called SDK APIs and the resulting bot message was visible on the platform.
|
||||||
|
- `unit`: mocked converter/API coverage only.
|
||||||
|
- `blocked`: not completed, either because the platform/simulator/client could not trigger it or because a safe disposable fixture was unavailable.
|
||||||
|
- `not-supported`: the platform has no equivalent capability.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Adapter | Status | Honest acceptance summary |
|
||||||
|
|---------|--------|---------------------------|
|
||||||
|
| Telegram | Partial EBA acceptance | Real Telegram UI covered private text, group mention text, bot invite, inbound private image/file, outbound component sweep, safe SDK APIs, and safe Telegram platform APIs. Real UI inbound voice/quote was not completed in the latest plugin run. |
|
||||||
|
| Discord | Partial EBA acceptance | Real Discord UI covered group text, outbound image/file/quote/mention components, safe SDK APIs, and safe Discord platform APIs. Real UI inbound attachment/image/file/reply/mention was not completed. A later UI retry was blocked because the Discord client kept the send button disabled. |
|
||||||
|
| OneBot v11 / aiocqhttp | Partial EBA acceptance | Matcha UI covered real group text and outbound supported components/APIs. Multi-component inbound `Source/Plain/At/Face/Image/Voice/File/Quote` was verified through the real OneBot reverse WebSocket adapter endpoint, but not through Matcha UI upload/send. Matcha blocks file-send and merged-forward APIs. |
|
||||||
|
| DingTalk | Partial EBA acceptance | Real DingTalk UI covered private text, emoji-as-text inbound, private inbound image/file, outbound image/file/quote/mention fallback components, safe SDK APIs, and safe DingTalk platform APIs. Real UI inbound voice/quote and group trigger were not completed. |
|
||||||
|
| Lark / Feishu | Partial EBA acceptance | EBA adapter structure, self-built/store app config, WebSocket/Webhook mode handling, converters, common APIs, platform APIs, and unit tests are in place. One real LangBot organization WebSocket private text event reached `EBAEventProbe`; outbound component sweep was visible in Feishu. Latest real UI image/file sends did not reach local plugin evidence, so media receive remains blocked. |
|
||||||
|
| WeCom | Partial EBA acceptance | Regular WeCom application-message adapter is split into the EBA directory with manifest, converters, API mixin, platform API map, and unit tests. Private text reached `EBAEventProbe` through standalone runtime and the real WeCom client; safe plugin APIs passed. Real inbound media and broader event coverage remain pending. |
|
||||||
|
| WeComBot | Partial EBA acceptance | WeCom AI Bot is split into the EBA directory with WebSocket long connection mode and optional webhook mode, EBA message/feedback/platform-specific conversion, cache-backed common APIs, platform API map, unit tests, and a direct live probe. Private text, outbound component sweep, safe common APIs, and all declared WeComBot platform APIs reached `EBAEventProbe`; group, real inbound media, and feedback callback evidence remain pending. |
|
||||||
|
| WeCom Customer Service | Partial EBA acceptance | WeCom Customer Service is split into the EBA directory with manifest, converters, API mixin, platform API map, unit tests, docs, and a direct live probe scaffold. Real WeChat customer-side UI text reached `EBAEventProbe`; plugin outbound text/image and safe cache-backed common APIs passed. Inbound media and platform-specific API live coverage remain pending; later fallback text sends were blocked by WeCom `95001 send msg count limit`. |
|
||||||
|
| Official Account | Partial EBA acceptance | WeChat Official Account is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, and a direct live probe scaffold. Real WeChat Official Account UI private text reached `EBAEventProbe`; safe cache-backed common APIs and declared platform APIs passed. Proactive outbound `send_message` is not supported because replies must be tied to inbound webhook windows; inbound image/voice live UI evidence remains pending. |
|
||||||
|
| QQ Official API | Partial EBA acceptance | QQ Official API is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; reply/outbound evidence is blocked by the test model provider returning `model_not_found` for `deepseek-v3`. |
|
||||||
|
| Slack | Partial EBA acceptance | Slack is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. Real Slack private text reached `EBAEventProbe`; safe common APIs, outbound component fallback sweep, and declared Slack platform APIs passed. Channel mention and real inbound media evidence remain pending. |
|
||||||
|
|
||||||
|
Telegram and DingTalk now have real user-side UI image/file upload evidence in plugin JSONL. Discord and aiocqhttp do not yet have real UI inbound image/file evidence.
|
||||||
|
|
||||||
|
## Evidence Files
|
||||||
|
|
||||||
|
| Adapter | Endpoint | Evidence |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| Telegram private | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-rerun.jsonl` |
|
||||||
|
| Telegram private media | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-media-ui.jsonl` |
|
||||||
|
| Telegram group | Telegram Lite, `Rock'sBotGroup` | `data/temp/telegram-plugin-e2e-group.jsonl` |
|
||||||
|
| Discord | Discord client, LangBot server, `#debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` |
|
||||||
|
| aiocqhttp UI | local Matcha, group `test group` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||||
|
| aiocqhttp protocol | OneBot reverse WebSocket endpoint `127.0.0.1:2280/ws` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||||
|
| DingTalk | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` |
|
||||||
|
| DingTalk private media | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-media-ui.jsonl` |
|
||||||
|
| Lark / Feishu unit | local mocked Feishu SDK/client paths | `tests/unit_tests/platform/test_lark_eba_adapter.py` |
|
||||||
|
| Lark / Feishu partial live | Feishu Mac, LangBot organization `LangBotDev` private chat | `data/temp/lark-plugin-e2e-ws.jsonl` |
|
||||||
|
| WeCom Customer Service | WeChat customer-side UI, `客服消息 -> 浪波智能客服` on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` |
|
||||||
|
| Official Account | WeChat desktop client, subscribed Official Account on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` |
|
||||||
|
| QQ Official API unit | local mocked QQ Official client paths | `tests/unit_tests/platform/test_qqofficial_eba_adapter.py` |
|
||||||
|
| Slack unit | local mocked Slack client paths | `tests/unit_tests/platform/test_slack_eba_adapter.py` |
|
||||||
|
| Slack private | Slack workspace private DM on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl` |
|
||||||
|
|
||||||
|
All plugin runs used SDK standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the real plugin at `langbot-plugin-demo/EBAEventProbe`.
|
||||||
|
|
||||||
|
## Unified Shape Verification
|
||||||
|
|
||||||
|
All four adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event.
|
||||||
|
|
||||||
|
| Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-------------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending |
|
||||||
|
| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-eba` in current unit/code; older live text evidence recorded `lark` before the naming fix |
|
||||||
|
| common `MessageChain` delivered | `Plain`, group `At + Plain`, private `Image`, private `File` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain`, private `Source + Image`, private `Source + File` | live private `Source + Plain`; unit `Source + Plain + At/Image/File`; latest live image/file blocked |
|
||||||
|
| common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed | live private user; unit private/group |
|
||||||
|
| raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` |
|
||||||
|
|
||||||
|
## Message Receive Components
|
||||||
|
|
||||||
|
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-----------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `Source` | design gap: event has message id but chain omits `Source` | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||||
|
| `Plain` | plugin-e2e-ui private/group | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||||
|
| `At` | plugin-e2e-ui group mention | unit; real UI mention not completed in latest run | plugin-e2e-protocol; unit | unit; group trigger not completed | unit; group trigger not completed |
|
||||||
|
| `AtAll` | not-supported | unit only | unit only | unit/send fallback only | unit only |
|
||||||
|
| `Image` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI image sent but not observed in plugin evidence |
|
||||||
|
| `Voice` | converter/unit; real UI inbound not completed | not-supported as native voice; audio is attachment/file | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed | unit; real UI inbound not completed |
|
||||||
|
| `File` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI file sent but not observed in plugin evidence |
|
||||||
|
| `Quote` | converter/unit; real UI reply not completed | unit; real UI reply not completed | plugin-e2e-protocol | converter/unit; real UI quote not completed | unit/API-backed quote lookup; real UI quote not completed |
|
||||||
|
| `Face` | not-supported as common `Face` | not-supported as common `Face` | plugin-e2e-protocol | UI emoji becomes `Plain` (`[smile]` text), not `Face` | not-supported as common `Face` |
|
||||||
|
| `Forward` | not-supported inbound | not-supported inbound | unit; Matcha forward UI/action blocked | not-supported inbound | not-supported inbound |
|
||||||
|
| Mixed chain | group `At + Plain`; media tested as separate messages | not completed inbound | plugin-e2e-protocol | media tested as separate messages; mixed inbound not completed | unit only |
|
||||||
|
|
||||||
|
## Message Send Components
|
||||||
|
|
||||||
|
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-----------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `Plain` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `At` | plugin-e2e-outbound equivalent | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback/equivalent | plugin-e2e-outbound |
|
||||||
|
| `AtAll` | plugin-e2e-outbound fallback | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | unit; group live not completed |
|
||||||
|
| `Image` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `Voice` | not-supported in current send converter | not-supported as native voice | converter path; not completed against Matcha UI | fallback as file/text depending DingTalk media support | converter path; live not completed |
|
||||||
|
| `File` | plugin-e2e-outbound | plugin-e2e-outbound | blocked by Matcha endpoint error | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `Quote` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | plugin-e2e-outbound fallback |
|
||||||
|
| `Face` | not-supported | not-supported | plugin-e2e-outbound attempted in mixed chain | fallback text | not-supported |
|
||||||
|
| `Forward` | flattened fallback | flattened fallback | blocked by Matcha unsupported action | flattened fallback | plugin-e2e-outbound flattened fallback |
|
||||||
|
| Mixed chain | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound except blocked file/forward | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
|
||||||
|
## Event Acceptance
|
||||||
|
|
||||||
|
| Event category | Telegram | Discord | aiocqhttp | DingTalk |
|
||||||
|
|----------------|----------|---------|-----------|----------|
|
||||||
|
| `message.received` | plugin-e2e-ui | plugin-e2e-ui | plugin-e2e-ui and plugin-e2e-protocol | plugin-e2e-ui private |
|
||||||
|
| `message.edited` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||||
|
| `message.deleted` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||||
|
| `message.reaction` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | not-supported in standard OneBot message path | not declared |
|
||||||
|
| member join/left/ban | implemented/unit or blocked without disposable users | blocked without disposable users | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| bot invited/removed | invite plugin-e2e-ui for Telegram; removal blocked | invite historical/plugin-series; removal blocked | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| requests/friend events | not applicable | not applicable | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| `platform.specific` | implemented; not latest plugin-e2e | not latest plugin-e2e | adapter lifecycle observed; plugin focus was message path | declared for fallback; not reproduced in UI run |
|
||||||
|
|
||||||
|
## Common API Acceptance
|
||||||
|
|
||||||
|
| API area | Telegram | Discord | aiocqhttp | DingTalk |
|
||||||
|
|----------|----------|---------|-----------|----------|
|
||||||
|
| send/reply | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound, with Matcha file/forward gaps | plugin-e2e-outbound |
|
||||||
|
| edit/delete | historical/direct or unit; destructive/current UI not repeated | historical/direct; destructive/current UI not repeated | unit/destructive blocked | not declared or blocked |
|
||||||
|
| message lookup | not-supported | not-supported | plugin-e2e | inbound cache-backed where available; limited live coverage |
|
||||||
|
| group info/member info | plugin-e2e safe subset | plugin-e2e safe subset | plugin-e2e safe subset | private path only; group not completed |
|
||||||
|
| user/friend info | plugin-e2e where platform allows | plugin-e2e where platform allows | plugin-e2e | plugin-e2e private user |
|
||||||
|
| moderation/leave | blocked without disposable safe targets | blocked without disposable safe targets | blocked without disposable safe targets | blocked/not declared |
|
||||||
|
| `get_file_url` | implemented; latest inbound `File` carried downloadable file data in plugin evidence | URL passthrough for attachments; inbound attachment not completed | not portable/endpoint-dependent | implemented through DingTalk media API; latest inbound `File` carried a platform file URL |
|
||||||
|
| `call_platform_api` | plugin-e2e safe actions | plugin-e2e safe actions | plugin-e2e safe actions, Matcha gaps documented | plugin-e2e safe `check_access_token` |
|
||||||
|
|
||||||
|
## Platform-Specific API Acceptance
|
||||||
|
|
||||||
|
| Adapter | plugin-e2e verified | Blocked or not reproduced |
|
||||||
|
|---------|---------------------|---------------------------|
|
||||||
|
| Telegram | safe chat/admin/member count/chat-action actions | mutating actions and callback-only actions were not repeated |
|
||||||
|
| Discord | safe channel/guild/role/typing actions | mutating pin/reaction/invite actions were not repeated in the latest plugin run; inbound attachment paths not completed |
|
||||||
|
| aiocqhttp | safe OneBot actions such as status/version/can-send checks | `get_group_honor_info` unsupported by Matcha; admin/card/title/ban/record/file/forward require better endpoint fixtures |
|
||||||
|
| DingTalk | `check_access_token`; real inbound file produced a file URL in the common `File` component | separate media-download replay APIs and group actions need a working follow-up fixture |
|
||||||
|
|
||||||
|
## SDK API Acceptance
|
||||||
|
|
||||||
|
`EBAEventProbe` exercised the standalone runtime path for:
|
||||||
|
|
||||||
|
- bot discovery and bot info lookup
|
||||||
|
- send message
|
||||||
|
- component sweep where enabled
|
||||||
|
- platform API sweep where enabled
|
||||||
|
- plugin storage
|
||||||
|
- workspace storage
|
||||||
|
- plugin/command/tool/knowledge-base list APIs
|
||||||
|
|
||||||
|
The probe logs set `ok=true` when the sweep completed with only expected unsupported/blocked items. Individual call details are stored in the JSONL evidence files.
|
||||||
|
|
||||||
|
## Residual Risks And Required Follow-Up
|
||||||
|
|
||||||
|
- Discord still requires real UI inbound image/file upload evidence before it can be called media-complete.
|
||||||
|
- aiocqhttp has rich inbound component evidence only at the OneBot reverse WebSocket boundary; Matcha UI did not provide image/file upload coverage.
|
||||||
|
- DingTalk group trigger remains unclosed; current evidence is private chat only.
|
||||||
|
- Lark / Feishu requires a clean follow-up live pass: the latest LangBot organization WebSocket run connected, but UI-sent text/image/file after the loop-scheduling fix did not append plugin events.
|
||||||
|
- Discord UI retry on May 10, 2026 was blocked by the client keeping the send button disabled even after text was entered.
|
||||||
|
- Destructive moderation and leave APIs are intentionally blocked until disposable users/groups are available.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The EBA conversion path is implemented and partially proven for the migrated adapters. Telegram and DingTalk now have real UI private-chat image/file inbound evidence. Discord, aiocqhttp, and Lark / Feishu still have explicit UI-level media gaps, so the overall adapter set remains partial acceptance rather than production-complete media acceptance.
|
||||||
162
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
162
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# OneBot v11 / aiocqhttp EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
OneBot v11 has been migrated to the EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/aiocqhttp/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
├── types.py
|
||||||
|
└── onebot.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `host` | Yes | `0.0.0.0` | Host for the reverse WebSocket server that the OneBot endpoint connects to. |
|
||||||
|
| `port` | Yes | `2280` | Reverse WebSocket listen port. |
|
||||||
|
| `access-token` | No | `""` | OneBot access token, if the endpoint is configured to use one. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
The adapter declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.deleted`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `friend.request_received`
|
||||||
|
- `friend.added`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `bot.muted`
|
||||||
|
- `bot.unmuted`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`platform.specific` is used for OneBot notice/request/meta events that do not yet have a common EBA event type, such as group admin changes, group file uploads, pokes, honor changes, and group join requests from non-bot users.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Supports private and group text, mentions, images, voice, files, faces, and flattened forwards. Group merged forwards are sent through OneBot forward APIs when possible. |
|
||||||
|
| `reply_message` | Supported | Uses the original OneBot event and can prepend a reply segment. |
|
||||||
|
| `edit_message` | Not supported | OneBot v11 has no standard message edit action. |
|
||||||
|
| `delete_message` | Supported | Uses `delete_msg`; permission depends on endpoint and group role. |
|
||||||
|
| `forward_message` | Supported | Emulates forward by fetching the source message with `get_msg` and sending its content to the target chat. |
|
||||||
|
| `get_message` | Supported | Uses `get_msg` and converts the response into `MessageReceivedEvent`. |
|
||||||
|
| `get_group_info` | Supported | Uses `get_group_info`. |
|
||||||
|
| `get_group_list` | Supported | Uses `get_group_list`. |
|
||||||
|
| `get_group_member_list` | Supported | Uses `get_group_member_list`. |
|
||||||
|
| `get_group_member_info` | Supported | Uses `get_group_member_info`. |
|
||||||
|
| `set_group_name` | Supported | Uses `set_group_name`; may be unsupported by mock endpoints. |
|
||||||
|
| `get_user_info` | Supported | Uses `get_stranger_info`. |
|
||||||
|
| `get_friend_list` | Supported | Uses `get_friend_list`. |
|
||||||
|
| `approve_friend_request` | Supported | Uses `set_friend_add_request`. |
|
||||||
|
| `approve_group_invite` | Supported | Uses `set_group_add_request` with `sub_type=invite`. |
|
||||||
|
| `upload_file` | Not supported | OneBot v11 has endpoint-specific file upload extensions but no portable standalone upload action. |
|
||||||
|
| `get_file_url` | Not supported | OneBot v11 file URL resolution is endpoint-specific. Use `call_platform_api("get_image")`, `get_record`, or endpoint extensions when available. |
|
||||||
|
| `mute_member` | Supported | Uses `set_group_ban`. |
|
||||||
|
| `unmute_member` | Supported | Uses `set_group_ban` with duration `0`. |
|
||||||
|
| `kick_member` | Supported | Destructive; test only with disposable members. |
|
||||||
|
| `leave_group` | Supported | Destructive; should run last in live tests. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `get_login_info`
|
||||||
|
- `get_status`
|
||||||
|
- `get_version_info`
|
||||||
|
- `get_group_honor_info`
|
||||||
|
- `set_group_card`
|
||||||
|
- `set_group_special_title`
|
||||||
|
- `set_group_admin`
|
||||||
|
- `set_group_whole_ban`
|
||||||
|
- `send_group_forward_msg`
|
||||||
|
- `get_forward_msg`
|
||||||
|
- `get_record`
|
||||||
|
- `get_image`
|
||||||
|
- `can_send_image`
|
||||||
|
- `can_send_record`
|
||||||
|
|
||||||
|
## Message Conversion Notes
|
||||||
|
|
||||||
|
Incoming OneBot segments are converted into common `MessageChain` components before LangBot core/plugin dispatch:
|
||||||
|
|
||||||
|
- `text` -> `Plain`
|
||||||
|
- `at` -> `At` / `AtAll`
|
||||||
|
- `image` -> `Image` or `Face` for OneBot emoji-package images
|
||||||
|
- `record` -> `Voice`
|
||||||
|
- `file` -> `File`
|
||||||
|
- `reply` -> `Quote`
|
||||||
|
- `face`, `rps`, `dice` -> `Face`
|
||||||
|
- unsupported segments -> `Unknown`
|
||||||
|
|
||||||
|
Outgoing `MessageChain` components are converted back into `aiocqhttp.Message` segments. Base64 media strings are normalized to OneBot `base64://...` format.
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The direct live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/qinjunyan/code/projects/langbot/langbot-plugin-sdk/src \
|
||||||
|
uv run python tests/e2e/live_aiocqhttp_eba_probe.py --host 127.0.0.1 --port 2280
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts the reverse WebSocket adapter directly, records observed EBA events to `data/temp/aiocqhttp_eba_live_probe.jsonl`, waits for a real Matcha or OneBot message, then tries reply/send/get/delete/group/user/platform API calls as far as the endpoint supports them.
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with local Matcha connected to `ws://127.0.0.1:2280/ws`:
|
||||||
|
|
||||||
|
- Real inbound group message converted to `MessageReceivedEvent`.
|
||||||
|
- Real lifecycle connection converted to `PlatformSpecificEvent`.
|
||||||
|
- Real reply API succeeded and rendered a quoted bot reply in Matcha.
|
||||||
|
- Real proactive send API succeeded and rendered a bot group message in Matcha.
|
||||||
|
- Real outgoing component sweep succeeded for text, `At`, `AtAll`, `Face`, and base64 `Image`.
|
||||||
|
- Real `get_message`, `get_group_info`, `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record` calls succeeded against Matcha.
|
||||||
|
- Unit conversion and API-shape tests passed for `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `rps`, `dice`, `Forward`, `Unknown`, private/group message events, delete notices, group join/leave/ban notices, bot mute notices, friend requests, group invites, friend added notices, dispatch specificity, send, reply, delete, forward, get message, group APIs, user APIs, request approval APIs, moderation APIs, leave group, unsupported file APIs, and all declared `call_platform_api` actions.
|
||||||
|
|
||||||
|
Skipped or residual live-test items:
|
||||||
|
|
||||||
|
- `edit_message`: not implemented because OneBot v11 has no standard edit action.
|
||||||
|
- `upload_file` and `get_file_url`: not implemented as common APIs because portable OneBot v11 file upload/download URL semantics are endpoint-specific.
|
||||||
|
- `kick_member` and `leave_group`: destructive; run only with explicit `--destructive` and disposable Matcha/OneBot state.
|
||||||
|
- `group.info_updated`, message reactions, and message edits are not declared because OneBot v11 does not provide standard equivalents for them.
|
||||||
|
- Matcha returned `ActionFailed` for outgoing `File` segment rendering and did not support merged-forward actions in this run. The adapter keeps the conversion/API implementations because they are valid OneBot/NapCat-style capabilities, but the Matcha live probe records them as skipped.
|
||||||
|
- Matcha returned an empty `get_group_member_list` for the test group, so `get_group_member_info`, mute/unmute, kick, and leave were covered by unit/API-shape tests only in this run.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot `--standalone-runtime`, local Matcha, and group `测试群`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real Matcha group message reached the plugin as `MessageReceived` with `bot_uuid=eba-aiocqhttp-matcha`, `adapter_name=aiocqhttp`, common `Source`/`Plain` message components, common sender, and common group identifiers.
|
||||||
|
- A protocol-level OneBot reverse WebSocket event reached the plugin as `MessageReceived` with a mixed common chain: `Source`, `Plain`, `At`, `Face`, `Image`, `Voice`, `File`, `Quote`, and trailing `Plain`. This proves the real adapter + LangBot + standalone runtime + plugin path for mixed inbound OneBot payloads, but it was not sent through Matcha UI.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded for plain text plus `At`/`Face`, `AtAll`, base64 `Image`, and quoted reply.
|
||||||
|
- Common APIs succeeded through the plugin path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
- Safe OneBot platform APIs succeeded through `call_platform_api`: `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record`.
|
||||||
|
|
||||||
|
Documented Matcha limits in this E2E run:
|
||||||
|
|
||||||
|
- Matcha UI did not provide a completed image/file upload/send path for inbound media. The rich inbound media evidence is `plugin-e2e-protocol`, not UI-level media upload evidence.
|
||||||
|
- Outbound `File` failed in Matcha even after the adapter emitted an official `file` segment shape.
|
||||||
|
- Outbound `Forward` failed because Matcha returned unsupported action for merged-forward.
|
||||||
|
- `get_group_honor_info` failed because Matcha returned unsupported action.
|
||||||
|
- Destructive/admin APIs such as mute, unmute, kick, leave, group rename, card/title/admin/whole-ban changes, and request approvals were not run without disposable fixtures.
|
||||||
114
docs/event-based-agents/adapters/dingtalk.md
Normal file
114
docs/event-based-agents/adapters/dingtalk.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# DingTalk EBA Adapter Migration Record
|
||||||
|
|
||||||
|
Status: migrated with partial plugin E2E evidence.
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/dingtalk/`
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The DingTalk adapter now has an Event-Based Agents adapter package with:
|
||||||
|
|
||||||
|
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs.
|
||||||
|
- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch.
|
||||||
|
- `event_converter.py` for native DingTalk events to common EBA events.
|
||||||
|
- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
|
||||||
|
- `api_impl.py` for common EBA API implementations.
|
||||||
|
- `platform_api.py` for DingTalk-specific `call_platform_api` actions.
|
||||||
|
|
||||||
|
The legacy DingTalk HTTP client now returns successful JSON response bodies from proactive send methods and raises with response details on non-200 responses.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `client-id` | yes | DingTalk robot/client identifier. |
|
||||||
|
| `client-secret` | yes | DingTalk client secret. |
|
||||||
|
| `robot-code` | yes | Robot code used for send APIs. |
|
||||||
|
| `robot-name` | no | Used for bot mention/self filtering and display. |
|
||||||
|
| `encrypt-key` | no | DingTalk callback encryption key when configured. |
|
||||||
|
| `verification-token` | no | DingTalk callback verification token when configured. |
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
| Event | Support | Evidence |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. |
|
||||||
|
| `platform.specific` | implemented | Not reproduced in the latest UI run. |
|
||||||
|
|
||||||
|
## Receive Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Source` | supported | `plugin-e2e-ui` private message. |
|
||||||
|
| `Plain` | supported | `plugin-e2e-ui` private text. DingTalk emoji currently arrives as plain text such as `[smile]`. |
|
||||||
|
| `At` | converter path | Group trigger was not completed in the latest run. |
|
||||||
|
| `AtAll` | fallback/send-side only | Not completed inbound. |
|
||||||
|
| `Image` | supported | Real DingTalk Mac private-chat image upload reached the plugin as common `Image`. |
|
||||||
|
| `Voice` | converter path | Real UI inbound voice was not completed. |
|
||||||
|
| `File` | supported | Real DingTalk Mac private-chat file upload reached the plugin as common `File`. |
|
||||||
|
| `Quote` | converter path | Real UI inbound quote was not completed. |
|
||||||
|
| `Face` | not native common mapping | DingTalk emoji was observed as `Plain`, not `Face`. |
|
||||||
|
| `Forward` | not-supported inbound | DingTalk does not expose a portable structured forward event in this adapter. |
|
||||||
|
|
||||||
|
## Send Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Plain` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `At` | supported or text fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `AtAll` | fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Image` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `File` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `Quote` | fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Face` | fallback | `plugin-e2e-outbound` as text fallback. |
|
||||||
|
| `Forward` | flattened fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Voice` | fallback/endpoint-dependent | Not separately verified as a native DingTalk voice send. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Support | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `send_message` | supported | Verified through `EBAEventProbe`. |
|
||||||
|
| `reply_message` | supported | Verified through quoted/fallback send path. |
|
||||||
|
| `get_message` | cache-backed | Requires the message to have been observed by this adapter process. |
|
||||||
|
| `get_group_info` | cache-backed/API-backed where available | Group path not completed in latest UI run. |
|
||||||
|
| `get_group_list` | supported where DingTalk API allows | Limited live coverage. |
|
||||||
|
| `get_group_member_info` | supported where DingTalk API allows | Limited live coverage. |
|
||||||
|
| `get_user_info` | supported | Private sender path verified. |
|
||||||
|
| `get_friend_list` | limited | DingTalk does not expose a portable friend-list equivalent. |
|
||||||
|
| `get_file_url` | supported with media/file identifiers | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||||
|
| `call_platform_api` | supported | Safe action `check_access_token` verified. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Support | Evidence |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `check_access_token` | supported | `plugin-e2e`. |
|
||||||
|
| `refresh_access_token` | supported | Implemented; not separately reproduced in the latest plugin run. |
|
||||||
|
| `get_file_url` | supported | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||||
|
| `get_audio_base64` | supported | Needs real inbound audio/media ID. |
|
||||||
|
| `download_image_base64` | supported | Real inbound image reached the plugin as `Image`; separate image-download API replay was not completed. |
|
||||||
|
|
||||||
|
## End-to-End Evidence
|
||||||
|
|
||||||
|
Evidence files:
|
||||||
|
|
||||||
|
- Text/API/component JSONL: `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl`
|
||||||
|
- Real UI inbound media JSONL: `data/temp/dingtalk-plugin-e2e-media-ui.jsonl`
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
- DingTalk Mac private chat in the `LangBot Team` organization produced `MessageReceived` through LangBot standalone runtime and `EBAEventProbe`.
|
||||||
|
- The common chain was `Source + Plain` for normal text.
|
||||||
|
- DingTalk emoji was received as `Source + Plain`, not common `Face`.
|
||||||
|
- Real DingTalk Mac private-chat image upload was received as `Source + Image`.
|
||||||
|
- Real DingTalk Mac private-chat file upload was received as `Source + File`.
|
||||||
|
- The plugin sent outbound text, mention/fallback, image, quote/fallback, file, and forward/fallback messages visible in DingTalk.
|
||||||
|
- The plugin called safe SDK and DingTalk platform APIs.
|
||||||
|
|
||||||
|
Not completed:
|
||||||
|
|
||||||
|
- Real UI inbound voice.
|
||||||
|
- Real UI inbound quote.
|
||||||
|
- Group trigger with a real robot mention.
|
||||||
|
- Destructive or organization-mutating APIs.
|
||||||
147
docs/event-based-agents/adapters/discord.md
Normal file
147
docs/event-based-agents/adapters/discord.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Discord EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Discord has been migrated from the legacy source adapter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/sources/discord.py
|
||||||
|
src/langbot/pkg/platform/sources/discord.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/discord/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
├── types.py
|
||||||
|
└── voice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `discord-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `client_id` | Yes | `""` | Discord application client ID. |
|
||||||
|
| `token` | Yes | `""` | Discord bot token. |
|
||||||
|
|
||||||
|
The bot needs gateway permissions and intents for the target test server. Message Content intent is required for message bodies, Server Members intent is required for member APIs/events, and reaction events require the Reactions intent and channel permissions.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Discord declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.edited`
|
||||||
|
- `message.deleted`
|
||||||
|
- `message.reaction`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
Discord-specific events that do not map cleanly to common events should be surfaced as `platform.specific`.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|-----------------|-------|
|
||||||
|
| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. |
|
||||||
|
| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. |
|
||||||
|
| `edit_message` | Supported | Bot can edit its own messages. File edits are implemented by clearing old attachments and sending replacement files when needed. |
|
||||||
|
| `delete_message` | Supported | Requires message management permissions for non-bot messages. |
|
||||||
|
| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. |
|
||||||
|
| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. |
|
||||||
|
| `get_group_member_list` | Supported | Requires member cache or the Server Members intent/fetch permission. |
|
||||||
|
| `get_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. |
|
||||||
|
| `get_user_info` | Supported | Uses Discord user fetch/cache. |
|
||||||
|
| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. |
|
||||||
|
| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. |
|
||||||
|
| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. |
|
||||||
|
| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. |
|
||||||
|
| `kick_member` | Supported | Destructive; test only with a disposable account/bot. |
|
||||||
|
| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. |
|
||||||
|
| `call_platform_api` | Supported | Discord-specific actions live here. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `get_channel`
|
||||||
|
- `get_guild`
|
||||||
|
- `get_guild_channels`
|
||||||
|
- `get_guild_roles`
|
||||||
|
- `create_invite`
|
||||||
|
- `pin_message`
|
||||||
|
- `unpin_message`
|
||||||
|
- `add_reaction`
|
||||||
|
- `remove_reaction`
|
||||||
|
- `typing`
|
||||||
|
|
||||||
|
Voice helpers are intentionally kept Discord-specific:
|
||||||
|
|
||||||
|
- `join_voice_channel`
|
||||||
|
- `leave_voice_channel`
|
||||||
|
- `get_voice_connection_status`
|
||||||
|
- `list_active_voice_connections`
|
||||||
|
- `get_voice_channel_info`
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tests/e2e/live_discord_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified on May 7, 2026 with a newly created Discord application/bot named `LangBot EBA Test 0507`, the LangBot Discord server, and the `#🐞-debugging` channel:
|
||||||
|
|
||||||
|
- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`.
|
||||||
|
- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`.
|
||||||
|
- Plugin runtime API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage APIs, workspace storage APIs, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Direct live adapter probe observed `message.received`, `message.edited`, `message.deleted`, and `bot.removed_from_group`.
|
||||||
|
- Message APIs verified: send, reply, edit, delete, forward, text/image/file mixed message chains.
|
||||||
|
- User and guild APIs verified: `get_user_info`, `get_group_info`, `get_group_member_list`, `get_group_member_info`.
|
||||||
|
- Platform-specific APIs verified: `get_channel`, `get_guild`, `get_guild_channels`, `get_guild_roles`, `create_invite`, `typing`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction`.
|
||||||
|
- Unsupported API behavior verified: `upload_file` raises `NotSupportedError`.
|
||||||
|
- Destructive API verified at the end: `leave_group`, which emitted `bot.removed_from_group`.
|
||||||
|
|
||||||
|
Not verified in the shared LangBot server live run: `mute_member`, `unmute_member`, and `kick_member`, because the run did not use a disposable target member. They are implemented through Discord timeout/kick APIs and should only be exercised against a disposable account or bot.
|
||||||
|
|
||||||
|
The test fixed one real test-fixture issue: `EBAEventProbe` previously assumed `get_bots()` returned UUID strings. The current standalone runtime returns bot dictionaries, so the probe now selects an enabled bot dictionary and passes its `uuid` to `get_bot_info` and `send_message`. The probe also now subscribes to `MessageDeleted`.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified again on May 10, 2026 with SDK standalone runtime, LangBot `--standalone-runtime`, Discord web client, the LangBot server, and `#🐞-debugging`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Main plugin JSONL: `data/temp/discord-plugin-e2e-20260510-final.jsonl`
|
||||||
|
- LangBot runtime log: `data/temp/discord-langbot-e2e-20260510-rerun.log`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A newly invited Discord bot connected to the LangBot server and received a real web-client message in `#🐞-debugging`.
|
||||||
|
- `MessageReceived` reached the plugin with `bot_uuid=eba-discord-live`, `adapter_name=discord`, common `Source`/`Plain` message components, common `User`, and common `UserGroup` for the guild.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded: plain text plus user mention, `AtAll`/`@everyone`, base64 image, quoted reply, file attachment, and flattened forward fallback.
|
||||||
|
- Common APIs succeeded: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
- Discord platform APIs succeeded through `call_platform_api`: `get_channel`, `typing`, `get_guild`, `get_guild_channels`, and `get_guild_roles`.
|
||||||
|
|
||||||
|
Documented limits in this E2E run:
|
||||||
|
|
||||||
|
- Real Discord UI inbound attachment/image/file, reply/quote, and fresh mention-chain messages were not completed in the plugin E2E evidence. Outbound image/file attachments from the bot do not prove inbound attachment conversion.
|
||||||
|
- A later May 10 UI retry could write text into the Discord message box, but the client kept the send button disabled and did not send the message, so it produced no new plugin evidence.
|
||||||
|
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Discord adapter.
|
||||||
|
- Destructive moderation and guild-leave APIs were not repeated against the shared LangBot server.
|
||||||
|
- Native Discord voice is not represented as common `Voice`; audio-like payloads are treated as file attachments.
|
||||||
|
- `create_invite`, pin/unpin, and reaction mutation were covered by prior direct live probes but were not repeated by the final plugin run to avoid extra shared-server side effects.
|
||||||
135
docs/event-based-agents/adapters/lark.md
Normal file
135
docs/event-based-agents/adapters/lark.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Lark / Feishu EBA Adapter Migration Record
|
||||||
|
|
||||||
|
Status: migrated with unit coverage and partial live plugin E2E. WebSocket text reached the standalone runtime once in the LangBot organization test app, but the latest real UI image/file inbound attempts did not reach the local adapter log, so media receive is not release-complete yet.
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/lark/`
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The Lark/Feishu adapter now has an Event-Based Agents adapter package with:
|
||||||
|
|
||||||
|
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, platform-specific APIs, app type, and communication mode.
|
||||||
|
- `adapter.py` for self-built/store app token handling, WebSocket long connection startup, Webhook callback handling, card feedback, streaming-card replies, and EBA dispatch.
|
||||||
|
- `event_converter.py` for native Feishu events to common EBA events.
|
||||||
|
- `message_converter.py` for Feishu text/post/image/file/audio payloads to/from common `MessageChain` components.
|
||||||
|
- `api_impl.py` for common EBA API implementations.
|
||||||
|
- `platform_api.py` for Feishu-specific `call_platform_api` actions.
|
||||||
|
|
||||||
|
The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `app_id` | yes | Feishu/Lark application App ID. |
|
||||||
|
| `app_secret` | yes | Feishu/Lark application App Secret. |
|
||||||
|
| `bot_name` | yes | Must match the bot name so group mentions can be recognized. |
|
||||||
|
| `enable-webhook` | yes | `false` uses WebSocket long connection; `true` uses Request URL/Webhook callbacks. |
|
||||||
|
| `webhook_url` | no | Generated callback URL for Webhook mode. |
|
||||||
|
| `encrypt-key` | no | Webhook decrypt key when event encryption is enabled. |
|
||||||
|
| `enable-stream-reply` | yes | Enables streaming replies through an updating Feishu card. |
|
||||||
|
| `app_type` | no | `self` for self-built apps; `isv` for store apps. |
|
||||||
|
| `bot_added_welcome` | no | Optional group welcome message sent after bot-added events. |
|
||||||
|
|
||||||
|
## Application And Communication Modes
|
||||||
|
|
||||||
|
| Mode | Support | Implementation |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| Self-built application | implemented | Uses standard app credentials and tenant token behavior from the Feishu SDK client. |
|
||||||
|
| Store application | implemented | Builds an ISV client, requests app tickets, and resolves app/tenant access tokens with per-tenant caching. |
|
||||||
|
| WebSocket long connection | implemented | Registers `im.message.receive_v1` and card-action callbacks through `lark_oapi.ws.Client`. |
|
||||||
|
| Webhook Request URL | implemented | Handles URL verification, encrypted payloads, message events, app-ticket events, bot-added events, and card-action feedback. |
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
| Event | Support | Evidence |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| `message.received` | implemented | Unit coverage for private and group native events to common EBA events. |
|
||||||
|
| `bot.invited_to_group` | implemented | Webhook bot-added event maps to common bot invite event and optional welcome send. |
|
||||||
|
| `platform.specific` | implemented | Unknown callback events are preserved as `platform.specific`. |
|
||||||
|
| `FeedbackEvent` | compatibility event | Card button feedback is still dispatched through the existing SDK `FeedbackEvent` type. |
|
||||||
|
|
||||||
|
## Receive Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Source` | supported | Unit coverage; live private text evidence. |
|
||||||
|
| `Plain` | supported | Text and post payloads convert to common text; live private text evidence. |
|
||||||
|
| `At` | supported | Feishu mentions map to common `At` with user ID and display name. |
|
||||||
|
| `AtAll` | supported | `user_id=all` maps to common `AtAll`. |
|
||||||
|
| `Image` | supported | Image payloads download through message resource API and map to common `Image`; real UI image send attempted, but not observed in local plugin evidence yet. |
|
||||||
|
| `Voice` | supported | Audio payloads download through message resource API and map to common `Voice`. |
|
||||||
|
| `File` | supported | File payloads download through message resource API and map to common `File`; real UI file send attempted, but not observed in local plugin evidence yet. |
|
||||||
|
| `Quote` | supported | Parent/thread reply lookup maps quoted content into common `Quote`. |
|
||||||
|
| `Face` | not native common mapping | Feishu emoji/stickers are not exposed as a portable common `Face` component here. |
|
||||||
|
| `Forward` | not-supported inbound | Feishu does not expose a portable structured forward event in this adapter. |
|
||||||
|
|
||||||
|
## Send Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Plain` | supported | Unit coverage; sends Feishu `text`. |
|
||||||
|
| `At` | supported | Unit coverage; sends Feishu `post` at element. |
|
||||||
|
| `AtAll` | supported | Unit coverage; sends Feishu `post` at-all element. |
|
||||||
|
| `Image` | supported | Uploads image resource and sends Feishu `image`. |
|
||||||
|
| `Voice` | supported | Uploads OPUS/audio resource and sends Feishu `audio`. |
|
||||||
|
| `File` | supported | Uploads file resource and sends Feishu `file`. |
|
||||||
|
| `Quote` | supported/fallback | Sends quote marker plus origin content. |
|
||||||
|
| `Face` | not-supported | No portable send mapping. |
|
||||||
|
| `Forward` | flattened fallback | Flattens forward nodes into text/media messages. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Support | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `send_message` | supported | Supports private/open_id and group/chat_id targets; live plugin outbound component sweep produced visible Feishu messages. |
|
||||||
|
| `reply_message` | supported | Replies to the source Feishu message; fixed to recover the native Feishu message ID from legacy-wrapped source events. |
|
||||||
|
| `get_message` | cache-backed/API-backed | Returns cached inbound event where possible and converts uncached Feishu message API items into common `MessageReceivedEvent`. |
|
||||||
|
| `get_group_info` | supported | Uses cached group or Feishu chat metadata. |
|
||||||
|
| `get_group_member_info` | limited | Uses cached user data when available. |
|
||||||
|
| `get_user_info` | limited | Uses cached user data when available. |
|
||||||
|
| `get_file_url` | limited | Returns `file://` paths from downloaded inbound resources; remote Feishu resource download uses platform-specific API params. |
|
||||||
|
| `call_platform_api` | supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Support | Evidence |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `check_tenant_access_token` | supported | Unit coverage. |
|
||||||
|
| `refresh_app_access_token` | supported | Store-app token path implemented. |
|
||||||
|
| `refresh_tenant_access_token` | supported | Store-app tenant token path implemented. |
|
||||||
|
| `get_chat` | supported | Feishu chat metadata API wrapper. |
|
||||||
|
| `get_message` | supported | Feishu message API wrapper with JSON-safe return values for plugin calls. |
|
||||||
|
| `get_message_resource` | supported | Feishu message resource download wrapper. |
|
||||||
|
|
||||||
|
## End-to-End Evidence
|
||||||
|
|
||||||
|
Current code-level evidence:
|
||||||
|
|
||||||
|
- `tests/unit_tests/platform/test_lark_eba_adapter.py`
|
||||||
|
- `PYTHONPATH=../langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_lark_eba_adapter.py -q`
|
||||||
|
|
||||||
|
Live evidence collected on May 11, 2026:
|
||||||
|
|
||||||
|
- Standalone runtime: `uv run lbp rt --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check`
|
||||||
|
- LangBot: `uv run main.py --standalone-runtime --debug`
|
||||||
|
- Plugin: `LangBot__EBAEventProbe`
|
||||||
|
- Feishu org/app: LangBot organization, `LangBotDev` private chat.
|
||||||
|
- Observed plugin JSONL: one private `MessageReceived` event with `Source + Plain`; plugin API probe then exercised bot discovery, bot info, `send_message`, outbound component sweep, storage/list APIs, and safe platform API calls.
|
||||||
|
- Real UI sends attempted after the fixes: private text, local file, and image/video image upload. These appeared in the Feishu client but did not append new `EBAEventProbe` records in the local JSONL during this run.
|
||||||
|
- Fixes from live testing: reply path now extracts the native Feishu `message_id` from legacy-wrapped source events; WebSocket callbacks are scheduled onto the adapter event loop instead of assuming the SDK callback has a running asyncio loop; platform API results are converted to JSON-safe values.
|
||||||
|
|
||||||
|
Live E2E items still required before marking release-complete:
|
||||||
|
|
||||||
|
- WebSocket self-built app in LangBot organization: repeat private text after callback-loop fix, plus private image/file/audio and group mention message received by `EBAEventProbe`.
|
||||||
|
- Webhook self-built app in LangBot organization: URL verification plus text/image/file message received by `EBAEventProbe`.
|
||||||
|
- Store app token path: at least token acquisition/tenant-token safe API through `call_platform_api`; full message E2E if a LangBot organization store-app fixture is available.
|
||||||
|
- Outbound component sweep: text, mention, at-all, image, file, voice where Feishu accepts the fixture, quote/fallback, and forward/fallback.
|
||||||
|
- Safe platform API sweep: token check, chat metadata, message lookup, and message resource download using real inbound IDs.
|
||||||
|
|
||||||
|
## Known Limits
|
||||||
|
|
||||||
|
- Store-app live E2E requires a real ISV app ticket/tenant installation fixture.
|
||||||
|
- Current LangBot organization WebSocket run connected successfully but did not deliver the latest UI-sent image/file attempts to local plugin evidence; this blocks release-complete media acceptance.
|
||||||
|
- Feishu native emoji/sticker semantics are not represented as common `Face`.
|
||||||
|
- Destructive org or chat mutations are not declared in this adapter.
|
||||||
101
docs/event-based-agents/adapters/officialaccount.md
Normal file
101
docs/event-based-agents/adapters/officialaccount.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# OfficialAccount EBA Adapter
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/`
|
||||||
|
|
||||||
|
Manifest name: `officialaccount-eba`
|
||||||
|
|
||||||
|
Status: partial migration. Unit/API-shape coverage is present, and private text `plugin-e2e-ui` plus safe API evidence has been verified against the `dev.rockchin.top` Official Account fixture. Proactive outbound `send_message` remains not supported by this adapter because WeChat Official Account replies must be tied to inbound webhook windows.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `webhook_url` | no | Generated by LangBot and copied into the Official Account callback settings. |
|
||||||
|
| `token` | yes | WeChat callback token. |
|
||||||
|
| `EncodingAESKey` | yes | WeChat message encryption key. |
|
||||||
|
| `AppID` | yes | Official Account app ID. |
|
||||||
|
| `AppSecret` | yes | Official Account app secret. |
|
||||||
|
| `Mode` | yes | `drop` waits for an in-callback reply; `passive` returns the loading text first and queues the answer for the user's next message. |
|
||||||
|
| `LoadingMessage` | no | Only used by `passive` mode. |
|
||||||
|
| `api_base_url` | no | Optional API base URL for proxy deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. |
|
||||||
|
| `platform.specific` | unit | Subscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `reply_message` | unit | Queues/passively returns text through the inbound webhook source event. |
|
||||||
|
| `get_message` | plugin-e2e-ui, unit | Cached inbound message retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `get_user_info` | plugin-e2e-ui, unit | Cached inbound sender retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `get_friend_list` | plugin-e2e-ui, unit | Cached inbound sender list retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `call_platform_api` | plugin-e2e-ui, unit | Safe diagnostic actions verified through `get_mode` and `get_cached_response_status`. |
|
||||||
|
| `send_message` | not-supported | Official Account customer-service proactive messaging is not implemented by the existing SDK adapter; only webhook reply is supported here. |
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `get_mode` | plugin-e2e-ui, unit | Returned `{"mode": "drop", "longer_response": false}` in live probe. |
|
||||||
|
| `get_cached_response_status` | plugin-e2e-ui, unit | Returned `{"pending": false}` in live probe. |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Receive Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Source` | plugin-e2e-ui, unit | Uses `MsgId` and `CreateTime`; live UI text message included `Source`. |
|
||||||
|
| `Plain` | plugin-e2e-ui, unit | Live UI text message mapped to `Plain`. |
|
||||||
|
| `Image` | unit | `PicUrl` and `MediaId` map to common `Image`. |
|
||||||
|
| `Voice` | unit | `MediaId` maps to common `Voice`. |
|
||||||
|
| `Unknown` | unit | Unsupported message/event types do not crash. |
|
||||||
|
| `At`, `AtAll`, `File`, `Quote`, `Face`, `Forward`, mixed chain | not-supported | WeChat Official Account inbound webhook payloads used by the current SDK do not expose these as common structured components. |
|
||||||
|
|
||||||
|
| Send Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Plain` | unit | Sent as webhook reply text. |
|
||||||
|
| `Image`, `Voice`, `File`, `Quote`, `At`, `AtAll`, `Face`, `Forward`, mixed chain | not-supported | Existing SDK reply path is text XML only; non-text components degrade to readable placeholders in tests and are not declared as supported outbound components. |
|
||||||
|
|
||||||
|
## Verification Record
|
||||||
|
|
||||||
|
Test date: 2026-05-28
|
||||||
|
|
||||||
|
Endpoint/simulator: `dev.rockchin.top` with WeChat desktop client and a real subscribed Official Account conversation. The running EBA test stack used SDK standalone runtime ports `5400/5401`, LangBot from `/home/wgc/LangBotxg/LangBotEbaTest`, and `EBAEventProbe`.
|
||||||
|
|
||||||
|
Verified UI message: `EBA officialaccount single probe 2026-05-28 16:53`
|
||||||
|
|
||||||
|
Observed event/API evidence:
|
||||||
|
|
||||||
|
- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-eba`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`.
|
||||||
|
- Common safe APIs through probe platform sweep: `get_message`, `get_user_info`, `get_friend_list`.
|
||||||
|
- Platform APIs through `call_platform_api`: `get_mode`, `get_cached_response_status`.
|
||||||
|
- `send_message` and outbound component sweep returned explicit `NotSupportedError: send_message:official_account_requires_inbound_webhook_reply`, as expected for this adapter.
|
||||||
|
|
||||||
|
Standalone runtime command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||||
|
|
||||||
|
Adapter live probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m py_compile tests/e2e/live_officialaccount_eba_probe.py
|
||||||
|
OFFICIALACCOUNT_TOKEN=... OFFICIALACCOUNT_ENCODING_AES_KEY=... OFFICIALACCOUNT_APP_SECRET=... OFFICIALACCOUNT_APP_ID=... uv run python tests/e2e/live_officialaccount_eba_probe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence JSONL path: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` for plugin E2E, or `data/temp/officialaccount_eba_probe.jsonl` for direct adapter live probe.
|
||||||
|
|
||||||
|
Destructive operations: none.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- `plugin-e2e-outbound`: proactive `send_message` is not supported for this adapter; Official Account responses must be produced through the inbound webhook reply window.
|
||||||
|
- Inbound image and voice live UI evidence remains pending; webhook conversion is covered by unit tests.
|
||||||
114
docs/event-based-agents/adapters/qqofficial.md
Normal file
114
docs/event-based-agents/adapters/qqofficial.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# QQOfficial EBA Adapter
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/qqofficial/`
|
||||||
|
|
||||||
|
Manifest name: `qqofficial-eba`
|
||||||
|
|
||||||
|
Status: partial migration. The EBA adapter structure, manifest, converters, cache-backed safe APIs, platform API map, unit tests, and direct live probe scaffold are in place. A real QQ Official WebSocket bot on `dev.rockchin.top` received an inbound user message and drove LangBot into the normal pipeline path; the response path was blocked by the test environment model service returning `model_not_found` for `deepseek-v3`.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `appid` | yes | QQ Official app ID. |
|
||||||
|
| `secret` | yes | QQ Official app secret. |
|
||||||
|
| `token` | yes | QQ Official callback token. |
|
||||||
|
| `enable-webhook` | yes | Uses LangBot unified webhook when true; otherwise uses the QQ WebSocket gateway. |
|
||||||
|
| `enable-stream-reply` | yes | Enables C2C streaming replies when supported by the QQ Official endpoint. |
|
||||||
|
| `webhook_url` | no | Generated by LangBot and copied into the QQ Official callback settings in webhook mode. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. |
|
||||||
|
| `platform.specific` | unit, blocked | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `send_message` | unit, blocked | Sends private C2C, group, and text-only channel messages through the existing QQ Official client. Live outbound UI verification is pending because the test pipeline failed before producing a bot response. |
|
||||||
|
| `reply_message` | unit, blocked | Replies using the source `QQOfficialEvent` message ID when available. Live reply was blocked by the test environment model service returning `model_not_found`. |
|
||||||
|
| `get_message` | unit | Returns cached inbound `MessageReceivedEvent`. |
|
||||||
|
| `get_user_info` | unit | Returns cached inbound sender. |
|
||||||
|
| `get_friend_list` | unit | Returns cached private senders. |
|
||||||
|
| `get_group_info` | unit | Returns cached group/channel metadata from inbound events. |
|
||||||
|
| `get_group_member_info` | unit | Returns cached group sender as a common member. |
|
||||||
|
| `get_group_member_list` | unit | Returns cached group members observed by the adapter. |
|
||||||
|
| `call_platform_api` | unit, blocked | Safe diagnostic actions are implemented; live calls are pending credentials. |
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `check_access_token` | unit, blocked | Calls the existing client token check. |
|
||||||
|
| `refresh_access_token` | unit, blocked | Forces token refresh. |
|
||||||
|
| `get_gateway_url` | unit, blocked | Fetches the WebSocket gateway URL. |
|
||||||
|
| `get_mode` | unit | Returns webhook and stream-reply mode. |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Receive Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Source` | unit | Uses QQ message/event IDs and timestamp. |
|
||||||
|
| `Plain` | unit | Preserves text content. |
|
||||||
|
| `At` | unit | Group and channel mention events insert an adapter bot mention marker. |
|
||||||
|
| `Image` | unit | QQ image attachment URL is converted to common `Image`; falls back to URL if download fails. |
|
||||||
|
| `Unknown` | unit | Unsupported/empty native payloads become `Unknown`. |
|
||||||
|
| `Voice`, `File`, `Quote`, `Face`, `Forward`, mixed chain | blocked | Current native parser only exposes text and image attachments; live endpoint behavior still needs verification. |
|
||||||
|
|
||||||
|
| Send Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Plain` | unit, blocked | Sends through private, group, or channel text APIs. |
|
||||||
|
| `At`, `AtAll` | unit, blocked | Converted to readable mention text. |
|
||||||
|
| `Image` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `Voice` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `File` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `Quote`, `Forward`, mixed chain | unit, blocked | Flattened to ordered send payloads where possible. |
|
||||||
|
| `Face` | not-supported | No common QQ Official face mapping is implemented. |
|
||||||
|
|
||||||
|
## Verification Record
|
||||||
|
|
||||||
|
Test date: 2026-06-02
|
||||||
|
|
||||||
|
Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-eba`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`.
|
||||||
|
|
||||||
|
Observed evidence:
|
||||||
|
|
||||||
|
- The QQ Official WebSocket bot was enabled with `enable-webhook=false`.
|
||||||
|
- A real user message reached LangBot and entered the standard pipeline path.
|
||||||
|
- The response path stopped at the model layer with `model_not_found` for `deepseek-v3`; this is a model/provider configuration issue, not an adapter conversion failure.
|
||||||
|
- `qq-webhook.langbot.dev` was temporarily routed through Caddy to `127.0.0.1:5301` for webhook checks, but the observed EBA bot used WebSocket mode.
|
||||||
|
|
||||||
|
Standalone runtime command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||||
|
|
||||||
|
Adapter live probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m py_compile tests/e2e/live_qqofficial_eba_probe.py
|
||||||
|
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook-mode probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py --webhook --host 0.0.0.0 --port 5312
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence JSONL path: `data/temp/qqofficial_eba_probe.jsonl` for direct adapter live probe; plugin E2E evidence should use `data/temp/qqofficial_eba_plugin_probe.jsonl`.
|
||||||
|
|
||||||
|
Destructive operations: none implemented.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui`: standalone probe plugin JSONL evidence is still pending; the observed live run reached LangBot core/pipeline but was not recorded by the EBA probe plugin.
|
||||||
|
- `plugin-e2e-outbound`: waiting for visible QQ client verification of plugin `send_message`/`reply_message` output after a working model/provider is configured.
|
||||||
|
- Inbound non-text media and platform lifecycle events require endpoint evidence before they can be marked complete.
|
||||||
84
docs/event-based-agents/adapters/slack.md
Normal file
84
docs/event-based-agents/adapters/slack.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Slack EBA Adapter
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
Slack is migrated into `src/langbot/pkg/platform/adapters/slack/` with the standard EBA adapter layout:
|
||||||
|
|
||||||
|
- `adapter.py` owns lifecycle, listener dispatch, unified webhook handling, outbound send/reply, and event caches.
|
||||||
|
- `event_converter.py` maps Slack `im` and `app_mention` channel events to `message.received`.
|
||||||
|
- `message_converter.py` maps common `MessageChain` components to Slack text fallback and maps inbound Slack text/image payloads back to EBA components.
|
||||||
|
- `api_impl.py` provides cache-backed common read APIs.
|
||||||
|
- `platform_api.py` declares safe Slack-specific API actions.
|
||||||
|
- `manifest.yaml` declares `slack-eba`.
|
||||||
|
|
||||||
|
The legacy `src/langbot/pkg/platform/sources/slack.py` adapter is kept unchanged.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `webhook_url` | No | Generated by LangBot. Paste it into Slack Event Subscriptions. |
|
||||||
|
| `bot_token` | Yes | Slack bot token, usually `xoxb-...`. |
|
||||||
|
| `signing_secret` | Yes | Slack app signing secret. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `message.received` | Emitted for private `im` messages and channel `app_mention` events. Channel messages are mapped to group chats. |
|
||||||
|
| `platform.specific` | Reserved for Slack event types that are not converted into common message events. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `send_message`
|
||||||
|
- `reply_message`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `get_message`
|
||||||
|
- `get_user_info`
|
||||||
|
- `get_friend_list`
|
||||||
|
- `get_group_info`
|
||||||
|
- `get_group_list`
|
||||||
|
- `get_group_member_list`
|
||||||
|
- `get_group_member_info`
|
||||||
|
- `call_platform_api`
|
||||||
|
|
||||||
|
Cache-backed APIs are only available after the relevant inbound event has been observed.
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `get_mode` | Returns webhook mode and configured bot account id. |
|
||||||
|
| `auth_test` | Calls Slack `auth.test` with the configured bot token. |
|
||||||
|
|
||||||
|
## Known Limits
|
||||||
|
|
||||||
|
- Slack file/image outbound is currently represented as text fallback because the existing Slack SDK wrapper only exposes `chat_postMessage`.
|
||||||
|
- Inbound channel coverage follows the legacy adapter behavior: only `app_mention` events are treated as group messages.
|
||||||
|
- Real live testing requires a public callback URL configured in Slack Event Subscriptions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Local mocked unit coverage validates manifest parity, event conversion, legacy listener compatibility, cache-backed APIs, send/reply routing, and declared platform APIs.
|
||||||
|
|
||||||
|
Plugin E2E evidence was captured on June 2, 2026 against `dev.rockchin.top` with Slack private DM input and `EBAEventProbe` through the standalone runtime.
|
||||||
|
|
||||||
|
Evidence file: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl`.
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Real Slack private text produced `MessageReceived` with `adapter_name=slack-eba`, `Source + Plain`, private chat type, and filled `bot_uuid`.
|
||||||
|
- Safe common APIs passed: `get_message`, `get_user_info`, `get_friend_list`.
|
||||||
|
- Outbound component fallback sweep passed through `send_message`: plain/at/face, image, quote, file, and forward.
|
||||||
|
- Declared Slack platform APIs passed: `get_mode`, `auth_test`.
|
||||||
|
|
||||||
|
Still pending:
|
||||||
|
|
||||||
|
- Channel `app_mention` plugin E2E.
|
||||||
|
- Real inbound Slack file/image UI evidence.
|
||||||
|
|
||||||
|
Live probe scaffold: `tests/e2e/live_slack_eba_probe.py`.
|
||||||
139
docs/event-based-agents/adapters/telegram.md
Normal file
139
docs/event-based-agents/adapters/telegram.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Telegram EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Telegram has been migrated to the EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/telegram/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `telegram-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `token` | Yes | `""` | Telegram Bot API token from BotFather. |
|
||||||
|
| `markdown_card` | No | `true` | Whether to render Markdown card style replies. |
|
||||||
|
| `enable-stream-reply` | Yes | `false` | Whether to use Telegram streaming reply mode. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Telegram declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.edited`
|
||||||
|
- `message.reaction`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `bot.muted`
|
||||||
|
- `bot.unmuted`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`platform.specific` is currently used for Telegram-only callback and chat-member update payloads that do not yet have a more specific common event type.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Supports text, image, file, and mixed message chains. |
|
||||||
|
| `reply_message` | Supported | Supports quoted replies through the original message event. |
|
||||||
|
| `edit_message` | Supported | Uses Telegram message editing APIs. |
|
||||||
|
| `delete_message` | Supported | Deletes messages where bot permissions allow it. |
|
||||||
|
| `forward_message` | Supported | Forwards a message between Telegram chats. |
|
||||||
|
| `get_group_info` | Supported | Uses Telegram chat metadata. |
|
||||||
|
| `get_group_member_list` | Supported | Telegram only exposes administrators through the Bot API; this returns the available member set. |
|
||||||
|
| `get_group_member_info` | Supported | Maps Telegram member status to EBA member roles. |
|
||||||
|
| `get_user_info` | Supported | Uses Telegram `get_chat` for user chat metadata. |
|
||||||
|
| `upload_file` | Not supported | Telegram has no standalone upload endpoint; files are uploaded as part of messages. The adapter raises `NotSupportedError`. |
|
||||||
|
| `get_file_url` | Supported | Returns the Bot API file URL. Test output redacts the bot token. |
|
||||||
|
| `mute_member` | Supported | Requires a supergroup and bot moderation permission. |
|
||||||
|
| `unmute_member` | Supported | Uses current `telegram.ChatPermissions` fields. |
|
||||||
|
| `kick_member` | Supported | Destructive; should only be run against disposable users/bots in tests. |
|
||||||
|
| `leave_group` | Supported | Destructive; should run at the end of a live test. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `pin_message`
|
||||||
|
- `unpin_message`
|
||||||
|
- `unpin_all_messages`
|
||||||
|
- `get_chat_administrators`
|
||||||
|
- `set_chat_title`
|
||||||
|
- `set_chat_description`
|
||||||
|
- `get_chat_member_count`
|
||||||
|
- `send_chat_action`
|
||||||
|
- `create_chat_invite_link`
|
||||||
|
- `answer_callback_query`
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tests/e2e/live_telegram_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
It supports private chat tests, group/supergroup tests, moderation tests, destructive tests, and a callback-only mode.
|
||||||
|
|
||||||
|
Verified on May 7, 2026:
|
||||||
|
|
||||||
|
- Private chat message APIs: send, reply, edit, delete, forward.
|
||||||
|
- Private chat media APIs: image/file sending and `get_file_url`.
|
||||||
|
- User API: `get_user_info`.
|
||||||
|
- Supergroup APIs: group info, member list, member info, administrators, member count, invite link.
|
||||||
|
- Supergroup mutation APIs: pin, unpin, unpin all, set title, restore title, set description, restore description.
|
||||||
|
- Moderation APIs: mute and unmute against a non-owner target bot.
|
||||||
|
- Destructive APIs: kick a disposable target bot, then make the test bot leave the test group.
|
||||||
|
- Event conversion observed for `message.received`, `group.member_banned`, `group.member_left`, `bot.removed_from_group`, and Telegram-specific chat-member updates.
|
||||||
|
|
||||||
|
The test fixed one real compatibility issue: `unmute_member` previously used Telegram's removed `can_send_media_messages` permission field. It now uses the split media permission fields required by current `python-telegram-bot`.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, Telegram Lite, `@rockchinq_bot`, and `Rock'sBotGroup`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Private chat JSONL: `data/temp/telegram-plugin-e2e-rerun.jsonl`
|
||||||
|
- Group chat JSONL: `data/temp/telegram-plugin-e2e-group.jsonl`
|
||||||
|
- Private media JSONL: `data/temp/telegram-plugin-e2e-media-ui.jsonl`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- `MessageReceived` reached the plugin with `bot_uuid=eba-telegram-live`, `adapter_name=telegram`, common sender/chat fields, and common `MessageChain` content.
|
||||||
|
- `BotInvitedToGroup` reached the plugin after adding the bot to `Rock'sBotGroup`.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded in private and group chats: plain text, mention text/equivalent, base64 image, quoted reply, file/document, and flattened forward fallback. Group mode also covered `AtAll` fallback behavior.
|
||||||
|
- Real Telegram Lite private-chat inbound media was verified through the plugin path: a sent document arrived as common `File`, and a sent photo arrived as common `Image`.
|
||||||
|
- Telegram platform API sweep succeeded for safe group actions: `get_chat_administrators`, `get_chat_member_count`, and `send_chat_action`.
|
||||||
|
- Common group/user APIs succeeded in group mode: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
|
||||||
|
Documented limits in this E2E run:
|
||||||
|
|
||||||
|
- Real Telegram UI inbound voice, sticker/emoji-as-common-component, and reply/quote messages were not completed in the plugin E2E evidence.
|
||||||
|
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Telegram adapter.
|
||||||
|
- Mutating/destructive Telegram-specific actions such as pin/unpin, title/description changes, invite-link creation, moderation, kick, and leave were not repeated in the plugin run. They remain opt-in live-probe cases.
|
||||||
|
- Telegram does not expose a portable common `Face` component for native sticker/emoji semantics in the current adapter.
|
||||||
|
|
||||||
|
## Notes for Future Adapters
|
||||||
|
|
||||||
|
Telegram is the reference implementation for:
|
||||||
|
|
||||||
|
- Keeping platform-specific actions behind `call_platform_api`.
|
||||||
|
- Treating unsupported common APIs as explicit `NotSupportedError`.
|
||||||
|
- Marking destructive live test operations behind CLI flags.
|
||||||
|
- Redacting access tokens from live probe output.
|
||||||
130
docs/event-based-agents/adapters/wecom.md
Normal file
130
docs/event-based-agents/adapters/wecom.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# WeCom EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom application messages now have an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecom/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecom-eba`.
|
||||||
|
|
||||||
|
This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-eba`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom application callback settings. |
|
||||||
|
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||||
|
| `secret` | Yes | `""` | WeCom application secret. |
|
||||||
|
| `token` | Yes | `""` | WeCom callback token. |
|
||||||
|
| `EncodingAESKey` | Yes | `""` | WeCom callback encryption key. |
|
||||||
|
| `contacts_secret` | No | `""` | Contacts secret for contact-list based helper APIs. |
|
||||||
|
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
WeCom declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`message.received` currently covers text and image application callbacks. Other WeCom callback types are surfaced as `platform.specific` so plugins can inspect the raw structured payload without crashing the common message path.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Private/person target only. `target_id` must be `user_id|agent_id`. Supports text, image, voice, file, flattened forward, and quote fallback. |
|
||||||
|
| `reply_message` | Supported | Replies to the original WeCom sender and application agent from `source_platform_object`. |
|
||||||
|
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Supported | Uses cached event users first, then WeCom `user/get`. |
|
||||||
|
| `get_friend_list` | Partial | Returns users seen by this adapter instance. Full contacts listing is not declared as common coverage. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
| `edit_message` | Not supported | WeCom application messages do not expose a general edit endpoint for sent messages. |
|
||||||
|
| `delete_message` | Not supported | WeCom application messages do not expose a general delete endpoint for sent messages. |
|
||||||
|
| `get_group_info` / member APIs | Not supported | Regular WeCom application callbacks handled here are private user messages, not group-chat bot messages. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported as common APIs | WeCom media upload is used internally while sending image/voice/file components; no portable standalone common file URL is exposed. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `check_access_token`
|
||||||
|
- `refresh_access_token`
|
||||||
|
- `get_user_info`
|
||||||
|
- `send_to_all`
|
||||||
|
|
||||||
|
`send_to_all` requires a configured `contacts_secret` with suitable contact visibility and should be treated as a broad-send operation in live testing.
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/unit_tests/platform/test_wecom_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit tests cover:
|
||||||
|
|
||||||
|
- Manifest events/APIs/platform actions match adapter declarations.
|
||||||
|
- Outbound component conversion for text, image, voice, file, quote fallback, and byte-safe text splitting.
|
||||||
|
- Text callback conversion to `MessageReceivedEvent`.
|
||||||
|
- Legacy `FriendMessage` compatibility.
|
||||||
|
- EBA listener dispatch and inbound message/user cache.
|
||||||
|
- `send_message`, `reply_message`, and safe platform API dispatch against a mocked WeCom client.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and a real WeCom desktop client against the server test environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
|
||||||
|
cd LangBot
|
||||||
|
uv run main.py --standalone-runtime
|
||||||
|
|
||||||
|
cd data/plugins/LangBot__EBAEventProbe
|
||||||
|
EBA_PROBE_API=1 EBA_PROBE_COMPONENT_SWEEP=1 EBA_PROBE_PLATFORM_API=1 \
|
||||||
|
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl`
|
||||||
|
- Bot: `wecom-eba`
|
||||||
|
- Client: real WeCom desktop client
|
||||||
|
- Environment: `dev.rockchin.top` test server
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-eba`, common sender/chat fields, and `Source + Plain`.
|
||||||
|
- SDK API calls succeeded through the standalone runtime, including `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, and manifest/list APIs.
|
||||||
|
- Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions.
|
||||||
|
|
||||||
|
Still required for stricter acceptance:
|
||||||
|
|
||||||
|
- Send a private image and confirm common `Image` reaches the plugin.
|
||||||
|
- Have the plugin call `send_message` and `reply_message` for text and one media component, then verify the WeCom client receives the bot output.
|
||||||
|
- Exercise `send_to_all` only with a disposable visible-contact scope.
|
||||||
|
- Trigger one non-text/image callback, if available, and confirm it becomes `PlatformSpecificEventReceived`.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- Real inbound image/voice/file evidence was not completed in this run.
|
||||||
|
- Inbound voice/file callback parsing is not present in the legacy `WecomClient.get_message()` path, so the EBA adapter does not claim those receive components yet.
|
||||||
|
- Group/member/moderation APIs do not apply to this regular WeCom application-message adapter.
|
||||||
148
docs/event-based-agents/adapters/wecombot.md
Normal file
148
docs/event-based-agents/adapters/wecombot.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# WeComBot EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom AI Bot now has an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecombot/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecombot-eba`.
|
||||||
|
|
||||||
|
This is separate from regular WeCom internal applications (`wecom-eba`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `BotId` | Yes for WebSocket mode | `""` | WeCom AI Bot ID. |
|
||||||
|
| `robot_name` | Yes | `""` | Bot display name used to strip bot mentions from incoming group text. |
|
||||||
|
| `enable-webhook` | Yes | `false` | `false` uses WebSocket long connection mode; `true` uses webhook callback mode. |
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL, only needed when webhook mode is enabled. |
|
||||||
|
| `Secret` | Yes for WebSocket mode | `""` | WeCom AI Bot secret for long connection mode. |
|
||||||
|
| `Corpid` | Yes for webhook mode | `""` | WeCom corporate ID for webhook callback mode. |
|
||||||
|
| `Token` | Yes for webhook mode | `""` | WeCom callback token. |
|
||||||
|
| `EncodingAESKey` | Yes for webhook mode; optional for WebSocket media decrypt | `""` | Message encryption/decryption key. |
|
||||||
|
| `enable-stream-reply` | No | `true` | Enables WeComBot streaming replies. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
WeComBot declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `feedback.received`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`message.received` covers private and group messages from the WeComBot SDK. `feedback.received` covers WeComBot like/dislike feedback callbacks. Native SDK events without a common EBA equivalent are emitted as `platform.specific`.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported in WebSocket mode | Sends proactive markdown/text to a person or group chat ID. Webhook mode raises `NotSupportedError` because the platform callback flow has no proactive send path here. |
|
||||||
|
| `reply_message` | Supported | Replies through native `req_id` in WebSocket mode or stream finalization/cache in webhook mode. |
|
||||||
|
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Supported from cache | WeComBot events carry user info; no full user lookup endpoint is declared. |
|
||||||
|
| `get_friend_list` | Partial | Returns users observed by this adapter instance. |
|
||||||
|
| `get_group_info` | Supported from cache | Returns groups observed from inbound group messages. |
|
||||||
|
| `get_group_member_info` | Supported from cache | Returns observed sender/group-member pairs. |
|
||||||
|
| `get_group_member_list` | Partial | Returns observed members for the cached group only. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
| `edit_message` / `delete_message` / `forward_message` | Not supported | WeComBot does not expose portable common APIs for these operations in the current SDK wrapper. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported as common APIs | Media is represented inside messages; no portable standalone file upload/URL API is declared. |
|
||||||
|
| moderation / leave APIs | Not supported | WeComBot does not expose equivalent common moderation operations through this adapter. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `is_websocket_mode`
|
||||||
|
- `get_stream_session_status`
|
||||||
|
- `send_markdown`
|
||||||
|
|
||||||
|
`send_markdown` is only available in WebSocket mode.
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecombot_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit tests cover:
|
||||||
|
|
||||||
|
- Manifest events/APIs/platform actions match adapter declarations.
|
||||||
|
- Outbound common components flatten to WeComBot markdown/text.
|
||||||
|
- Private and group native events become `MessageReceivedEvent`.
|
||||||
|
- Inbound image, file, voice, and quote components map to common `MessageChain`.
|
||||||
|
- Legacy `FriendMessage`/`GroupMessage` compatibility.
|
||||||
|
- EBA listener dispatch, message/user/group/member cache, reply, send, streaming chunk, feedback, and platform API calls.
|
||||||
|
|
||||||
|
## Live Probe
|
||||||
|
|
||||||
|
The direct adapter probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run python tests/e2e/live_wecombot_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Default mode is WebSocket long connection and requires:
|
||||||
|
|
||||||
|
- `WECOMBOT_BOT_ID`
|
||||||
|
- `WECOMBOT_SECRET`
|
||||||
|
- `WECOMBOT_ROBOT_NAME`
|
||||||
|
- optional `WECOMBOT_ENCODING_AES_KEY`
|
||||||
|
|
||||||
|
Webhook mode uses `--webhook` and requires:
|
||||||
|
|
||||||
|
- `WECOMBOT_TOKEN`
|
||||||
|
- `WECOMBOT_ENCODING_AES_KEY`
|
||||||
|
- `WECOMBOT_CORPID`
|
||||||
|
|
||||||
|
The probe writes JSONL evidence to `data/temp/wecombot_eba_live_probe.jsonl`, waits for a real WeComBot message, records common EBA event fields and message components, then runs safe cached/common/platform API checks.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and the real WeCom desktop client in a WeCom AI Bot private chat.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl`
|
||||||
|
- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111`
|
||||||
|
- Adapter: `wecombot-eba`
|
||||||
|
- Client: real WeCom desktop client, private `LangBot` BOT chat
|
||||||
|
- Mode: WebSocket long connection (`enable-webhook=false`)
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-eba`, common sender/chat fields, and `Source + Plain`.
|
||||||
|
- SDK API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, manifest/list APIs, and safe cached common platform APIs.
|
||||||
|
- Outbound component sweep was visible in the WeCom client and returned `errcode=0`: plain/mention/face fallback, base64 image marker, quote fallback, file marker, and flattened forward fallback.
|
||||||
|
- Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`.
|
||||||
|
- The `send_markdown` platform API produced visible bot output in the WeCom client.
|
||||||
|
|
||||||
|
Not completed:
|
||||||
|
|
||||||
|
- Clicking the visible WeCom AI feedback button did not produce a `FeedbackReceived` JSONL entry in this run, so `feedback.received` remains unverified at plugin E2E level.
|
||||||
|
- Group chat inbound and group cache/member coverage still need a real group-side trigger.
|
||||||
|
- Real inbound image/file/voice from the WeCom client was not exercised.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked or limited items:
|
||||||
|
|
||||||
|
- `feedback.received` is implemented and unit-covered, but real plugin E2E feedback evidence was not observed from the desktop client click.
|
||||||
|
- Outbound image/voice/file are flattened as textual markers because the WeComBot SDK reply/proactive path used here is markdown/text oriented.
|
||||||
|
- Group member APIs are cache-backed and only know members observed in received messages.
|
||||||
|
- Destructive or moderation APIs are not declared because the current WeComBot protocol surface does not provide safe common equivalents.
|
||||||
161
docs/event-based-agents/adapters/wecomcs.md
Normal file
161
docs/event-based-agents/adapters/wecomcs.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# WeCom Customer Service EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom Customer Service now has an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecomcs/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecomcs-eba`. It is separate from regular WeCom application messages (`wecom-eba`) and WeCom AI Bot (`wecombot-eba`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom Customer Service callback settings. |
|
||||||
|
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||||
|
| `secret` | Yes | `""` | Customer Service secret used for access tokens. |
|
||||||
|
| `token` | Yes | `""` | Customer Service callback token. |
|
||||||
|
| `EncodingAESKey` | Yes | `""` | Customer Service callback encryption key. |
|
||||||
|
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `message.received` | Plugin E2E UI covered for text | Text, image, file, and voice payloads convert to common EBA message components in unit tests. Real WeChat customer-side UI text reached `EBAEventProbe` on May 27, 2026. |
|
||||||
|
| `platform.specific` | Unit covered | Non-message or unknown Customer Service payloads become structured `PlatformSpecificEvent` records. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Plugin E2E outbound covered | Private/person target only. `target_id` must be `external_userid|open_kfid`. Text and image are implemented; voice/file are explicitly unsupported. |
|
||||||
|
| `reply_message` | Plugin E2E partial | Replies through Customer Service `kf/send_msg` using the original `source_platform_object`. The pipeline reply path reached the send API, but the dev account later hit WeCom `95001 send msg count limit`. |
|
||||||
|
| `get_message` | Plugin E2E covered from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Plugin E2E covered | Uses cached event users first, then Customer Service `customer/batchget`. |
|
||||||
|
| `get_friend_list` | Plugin E2E covered, partial | Returns customer users seen by this adapter instance. |
|
||||||
|
| `call_platform_api` | Unit covered | See platform-specific APIs below. |
|
||||||
|
| `edit_message` / `delete_message` | Not supported | WeCom Customer Service does not expose a general edit/delete endpoint for bot-sent messages in this adapter. |
|
||||||
|
| Group/member/moderation APIs | Not supported | Customer Service conversations handled here are private customer sessions, not group chats. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported | Media upload is used internally for outbound image; no portable file URL common API is exposed. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Status | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `check_access_token` | Unit covered | Checks whether the current access token is present. |
|
||||||
|
| `refresh_access_token` | Unit covered | Refreshes the Customer Service access token. |
|
||||||
|
| `get_customer_info` | Unit covered | Calls Customer Service customer lookup by `external_userid`. |
|
||||||
|
|
||||||
|
## Message Components
|
||||||
|
|
||||||
|
Receive:
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `Source` | Unit covered | Uses Customer Service `msgid` and `send_time`. |
|
||||||
|
| `Plain` | Unit covered | Text payload content is preserved. |
|
||||||
|
| `Image` | Unit covered | Uses the base64 data URL produced by the existing SDK image download path. |
|
||||||
|
| `Voice` | Unit covered | Maps exposed voice media ID to common `Voice.voice_id`; live UI evidence pending. |
|
||||||
|
| `File` | Unit covered | Maps exposed file media ID/name/size to common `File`; live UI evidence pending. |
|
||||||
|
| `Quote`, `At`, `AtAll`, `Face`, `Forward` | Not supported inbound | The current Customer Service SDK event model does not expose these as structured inbound fields. |
|
||||||
|
| `Unknown` | Unit covered | Unsupported message types become `Unknown` in message conversion or `platform.specific` at event level. |
|
||||||
|
|
||||||
|
Send:
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `Plain` | Plugin E2E outbound covered | Sends through `kf/send_msg` text. |
|
||||||
|
| `Image` | Plugin E2E outbound covered | Uploads media as WeCom image media and sends through `kf/send_msg` image. |
|
||||||
|
| `Quote`, `At`, `AtAll`, `Forward` | Unit covered fallback, live partially blocked | Flattened to text where possible. In the May 27 sweep, later text sends hit WeCom `95001 send msg count limit` after the successful text/image sends. |
|
||||||
|
| `Voice`, `File`, `Face` | Not supported | The adapter raises `NotSupportedError`; no tested Customer Service send path is implemented. |
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecomcs_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Result on May 27, 2026: `10 passed`.
|
||||||
|
|
||||||
|
The local `PYTHONPATH` is required in this workspace because the installed SDK package in the LangBot venv does not contain the newer `langbot_plugin.api.entities.builtin.platform.errors` module; the existing EBA adapter tests need the same SDK override.
|
||||||
|
|
||||||
|
## Live Probe
|
||||||
|
|
||||||
|
Auxiliary direct adapter probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/path/to/langbot-plugin-sdk/src uv run python -m py_compile tests/e2e/live_wecomcs_eba_probe.py
|
||||||
|
|
||||||
|
WECOMCS_CORPID=... \
|
||||||
|
WECOMCS_SECRET=... \
|
||||||
|
WECOMCS_TOKEN=... \
|
||||||
|
WECOMCS_ENCODING_AES_KEY=... \
|
||||||
|
PYTHONPATH=/path/to/langbot-plugin-sdk/src \
|
||||||
|
uv run python tests/e2e/live_wecomcs_eba_probe.py \
|
||||||
|
--path /wecomcs/callback \
|
||||||
|
--log data/temp/wecomcs_eba_live_probe.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
This probe is diagnostic only. Final EBA acceptance still requires the standalone SDK runtime plus `EBAEventProbe` plugin path.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Completed partial plugin E2E on May 27, 2026 against `dev.rockchin.top` and the WeChat customer-side UI entry `微信 -> 客服消息 -> 浪波智能客服`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl`
|
||||||
|
- Trigger text: `EBA wecomcs dedupe probe 2026-05-27`
|
||||||
|
- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4`
|
||||||
|
- `adapter_name`: `wecomcs-eba`
|
||||||
|
- Observed common event: `MessageReceived`, `event.type=message.received`
|
||||||
|
- Observed message chain: `Source + Plain`
|
||||||
|
- Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid`
|
||||||
|
- Observed sender: customer `User` with nickname/avatar from Customer Service lookup
|
||||||
|
- Plugin API probe: `send_message`, `get_message`, `get_user_info`, `get_friend_list`, plugin/workspace storage, and manifest/list APIs succeeded
|
||||||
|
- Component sweep: outbound `Plain` and `Image` succeeded; `Face` and `File` returned explicit `NotSupportedError`; later quote/forward fallback sends were blocked by WeCom `95001 send msg count limit`
|
||||||
|
|
||||||
|
Command shape used:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
|
||||||
|
cd LangBot
|
||||||
|
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run main.py --standalone-runtime
|
||||||
|
|
||||||
|
cd data/plugins/LangBot__EBAEventProbe
|
||||||
|
DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws \
|
||||||
|
EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/wecomcs_eba_plugin_probe.jsonl \
|
||||||
|
EBA_PROBE_API=1 \
|
||||||
|
EBA_PROBE_COMPONENT_SWEEP=1 \
|
||||||
|
EBA_PROBE_PLATFORM_API=1 \
|
||||||
|
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||||
|
```
|
||||||
|
|
||||||
|
Required real UI trigger: send a Customer Service message from the WeCom/WeChat customer-side UI to the configured `dev.rockchin.top` Customer Service account.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked or pending items:
|
||||||
|
|
||||||
|
- Inbound UI media (`Image`, `Voice`, `File`) was not sent from the real WeChat customer UI during this run, so receive-side media remains unit-covered only.
|
||||||
|
- Pipeline auto-reply reached `kf/send_msg`, but the test account hit WeCom `95001 send msg count limit` after successful plugin outbound text/image sends. This is recorded as an account/platform rate-limit block, not a conversion or API-shape failure.
|
||||||
|
- The current `EBAEventProbe` run did not call the adapter-specific `call_platform_api` actions (`check_access_token`, `refresh_access_token`, `get_customer_info`); the platform API map remains unit-covered.
|
||||||
|
- Inbound voice/file depends on whether the real Customer Service callback plus `sync_msg` endpoint returns those fields in the shape the local SDK models.
|
||||||
|
- Group, member, edit, delete, moderation, and standalone file URL APIs are intentionally not declared because this Customer Service protocol path does not provide tested common equivalents.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.7"
|
version = "4.9.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -22,7 +22,7 @@ dependencies = [
|
|||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.5.5",
|
"lark-oapi>=1.4.15",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
@@ -35,7 +35,6 @@ dependencies = [
|
|||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
"qrcode>=7.4",
|
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
@@ -70,7 +69,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.11",
|
"langbot-plugin==0.3.10",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -105,9 +104,6 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
langbot-plugin = { path = "../langbot-plugin-sdk", editable = true }
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
@@ -121,11 +117,10 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"moto>=5.2.1",
|
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ python_files = test_*.py
|
|||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|
||||||
# Python path for imports
|
|
||||||
pythonpath = . tests
|
|
||||||
|
|
||||||
# Test paths
|
# Test paths
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
|
||||||
@@ -25,9 +22,7 @@ markers =
|
|||||||
asyncio: mark test as async
|
asyncio: mark test as async
|
||||||
unit: mark test as unit test
|
unit: mark test as unit test
|
||||||
integration: mark test as integration test
|
integration: mark test as integration test
|
||||||
smoke: mark test as smoke test
|
|
||||||
slow: mark test as slow running
|
slow: mark test as slow running
|
||||||
e2e: mark test as end-to-end test (requires real LangBot process)
|
|
||||||
|
|
||||||
# Coverage options (when using pytest-cov)
|
# Coverage options (when using pytest-cov)
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Coverage gate script
|
|
||||||
# Runs all tests with coverage, enforcing minimum coverage threshold
|
|
||||||
# Uses separate pytest invocations to avoid sys.modules pollution between test types
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "=== LangBot Coverage Gate ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Coverage threshold (baseline from current coverage, conservative buffer)
|
|
||||||
# Current: ~22.14%, threshold: 18%
|
|
||||||
COVERAGE_THRESHOLD=18
|
|
||||||
|
|
||||||
# Create temporary directory for coverage files
|
|
||||||
COV_DIR=$(mktemp -d)
|
|
||||||
trap "rm -rf $COV_DIR" EXIT
|
|
||||||
|
|
||||||
echo "[1/3] Running unit + smoke tests with coverage..."
|
|
||||||
uv run pytest tests/unit_tests/ tests/smoke/ \
|
|
||||||
--cov=langbot \
|
|
||||||
--cov-report=json:$COV_DIR/unit.json \
|
|
||||||
--cov-report=term-missing \
|
|
||||||
-q --tb=short
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "[2/3] Running fast integration tests with coverage..."
|
|
||||||
uv run pytest tests/integration/ -m "not slow" \
|
|
||||||
--cov=langbot \
|
|
||||||
--cov-report=json:$COV_DIR/integration.json \
|
|
||||||
--cov-report=term-missing \
|
|
||||||
-q --tb=short
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "[3/3] Combining coverage reports..."
|
|
||||||
# Use coverage combine if available, otherwise just report total
|
|
||||||
if command -v coverage &> /dev/null; then
|
|
||||||
# Combine JSON reports
|
|
||||||
coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \
|
|
||||||
--data-file=$COV_DIR/combined.data 2>/dev/null || true
|
|
||||||
|
|
||||||
coverage report --data-file=$COV_DIR/combined.data || true
|
|
||||||
else
|
|
||||||
echo "Note: coverage combine not available, showing individual reports above"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate final XML report for CI (from last run)
|
|
||||||
uv run pytest tests/unit_tests/ tests/smoke/ \
|
|
||||||
--cov=langbot \
|
|
||||||
--cov-report=xml:coverage.xml \
|
|
||||||
--cov-report=term \
|
|
||||||
--cov-fail-under=$COVERAGE_THRESHOLD \
|
|
||||||
-q 2>/dev/null || {
|
|
||||||
# If threshold check fails on combined, check unit+smoke baseline
|
|
||||||
echo ""
|
|
||||||
echo "Coverage threshold: $COVERAGE_THRESHOLD%"
|
|
||||||
echo "Note: Full coverage requires running all test types separately"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Coverage Gate Complete ==="
|
|
||||||
echo ""
|
|
||||||
echo "Coverage baseline: $COVERAGE_THRESHOLD%"
|
|
||||||
echo "Coverage report saved to coverage.xml"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Fast integration tests
|
|
||||||
# Runs integration tests excluding slow ones (PostgreSQL, external services)
|
|
||||||
# Uses fake runner/provider, no real credentials needed
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "=== LangBot Fast Integration Tests ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "Running integration tests (excluding slow)..."
|
|
||||||
uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Fast Integration Tests Complete ==="
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Quick developer self-test command
|
|
||||||
# Runs linting, unit tests, and smoke tests without requiring real provider keys
|
|
||||||
# Suitable for local branch validation
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "=== LangBot Quick Self-Test ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 1. Ruff check
|
|
||||||
echo "[1/3] Running ruff check..."
|
|
||||||
uv run ruff check src/langbot/ tests/ --output-format=concise || {
|
|
||||||
echo ""
|
|
||||||
echo "⚠ Ruff check found issues. Run 'uv run ruff check --fix' to auto-fix."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "✓ Ruff check passed"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 2. Unit tests
|
|
||||||
echo "[2/3] Running unit tests..."
|
|
||||||
uv run pytest tests/unit_tests/ -q --tb=short
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 3. Smoke tests (if exists)
|
|
||||||
echo "[3/3] Running smoke tests..."
|
|
||||||
if [ -d "tests/smoke" ]; then
|
|
||||||
uv run pytest tests/smoke/ -q --tb=short
|
|
||||||
else
|
|
||||||
echo "No smoke tests found, skipping"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "=== Quick Self-Test Complete ==="
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.7'
|
__version__ = '4.9.6'
|
||||||
|
|||||||
@@ -438,8 +438,13 @@ class DingTalkClient:
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {'text': response.text}
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return body
|
||||||
|
raise Exception(f'Error: {response.status_code}, {body}')
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
@@ -464,8 +469,13 @@ class DingTalkClient:
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {'text': response.text}
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return body
|
||||||
|
raise Exception(f'Error: {response.status_code}, {body}')
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||||
|
|||||||
@@ -93,15 +93,30 @@ class OAClient:
|
|||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if req.method == 'GET':
|
||||||
# 校验签名
|
if msg_signature:
|
||||||
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
|
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if ret == 0:
|
||||||
|
return reply_echo
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount encrypted URL verification failed: '
|
||||||
|
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||||
|
f'echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plaintext callback verification.
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
if check_signature == signature:
|
if check_signature == signature:
|
||||||
return echostr # 验证成功返回echostr
|
return echostr # 验证成功返回echostr
|
||||||
else:
|
else:
|
||||||
await self.logger.error('拒绝请求')
|
await self.logger.error(
|
||||||
raise Exception('拒绝请求')
|
'OfficialAccount plaintext URL verification failed: '
|
||||||
|
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||||
|
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
return 'signature verification failed', 403
|
||||||
elif req.method == 'POST':
|
elif req.method == 'POST':
|
||||||
encryt_msg = await req.data
|
encryt_msg = await req.data
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
@@ -279,9 +294,27 @@ class OAClientForLongerResponse:
|
|||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if req.method == 'GET':
|
||||||
|
if msg_signature:
|
||||||
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
|
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if ret == 0:
|
||||||
|
return reply_echo
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount encrypted URL verification failed: '
|
||||||
|
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||||
|
f'echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
return echostr if check_signature == signature else '拒绝请求'
|
if check_signature == signature:
|
||||||
|
return echostr
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount plaintext URL verification failed: '
|
||||||
|
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||||
|
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
return 'signature verification failed', 403
|
||||||
|
|
||||||
elif req.method == 'POST':
|
elif req.method == 'POST':
|
||||||
encryt_msg = await req.data
|
encryt_msg = await req.data
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
@@ -7,7 +9,7 @@ import uuid
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable, Optional, Tuple
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -16,7 +18,9 @@ from quart import Quart, request, Response, jsonify
|
|||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ import json
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Callable, Optional
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,33 @@ class WecomCSClient:
|
|||||||
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
||||||
if data['errcode'] != 0:
|
if data['errcode'] != 0:
|
||||||
await self.logger.error(f'发送消息失败:{data}')
|
await self.logger.error(f'发送消息失败:{data}')
|
||||||
raise Exception('Failed to send message')
|
raise Exception(f'Failed to send message: {data}')
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片消息失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
@@ -322,7 +348,7 @@ class WecomCSClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=image'
|
||||||
file_bytes = None
|
file_bytes = None
|
||||||
file_name = 'uploaded_file.txt'
|
file_name = 'uploaded_file.txt'
|
||||||
|
|
||||||
@@ -368,7 +394,7 @@ class WecomCSClient:
|
|||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
media_id = await self.upload_to_work(image)
|
media_id = await self.upload_to_work(image)
|
||||||
if data.get('errcode', 0) != 0:
|
if data.get('errcode', 0) != 0:
|
||||||
raise Exception('failed to upload file')
|
raise Exception(f'failed to upload image: {data}')
|
||||||
|
|
||||||
media_id = data.get('media_id')
|
media_id = data.get('media_id')
|
||||||
return media_id
|
return media_id
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Agent runner subsystem for LangBot."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .runner.descriptor import AgentRunnerDescriptor
|
|
||||||
from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id
|
|
||||||
from .runner.errors import (
|
|
||||||
AgentRunnerError,
|
|
||||||
RunnerNotFoundError,
|
|
||||||
RunnerNotAuthorizedError,
|
|
||||||
RunnerProtocolError,
|
|
||||||
RunnerExecutionError,
|
|
||||||
)
|
|
||||||
from .runner.registry import AgentRunnerRegistry
|
|
||||||
from .runner.context_builder import AgentRunContextBuilder
|
|
||||||
from .runner.resource_builder import AgentResourceBuilder
|
|
||||||
from .runner.result_normalizer import AgentResultNormalizer
|
|
||||||
from .runner.orchestrator import AgentRunOrchestrator
|
|
||||||
from .runner.config_migration import ConfigMigration
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'AgentRunnerDescriptor',
|
|
||||||
'parse_runner_id',
|
|
||||||
'format_runner_id',
|
|
||||||
'is_plugin_runner_id',
|
|
||||||
'RunnerIdParts',
|
|
||||||
'AgentRunnerError',
|
|
||||||
'RunnerNotFoundError',
|
|
||||||
'RunnerNotAuthorizedError',
|
|
||||||
'RunnerProtocolError',
|
|
||||||
'RunnerExecutionError',
|
|
||||||
'AgentRunnerRegistry',
|
|
||||||
'AgentRunContextBuilder',
|
|
||||||
'AgentResourceBuilder',
|
|
||||||
'AgentResultNormalizer',
|
|
||||||
'AgentRunOrchestrator',
|
|
||||||
'ConfigMigration',
|
|
||||||
]
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Agent runner modules."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .id import parse_runner_id, format_runner_id, RunnerIdParts
|
|
||||||
from .errors import (
|
|
||||||
AgentRunnerError,
|
|
||||||
RunnerNotFoundError,
|
|
||||||
RunnerNotAuthorizedError,
|
|
||||||
RunnerProtocolError,
|
|
||||||
RunnerExecutionError,
|
|
||||||
)
|
|
||||||
from .registry import AgentRunnerRegistry
|
|
||||||
from .context_builder import AgentRunContextBuilder
|
|
||||||
from .resource_builder import AgentResourceBuilder
|
|
||||||
from .result_normalizer import AgentResultNormalizer
|
|
||||||
from .orchestrator import AgentRunOrchestrator
|
|
||||||
from .config_migration import ConfigMigration
|
|
||||||
from .session_registry import AgentRunSessionRegistry, AgentRunSession, 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',
|
|
||||||
'AgentRunSessionRegistry',
|
|
||||||
'AgentRunSession',
|
|
||||||
'get_session_registry',
|
|
||||||
'MESSAGE_RECEIVED',
|
|
||||||
'MESSAGE_RECALLED',
|
|
||||||
'GROUP_MEMBER_JOINED',
|
|
||||||
'FRIEND_REQUEST_RECEIVED',
|
|
||||||
'RESERVED_EVENT_TYPES',
|
|
||||||
]
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
"""Artifact store for managing Host-owned artifacts."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
import base64
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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_artifact(
|
|
||||||
self,
|
|
||||||
artifact_id: str | None,
|
|
||||||
artifact_type: str,
|
|
||||||
source: str,
|
|
||||||
storage_key: str | None = None,
|
|
||||||
storage_type: str = 'binary_storage',
|
|
||||||
mime_type: str | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
size_bytes: int | None = None,
|
|
||||||
sha256: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
bot_id: str | None = None,
|
|
||||||
workspace_id: str | None = None,
|
|
||||||
expires_at: datetime.datetime | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
content: bytes | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Register a new artifact.
|
|
||||||
|
|
||||||
If content is provided and storage_key is None, stores content
|
|
||||||
in BinaryStorage automatically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Unique artifact ID (generated if None)
|
|
||||||
artifact_type: Type of artifact (image, file, voice, tool_result, etc.)
|
|
||||||
source: Source of artifact (platform, runner, tool, system)
|
|
||||||
storage_key: Key in BinaryStorage or external reference
|
|
||||||
storage_type: Storage type (binary_storage, file, url)
|
|
||||||
mime_type: MIME type
|
|
||||||
name: Original file name
|
|
||||||
size_bytes: Size in bytes
|
|
||||||
sha256: SHA256 hash
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
run_id: Run ID that created this
|
|
||||||
runner_id: Runner ID that created this
|
|
||||||
bot_id: Bot UUID
|
|
||||||
workspace_id: Workspace ID
|
|
||||||
expires_at: Expiration time
|
|
||||||
metadata: Additional metadata
|
|
||||||
content: Optional content to store in BinaryStorage
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The artifact_id
|
|
||||||
"""
|
|
||||||
if artifact_id is None:
|
|
||||||
artifact_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# If content provided, store in BinaryStorage
|
|
||||||
if content is not None and storage_key is None:
|
|
||||||
storage_key = f"artifact:{artifact_id}"
|
|
||||||
storage_type = 'binary_storage'
|
|
||||||
if size_bytes is None:
|
|
||||||
size_bytes = len(content)
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
# Store content in BinaryStorage if provided
|
|
||||||
if content is not None:
|
|
||||||
binary_storage = BinaryStorage(
|
|
||||||
unique_key=f'artifact:{artifact_id}',
|
|
||||||
key=storage_key,
|
|
||||||
owner_type='artifact',
|
|
||||||
owner='host',
|
|
||||||
value=content,
|
|
||||||
)
|
|
||||||
session.add(binary_storage)
|
|
||||||
|
|
||||||
# Store artifact metadata
|
|
||||||
artifact = AgentArtifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
mime_type=mime_type,
|
|
||||||
name=name,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
sha256=sha256,
|
|
||||||
source=source,
|
|
||||||
storage_key=storage_key,
|
|
||||||
storage_type=storage_type,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=bot_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
expires_at=expires_at,
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
)
|
|
||||||
session.add(artifact)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return artifact_id
|
|
||||||
|
|
||||||
async def get_metadata(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Get artifact metadata (public fields only, no internal storage info).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Artifact metadata dict compatible with SDK ArtifactMetadata, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(AgentArtifact).where(
|
|
||||||
AgentArtifact.artifact_id == artifact_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return self._row_to_public_dict(row)
|
|
||||||
|
|
||||||
async def _get_internal_record(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
) -> AgentArtifact | None:
|
|
||||||
"""Get full artifact record including internal fields.
|
|
||||||
|
|
||||||
Used internally by read_artifact to access storage_key/storage_type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentArtifact ORM instance, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(AgentArtifact).where(
|
|
||||||
AgentArtifact.artifact_id == artifact_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def read_artifact(
|
|
||||||
self,
|
|
||||||
artifact_id: str,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int | None = None,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Read artifact content.
|
|
||||||
|
|
||||||
For small artifacts, returns content_base64 directly.
|
|
||||||
For large artifacts, returns file_key for chunked transfer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
artifact_id: Artifact ID
|
|
||||||
offset: Byte offset to start reading from (must be >= 0)
|
|
||||||
limit: Maximum bytes to read (must be > 0 if provided)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ArtifactReadResult dict, or None if not found
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If offset < 0 or limit <= 0
|
|
||||||
"""
|
|
||||||
# Validate offset and limit
|
|
||||||
if offset < 0:
|
|
||||||
raise ValueError("offset must be >= 0")
|
|
||||||
|
|
||||||
if limit is not None and limit <= 0:
|
|
||||||
raise ValueError("limit must be > 0")
|
|
||||||
|
|
||||||
# Get internal record (includes storage_key/storage_type)
|
|
||||||
record = await self._get_internal_record(artifact_id)
|
|
||||||
if record is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
storage_type = record.storage_type or 'binary_storage'
|
|
||||||
storage_key = record.storage_key
|
|
||||||
size_bytes = record.size_bytes or 0
|
|
||||||
|
|
||||||
# Cap limit at hard limit
|
|
||||||
if limit is None:
|
|
||||||
limit = self.MAX_INLINE_READ_BYTES
|
|
||||||
limit = min(limit, self.MAX_RANGE_READ_BYTES)
|
|
||||||
|
|
||||||
# For binary_storage, read content
|
|
||||||
if storage_type == 'binary_storage' and storage_key:
|
|
||||||
content = await self._read_binary_storage(storage_key)
|
|
||||||
if content is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Apply offset and limit
|
|
||||||
if offset > 0:
|
|
||||||
content = content[offset:]
|
|
||||||
if limit and len(content) > limit:
|
|
||||||
content = content[:limit]
|
|
||||||
has_more = True
|
|
||||||
else:
|
|
||||||
has_more = False
|
|
||||||
|
|
||||||
return {
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'mime_type': record.mime_type,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
'offset': offset,
|
|
||||||
'length': len(content),
|
|
||||||
'content_base64': base64.b64encode(content).decode('utf-8'),
|
|
||||||
'file_key': None,
|
|
||||||
'has_more': has_more,
|
|
||||||
}
|
|
||||||
|
|
||||||
# For other storage types, return storage reference
|
|
||||||
# (caller can use file_key for chunked transfer)
|
|
||||||
return {
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'mime_type': record.mime_type,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
'offset': offset,
|
|
||||||
'length': None,
|
|
||||||
'content_base64': None,
|
|
||||||
'file_key': storage_key,
|
|
||||||
'has_more': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _read_binary_storage(self, key: str) -> bytes | None:
|
|
||||||
"""Read content from BinaryStorage.
|
|
||||||
|
|
||||||
Uses unique_key for isolation to prevent cross-artifact access.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: The unique_key used when storing the artifact
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content bytes, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(BinaryStorage).where(BinaryStorage.unique_key == key)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row.value
|
|
||||||
|
|
||||||
def _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': json.loads(row.metadata_json) if row.metadata_json else {},
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
"""Configuration migration for agent runner IDs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .id import is_plugin_runner_id
|
|
||||||
|
|
||||||
|
|
||||||
# Mapping from old built-in runner names to official plugin runner IDs
|
|
||||||
OLD_RUNNER_TO_PLUGIN_RUNNER_ID = {
|
|
||||||
'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 migration helper for agent runner IDs.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Resolve runner ID from new ai.runner.id or old ai.runner.runner
|
|
||||||
- Map old built-in runner names to official plugin runner IDs
|
|
||||||
- Extract runtime runner config from ai.runner_config
|
|
||||||
- Migrate old ai.<runner-name> blocks into ai.runner_config
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None:
|
|
||||||
"""Resolve runner ID from pipeline configuration.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1. New format: ai.runner.id (must be plugin:* format)
|
|
||||||
2. Old format: ai.runner.runner (mapped to plugin:* if built-in)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Pipeline configuration dict
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner ID string, or None if not configured
|
|
||||||
"""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
runner_config = ai_config.get('runner', {})
|
|
||||||
|
|
||||||
# Check new format first
|
|
||||||
runner_id = runner_config.get('id')
|
|
||||||
if runner_id:
|
|
||||||
if is_plugin_runner_id(runner_id):
|
|
||||||
return runner_id
|
|
||||||
# If it's not a plugin ID, try to map it as old runner name
|
|
||||||
return OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(runner_id, runner_id)
|
|
||||||
|
|
||||||
# Check old format
|
|
||||||
old_runner_name = runner_config.get('runner')
|
|
||||||
if old_runner_name:
|
|
||||||
# If already plugin:* format, return directly
|
|
||||||
if is_plugin_runner_id(old_runner_name):
|
|
||||||
return old_runner_name
|
|
||||||
# Map old built-in runner to official plugin ID
|
|
||||||
mapped_id = OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(old_runner_name)
|
|
||||||
if mapped_id:
|
|
||||||
return mapped_id
|
|
||||||
# Return old name if no mapping exists (will error in registry)
|
|
||||||
return old_runner_name
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_runner_config(
|
|
||||||
pipeline_config: dict[str, typing.Any],
|
|
||||||
runner_id: str,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Resolve runner binding configuration from pipeline configuration.
|
|
||||||
|
|
||||||
Runtime code should only read the migrated format. Legacy
|
|
||||||
ai.<runner-name> blocks are handled by migration helpers, not by the
|
|
||||||
hot path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Pipeline configuration dict
|
|
||||||
runner_id: Resolved runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner configuration dict (empty if not found)
|
|
||||||
"""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
|
|
||||||
# Check new format
|
|
||||||
runner_configs = ai_config.get('runner_config', {})
|
|
||||||
if runner_id in runner_configs:
|
|
||||||
return runner_configs[runner_id]
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_legacy_runner_config(
|
|
||||||
pipeline_config: dict[str, typing.Any],
|
|
||||||
runner_id: str,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Resolve old ai.<runner-name> config for migration only."""
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
|
|
||||||
# Try to find old runner name from runner_id
|
|
||||||
old_runner_name = None
|
|
||||||
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
|
|
||||||
if mapped_id == runner_id:
|
|
||||||
old_runner_name = old_name
|
|
||||||
break
|
|
||||||
|
|
||||||
if old_runner_name:
|
|
||||||
old_config = ai_config.get(old_runner_name, {})
|
|
||||||
if old_config:
|
|
||||||
old_config = dict(old_config)
|
|
||||||
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
|
|
||||||
old_config.pop('max-round', None)
|
|
||||||
return ConfigMigration.normalize_runner_config_for_migration(runner_id, old_config)
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def normalize_runner_config_for_migration(
|
|
||||||
runner_id: str,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Normalize released legacy runner config before storing binding config.
|
|
||||||
|
|
||||||
Runtime code should not carry aliases. This helper is intentionally used
|
|
||||||
only by config migration so AgentRunner implementations can consume the
|
|
||||||
current manifest-defined field names.
|
|
||||||
"""
|
|
||||||
normalized = dict(runner_config)
|
|
||||||
|
|
||||||
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
|
|
||||||
legacy_kb = normalized.pop('knowledge-base', None)
|
|
||||||
if 'knowledge-bases' not in normalized:
|
|
||||||
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
|
|
||||||
normalized['knowledge-bases'] = [legacy_kb]
|
|
||||||
elif legacy_kb is not None:
|
|
||||||
normalized['knowledge-bases'] = []
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_old_runner_name(runner_id: str) -> str | None:
|
|
||||||
"""Get old runner name from mapped runner ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_id: Plugin runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Old runner name if mapped, None otherwise
|
|
||||||
"""
|
|
||||||
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
|
|
||||||
if mapped_id == runner_id:
|
|
||||||
return old_name
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int:
|
|
||||||
"""Get conversation expire time from configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Pipeline configuration dict
|
|
||||||
|
|
||||||
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]:
|
|
||||||
"""Migrate pipeline config to new format.
|
|
||||||
|
|
||||||
This converts old ai.runner.runner and ai.<runner-name> to
|
|
||||||
new ai.runner.id and ai.runner_config format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline_config: Original pipeline configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Migrated pipeline configuration
|
|
||||||
"""
|
|
||||||
# Create copy
|
|
||||||
new_config = dict(pipeline_config)
|
|
||||||
ai_config = new_config.get('ai', {})
|
|
||||||
if not ai_config:
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
runner_config = ai_config.get('runner', {})
|
|
||||||
runner_configs = ai_config.get('runner_config', {})
|
|
||||||
|
|
||||||
# Resolve runner ID
|
|
||||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
|
||||||
if runner_id:
|
|
||||||
# Set new format
|
|
||||||
runner_config['id'] = runner_id
|
|
||||||
# Remove old runner field if present
|
|
||||||
if 'runner' in runner_config and is_plugin_runner_id(runner_config['runner']):
|
|
||||||
# Already migrated plugin:* format, keep as id
|
|
||||||
pass
|
|
||||||
elif 'runner' in runner_config:
|
|
||||||
# Old built-in runner name, remove after migration
|
|
||||||
old_name = runner_config['runner']
|
|
||||||
if old_name in OLD_RUNNER_TO_PLUGIN_RUNNER_ID:
|
|
||||||
del runner_config['runner']
|
|
||||||
|
|
||||||
# Migrate runner config
|
|
||||||
resolved_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
|
||||||
if not resolved_config:
|
|
||||||
resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id)
|
|
||||||
if resolved_config:
|
|
||||||
resolved_config = ConfigMigration.normalize_runner_config_for_migration(runner_id, resolved_config)
|
|
||||||
runner_configs[runner_id] = resolved_config
|
|
||||||
# Remove old runner config block
|
|
||||||
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
|
|
||||||
if mapped_id == runner_id and old_name in ai_config:
|
|
||||||
del ai_config[old_name]
|
|
||||||
|
|
||||||
# Update configs
|
|
||||||
ai_config['runner'] = runner_config
|
|
||||||
ai_config['runner_config'] = runner_configs
|
|
||||||
new_config['ai'] = ai_config
|
|
||||||
|
|
||||||
return new_config
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""Helpers for interpreting AgentRunner DynamicForm configuration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
|
|
||||||
|
|
||||||
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
|
|
||||||
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
|
||||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
|
||||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
|
||||||
|
|
||||||
|
|
||||||
def iter_schema_items(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
field_types: set[str],
|
|
||||||
) -> typing.Iterator[dict[str, typing.Any]]:
|
|
||||||
"""Yield descriptor config schema items whose type is in field_types."""
|
|
||||||
if descriptor is None:
|
|
||||||
return
|
|
||||||
for item in descriptor.config_schema or []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
if item.get('type') in field_types:
|
|
||||||
yield item
|
|
||||||
|
|
||||||
|
|
||||||
def has_permission(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
name: str,
|
|
||||||
actions: set[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Return whether a runner descriptor requests one of the given actions."""
|
|
||||||
if descriptor is None:
|
|
||||||
return False
|
|
||||||
configured_actions = descriptor.permissions.get(name, [])
|
|
||||||
return any(action in configured_actions for action in actions)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should resolve model resources for this runner."""
|
|
||||||
return (
|
|
||||||
has_permission(descriptor, 'models', {'invoke', 'stream', 'list'})
|
|
||||||
and any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should expose tool resources to this runner."""
|
|
||||||
return (
|
|
||||||
descriptor is not None
|
|
||||||
and descriptor.supports_tool_calling()
|
|
||||||
and has_permission(descriptor, 'tools', {'list', 'detail', 'call'})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether LangBot should expose knowledge-base resources to this runner."""
|
|
||||||
return (
|
|
||||||
descriptor is not None
|
|
||||||
and descriptor.supports_knowledge_retrieval()
|
|
||||||
and has_permission(descriptor, 'knowledge_bases', {'list', 'retrieve'})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_prompt_config(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
default_prompt: list[dict[str, typing.Any]],
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Extract the prompt-editor value selected by the runner schema."""
|
|
||||||
for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if field_name and field_name in runner_config:
|
|
||||||
configured_prompt = runner_config[field_name]
|
|
||||||
if isinstance(configured_prompt, list):
|
|
||||||
return configured_prompt
|
|
||||||
default_value = item.get('default')
|
|
||||||
if isinstance(default_value, list):
|
|
||||||
return default_value
|
|
||||||
return default_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def extract_model_selection(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> tuple[str, list[str]]:
|
|
||||||
"""Extract primary/fallback LLM selections from schema-defined fields."""
|
|
||||||
primary_uuid = ''
|
|
||||||
fallback_uuids: list[str] = []
|
|
||||||
|
|
||||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name, item.get('default'))
|
|
||||||
if item.get('type') == 'model-fallback-selector':
|
|
||||||
if isinstance(value, str):
|
|
||||||
primary_uuid = value
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
primary_uuid = value.get('primary') or ''
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
if isinstance(fallbacks, list):
|
|
||||||
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
|
|
||||||
break
|
|
||||||
|
|
||||||
if item.get('type') == 'llm-model-selector' and isinstance(value, str):
|
|
||||||
primary_uuid = value
|
|
||||||
break
|
|
||||||
|
|
||||||
return primary_uuid, fallback_uuids
|
|
||||||
|
|
||||||
|
|
||||||
def extract_knowledge_base_uuids(
|
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Extract configured knowledge-base UUIDs from schema-defined fields."""
|
|
||||||
if not uses_host_knowledge_bases(descriptor):
|
|
||||||
return []
|
|
||||||
|
|
||||||
kb_uuids: list[str] = []
|
|
||||||
for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
value = runner_config.get(field_name, item.get('default', []))
|
|
||||||
if isinstance(value, list):
|
|
||||||
kb_uuids.extend(
|
|
||||||
kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS
|
|
||||||
)
|
|
||||||
|
|
||||||
return list(dict.fromkeys(kb_uuids))
|
|
||||||
|
|
||||||
|
|
||||||
def iter_config_model_refs(
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> typing.Iterator[tuple[str, str]]:
|
|
||||||
"""Yield model references declared by schema-defined model selector fields."""
|
|
||||||
for item in descriptor.config_schema or []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
field_name = item.get('name')
|
|
||||||
field_type = item.get('type')
|
|
||||||
if not field_name or field_name not in runner_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name)
|
|
||||||
if field_type == 'model-fallback-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'llm', value
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
primary = value.get('primary')
|
|
||||||
if isinstance(primary, str) and primary not in NONE_SENTINELS:
|
|
||||||
yield 'llm', primary
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
if isinstance(fallbacks, list):
|
|
||||||
for fallback_uuid in fallbacks:
|
|
||||||
if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS:
|
|
||||||
yield 'llm', fallback_uuid
|
|
||||||
elif field_type == 'llm-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'llm', value
|
|
||||||
elif field_type == 'rerank-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
yield 'rerank', value
|
|
||||||
|
|
||||||
|
|
||||||
def set_empty_llm_model_selection(
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
model_uuid: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Set the first empty schema-defined LLM selector to model_uuid."""
|
|
||||||
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
||||||
field_name = item.get('name')
|
|
||||||
field_type = item.get('type')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = runner_config.get(field_name, item.get('default'))
|
|
||||||
if field_type == 'model-fallback-selector':
|
|
||||||
if isinstance(value, dict):
|
|
||||||
primary = value.get('primary') or ''
|
|
||||||
if primary not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
fallbacks = value.get('fallbacks', [])
|
|
||||||
runner_config[field_name] = {
|
|
||||||
'primary': model_uuid,
|
|
||||||
'fallbacks': fallbacks if isinstance(fallbacks, list) else [],
|
|
||||||
}
|
|
||||||
return True
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []}
|
|
||||||
return True
|
|
||||||
|
|
||||||
if field_type == 'llm-model-selector':
|
|
||||||
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
||||||
return False
|
|
||||||
runner_config[field_name] = model_uuid
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
"""Agent run context builder for provisioning AgentRunContext envelopes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .persistent_state_store import get_persistent_state_store
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
# Internal models for the agent runner context protocol.
|
|
||||||
|
|
||||||
|
|
||||||
class AgentTrigger(typing.TypedDict):
|
|
||||||
"""Agent trigger information."""
|
|
||||||
|
|
||||||
type: str
|
|
||||||
source: str # 'pipeline' or 'event_router'
|
|
||||||
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
|
|
||||||
pipeline_uuid: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentInput(typing.TypedDict):
|
|
||||||
"""Agent input."""
|
|
||||||
|
|
||||||
text: str | None
|
|
||||||
contents: list[dict[str, typing.Any]]
|
|
||||||
message_chain: dict[str, typing.Any] | None
|
|
||||||
attachments: list[dict[str, typing.Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunState(typing.TypedDict):
|
|
||||||
"""Agent run state with 4 scopes."""
|
|
||||||
|
|
||||||
conversation: dict[str, typing.Any]
|
|
||||||
actor: dict[str, typing.Any]
|
|
||||||
subject: dict[str, typing.Any]
|
|
||||||
runner: dict[str, typing.Any]
|
|
||||||
|
|
||||||
|
|
||||||
# Resource payload models matching langbot-plugin-sdk/resources.py.
|
|
||||||
|
|
||||||
|
|
||||||
class ModelResource(typing.TypedDict):
|
|
||||||
"""Model resource payload."""
|
|
||||||
|
|
||||||
model_id: str
|
|
||||||
model_type: str | None
|
|
||||||
provider: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ToolResource(typing.TypedDict):
|
|
||||||
"""Tool resource payload."""
|
|
||||||
|
|
||||||
tool_name: str
|
|
||||||
tool_type: str | None
|
|
||||||
description: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeBaseResource(typing.TypedDict):
|
|
||||||
"""Knowledge base resource payload."""
|
|
||||||
|
|
||||||
kb_id: str
|
|
||||||
kb_name: str | None
|
|
||||||
kb_type: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class FileResource(typing.TypedDict):
|
|
||||||
"""File resource payload."""
|
|
||||||
|
|
||||||
file_id: str
|
|
||||||
file_name: str | None
|
|
||||||
mime_type: str | None
|
|
||||||
source: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class StorageResource(typing.TypedDict):
|
|
||||||
"""Storage resource payload."""
|
|
||||||
|
|
||||||
plugin_storage: bool
|
|
||||||
workspace_storage: bool
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResources(typing.TypedDict):
|
|
||||||
"""Agent resources payload."""
|
|
||||||
|
|
||||||
models: list[ModelResource]
|
|
||||||
tools: list[ToolResource]
|
|
||||||
knowledge_bases: list[KnowledgeBaseResource]
|
|
||||||
files: list[FileResource]
|
|
||||||
storage: StorageResource
|
|
||||||
platform_capabilities: dict[str, typing.Any]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRuntimeContext(typing.TypedDict):
|
|
||||||
"""Agent runtime context."""
|
|
||||||
|
|
||||||
langbot_version: str | None
|
|
||||||
sdk_protocol_version: str
|
|
||||||
query_id: int | 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 binding config from ai.runner_config[runner_id],
|
|
||||||
which is Pipeline's configuration for this specific runner binding (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] # Binding config from ai.runner_config[runner_id]
|
|
||||||
bootstrap: dict[str, typing.Any] | None # Optional bootstrap context
|
|
||||||
adapter: dict[str, typing.Any] | None # Pipeline 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 runner binding configuration.
|
|
||||||
|
|
||||||
Pipeline Query adaptation belongs to PipelineAdapter, 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 configuration
|
|
||||||
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, # Pipeline adapter field
|
|
||||||
'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,
|
|
||||||
'pipeline_uuid': binding.pipeline_uuid, # Pipeline adapter field
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build event context (Protocol v1 event-first)
|
|
||||||
event_context = {
|
|
||||||
'event_id': event.event_id,
|
|
||||||
'event_type': event.event_type,
|
|
||||||
'event_time': event.event_time,
|
|
||||||
'source': event.source,
|
|
||||||
'source_event_type': event.source_event_type,
|
|
||||||
'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None,
|
|
||||||
'data': event.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build actor context
|
|
||||||
actor_context = None
|
|
||||||
if event.actor:
|
|
||||||
actor_context = {
|
|
||||||
'actor_type': event.actor.actor_type,
|
|
||||||
'actor_id': event.actor.actor_id,
|
|
||||||
'actor_name': event.actor.actor_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build subject context
|
|
||||||
subject_context = None
|
|
||||||
if event.subject:
|
|
||||||
subject_context = {
|
|
||||||
'subject_type': event.subject.subject_type,
|
|
||||||
'subject_id': event.subject.subject_id,
|
|
||||||
'data': event.subject.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build input from event
|
|
||||||
input: AgentInput = {
|
|
||||||
'text': event.input.text,
|
|
||||||
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
|
||||||
'message_chain': event.input.message_chain,
|
|
||||||
'attachments': [
|
|
||||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build context access (no history inlined by default for Protocol v1)
|
|
||||||
# Populate with actual values from stores
|
|
||||||
context_access = await self._build_context_access(event, descriptor, binding)
|
|
||||||
|
|
||||||
# Build state snapshot from persistent state store (event-first Protocol v1)
|
|
||||||
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
|
|
||||||
|
|
||||||
# Build runtime context
|
|
||||||
runtime: AgentRuntimeContext = {
|
|
||||||
'langbot_version': self.ap.ver_mgr.get_current_version(),
|
|
||||||
'sdk_protocol_version': descriptor.protocol_version,
|
|
||||||
'query_id': None, # No query_id in event-first mode
|
|
||||||
'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
|
|
||||||
# binding 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 = {
|
|
||||||
'query_id': None,
|
|
||||||
'pipeline_uuid': binding.pipeline_uuid,
|
|
||||||
'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,
|
|
||||||
'bootstrap': None,
|
|
||||||
'adapter': adapter_context,
|
|
||||||
'metadata': {}, # Additional metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def _build_deadline_from_binding(self, binding: AgentBinding) -> float | None:
|
|
||||||
"""Build deadline timestamp from binding timeout config.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
binding: Agent binding with runner_config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Deadline timestamp or None
|
|
||||||
"""
|
|
||||||
timeout = binding.runner_config.get('timeout', DEFAULT_RUNNER_TIMEOUT_SECONDS)
|
|
||||||
if timeout is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
timeout_seconds = float(timeout)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if timeout_seconds <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return time.time() + timeout_seconds
|
|
||||||
|
|
||||||
async def _build_context_access(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
binding: AgentBinding | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Build ContextAccess with actual values from stores.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
descriptor: Runner descriptor
|
|
||||||
binding: Agent binding (required for state_policy in event-first mode)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ContextAccess dict
|
|
||||||
"""
|
|
||||||
conversation_id = event.conversation_id
|
|
||||||
|
|
||||||
# Check if history APIs are available for this runner
|
|
||||||
# Based on runner permissions
|
|
||||||
permissions = descriptor.permissions or {}
|
|
||||||
history_permissions = permissions.get('history', [])
|
|
||||||
event_permissions = permissions.get('events', [])
|
|
||||||
artifact_permissions = permissions.get('artifacts', [])
|
|
||||||
|
|
||||||
history_page_enabled = 'page' in history_permissions and conversation_id is not None
|
|
||||||
history_search_enabled = 'search' in history_permissions and conversation_id is not None
|
|
||||||
event_get_enabled = 'get' in event_permissions
|
|
||||||
event_page_enabled = 'page' in event_permissions and conversation_id is not None
|
|
||||||
artifact_metadata_enabled = 'metadata' in artifact_permissions
|
|
||||||
artifact_read_enabled = 'read' in artifact_permissions
|
|
||||||
|
|
||||||
# Determine state API availability based on binding state_policy.
|
|
||||||
state_enabled = False
|
|
||||||
if binding is not None:
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
if state_policy.enable_state and state_policy.state_scopes:
|
|
||||||
state_enabled = True
|
|
||||||
|
|
||||||
# Get latest cursor and has_history_before if conversation exists
|
|
||||||
latest_cursor = None
|
|
||||||
has_history_before = False
|
|
||||||
|
|
||||||
if conversation_id:
|
|
||||||
try:
|
|
||||||
from .transcript_store import TranscriptStore
|
|
||||||
|
|
||||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
latest_cursor = await store.get_latest_cursor(conversation_id)
|
|
||||||
if latest_cursor:
|
|
||||||
has_history_before = True
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to get transcript cursor: {e}')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'conversation_id': conversation_id,
|
|
||||||
'thread_id': event.thread_id,
|
|
||||||
'latest_cursor': latest_cursor,
|
|
||||||
'event_seq': None, # Will be populated when EventLog is written
|
|
||||||
'transcript_seq': int(latest_cursor) if latest_cursor else None,
|
|
||||||
'has_history_before': has_history_before,
|
|
||||||
'inline_policy': {
|
|
||||||
'mode': 'current_event',
|
|
||||||
'delivered_count': 0,
|
|
||||||
'source_total_count': None,
|
|
||||||
'messages_complete': False,
|
|
||||||
'reason': 'self_managed_context',
|
|
||||||
},
|
|
||||||
'available_apis': {
|
|
||||||
'history_page': history_page_enabled,
|
|
||||||
'history_search': history_search_enabled,
|
|
||||||
'event_get': event_get_enabled,
|
|
||||||
'event_page': event_page_enabled,
|
|
||||||
'artifact_metadata': artifact_metadata_enabled,
|
|
||||||
'artifact_read': artifact_read_enabled,
|
|
||||||
'state': state_enabled,
|
|
||||||
'storage': True,
|
|
||||||
'prompt_get': False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""Agent runner descriptor."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerDescriptor(pydantic.BaseModel):
|
|
||||||
"""Descriptor for an agent runner.
|
|
||||||
|
|
||||||
Represents the discovered metadata for a runner, including
|
|
||||||
its identity, capabilities, permissions, and configuration schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
"""Unique runner ID: plugin:author/plugin_name/runner_name"""
|
|
||||||
|
|
||||||
source: typing.Literal['plugin']
|
|
||||||
"""Runner source type"""
|
|
||||||
|
|
||||||
label: dict[str, str]
|
|
||||||
"""Display labels keyed by locale (e.g., en_US, zh_Hans)"""
|
|
||||||
|
|
||||||
description: dict[str, str] | None = None
|
|
||||||
"""Optional description keyed by locale"""
|
|
||||||
|
|
||||||
plugin_author: str
|
|
||||||
"""Plugin author from manifest"""
|
|
||||||
|
|
||||||
plugin_name: str
|
|
||||||
"""Plugin name from manifest"""
|
|
||||||
|
|
||||||
runner_name: str
|
|
||||||
"""AgentRunner component name from manifest"""
|
|
||||||
|
|
||||||
plugin_version: str | None = None
|
|
||||||
"""Optional plugin version"""
|
|
||||||
|
|
||||||
protocol_version: str = '1'
|
|
||||||
"""SDK protocol version, default '1'"""
|
|
||||||
|
|
||||||
config_schema: list[dict[str, typing.Any]] = []
|
|
||||||
"""Configuration schema using DynamicForm format"""
|
|
||||||
|
|
||||||
capabilities: dict[str, bool] = {}
|
|
||||||
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
|
|
||||||
|
|
||||||
permissions: dict[str, list[str]] = {}
|
|
||||||
"""Requested permissions: models, tools, knowledge_bases, storage, files, platform_api"""
|
|
||||||
|
|
||||||
raw_manifest: dict[str, typing.Any] = {}
|
|
||||||
"""Original manifest for reference"""
|
|
||||||
|
|
||||||
model_config = pydantic.ConfigDict(
|
|
||||||
extra='allow',
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_plugin_id(self) -> str:
|
|
||||||
"""Return plugin identifier as author/name."""
|
|
||||||
return f'{self.plugin_author}/{self.plugin_name}'
|
|
||||||
|
|
||||||
def supports_streaming(self) -> bool:
|
|
||||||
"""Check if runner supports streaming output."""
|
|
||||||
return self.capabilities.get('streaming', False)
|
|
||||||
|
|
||||||
def supports_tool_calling(self) -> bool:
|
|
||||||
"""Check if runner supports tool calling."""
|
|
||||||
return self.capabilities.get('tool_calling', False)
|
|
||||||
|
|
||||||
def supports_knowledge_retrieval(self) -> bool:
|
|
||||||
"""Check if runner supports knowledge retrieval."""
|
|
||||||
return self.capabilities.get('knowledge_retrieval', False)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Agent runner errors."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerError(Exception):
|
|
||||||
"""Base error for agent runner operations."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerNotFoundError(AgentRunnerError):
|
|
||||||
"""Runner not found in registry."""
|
|
||||||
def __init__(self, runner_id: str):
|
|
||||||
self.runner_id = runner_id
|
|
||||||
super().__init__(f'Agent runner not found: {runner_id}')
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerNotAuthorizedError(AgentRunnerError):
|
|
||||||
"""Runner not authorized for this pipeline."""
|
|
||||||
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
|
||||||
self.runner_id = runner_id
|
|
||||||
self.bound_plugins = bound_plugins
|
|
||||||
super().__init__(f'Agent runner {runner_id} not authorized for bound_plugins={bound_plugins}')
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerProtocolError(AgentRunnerError):
|
|
||||||
"""Runner protocol version mismatch or invalid manifest."""
|
|
||||||
def __init__(self, runner_id: str, message: str):
|
|
||||||
self.runner_id = runner_id
|
|
||||||
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerExecutionError(AgentRunnerError):
|
|
||||||
"""Runner execution failed."""
|
|
||||||
def __init__(self, runner_id: str, message: str, retryable: bool = False):
|
|
||||||
self.runner_id = runner_id
|
|
||||||
self.retryable = retryable
|
|
||||||
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"""EventLog store for writing and querying event records."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from ...entity.persistence.event_log import EventLog
|
|
||||||
|
|
||||||
|
|
||||||
class EventLogStore:
|
|
||||||
"""Store for EventLog records.
|
|
||||||
|
|
||||||
Handles writing events to the event log and querying them.
|
|
||||||
All methods are async and use the provided database engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
|
|
||||||
# Hard limits
|
|
||||||
MAX_INPUT_SUMMARY_LENGTH = 1000
|
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
|
||||||
self.engine = engine
|
|
||||||
self._session_factory = sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def append_event(
|
|
||||||
self,
|
|
||||||
event_id: str | None,
|
|
||||||
event_type: str,
|
|
||||||
source: str,
|
|
||||||
bot_id: str | None = None,
|
|
||||||
workspace_id: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
thread_id: str | None = None,
|
|
||||||
actor_type: str | None = None,
|
|
||||||
actor_id: str | None = None,
|
|
||||||
actor_name: str | None = None,
|
|
||||||
subject_type: str | None = None,
|
|
||||||
subject_id: str | None = None,
|
|
||||||
input_summary: str | None = None,
|
|
||||||
input_json: dict[str, typing.Any] | None = None,
|
|
||||||
raw_ref: str | None = None,
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
event_time: datetime.datetime | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append an event to the event log.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: Unique event ID (generated if None)
|
|
||||||
event_type: Event type
|
|
||||||
source: Event source
|
|
||||||
bot_id: Bot UUID
|
|
||||||
workspace_id: Workspace ID
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
thread_id: Thread ID
|
|
||||||
actor_type: Actor type
|
|
||||||
actor_id: Actor ID
|
|
||||||
actor_name: Actor display name
|
|
||||||
subject_type: Subject type
|
|
||||||
subject_id: Subject ID
|
|
||||||
input_summary: Brief input summary
|
|
||||||
input_json: Full input JSON
|
|
||||||
raw_ref: Reference to raw event payload
|
|
||||||
run_id: Run ID processing this event
|
|
||||||
runner_id: Runner ID processing this event
|
|
||||||
event_time: When the event occurred
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The event_id
|
|
||||||
"""
|
|
||||||
if event_id is None:
|
|
||||||
event_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Truncate input summary if too long
|
|
||||||
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
|
|
||||||
input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..."
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
event = EventLog(
|
|
||||||
event_id=event_id,
|
|
||||||
event_type=event_type,
|
|
||||||
event_time=event_time,
|
|
||||||
source=source,
|
|
||||||
bot_id=bot_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
actor_type=actor_type,
|
|
||||||
actor_id=actor_id,
|
|
||||||
actor_name=actor_name,
|
|
||||||
subject_type=subject_type,
|
|
||||||
subject_id=subject_id,
|
|
||||||
input_summary=input_summary,
|
|
||||||
input_json=json.dumps(input_json) if input_json else None,
|
|
||||||
raw_ref=raw_ref,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
)
|
|
||||||
session.add(event)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return event_id
|
|
||||||
|
|
||||||
async def get_event(
|
|
||||||
self,
|
|
||||||
event_id: str,
|
|
||||||
) -> dict[str, typing.Any] | None:
|
|
||||||
"""Get a single event by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: Event ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Event record as dict, or None if not found
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return self._row_to_dict(row)
|
|
||||||
|
|
||||||
async def page_events(
|
|
||||||
self,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
event_types: list[str] | None = None,
|
|
||||||
before_seq: int | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
) -> tuple[list[dict[str, typing.Any]], int | None, bool]:
|
|
||||||
"""Page through event records.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Filter by conversation ID
|
|
||||||
event_types: Filter by event types
|
|
||||||
before_seq: Get events before this sequence number
|
|
||||||
limit: Maximum items to return (capped at 100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (items, next_seq, has_more)
|
|
||||||
"""
|
|
||||||
limit = min(limit, 100) # Hard cap
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(EventLog)
|
|
||||||
|
|
||||||
if conversation_id is not None:
|
|
||||||
query = query.where(EventLog.conversation_id == conversation_id)
|
|
||||||
|
|
||||||
if event_types:
|
|
||||||
query = query.where(EventLog.event_type.in_(event_types))
|
|
||||||
|
|
||||||
if before_seq is not None:
|
|
||||||
query = query.where(EventLog.id < before_seq)
|
|
||||||
|
|
||||||
query = query.order_by(EventLog.id.desc()).limit(limit + 1)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
items = [self._row_to_dict(row) for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
next_seq = items[-1]['id'] if items and has_more else None
|
|
||||||
|
|
||||||
return items, next_seq, has_more
|
|
||||||
|
|
||||||
async def get_latest_cursor(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get the latest cursor for a conversation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cursor string (seq number), or None if no events
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(EventLog.id)
|
|
||||||
.where(EventLog.conversation_id == conversation_id)
|
|
||||||
.order_by(EventLog.id.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return str(row)
|
|
||||||
|
|
||||||
async def has_events_before(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
seq: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if there are events before a sequence number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
seq: Sequence number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if there are events before
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(EventLog)
|
|
||||||
.where(
|
|
||||||
EventLog.conversation_id == conversation_id,
|
|
||||||
EventLog.id < seq,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
count = result.scalar()
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]:
|
|
||||||
"""Convert an EventLog row to dict."""
|
|
||||||
return {
|
|
||||||
'id': row.id,
|
|
||||||
'event_id': row.event_id,
|
|
||||||
'event_type': row.event_type,
|
|
||||||
'event_time': int(row.event_time.timestamp()) if row.event_time else None,
|
|
||||||
'source': row.source,
|
|
||||||
'bot_id': row.bot_id,
|
|
||||||
'workspace_id': row.workspace_id,
|
|
||||||
'conversation_id': row.conversation_id,
|
|
||||||
'thread_id': row.thread_id,
|
|
||||||
'actor_type': row.actor_type,
|
|
||||||
'actor_id': row.actor_id,
|
|
||||||
'actor_name': row.actor_name,
|
|
||||||
'subject_type': row.subject_type,
|
|
||||||
'subject_id': row.subject_id,
|
|
||||||
'input_summary': row.input_summary,
|
|
||||||
'input_json': json.loads(row.input_json) if row.input_json else None,
|
|
||||||
'raw_ref': row.raw_ref,
|
|
||||||
'run_id': row.run_id,
|
|
||||||
'runner_id': row.runner_id,
|
|
||||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
|
||||||
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Canonical AgentRunner event names reserved for future EBA integration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
MESSAGE_RECEIVED = 'message.received'
|
|
||||||
"""A normal message entered the current Pipeline."""
|
|
||||||
|
|
||||||
MESSAGE_RECALLED = 'message.recalled'
|
|
||||||
"""A platform message was recalled or deleted."""
|
|
||||||
|
|
||||||
GROUP_MEMBER_JOINED = 'group.member_joined'
|
|
||||||
"""A new member joined a group/channel conversation."""
|
|
||||||
|
|
||||||
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
|
|
||||||
"""A new friend/contact request was received."""
|
|
||||||
|
|
||||||
|
|
||||||
RESERVED_EVENT_TYPES = frozenset(
|
|
||||||
{
|
|
||||||
MESSAGE_RECEIVED,
|
|
||||||
MESSAGE_RECALLED,
|
|
||||||
GROUP_MEMBER_JOINED,
|
|
||||||
FRIEND_REQUEST_RECEIVED,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""Agent event envelope and binding models for LangBot Host.
|
|
||||||
|
|
||||||
These are Host-internal models, not exposed to SDK.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
|
||||||
ActorContext,
|
|
||||||
SubjectContext,
|
|
||||||
RawEventRef,
|
|
||||||
)
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|
||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
|
||||||
|
|
||||||
|
|
||||||
class AgentEventEnvelope(pydantic.BaseModel):
|
|
||||||
"""Event envelope for LangBot Host event gateway.
|
|
||||||
|
|
||||||
This is the unified input model that replaces Query-first approach.
|
|
||||||
IM / WebUI / API / EventRouter all produce this envelope.
|
|
||||||
"""
|
|
||||||
|
|
||||||
event_id: str
|
|
||||||
"""Unique event identifier."""
|
|
||||||
|
|
||||||
event_type: str
|
|
||||||
"""Event type (message.received, message.recalled, etc.)."""
|
|
||||||
|
|
||||||
event_time: int | None = None
|
|
||||||
"""Event timestamp (epoch seconds)."""
|
|
||||||
|
|
||||||
source: str
|
|
||||||
"""Event source (platform, webui, api, scheduler, system)."""
|
|
||||||
|
|
||||||
source_event_type: str | None = None
|
|
||||||
"""Original source event type, when available."""
|
|
||||||
|
|
||||||
bot_id: str | None = None
|
|
||||||
"""Bot UUID handling this event."""
|
|
||||||
|
|
||||||
workspace_id: str | None = None
|
|
||||||
"""Workspace ID (for multi-tenant)."""
|
|
||||||
|
|
||||||
conversation_id: str | None = None
|
|
||||||
"""Conversation ID."""
|
|
||||||
|
|
||||||
thread_id: str | None = None
|
|
||||||
"""Thread ID (for platforms supporting threads)."""
|
|
||||||
|
|
||||||
actor: ActorContext | None = None
|
|
||||||
"""Actor (who triggered the event)."""
|
|
||||||
|
|
||||||
subject: SubjectContext | None = None
|
|
||||||
"""Subject (what the event is about)."""
|
|
||||||
|
|
||||||
input: AgentInput
|
|
||||||
"""Event input."""
|
|
||||||
|
|
||||||
delivery: DeliveryContext
|
|
||||||
"""Delivery context."""
|
|
||||||
|
|
||||||
raw_ref: RawEventRef | None = None
|
|
||||||
"""Reference to raw event payload."""
|
|
||||||
|
|
||||||
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
|
||||||
"""Small structured event payload. Large payloads should be referenced via raw_ref/artifacts."""
|
|
||||||
|
|
||||||
|
|
||||||
# Binding scope types
|
|
||||||
class BindingScope(pydantic.BaseModel):
|
|
||||||
"""Scope for agent binding."""
|
|
||||||
|
|
||||||
scope_type: typing.Literal["bot", "pipeline", "workspace", "global"] = "pipeline"
|
|
||||||
"""Scope type."""
|
|
||||||
|
|
||||||
scope_id: str | None = None
|
|
||||||
"""Scope identifier (bot_uuid, pipeline_uuid, etc.)."""
|
|
||||||
|
|
||||||
|
|
||||||
class ResourcePolicy(pydantic.BaseModel):
|
|
||||||
"""Resource policy for agent binding.
|
|
||||||
|
|
||||||
Controls what resources the runner can access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
allowed_model_uuids: list[str] | None = None
|
|
||||||
"""Additional model UUID grants. None means no additional model grants."""
|
|
||||||
|
|
||||||
allowed_tool_names: list[str] | None = None
|
|
||||||
"""Additional tool name grants. None means no additional tool grants."""
|
|
||||||
|
|
||||||
allowed_kb_uuids: list[str] | None = None
|
|
||||||
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
|
||||||
|
|
||||||
allow_plugin_storage: bool = True
|
|
||||||
"""Whether plugin storage is allowed."""
|
|
||||||
|
|
||||||
allow_workspace_storage: bool = False
|
|
||||||
"""Whether workspace storage is allowed."""
|
|
||||||
|
|
||||||
|
|
||||||
class StatePolicy(pydantic.BaseModel):
|
|
||||||
"""State policy for agent binding.
|
|
||||||
|
|
||||||
Controls state management behavior.
|
|
||||||
"""
|
|
||||||
|
|
||||||
enable_state: bool = True
|
|
||||||
"""Whether host-owned state is enabled."""
|
|
||||||
|
|
||||||
state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = (
|
|
||||||
pydantic.Field(default_factory=lambda: ["conversation", "actor"])
|
|
||||||
)
|
|
||||||
"""Enabled state scopes."""
|
|
||||||
|
|
||||||
|
|
||||||
class DeliveryPolicy(pydantic.BaseModel):
|
|
||||||
"""Delivery policy for agent binding.
|
|
||||||
|
|
||||||
Controls how results are delivered.
|
|
||||||
"""
|
|
||||||
|
|
||||||
enable_streaming: bool = True
|
|
||||||
"""Whether streaming output is enabled."""
|
|
||||||
|
|
||||||
enable_reply: bool = True
|
|
||||||
"""Whether reply is enabled."""
|
|
||||||
|
|
||||||
max_message_size: int | None = None
|
|
||||||
"""Maximum message size."""
|
|
||||||
|
|
||||||
|
|
||||||
class 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)
|
|
||||||
"""Runner binding 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."""
|
|
||||||
|
|
||||||
# Fields for Pipeline adapter
|
|
||||||
pipeline_uuid: str | None = None
|
|
||||||
"""Pipeline UUID (for Pipeline adapter)."""
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Agent runner ID parsing and formatting."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class RunnerIdParts:
|
|
||||||
"""Parsed runner ID components."""
|
|
||||||
source: str # 'plugin' (future: 'builtin')
|
|
||||||
plugin_author: str
|
|
||||||
plugin_name: str
|
|
||||||
runner_name: str
|
|
||||||
|
|
||||||
def to_plugin_id(self) -> str:
|
|
||||||
"""Return plugin identifier as author/name."""
|
|
||||||
return f'{self.plugin_author}/{self.plugin_name}'
|
|
||||||
|
|
||||||
|
|
||||||
def parse_runner_id(runner_id: str) -> RunnerIdParts:
|
|
||||||
"""Parse runner ID string into components.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RunnerIdParts with parsed components
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If runner_id format is invalid
|
|
||||||
"""
|
|
||||||
if runner_id.startswith('plugin:'):
|
|
||||||
parts = runner_id[7:].split('/')
|
|
||||||
if len(parts) != 3:
|
|
||||||
raise ValueError(
|
|
||||||
f'Invalid plugin runner ID format: {runner_id}. '
|
|
||||||
f'Expected: plugin:author/plugin_name/runner_name'
|
|
||||||
)
|
|
||||||
plugin_author, plugin_name, runner_name = parts
|
|
||||||
if not plugin_author or not plugin_name or not runner_name:
|
|
||||||
raise ValueError(
|
|
||||||
f'Invalid plugin runner ID: {runner_id}. '
|
|
||||||
f'author, plugin_name, and runner_name must be non-empty'
|
|
||||||
)
|
|
||||||
return RunnerIdParts(
|
|
||||||
source='plugin',
|
|
||||||
plugin_author=plugin_author,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
runner_name=runner_name,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Only plugin runner IDs are valid at the protocol boundary.
|
|
||||||
raise ValueError(
|
|
||||||
f'Invalid runner ID format: {runner_id}. '
|
|
||||||
f'Expected: plugin:author/plugin_name/runner_name'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_runner_id(
|
|
||||||
source: str,
|
|
||||||
plugin_author: str,
|
|
||||||
plugin_name: str,
|
|
||||||
runner_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""Format runner ID from components.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: Runner source ('plugin')
|
|
||||||
plugin_author: Plugin author
|
|
||||||
plugin_name: Plugin name
|
|
||||||
runner_name: Runner component name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner ID string
|
|
||||||
"""
|
|
||||||
if source == 'plugin':
|
|
||||||
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Invalid runner source: {source}')
|
|
||||||
|
|
||||||
|
|
||||||
def is_plugin_runner_id(runner_id: str) -> bool:
|
|
||||||
"""Check if runner ID is a plugin runner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_id: Runner ID string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if runner ID starts with 'plugin:'
|
|
||||||
"""
|
|
||||||
return runner_id.startswith('plugin:')
|
|
||||||
@@ -1,884 +0,0 @@
|
|||||||
"""Agent run orchestrator for coordinating runner execution."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import traceback
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
|
||||||
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .registry import AgentRunnerRegistry
|
|
||||||
from .context_builder import AgentRunContextBuilder, AgentRunContextPayload
|
|
||||||
from .resource_builder import AgentResourceBuilder
|
|
||||||
from .result_normalizer import AgentResultNormalizer
|
|
||||||
from .persistent_state_store import get_persistent_state_store, PersistentStateStore
|
|
||||||
from .session_registry import get_session_registry, AgentRunSessionRegistry
|
|
||||||
from .config_migration import ConfigMigration
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
from .pipeline_adapter import PipelineAdapter
|
|
||||||
from .state_scope import build_state_context
|
|
||||||
from .errors import (
|
|
||||||
RunnerNotFoundError,
|
|
||||||
RunnerExecutionError,
|
|
||||||
RunnerProtocolError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum inline artifact content size (1MB)
|
|
||||||
MAX_ARTIFACT_INLINE_BYTES = 1 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunOrchestrator:
|
|
||||||
"""Orchestrator for agent runner execution.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Resolve runner ID from pipeline config (new or old format)
|
|
||||||
- Get runner descriptor from registry
|
|
||||||
- Provision AgentRunContext envelope from Query
|
|
||||||
- Build AgentResources with permission filtering
|
|
||||||
- Invoke plugin runtime RUN_AGENT action
|
|
||||||
- Normalize AgentRunResult to Pipeline messages
|
|
||||||
- Handle errors, timeouts, protocol errors
|
|
||||||
- Maintain streaming card behavior
|
|
||||||
|
|
||||||
Entry points:
|
|
||||||
- run(event, binding): Main entry for event-first Protocol v1
|
|
||||||
- run_from_query(query): Pipeline adapter wrapper
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
registry: AgentRunnerRegistry
|
|
||||||
|
|
||||||
context_builder: AgentRunContextBuilder
|
|
||||||
|
|
||||||
resource_builder: AgentResourceBuilder
|
|
||||||
|
|
||||||
result_normalizer: AgentResultNormalizer
|
|
||||||
|
|
||||||
# Cached singleton references (set in __init__)
|
|
||||||
_session_registry: AgentRunSessionRegistry
|
|
||||||
_persistent_state_store: PersistentStateStore | None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: app.Application,
|
|
||||||
registry: AgentRunnerRegistry,
|
|
||||||
):
|
|
||||||
self.ap = ap
|
|
||||||
self.registry = registry
|
|
||||||
self.context_builder = AgentRunContextBuilder(ap)
|
|
||||||
self.resource_builder = AgentResourceBuilder(ap)
|
|
||||||
self.result_normalizer = AgentResultNormalizer(ap)
|
|
||||||
# Cache singleton references to avoid per-request getter calls
|
|
||||||
self._session_registry = get_session_registry()
|
|
||||||
self._persistent_state_store = None # Lazy init on first use
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
adapter_context: dict[str, typing.Any] | None = None,
|
|
||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
|
||||||
"""Run agent runner from event-first envelope.
|
|
||||||
|
|
||||||
This is the main entry point for Protocol v1.
|
|
||||||
Event Gateway -> AgentBindingResolver -> run(event, binding).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope from event gateway
|
|
||||||
binding: Agent binding configuration
|
|
||||||
bound_plugins: Optional list of bound plugin identities for authorization
|
|
||||||
adapter_context: Optional adapter context from Pipeline adapter
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Message or MessageChunk for pipeline response
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerNotFoundError: If runner not found
|
|
||||||
RunnerNotAuthorizedError: If runner not authorized
|
|
||||||
RunnerExecutionError: If runner execution failed
|
|
||||||
"""
|
|
||||||
runner_id = binding.runner_id
|
|
||||||
|
|
||||||
# Get runner descriptor
|
|
||||||
descriptor = await self.registry.get(runner_id, bound_plugins)
|
|
||||||
|
|
||||||
# Build resources from binding
|
|
||||||
resources = await self.resource_builder.build_resources_from_binding(
|
|
||||||
event=event,
|
|
||||||
binding=binding,
|
|
||||||
descriptor=descriptor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build context from event + binding
|
|
||||||
context = await self.context_builder.build_context_from_event(
|
|
||||||
event=event,
|
|
||||||
binding=binding,
|
|
||||||
descriptor=descriptor,
|
|
||||||
resources=resources,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge adapter context if provided (for Pipeline adapter)
|
|
||||||
if adapter_context:
|
|
||||||
# Merge params into adapter.extra
|
|
||||||
if 'params' in adapter_context:
|
|
||||||
context['adapter']['extra']['params'] = adapter_context['params']
|
|
||||||
# Merge prompt into adapter.extra for Pipeline adapter consumers.
|
|
||||||
if 'prompt' in adapter_context:
|
|
||||||
context['adapter']['extra']['prompt'] = adapter_context['prompt']
|
|
||||||
# Set query_id if provided
|
|
||||||
if adapter_context.get('query_id'):
|
|
||||||
context['runtime']['query_id'] = adapter_context['query_id']
|
|
||||||
|
|
||||||
# Build state context for State API handlers
|
|
||||||
state_context = build_state_context(event, binding, descriptor)
|
|
||||||
|
|
||||||
# Register session for proxy action permission validation
|
|
||||||
run_id = context['run_id']
|
|
||||||
query_id = context['runtime'].get('query_id') # May be None for pure event-first mode
|
|
||||||
await self._session_registry.register(
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
query_id=query_id,
|
|
||||||
plugin_identity=descriptor.get_plugin_id(),
|
|
||||||
resources=resources,
|
|
||||||
permissions=descriptor.permissions or {},
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
state_policy={
|
|
||||||
'enable_state': binding.state_policy.enable_state,
|
|
||||||
'state_scopes': list(binding.state_policy.state_scopes),
|
|
||||||
},
|
|
||||||
state_context=state_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write incoming event to EventLog
|
|
||||||
event_log_id = await self._write_event_log(
|
|
||||||
event=event,
|
|
||||||
binding=binding,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register incoming attachments so input/transcript artifact_refs are resolvable.
|
|
||||||
await self._register_input_artifacts(
|
|
||||||
event=event,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write user message to Transcript if message.received
|
|
||||||
if event.event_type == 'message.received' and event.conversation_id:
|
|
||||||
await self._write_user_transcript(
|
|
||||||
event=event,
|
|
||||||
event_log_id=event_log_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track artifact refs for assistant transcript (cleared after each message.completed)
|
|
||||||
pending_artifact_refs: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run via plugin connector
|
|
||||||
async for result_dict in self._invoke_runner(descriptor, context):
|
|
||||||
# Handle artifact.created first - consume before normalizer
|
|
||||||
if result_dict.get('type') == 'artifact.created':
|
|
||||||
artifact_ref = await self._handle_artifact_created(
|
|
||||||
result_dict=result_dict,
|
|
||||||
event=event,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
)
|
|
||||||
pending_artifact_refs.append(artifact_ref)
|
|
||||||
# Pass to normalizer for logging, but don't yield to pipeline
|
|
||||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle state.updated first - consume before normalizer
|
|
||||||
if result_dict.get('type') == 'state.updated':
|
|
||||||
await self._handle_state_updated_event(result_dict, event, binding, descriptor)
|
|
||||||
# Pass to normalizer for logging, but don't yield to pipeline
|
|
||||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle message.completed - write to Transcript
|
|
||||||
if result_dict.get('type') == 'message.completed' and event.conversation_id:
|
|
||||||
# Merge pending artifact refs with message's own refs
|
|
||||||
merged_refs = self._merge_artifact_refs(
|
|
||||||
pending_artifact_refs,
|
|
||||||
result_dict,
|
|
||||||
)
|
|
||||||
# Clear pending refs after attaching to this message
|
|
||||||
pending_artifact_refs.clear()
|
|
||||||
|
|
||||||
await self._write_assistant_transcript(
|
|
||||||
result_dict=result_dict,
|
|
||||||
event=event,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
artifact_refs=merged_refs if merged_refs else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize result for other types
|
|
||||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
|
||||||
if result is not None:
|
|
||||||
yield result
|
|
||||||
finally:
|
|
||||||
# Unregister session after run completes (success or error)
|
|
||||||
await self._session_registry.unregister(run_id)
|
|
||||||
|
|
||||||
async def run_from_query(
|
|
||||||
self,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
|
||||||
"""Run agent runner from pipeline query.
|
|
||||||
|
|
||||||
This is the Pipeline adapter wrapper for the Query-based flow.
|
|
||||||
It delegates to the event-first run(event, binding) method.
|
|
||||||
|
|
||||||
For the new event-first Protocol v1, use run(event, binding) instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Pipeline query with pipeline_config, session, messages, etc.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Message or MessageChunk for pipeline response
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerNotFoundError: If runner not found
|
|
||||||
RunnerNotAuthorizedError: If runner not authorized
|
|
||||||
RunnerExecutionError: If runner execution failed
|
|
||||||
"""
|
|
||||||
# Resolve runner ID using ConfigMigration
|
|
||||||
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
|
||||||
if not runner_id:
|
|
||||||
raise RunnerNotFoundError('no runner configured')
|
|
||||||
|
|
||||||
# Convert Query to event-first envelope
|
|
||||||
event = PipelineAdapter.query_to_event(query)
|
|
||||||
|
|
||||||
# Convert Pipeline config to binding
|
|
||||||
binding = PipelineAdapter.pipeline_config_to_binding(query, runner_id)
|
|
||||||
|
|
||||||
# Extract bound plugins for authorization
|
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins')
|
|
||||||
|
|
||||||
# Build adapter context for Pipeline-specific fields
|
|
||||||
adapter_context = PipelineAdapter.build_adapter_context(query, binding)
|
|
||||||
|
|
||||||
# Delegate to event-first run()
|
|
||||||
async for result in self.run(
|
|
||||||
event,
|
|
||||||
binding,
|
|
||||||
bound_plugins=bound_plugins,
|
|
||||||
adapter_context=adapter_context,
|
|
||||||
):
|
|
||||||
yield result
|
|
||||||
|
|
||||||
async def _invoke_runner(
|
|
||||||
self,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
context: AgentRunContextPayload,
|
|
||||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
|
||||||
"""Invoke runner via plugin connector.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
descriptor: Runner descriptor
|
|
||||||
context: AgentRunContext dict
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Raw result dicts from plugin runtime
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerExecutionError: If plugin system disabled or runtime error
|
|
||||||
"""
|
|
||||||
if not self.ap.plugin_connector.is_enable_plugin:
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
'Plugin system is disabled',
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
gen = self.ap.plugin_connector.run_agent(
|
|
||||||
plugin_author=descriptor.plugin_author,
|
|
||||||
plugin_name=descriptor.plugin_name,
|
|
||||||
runner_name=descriptor.runner_name,
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
result_dict = await self._next_with_deadline(gen, descriptor, context)
|
|
||||||
except StopAsyncIteration:
|
|
||||||
break
|
|
||||||
yield result_dict
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
'Runner timed out (code: runner.timeout)',
|
|
||||||
retryable=True,
|
|
||||||
) from e
|
|
||||||
except ActionCallTimeoutError as e:
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
f'{e} (code: runner.timeout)',
|
|
||||||
retryable=True,
|
|
||||||
) from e
|
|
||||||
except RunnerExecutionError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# Wrap unexpected errors
|
|
||||||
self.ap.logger.error(
|
|
||||||
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
str(e),
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _next_with_deadline(
|
|
||||||
self,
|
|
||||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
context: AgentRunContextPayload,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Read the next runner result while enforcing the run deadline."""
|
|
||||||
remaining = self._remaining_deadline_seconds(context)
|
|
||||||
if remaining is not None and remaining <= 0:
|
|
||||||
await self._close_generator(gen, descriptor)
|
|
||||||
raise asyncio.TimeoutError
|
|
||||||
|
|
||||||
try:
|
|
||||||
if remaining is None:
|
|
||||||
return await anext(gen)
|
|
||||||
return await asyncio.wait_for(anext(gen), timeout=remaining)
|
|
||||||
except StopAsyncIteration:
|
|
||||||
if self._is_deadline_exhausted(context):
|
|
||||||
raise asyncio.TimeoutError
|
|
||||||
raise
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
await self._close_generator(gen, descriptor)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _remaining_deadline_seconds(
|
|
||||||
self,
|
|
||||||
context: AgentRunContextPayload,
|
|
||||||
) -> float | None:
|
|
||||||
runtime = context.get('runtime') or {}
|
|
||||||
deadline_at = runtime.get('deadline_at')
|
|
||||||
if deadline_at is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(deadline_at) - time.time()
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool:
|
|
||||||
remaining = self._remaining_deadline_seconds(context)
|
|
||||||
return remaining is not None and remaining <= 0
|
|
||||||
|
|
||||||
async def _close_generator(
|
|
||||||
self,
|
|
||||||
gen: typing.AsyncGenerator[dict[str, typing.Any], None],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
await gen.aclose()
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}')
|
|
||||||
|
|
||||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
|
||||||
"""Resolve runner ID for telemetry/logging without full execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Pipeline query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Runner ID string, or None
|
|
||||||
"""
|
|
||||||
return ConfigMigration.resolve_runner_id(query.pipeline_config)
|
|
||||||
|
|
||||||
async def _handle_state_updated_event(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> None:
|
|
||||||
"""Handle state.updated result in event-first mode.
|
|
||||||
|
|
||||||
Persists state to database via PersistentStateStore.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result_dict: Raw result dict with type='state.updated'
|
|
||||||
event: Event envelope
|
|
||||||
binding: Agent binding configuration
|
|
||||||
descriptor: Runner descriptor
|
|
||||||
"""
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
|
|
||||||
scope = data.get('scope')
|
|
||||||
if not scope:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
descriptor.id,
|
|
||||||
'state.updated missing required field: scope',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract key and value
|
|
||||||
key = data.get('key')
|
|
||||||
value = data.get('value')
|
|
||||||
|
|
||||||
if not key:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
descriptor.id,
|
|
||||||
'state.updated missing required field: key',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lazy init persistent state store
|
|
||||||
if self._persistent_state_store is None:
|
|
||||||
self._persistent_state_store = get_persistent_state_store(
|
|
||||||
self.ap.persistence_mgr.get_db_engine()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply update to persistent state store
|
|
||||||
success, error = await self._persistent_state_store.apply_update_from_event(
|
|
||||||
event=event,
|
|
||||||
binding=binding,
|
|
||||||
descriptor=descriptor,
|
|
||||||
scope=scope,
|
|
||||||
key=key,
|
|
||||||
value=value,
|
|
||||||
logger=self.ap.logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}'
|
|
||||||
)
|
|
||||||
elif error:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Runner {descriptor.id} state.updated rejected: {error}'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _write_event_log(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
) -> str:
|
|
||||||
"""Write incoming event to EventLog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
binding: Agent binding
|
|
||||||
run_id: Run ID
|
|
||||||
runner_id: Runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Event log ID
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .event_log_store import EventLogStore
|
|
||||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
# Build input summary
|
|
||||||
input_summary = None
|
|
||||||
input_json = None
|
|
||||||
if event.input:
|
|
||||||
if event.input.text:
|
|
||||||
input_summary = event.input.text[:1000]
|
|
||||||
input_json = {
|
|
||||||
'text': event.input.text,
|
|
||||||
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
|
|
||||||
'attachments': [a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments],
|
|
||||||
}
|
|
||||||
|
|
||||||
return await store.append_event(
|
|
||||||
event_id=event.event_id,
|
|
||||||
event_type=event.event_type,
|
|
||||||
source=event.source,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
actor_type=event.actor.actor_type if event.actor else None,
|
|
||||||
actor_id=event.actor.actor_id if event.actor else None,
|
|
||||||
actor_name=event.actor.actor_name if event.actor else None,
|
|
||||||
subject_type=event.subject.subject_type if event.subject else None,
|
|
||||||
subject_id=event.subject.subject_id if event.subject else None,
|
|
||||||
input_summary=input_summary,
|
|
||||||
input_json=input_json,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
event_time=datetime.datetime.fromtimestamp(event.event_time) if event.event_time else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _register_input_artifacts(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Register current-event attachments referenced by AgentInput."""
|
|
||||||
if not event.input or not event.input.attachments:
|
|
||||||
return
|
|
||||||
|
|
||||||
from .artifact_store import ArtifactStore
|
|
||||||
store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
for attachment in event.input.attachments:
|
|
||||||
data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
artifact_id = data.get('artifact_id')
|
|
||||||
artifact_type = data.get('artifact_type') or 'file'
|
|
||||||
if not artifact_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
content, parsed_mime_type = self._decode_attachment_content(data.get('content'))
|
|
||||||
url = data.get('url')
|
|
||||||
platform_ref_id = data.get('id')
|
|
||||||
storage_key = None
|
|
||||||
storage_type = 'metadata_only'
|
|
||||||
if content is None:
|
|
||||||
if url:
|
|
||||||
storage_key = url
|
|
||||||
storage_type = 'url'
|
|
||||||
elif platform_ref_id:
|
|
||||||
storage_key = platform_ref_id
|
|
||||||
storage_type = 'platform_ref'
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
'input_attachment': True,
|
|
||||||
'input_source': data.get('source') or 'platform',
|
|
||||||
}
|
|
||||||
if url:
|
|
||||||
metadata['url'] = url
|
|
||||||
if platform_ref_id:
|
|
||||||
metadata['platform_ref_id'] = platform_ref_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
await store.register_artifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
source='platform',
|
|
||||||
storage_key=storage_key,
|
|
||||||
storage_type=storage_type,
|
|
||||||
mime_type=data.get('mime_type') or parsed_mime_type,
|
|
||||||
name=data.get('name'),
|
|
||||||
size_bytes=data.get('size') or (len(content) if content is not None else None),
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
metadata=metadata,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Failed to register input artifact {artifact_id}: {e}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _decode_attachment_content(
|
|
||||||
self,
|
|
||||||
content: typing.Any,
|
|
||||||
) -> tuple[bytes | None, str | None]:
|
|
||||||
"""Decode base64 attachment content, including data URLs."""
|
|
||||||
if not isinstance(content, str) or not content:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
mime_type = None
|
|
||||||
payload = content
|
|
||||||
if content.startswith('data:') and ',' in content:
|
|
||||||
header, payload = content.split(',', 1)
|
|
||||||
if ';base64' in header:
|
|
||||||
mime_type = header[5:].split(';', 1)[0] or None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return base64.b64decode(payload, validate=False), mime_type
|
|
||||||
except (binascii.Error, ValueError):
|
|
||||||
return None, mime_type
|
|
||||||
|
|
||||||
async def _write_user_transcript(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
event_log_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Write user message to Transcript.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
event_log_id: Event log ID
|
|
||||||
"""
|
|
||||||
from .transcript_store import TranscriptStore
|
|
||||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
# Build content
|
|
||||||
content = event.input.text if event.input else None
|
|
||||||
content_json = None
|
|
||||||
if event.input:
|
|
||||||
content_json = {
|
|
||||||
'role': 'user',
|
|
||||||
'content': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents] if event.input.contents else [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build artifact refs
|
|
||||||
artifact_refs = []
|
|
||||||
if event.input and event.input.attachments:
|
|
||||||
for a in event.input.attachments:
|
|
||||||
artifact_refs.append(a.model_dump(mode='json') if hasattr(a, 'model_dump') else a)
|
|
||||||
|
|
||||||
await store.append_transcript(
|
|
||||||
transcript_id=None, # Auto-generate
|
|
||||||
event_id=event_log_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
role='user',
|
|
||||||
content=content,
|
|
||||||
content_json=content_json,
|
|
||||||
artifact_refs=artifact_refs if artifact_refs else None,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
item_type='message',
|
|
||||||
metadata={
|
|
||||||
'actor_type': event.actor.actor_type if event.actor else None,
|
|
||||||
'actor_id': event.actor.actor_id if event.actor else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_artifact_created(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Handle artifact.created result - register artifact and write EventLog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result_dict: Raw result dict with type='artifact.created'
|
|
||||||
event: Event envelope
|
|
||||||
run_id: Current run ID
|
|
||||||
runner_id: Runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Artifact reference dict for Transcript
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerProtocolError: On validation failures or registration errors
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from .artifact_store import ArtifactStore
|
|
||||||
from .event_log_store import EventLogStore
|
|
||||||
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
|
|
||||||
# Validate run_id matches current context
|
|
||||||
result_run_id = result_dict.get('run_id')
|
|
||||||
if result_run_id and result_run_id != run_id:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
runner_id,
|
|
||||||
f'artifact.created run_id mismatch: expected {run_id}, got {result_run_id}',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract artifact fields
|
|
||||||
artifact_id = data.get('artifact_id') or str(uuid.uuid4())
|
|
||||||
artifact_type = data.get('artifact_type')
|
|
||||||
if not artifact_type:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
runner_id,
|
|
||||||
'artifact.created missing required field: artifact_type',
|
|
||||||
)
|
|
||||||
|
|
||||||
mime_type = data.get('mime_type')
|
|
||||||
name = data.get('name')
|
|
||||||
size_bytes = data.get('size_bytes')
|
|
||||||
sha256 = data.get('sha256')
|
|
||||||
metadata = data.get('metadata')
|
|
||||||
content_base64 = data.get('content_base64')
|
|
||||||
|
|
||||||
# Decode and validate content if provided
|
|
||||||
content: bytes | None = None
|
|
||||||
if content_base64:
|
|
||||||
try:
|
|
||||||
content = base64.b64decode(content_base64, validate=True)
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
runner_id,
|
|
||||||
f'artifact.created invalid base64 content: {e}',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate content size
|
|
||||||
if len(content) > MAX_ARTIFACT_INLINE_BYTES:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
runner_id,
|
|
||||||
f'artifact.created content size {len(content)} bytes exceeds limit {MAX_ARTIFACT_INLINE_BYTES} bytes',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register artifact via ArtifactStore
|
|
||||||
artifact_store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
try:
|
|
||||||
registered_id = await artifact_store.register_artifact(
|
|
||||||
artifact_id=artifact_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
source='runner',
|
|
||||||
mime_type=mime_type,
|
|
||||||
name=name,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
sha256=sha256,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
metadata=metadata,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(
|
|
||||||
runner_id,
|
|
||||||
f'artifact.created failed to register artifact: {e}',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write to EventLog
|
|
||||||
event_log_store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
await event_log_store.append_event(
|
|
||||||
event_id=str(uuid.uuid4()),
|
|
||||||
event_type='artifact.created',
|
|
||||||
source='runner',
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
actor_type=event.actor.actor_type if event.actor else None,
|
|
||||||
actor_id=event.actor.actor_id if event.actor else None,
|
|
||||||
actor_name=event.actor.actor_name if event.actor else None,
|
|
||||||
input_summary=f'Artifact created: {artifact_type}',
|
|
||||||
input_json={
|
|
||||||
'artifact_id': registered_id,
|
|
||||||
'artifact_type': artifact_type,
|
|
||||||
'mime_type': mime_type,
|
|
||||||
'name': name,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
},
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return artifact ref for Transcript
|
|
||||||
return {
|
|
||||||
'artifact_id': registered_id,
|
|
||||||
'artifact_type': artifact_type,
|
|
||||||
'mime_type': mime_type,
|
|
||||||
'name': name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _merge_artifact_refs(
|
|
||||||
self,
|
|
||||||
pending_refs: list[dict[str, typing.Any]],
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Merge pending artifact refs with message's own refs, deduplicating by artifact_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pending_refs: Artifact refs accumulated from artifact.created events
|
|
||||||
result_dict: Result dict that may contain message with artifact_refs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Merged and deduplicated list of artifact refs
|
|
||||||
"""
|
|
||||||
# Start with pending refs
|
|
||||||
merged = list(pending_refs)
|
|
||||||
seen_ids = {ref.get('artifact_id') for ref in pending_refs if ref.get('artifact_id')}
|
|
||||||
|
|
||||||
# Extract refs from message data if present
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
message = data.get('message', {})
|
|
||||||
message_refs = message.get('artifact_refs', [])
|
|
||||||
|
|
||||||
if isinstance(message_refs, list):
|
|
||||||
for ref in message_refs:
|
|
||||||
if isinstance(ref, dict):
|
|
||||||
artifact_id = ref.get('artifact_id')
|
|
||||||
if artifact_id and artifact_id not in seen_ids:
|
|
||||||
merged.append(ref)
|
|
||||||
seen_ids.add(artifact_id)
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def _write_assistant_transcript(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Write assistant message to Transcript.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result_dict: Result dict from runner
|
|
||||||
event: Original event envelope
|
|
||||||
run_id: Run ID
|
|
||||||
runner_id: Runner ID
|
|
||||||
artifact_refs: Optional artifact references to include
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from .transcript_store import TranscriptStore
|
|
||||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
|
||||||
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
message = data.get('message', {})
|
|
||||||
|
|
||||||
# Build content
|
|
||||||
content = None
|
|
||||||
content_json = None
|
|
||||||
|
|
||||||
if isinstance(message.get('content'), str):
|
|
||||||
content = message['content']
|
|
||||||
content_json = message
|
|
||||||
elif isinstance(message.get('content'), list):
|
|
||||||
# Extract text from content list
|
|
||||||
text_parts = []
|
|
||||||
for c in message['content']:
|
|
||||||
if isinstance(c, dict) and c.get('type') == 'text':
|
|
||||||
text_parts.append(c.get('text', ''))
|
|
||||||
content = ' '.join(text_parts) if text_parts else None
|
|
||||||
content_json = message
|
|
||||||
|
|
||||||
# Generate a unique event ID for assistant message
|
|
||||||
assistant_event_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
await store.append_transcript(
|
|
||||||
transcript_id=str(uuid.uuid4()),
|
|
||||||
event_id=assistant_event_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
role='assistant',
|
|
||||||
content=content,
|
|
||||||
content_json=content_json,
|
|
||||||
artifact_refs=artifact_refs,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
item_type='message',
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
metadata={
|
|
||||||
'run_id': run_id,
|
|
||||||
'runner_id': runner_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
"""Persistent state store for AgentRunner protocol state.
|
|
||||||
|
|
||||||
This module provides a database-backed state store for event-first Protocol v1.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlalchemy import select, delete, update
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
from .state_scope import (
|
|
||||||
VALID_STATE_SCOPES,
|
|
||||||
build_state_scope_key,
|
|
||||||
get_binding_identity,
|
|
||||||
normalize_state_key,
|
|
||||||
)
|
|
||||||
from ...entity.persistence.agent_runner_state import AgentRunnerState
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum value_json size (256KB)
|
|
||||||
MAX_VALUE_JSON_BYTES = 256 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class PersistentStateStore:
|
|
||||||
"""Database-backed state store for AgentRunner protocol state.
|
|
||||||
|
|
||||||
IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state.
|
|
||||||
|
|
||||||
This store provides:
|
|
||||||
1. Persistent storage across runs via database
|
|
||||||
2. Scope isolation by runner_id + binding_identity + scope
|
|
||||||
3. Policy enforcement (enable_state, state_scopes)
|
|
||||||
4. JSON value validation and size limits
|
|
||||||
|
|
||||||
Used by:
|
|
||||||
- Event-first Protocol v1 (async methods)
|
|
||||||
- State API handlers (get/set/delete/list)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_engine: AsyncEngine):
|
|
||||||
self._db_engine = db_engine
|
|
||||||
|
|
||||||
def _get_scope_key(
|
|
||||||
self,
|
|
||||||
scope: str,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get scope key for given scope."""
|
|
||||||
return build_state_scope_key(scope, event, binding, descriptor)
|
|
||||||
|
|
||||||
def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool:
|
|
||||||
"""Check if scope is enabled by binding's state_policy."""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return False
|
|
||||||
return scope in state_policy.state_scopes
|
|
||||||
|
|
||||||
def _validate_json_value(
|
|
||||||
self,
|
|
||||||
value: typing.Any,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[str | None, str | None]:
|
|
||||||
"""Validate and serialize value to JSON.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (json_string, error_message). If error_message is not None,
|
|
||||||
json_string will be None.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
json_str = json.dumps(value, ensure_ascii=False)
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
return None, f'Value is not JSON-serializable: {e}'
|
|
||||||
|
|
||||||
# Check size limit
|
|
||||||
json_bytes = len(json_str.encode('utf-8'))
|
|
||||||
if json_bytes > MAX_VALUE_JSON_BYTES:
|
|
||||||
return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes'
|
|
||||||
|
|
||||||
return json_str, None
|
|
||||||
|
|
||||||
# ========== Async DB Operations ==========
|
|
||||||
|
|
||||||
async def build_snapshot_from_event(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, dict[str, typing.Any]]:
|
|
||||||
"""Build state snapshot for all scopes from event and binding.
|
|
||||||
|
|
||||||
Reads from database, respects state_policy.
|
|
||||||
"""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
|
|
||||||
# If state is disabled, return all empty scopes
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return {
|
|
||||||
'conversation': {},
|
|
||||||
'actor': {},
|
|
||||||
'subject': {},
|
|
||||||
'runner': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot: dict[str, dict[str, typing.Any]] = {
|
|
||||||
'conversation': {},
|
|
||||||
'actor': {},
|
|
||||||
'subject': {},
|
|
||||||
'runner': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
for scope in VALID_STATE_SCOPES:
|
|
||||||
if not self._check_scope_enabled(scope, binding):
|
|
||||||
continue
|
|
||||||
|
|
||||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
|
||||||
if not scope_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Query all state entries for this scope_key
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.state_key, AgentRunnerState.value_json)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
key = row.state_key
|
|
||||||
value_json = row.value_json
|
|
||||||
if value_json:
|
|
||||||
try:
|
|
||||||
snapshot[scope][key] = json.loads(value_json)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass # Skip invalid JSON
|
|
||||||
|
|
||||||
# Seed external.conversation_id from event.conversation_id if not set
|
|
||||||
if self._check_scope_enabled('conversation', binding) and event.conversation_id:
|
|
||||||
if 'external.conversation_id' not in snapshot['conversation']:
|
|
||||||
snapshot['conversation']['external.conversation_id'] = event.conversation_id
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
async def apply_update_from_event(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
scope: str,
|
|
||||||
key: str,
|
|
||||||
value: typing.Any,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Apply a state update from event context.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (success, error_message). If success is False, error_message
|
|
||||||
contains the reason.
|
|
||||||
"""
|
|
||||||
state_policy = binding.state_policy
|
|
||||||
|
|
||||||
# Check if state is disabled
|
|
||||||
if not state_policy.enable_state:
|
|
||||||
return False, 'State is disabled by binding policy'
|
|
||||||
|
|
||||||
# Validate scope
|
|
||||||
if scope not in VALID_STATE_SCOPES:
|
|
||||||
return False, f'Invalid scope: {scope}'
|
|
||||||
|
|
||||||
# Check if scope is enabled
|
|
||||||
if not self._check_scope_enabled(scope, binding):
|
|
||||||
return False, f'Scope "{scope}" not enabled by binding policy'
|
|
||||||
|
|
||||||
# Map accepted key aliases
|
|
||||||
key = normalize_state_key(key)
|
|
||||||
|
|
||||||
# Get scope key
|
|
||||||
scope_key = self._get_scope_key(scope, event, binding, descriptor)
|
|
||||||
if not scope_key:
|
|
||||||
return False, f'Missing identity for scope "{scope}"'
|
|
||||||
|
|
||||||
# Validate and serialize value
|
|
||||||
value_json, error = self._validate_json_value(value, logger)
|
|
||||||
if error:
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
# Build context fields
|
|
||||||
binding_identity = get_binding_identity(binding)
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
# Check if entry exists
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.id)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == key)
|
|
||||||
)
|
|
||||||
existing = result.first()
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing entry
|
|
||||||
await conn.execute(
|
|
||||||
update(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.id == existing.id)
|
|
||||||
.values(
|
|
||||||
value_json=value_json,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Insert new entry
|
|
||||||
await conn.execute(
|
|
||||||
sqlalchemy.insert(AgentRunnerState).values(
|
|
||||||
runner_id=descriptor.id,
|
|
||||||
binding_identity=binding_identity,
|
|
||||||
scope=scope,
|
|
||||||
scope_key=scope_key,
|
|
||||||
state_key=key,
|
|
||||||
value_json=value_json,
|
|
||||||
bot_id=event.bot_id,
|
|
||||||
workspace_id=event.workspace_id,
|
|
||||||
conversation_id=event.conversation_id,
|
|
||||||
thread_id=event.thread_id,
|
|
||||||
actor_type=event.actor.actor_type if event.actor else None,
|
|
||||||
actor_id=event.actor.actor_id if event.actor else None,
|
|
||||||
subject_type=event.subject.subject_type if event.subject else None,
|
|
||||||
subject_id=event.subject.subject_id if event.subject else None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
async def state_get(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
) -> typing.Any:
|
|
||||||
"""Get a single state value by scope_key and state_key.
|
|
||||||
|
|
||||||
Used by State API handlers.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.value_json)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
|
|
||||||
if not row or not row.value_json:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(row.value_json)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def state_set(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
value: typing.Any,
|
|
||||||
runner_id: str,
|
|
||||||
binding_identity: str,
|
|
||||||
scope: str,
|
|
||||||
context: dict[str, typing.Any] | None = None,
|
|
||||||
logger: typing.Any = None,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Set a state value.
|
|
||||||
|
|
||||||
Used by State API handlers.
|
|
||||||
Context contains optional fields like bot_id, conversation_id, etc.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
# Validate and serialize value
|
|
||||||
value_json, error = self._validate_json_value(value, logger)
|
|
||||||
if error:
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
context = context or {}
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
# Check if entry exists
|
|
||||||
result = await conn.execute(
|
|
||||||
select(AgentRunnerState.id)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
)
|
|
||||||
existing = result.first()
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing entry
|
|
||||||
await conn.execute(
|
|
||||||
update(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.id == existing.id)
|
|
||||||
.values(
|
|
||||||
value_json=value_json,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Insert new entry
|
|
||||||
await conn.execute(
|
|
||||||
sqlalchemy.insert(AgentRunnerState).values(
|
|
||||||
runner_id=runner_id,
|
|
||||||
binding_identity=binding_identity,
|
|
||||||
scope=scope,
|
|
||||||
scope_key=scope_key,
|
|
||||||
state_key=state_key,
|
|
||||||
value_json=value_json,
|
|
||||||
bot_id=context.get('bot_id'),
|
|
||||||
workspace_id=context.get('workspace_id'),
|
|
||||||
conversation_id=context.get('conversation_id'),
|
|
||||||
thread_id=context.get('thread_id'),
|
|
||||||
actor_type=context.get('actor_type'),
|
|
||||||
actor_id=context.get('actor_id'),
|
|
||||||
subject_type=context.get('subject_type'),
|
|
||||||
subject_id=context.get('subject_id'),
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
async def state_delete(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
state_key: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Delete a state value.
|
|
||||||
|
|
||||||
Returns True if deleted, False if not found.
|
|
||||||
"""
|
|
||||||
state_key = normalize_state_key(state_key)
|
|
||||||
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
delete(AgentRunnerState)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.where(AgentRunnerState.state_key == state_key)
|
|
||||||
.returning(AgentRunnerState.id)
|
|
||||||
)
|
|
||||||
deleted = result.first()
|
|
||||||
return deleted is not None
|
|
||||||
|
|
||||||
async def state_list(
|
|
||||||
self,
|
|
||||||
scope_key: str,
|
|
||||||
prefix: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> tuple[list[str], bool]:
|
|
||||||
"""List state keys in a scope.
|
|
||||||
|
|
||||||
Returns tuple of (keys, has_more).
|
|
||||||
"""
|
|
||||||
# Enforce limit cap
|
|
||||||
limit = min(limit, 100)
|
|
||||||
|
|
||||||
async with self._db_engine.connect() as conn:
|
|
||||||
query = (
|
|
||||||
select(AgentRunnerState.state_key)
|
|
||||||
.where(AgentRunnerState.scope_key == scope_key)
|
|
||||||
.order_by(AgentRunnerState.state_key)
|
|
||||||
.limit(limit + 1) # Fetch one extra to check has_more
|
|
||||||
)
|
|
||||||
|
|
||||||
if prefix:
|
|
||||||
prefix = normalize_state_key(prefix)
|
|
||||||
query = query.where(
|
|
||||||
AgentRunnerState.state_key.like(f'{prefix}%')
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await conn.execute(query)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
keys = [row.state_key for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
|
|
||||||
return keys, has_more
|
|
||||||
|
|
||||||
async def clear_all(self) -> None:
|
|
||||||
"""Clear all state entries (for testing)."""
|
|
||||||
async with self._db_engine.begin() as conn:
|
|
||||||
await conn.execute(delete(AgentRunnerState))
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton persistent state store
|
|
||||||
_persistent_state_store: PersistentStateStore | None = None
|
|
||||||
_persistent_state_store_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore:
|
|
||||||
"""Get the global persistent state store singleton.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_engine: Database engine (required on first call)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PersistentStateStore singleton
|
|
||||||
"""
|
|
||||||
global _persistent_state_store
|
|
||||||
with _persistent_state_store_lock:
|
|
||||||
if _persistent_state_store is None:
|
|
||||||
if db_engine is None:
|
|
||||||
raise RuntimeError("db_engine required for first call to get_persistent_state_store")
|
|
||||||
_persistent_state_store = PersistentStateStore(db_engine)
|
|
||||||
return _persistent_state_store
|
|
||||||
|
|
||||||
|
|
||||||
def reset_persistent_state_store() -> None:
|
|
||||||
"""Reset the global persistent state store (for testing)."""
|
|
||||||
global _persistent_state_store
|
|
||||||
with _persistent_state_store_lock:
|
|
||||||
_persistent_state_store = None
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
"""Pipeline adapter for converting Query to event-first envelope.
|
|
||||||
|
|
||||||
This adapter bridges the Query/Pipeline entry point with the event-first
|
|
||||||
Protocol v1 architecture.
|
|
||||||
"""
|
|
||||||
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 (
|
|
||||||
AgentEventEnvelope,
|
|
||||||
AgentBinding,
|
|
||||||
BindingScope,
|
|
||||||
ResourcePolicy,
|
|
||||||
StatePolicy,
|
|
||||||
DeliveryPolicy,
|
|
||||||
)
|
|
||||||
from . import events as runner_events
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineAdapter:
|
|
||||||
"""Adapter for converting Pipeline Query to event-first envelope.
|
|
||||||
|
|
||||||
This adapter is responsible for:
|
|
||||||
- Converting Query to AgentEventEnvelope
|
|
||||||
- Converting Pipeline config to temporary AgentBinding
|
|
||||||
- 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 Pipeline Query to AgentEventEnvelope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Pipeline 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="pipeline_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 pipeline_config_to_binding(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
runner_id: str,
|
|
||||||
) -> AgentBinding:
|
|
||||||
"""Convert Pipeline config to temporary AgentBinding.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Pipeline query
|
|
||||||
runner_id: Resolved runner ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentBinding for this run
|
|
||||||
"""
|
|
||||||
pipeline_config = query.pipeline_config or {}
|
|
||||||
ai_config = pipeline_config.get('ai', {})
|
|
||||||
runner_config = ai_config.get('runner_config', {}).get(runner_id, {})
|
|
||||||
pipeline_uuid = getattr(query, 'pipeline_uuid', None)
|
|
||||||
|
|
||||||
# Build scope
|
|
||||||
scope = BindingScope(
|
|
||||||
scope_type="pipeline",
|
|
||||||
scope_id=pipeline_uuid,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build resource policy from pipeline config
|
|
||||||
resource_policy = ResourcePolicy(
|
|
||||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
|
||||||
allowed_tool_names=cls._extract_allowed_tools(query),
|
|
||||||
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build state policy
|
|
||||||
state_policy = StatePolicy(
|
|
||||||
enable_state=True,
|
|
||||||
state_scopes=["conversation", "actor", "subject", "runner"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build delivery policy
|
|
||||||
delivery_policy = DeliveryPolicy(
|
|
||||||
enable_streaming=True,
|
|
||||||
enable_reply=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AgentBinding(
|
|
||||||
binding_id=f"pipeline_{pipeline_uuid or 'default'}_{runner_id}",
|
|
||||||
scope=scope,
|
|
||||||
event_types=[runner_events.MESSAGE_RECEIVED],
|
|
||||||
runner_id=runner_id,
|
|
||||||
runner_config=runner_config,
|
|
||||||
resource_policy=resource_policy,
|
|
||||||
state_policy=state_policy,
|
|
||||||
delivery_policy=delivery_policy,
|
|
||||||
enabled=True,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def build_adapter_context(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
binding: AgentBinding,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Build Query-derived fields for the Pipeline adapter entry."""
|
|
||||||
return {
|
|
||||||
'params': cls.build_params(query),
|
|
||||||
'prompt': cls.build_prompt(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 build_prompt(cls, query: pipeline_query.Query) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Build effective prompt messages from Pipeline preprocessing output."""
|
|
||||||
prompt = getattr(query, 'prompt', None)
|
|
||||||
messages = getattr(prompt, 'messages', None)
|
|
||||||
if not messages:
|
|
||||||
return []
|
|
||||||
return [cls._dump_message(msg) for msg in messages]
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dump_message(message: typing.Any) -> dict[str, typing.Any]:
|
|
||||||
"""Serialize a provider message-like object."""
|
|
||||||
if hasattr(message, 'model_dump'):
|
|
||||||
return message.model_dump(mode='json')
|
|
||||||
if isinstance(message, dict):
|
|
||||||
return message
|
|
||||||
return {
|
|
||||||
'role': getattr(message, 'role', None),
|
|
||||||
'content': getattr(message, 'content', None),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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="pipeline_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 = [
|
|
||||||
'pipeline_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'pipeline:{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)
|
|
||||||
|
|
||||||
# Handle pipeline_uuid
|
|
||||||
pipeline_uuid = getattr(query, 'pipeline_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,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
)
|
|
||||||
|
|
||||||
@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),
|
|
||||||
"pipeline_uuid": getattr(query, 'pipeline_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:
|
|
||||||
# Handle both real objects and mocks
|
|
||||||
if hasattr(elem, 'model_dump'):
|
|
||||||
contents.append(elem.model_dump(mode='json'))
|
|
||||||
elif isinstance(elem, dict):
|
|
||||||
contents.append(elem)
|
|
||||||
else:
|
|
||||||
# For mocks, extract type and text attributes
|
|
||||||
elem_type = getattr(elem, 'type', None)
|
|
||||||
if elem_type == 'text':
|
|
||||||
elem_text = getattr(elem, 'text', None)
|
|
||||||
contents.append({'type': 'text', 'text': elem_text})
|
|
||||||
if elem_text:
|
|
||||||
text_parts.append(elem_text)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract text for the text field
|
|
||||||
if hasattr(elem, 'type') and getattr(elem, 'type', None) == 'text':
|
|
||||||
elem_text = getattr(elem, 'text', None)
|
|
||||||
if elem_text:
|
|
||||||
text_parts.append(elem_text)
|
|
||||||
elif content is not None:
|
|
||||||
text = str(content)
|
|
||||||
contents.append({'type': 'text', 'text': text})
|
|
||||||
|
|
||||||
if text_parts:
|
|
||||||
text = ''.join(text_parts)
|
|
||||||
|
|
||||||
message_chain_dict = None
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
if message_chain:
|
|
||||||
if hasattr(message_chain, 'model_dump'):
|
|
||||||
message_chain_dict = message_chain.model_dump(mode='json')
|
|
||||||
|
|
||||||
attachments = cls._build_attachments(query, contents)
|
|
||||||
|
|
||||||
return AgentInput(
|
|
||||||
text=text,
|
|
||||||
contents=contents,
|
|
||||||
message_chain=message_chain_dict,
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _build_attachments(
|
|
||||||
cls,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
contents: list[dict[str, typing.Any]],
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Extract attachments from query."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
attachments: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
for elem in contents:
|
|
||||||
elem_type = elem.get('type')
|
|
||||||
artifact_id = str(uuid.uuid4()) # Generate unique ID
|
|
||||||
|
|
||||||
if elem_type == 'image_url':
|
|
||||||
image_url = elem.get('image_url') or {}
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'image',
|
|
||||||
'source': 'url',
|
|
||||||
'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url),
|
|
||||||
})
|
|
||||||
elif elem_type == 'image_base64':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'image',
|
|
||||||
'source': 'base64',
|
|
||||||
'content': elem.get('image_base64'),
|
|
||||||
})
|
|
||||||
elif elem_type == 'file_url':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'file',
|
|
||||||
'source': 'url',
|
|
||||||
'url': elem.get('file_url'),
|
|
||||||
'name': elem.get('file_name'),
|
|
||||||
})
|
|
||||||
elif elem_type == 'file_base64':
|
|
||||||
attachments.append({
|
|
||||||
'artifact_id': artifact_id,
|
|
||||||
'artifact_type': 'file',
|
|
||||||
'source': 'base64',
|
|
||||||
'content': elem.get('file_base64'),
|
|
||||||
'name': elem.get('file_name'),
|
|
||||||
})
|
|
||||||
|
|
||||||
message_chain = getattr(query, 'message_chain', None)
|
|
||||||
if message_chain:
|
|
||||||
try:
|
|
||||||
for component in message_chain:
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
except TypeError:
|
|
||||||
# message_chain is not iterable (e.g., a Mock object)
|
|
||||||
pass
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
"""Agent runner registry for discovering and caching runner descriptors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .id import parse_runner_id, format_runner_id
|
|
||||||
from .errors import RunnerNotFoundError, RunnerNotAuthorizedError
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerRegistry:
|
|
||||||
"""Registry for discovering and managing agent runners.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Discover runners from plugin runtime via LIST_AGENT_RUNNERS
|
|
||||||
- Validate runner manifests (kind, metadata, spec)
|
|
||||||
- Cache discovered runners for performance
|
|
||||||
- Filter runners by bound plugins
|
|
||||||
- Handle manifest errors gracefully (log warning, skip runner)
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
_cache: dict[str, AgentRunnerDescriptor] | None
|
|
||||||
"""Cached runner descriptors keyed by runner ID"""
|
|
||||||
|
|
||||||
_cache_lock: asyncio.Lock
|
|
||||||
"""Lock for cache refresh operations"""
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
self._cache = None
|
|
||||||
self._cache_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]:
|
|
||||||
"""Discover runners from plugin runtime.
|
|
||||||
|
|
||||||
Always discovers ALL runners (no bound_plugins filter).
|
|
||||||
The cache should contain unfiltered discovery results.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of runner descriptors keyed by runner ID
|
|
||||||
"""
|
|
||||||
if not self.ap.plugin_connector.is_enable_plugin:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
runners: dict[str, AgentRunnerDescriptor] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Always list all runners (bound_plugins=None)
|
|
||||||
plugin_runners = await self.ap.plugin_connector.list_agent_runners(None)
|
|
||||||
|
|
||||||
for runner_data in plugin_runners:
|
|
||||||
try:
|
|
||||||
descriptor = self._validate_and_build_descriptor(runner_data)
|
|
||||||
if descriptor is not None:
|
|
||||||
runners[descriptor.id] = descriptor
|
|
||||||
except Exception as e:
|
|
||||||
plugin_author = runner_data.get('plugin_author', 'unknown')
|
|
||||||
plugin_name = runner_data.get('plugin_name', 'unknown')
|
|
||||||
runner_name = runner_data.get('runner_name', 'unknown')
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}')
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return runners
|
|
||||||
|
|
||||||
def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None:
|
|
||||||
"""Validate runner manifest and build descriptor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_data: Raw runner data from plugin runtime with fields:
|
|
||||||
- plugin_author, plugin_name, runner_name
|
|
||||||
- manifest (full component manifest dict)
|
|
||||||
- protocol_version, capabilities, permissions, config (extracted from spec)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunnerDescriptor if valid, None if invalid
|
|
||||||
"""
|
|
||||||
plugin_author = runner_data.get('plugin_author', '')
|
|
||||||
plugin_name = runner_data.get('plugin_name', '')
|
|
||||||
runner_name = runner_data.get('runner_name', '')
|
|
||||||
|
|
||||||
if not plugin_author or not plugin_name or not runner_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
manifest = runner_data.get('manifest', {})
|
|
||||||
|
|
||||||
# Validate kind
|
|
||||||
kind = manifest.get('kind', '')
|
|
||||||
if kind != 'AgentRunner':
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate metadata
|
|
||||||
metadata = manifest.get('metadata', {})
|
|
||||||
name = metadata.get('name', '')
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# metadata.label must exist
|
|
||||||
label = metadata.get('label', {})
|
|
||||||
if not label:
|
|
||||||
label = {name: name} # fallback
|
|
||||||
|
|
||||||
spec = manifest.get('spec', {})
|
|
||||||
|
|
||||||
# SDK now provides these directly extracted from spec. Fall back to
|
|
||||||
# manifest.spec for older runtimes/tests that return the raw manifest.
|
|
||||||
protocol_version = runner_data.get('protocol_version') or spec.get('protocol_version', '1')
|
|
||||||
config_schema = runner_data.get('config') or spec.get('config', [])
|
|
||||||
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
|
|
||||||
permissions = runner_data.get('permissions') or spec.get('permissions', {})
|
|
||||||
|
|
||||||
# Build descriptor
|
|
||||||
runner_id = format_runner_id(
|
|
||||||
source='plugin',
|
|
||||||
plugin_author=plugin_author,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
runner_name=runner_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AgentRunnerDescriptor(
|
|
||||||
id=runner_id,
|
|
||||||
source='plugin',
|
|
||||||
label=label,
|
|
||||||
description=metadata.get('description') or runner_data.get('runner_description'),
|
|
||||||
plugin_author=plugin_author,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
runner_name=runner_name,
|
|
||||||
plugin_version=runner_data.get('plugin_version'),
|
|
||||||
protocol_version=protocol_version,
|
|
||||||
config_schema=config_schema,
|
|
||||||
capabilities=capabilities,
|
|
||||||
permissions=permissions,
|
|
||||||
raw_manifest=manifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def refresh(self) -> None:
|
|
||||||
"""Refresh runner cache.
|
|
||||||
|
|
||||||
Always discovers ALL runners (no bound_plugins filter).
|
|
||||||
The cache contains unfiltered discovery results.
|
|
||||||
"""
|
|
||||||
async with self._cache_lock:
|
|
||||||
self._cache = await self._discover_runners()
|
|
||||||
|
|
||||||
async def list_runners(
|
|
||||||
self,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
use_cache: bool = True,
|
|
||||||
) -> list[AgentRunnerDescriptor]:
|
|
||||||
"""List available runners.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bound_plugins: Optional filter for bound plugins (applied locally)
|
|
||||||
use_cache: Use cached data if available
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of runner descriptors
|
|
||||||
"""
|
|
||||||
if use_cache and self._cache is not None:
|
|
||||||
# Filter from cache
|
|
||||||
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
|
|
||||||
|
|
||||||
# Discover fresh (always full list)
|
|
||||||
runners = await self._discover_runners()
|
|
||||||
|
|
||||||
# Update cache (full list, unfiltered)
|
|
||||||
async with self._cache_lock:
|
|
||||||
self._cache = runners
|
|
||||||
|
|
||||||
# Filter locally
|
|
||||||
return self._filter_runners_by_bound_plugins(runners, bound_plugins)
|
|
||||||
|
|
||||||
def _filter_runners_by_bound_plugins(
|
|
||||||
self,
|
|
||||||
runners: dict[str, AgentRunnerDescriptor],
|
|
||||||
bound_plugins: list[str] | None,
|
|
||||||
) -> list[AgentRunnerDescriptor]:
|
|
||||||
"""Filter runners by bound plugins.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runners: Dict of runner descriptors
|
|
||||||
bound_plugins: Optional filter (None means all plugins allowed)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered list of runner descriptors
|
|
||||||
"""
|
|
||||||
if bound_plugins is None:
|
|
||||||
# All plugins allowed
|
|
||||||
return list(runners.values())
|
|
||||||
|
|
||||||
allowed_plugin_ids = set(bound_plugins)
|
|
||||||
filtered = []
|
|
||||||
for descriptor in runners.values():
|
|
||||||
plugin_id = descriptor.get_plugin_id()
|
|
||||||
if plugin_id in allowed_plugin_ids:
|
|
||||||
filtered.append(descriptor)
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
runner_id: str,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
) -> AgentRunnerDescriptor:
|
|
||||||
"""Get a specific runner descriptor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
runner_id: Runner ID to lookup
|
|
||||||
bound_plugins: Optional bound plugins filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunnerDescriptor
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerNotFoundError: If runner not found
|
|
||||||
RunnerNotAuthorizedError: If runner not in bound plugins
|
|
||||||
"""
|
|
||||||
# Parse and validate runner ID format
|
|
||||||
try:
|
|
||||||
parse_runner_id(runner_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise RunnerNotFoundError(runner_id) from e
|
|
||||||
|
|
||||||
# Get from cache or discover (always full list)
|
|
||||||
if self._cache is None:
|
|
||||||
await self.refresh()
|
|
||||||
|
|
||||||
if self._cache is None:
|
|
||||||
raise RunnerNotFoundError(runner_id)
|
|
||||||
|
|
||||||
descriptor = self._cache.get(runner_id)
|
|
||||||
if descriptor is None:
|
|
||||||
raise RunnerNotFoundError(runner_id)
|
|
||||||
|
|
||||||
# Check authorization
|
|
||||||
if bound_plugins is not None:
|
|
||||||
plugin_id = descriptor.get_plugin_id()
|
|
||||||
if plugin_id not in bound_plugins:
|
|
||||||
raise RunnerNotAuthorizedError(runner_id, bound_plugins)
|
|
||||||
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Get runner metadata for pipeline configuration UI.
|
|
||||||
|
|
||||||
Returns runner options and their config schemas for the DynamicForm.
|
|
||||||
"""
|
|
||||||
# Get all runners (no bound plugin filter for metadata listing)
|
|
||||||
runners = await self.list_runners(bound_plugins=None)
|
|
||||||
|
|
||||||
options = []
|
|
||||||
stages = []
|
|
||||||
|
|
||||||
for descriptor in runners:
|
|
||||||
config_schema = []
|
|
||||||
for index, config_item in enumerate(descriptor.config_schema):
|
|
||||||
item = dict(config_item)
|
|
||||||
if not item.get('id'):
|
|
||||||
item_name = item.get('name') or str(index)
|
|
||||||
item['id'] = f'{descriptor.id}.{item_name}'
|
|
||||||
config_schema.append(item)
|
|
||||||
|
|
||||||
# Add runner option
|
|
||||||
options.append(
|
|
||||||
{
|
|
||||||
'name': descriptor.id,
|
|
||||||
'label': descriptor.label,
|
|
||||||
'description': descriptor.description,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add config schema as stage if not empty
|
|
||||||
if descriptor.config_schema:
|
|
||||||
stages.append(
|
|
||||||
{
|
|
||||||
'name': descriptor.id,
|
|
||||||
'label': descriptor.label,
|
|
||||||
'description': descriptor.description,
|
|
||||||
'config': config_schema,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return options, stages
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
"""Agent resource builder for constructing authorized resources."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .context_builder import (
|
|
||||||
AgentResources,
|
|
||||||
ModelResource,
|
|
||||||
ToolResource,
|
|
||||||
KnowledgeBaseResource,
|
|
||||||
StorageResource,
|
|
||||||
)
|
|
||||||
from . import config_schema
|
|
||||||
from .host_models import AgentEventEnvelope, AgentBinding
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResourceBuilder:
|
|
||||||
"""Builder for constructing AgentResources with permission filtering.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Apply 3-layer permission filtering:
|
|
||||||
1. Runner manifest declared permissions
|
|
||||||
2. Pipeline extensions_preference (bound plugins/MCP servers)
|
|
||||||
3. Runner binding config selected resources
|
|
||||||
- Build models list from authorized models
|
|
||||||
- Build tools list from bound plugins/MCP servers
|
|
||||||
- Build knowledge_bases list from config
|
|
||||||
- Build storage and files permissions summary
|
|
||||||
|
|
||||||
Note: This only builds the resource declaration. The actual proxy actions
|
|
||||||
in handler.py must still validate against ctx.resources at runtime.
|
|
||||||
|
|
||||||
Resource field names match the plugin SDK payload:
|
|
||||||
- ModelResource: model_id, model_type, provider
|
|
||||||
- ToolResource: tool_name, tool_type, description
|
|
||||||
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
|
||||||
- StorageResource: plugin_storage, workspace_storage
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def build_resources_from_binding(
|
|
||||||
self,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> AgentResources:
|
|
||||||
"""Build AgentResources from event and binding.
|
|
||||||
|
|
||||||
This is the main entry point for Protocol v1.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event envelope
|
|
||||||
binding: Agent binding with resource policy
|
|
||||||
descriptor: Runner descriptor with permissions and capabilities
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentResources dict with filtered resource lists
|
|
||||||
"""
|
|
||||||
# Layer 1: Runner manifest permissions
|
|
||||||
manifest_perms = descriptor.permissions
|
|
||||||
|
|
||||||
# Layer 2: Binding resource policy
|
|
||||||
resource_policy = binding.resource_policy
|
|
||||||
|
|
||||||
# Layer 3: Runner binding config
|
|
||||||
runner_config = binding.runner_config
|
|
||||||
|
|
||||||
# Build each resource category
|
|
||||||
models = await self._build_models_from_binding(
|
|
||||||
manifest_perms, resource_policy, descriptor, runner_config
|
|
||||||
)
|
|
||||||
tools = await self._build_tools_from_binding(
|
|
||||||
manifest_perms, resource_policy, binding
|
|
||||||
)
|
|
||||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
|
||||||
manifest_perms, resource_policy, descriptor, runner_config
|
|
||||||
)
|
|
||||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'models': models,
|
|
||||||
'tools': tools,
|
|
||||||
'knowledge_bases': knowledge_bases,
|
|
||||||
'files': [], # Files are populated at runtime
|
|
||||||
'storage': storage,
|
|
||||||
'platform_capabilities': {}, # Reserved for EBA
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _build_models_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[ModelResource]:
|
|
||||||
"""Build models list from binding."""
|
|
||||||
models: list[ModelResource] = []
|
|
||||||
seen_model_ids: set[str] = set()
|
|
||||||
|
|
||||||
model_perms = manifest_perms.get('models', [])
|
|
||||||
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
|
|
||||||
allow_rerank = 'rerank' in model_perms
|
|
||||||
if not allow_llm and not allow_rerank:
|
|
||||||
return models
|
|
||||||
|
|
||||||
# Get additional model UUID grants from resource policy.
|
|
||||||
allowed_uuids = resource_policy.allowed_model_uuids
|
|
||||||
|
|
||||||
# Add model resources from binding config schema
|
|
||||||
await self._append_config_declared_model_resources(
|
|
||||||
models=models,
|
|
||||||
seen_model_ids=seen_model_ids,
|
|
||||||
descriptor=descriptor,
|
|
||||||
runner_config=runner_config,
|
|
||||||
include_llm=allow_llm,
|
|
||||||
include_rerank=allow_rerank,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add explicitly allowed models
|
|
||||||
if allowed_uuids and allow_llm:
|
|
||||||
for model_uuid in allowed_uuids:
|
|
||||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
|
|
||||||
return models
|
|
||||||
|
|
||||||
async def _build_tools_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
binding: AgentBinding,
|
|
||||||
) -> list[ToolResource]:
|
|
||||||
"""Build tools list from binding."""
|
|
||||||
tools: list[ToolResource] = []
|
|
||||||
|
|
||||||
# Check manifest permission
|
|
||||||
tool_perms = manifest_perms.get('tools', [])
|
|
||||||
if 'detail' not in tool_perms and 'call' not in tool_perms:
|
|
||||||
return tools
|
|
||||||
|
|
||||||
# Get tool names from resource policy
|
|
||||||
allowed_names = resource_policy.allowed_tool_names
|
|
||||||
|
|
||||||
if allowed_names:
|
|
||||||
for tool_name in allowed_names:
|
|
||||||
tools.append({
|
|
||||||
'tool_name': tool_name,
|
|
||||||
'tool_type': None,
|
|
||||||
'description': None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return tools
|
|
||||||
|
|
||||||
async def _build_knowledge_bases_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
resource_policy: typing.Any,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
) -> list[KnowledgeBaseResource]:
|
|
||||||
"""Build knowledge bases list from binding."""
|
|
||||||
kb_resources: list[KnowledgeBaseResource] = []
|
|
||||||
|
|
||||||
# Check manifest permission
|
|
||||||
kb_perms = manifest_perms.get('knowledge_bases', [])
|
|
||||||
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
|
|
||||||
return kb_resources
|
|
||||||
|
|
||||||
# Get KB UUID grants from schema-defined config fields.
|
|
||||||
kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
|
|
||||||
|
|
||||||
# Also include resource policy grants.
|
|
||||||
allowed_uuids = resource_policy.allowed_kb_uuids
|
|
||||||
if allowed_uuids:
|
|
||||||
kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids]))
|
|
||||||
|
|
||||||
for kb_uuid in kb_uuids:
|
|
||||||
try:
|
|
||||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
|
||||||
if kb:
|
|
||||||
kb_resources.append({
|
|
||||||
'kb_id': kb_uuid,
|
|
||||||
'kb_name': kb.get_name(),
|
|
||||||
'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}')
|
|
||||||
|
|
||||||
return kb_resources
|
|
||||||
|
|
||||||
def _build_storage_from_binding(
|
|
||||||
self,
|
|
||||||
manifest_perms: dict[str, list[str]],
|
|
||||||
binding: AgentBinding,
|
|
||||||
) -> StorageResource:
|
|
||||||
"""Build storage permissions from binding."""
|
|
||||||
storage_perms = manifest_perms.get('storage', [])
|
|
||||||
resource_policy = binding.resource_policy
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,
|
|
||||||
'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _append_config_declared_model_resources(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
runner_config: dict[str, typing.Any],
|
|
||||||
include_llm: bool,
|
|
||||||
include_rerank: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Authorize model-like values selected through DynamicForm fields."""
|
|
||||||
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
|
|
||||||
if model_type == 'llm' and include_llm:
|
|
||||||
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
elif model_type == 'rerank' and include_rerank:
|
|
||||||
await self._append_rerank_model_resource(models, seen_model_ids, model_uuid)
|
|
||||||
|
|
||||||
async def _append_llm_model_resource(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
model_uuid: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Append an LLM model resource if it exists and has not been added."""
|
|
||||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
|
||||||
if model and model.model_entity:
|
|
||||||
models.append({
|
|
||||||
'model_id': model_uuid,
|
|
||||||
'model_type': getattr(model.model_entity, 'model_type', None),
|
|
||||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
|
||||||
})
|
|
||||||
seen_model_ids.add(model_uuid)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}')
|
|
||||||
|
|
||||||
async def _append_rerank_model_resource(
|
|
||||||
self,
|
|
||||||
models: list[ModelResource],
|
|
||||||
seen_model_ids: set[str],
|
|
||||||
model_uuid: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Append a rerank model resource if it exists and has not been added."""
|
|
||||||
if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid)
|
|
||||||
if model and model.model_entity:
|
|
||||||
models.append({
|
|
||||||
'model_id': model_uuid,
|
|
||||||
'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank',
|
|
||||||
'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
|
|
||||||
})
|
|
||||||
seen_model_ids.add(model_uuid)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}')
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .errors import RunnerExecutionError, RunnerProtocolError
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum size for a single result payload (prevent memory exhaustion)
|
|
||||||
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
|
||||||
|
|
||||||
|
|
||||||
class AgentResultNormalizer:
|
|
||||||
"""Normalizer for converting AgentRunResult to Pipeline messages.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Accept only supported result types (message.delta, message.completed, etc.)
|
|
||||||
- Map message.delta -> MessageChunk
|
|
||||||
- Map message.completed -> Message
|
|
||||||
- Map run.completed (with message) -> Message
|
|
||||||
- Handle run.failed as controlled error
|
|
||||||
- Ignore unknown types with warning
|
|
||||||
- Validate result size
|
|
||||||
- Validate message schema
|
|
||||||
|
|
||||||
Accepted result types:
|
|
||||||
- message.delta
|
|
||||||
- message.completed
|
|
||||||
- tool.call.started
|
|
||||||
- tool.call.completed
|
|
||||||
- state.updated
|
|
||||||
- run.completed
|
|
||||||
- run.failed
|
|
||||||
- action.requested (log only, don't execute)
|
|
||||||
"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def normalize(
|
|
||||||
self,
|
|
||||||
result_dict: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.Message | provider_message.MessageChunk | None:
|
|
||||||
"""Normalize AgentRunResult to Message or MessageChunk.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result_dict: Raw result dict from plugin runtime
|
|
||||||
descriptor: Runner descriptor for error context
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Message, MessageChunk, or None (for non-message events)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RunnerExecutionError: On run.failed
|
|
||||||
RunnerProtocolError: On invalid result format
|
|
||||||
"""
|
|
||||||
# Validate result type
|
|
||||||
result_type = result_dict.get('type')
|
|
||||||
if not result_type:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'Missing result type')
|
|
||||||
|
|
||||||
# Validate result size
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
result_json = json.dumps(result_dict)
|
|
||||||
if len(result_json) > MAX_RESULT_SIZE_BYTES:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating'
|
|
||||||
)
|
|
||||||
# Truncate content if possible
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
if 'chunk' in data or 'message' in data:
|
|
||||||
content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '')
|
|
||||||
if isinstance(content, str) and len(content) > 10000:
|
|
||||||
# Keep reasonable length
|
|
||||||
data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'}
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}')
|
|
||||||
|
|
||||||
# Handle each result type
|
|
||||||
data = result_dict.get('data', {})
|
|
||||||
|
|
||||||
if result_type == 'message.delta':
|
|
||||||
return self._normalize_message_delta(data, descriptor)
|
|
||||||
|
|
||||||
elif result_type == 'message.completed':
|
|
||||||
return self._normalize_message_completed(data, descriptor)
|
|
||||||
|
|
||||||
elif result_type == 'tool.call.started':
|
|
||||||
# Log only, don't yield to pipeline
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'tool.call.completed':
|
|
||||||
# Log only, don't yield to pipeline
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'state.updated':
|
|
||||||
# Log for telemetry, don't yield to pipeline
|
|
||||||
# Orchestrator already handles the actual PersistentStateStore update.
|
|
||||||
scope = data.get('scope', 'unknown')
|
|
||||||
key = data.get('key', 'unknown')
|
|
||||||
value_repr = repr(data.get('value', '...'))[:100] # Truncate for log
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'run.completed':
|
|
||||||
# May include final message
|
|
||||||
if 'message' in data:
|
|
||||||
return self._normalize_message_completed(data, descriptor)
|
|
||||||
# If no message, it's just completion signal
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'run.failed':
|
|
||||||
error_msg = data.get('error', 'Unknown error')
|
|
||||||
error_code = data.get('code', 'unknown')
|
|
||||||
retryable = data.get('retryable', False)
|
|
||||||
raise RunnerExecutionError(
|
|
||||||
descriptor.id,
|
|
||||||
f'{error_msg} (code: {error_code})',
|
|
||||||
retryable=retryable,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif result_type == 'action.requested':
|
|
||||||
# Reserved for EBA - log only, don't execute
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Runner {descriptor.id} requested action (not executed in current phase): '
|
|
||||||
f'{data.get("action", "unknown")}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif result_type == 'artifact.created':
|
|
||||||
# Log for telemetry, consumed by orchestrator
|
|
||||||
artifact_id = data.get('artifact_id', 'unknown')
|
|
||||||
artifact_type = data.get('artifact_type', 'unknown')
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Runner {descriptor.id} artifact.created logged: artifact_id={artifact_id}, type={artifact_type}'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Unknown type - warn and ignore.
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Runner {descriptor.id} returned unknown result type: {result_type}. '
|
|
||||||
f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _normalize_message_delta(
|
|
||||||
self,
|
|
||||||
data: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.MessageChunk:
|
|
||||||
"""Normalize message.delta to MessageChunk."""
|
|
||||||
chunk_data = data.get('chunk', {})
|
|
||||||
if not chunk_data:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data')
|
|
||||||
|
|
||||||
try:
|
|
||||||
chunk = provider_message.MessageChunk.model_validate(chunk_data)
|
|
||||||
return chunk
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}')
|
|
||||||
|
|
||||||
def _normalize_message_completed(
|
|
||||||
self,
|
|
||||||
data: dict[str, typing.Any],
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> provider_message.Message:
|
|
||||||
"""Normalize message.completed to Message."""
|
|
||||||
message_data = data.get('message', {})
|
|
||||||
if not message_data:
|
|
||||||
raise RunnerProtocolError(descriptor.id, 'message.completed missing message data')
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = provider_message.Message.model_validate(message_data)
|
|
||||||
return msg
|
|
||||||
except Exception as e:
|
|
||||||
raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}')
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""Agent run session registry for proxy action permission validation."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import typing
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from .context_builder import AgentResources
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunSessionStatus(typing.TypedDict):
|
|
||||||
"""Status tracking for agent run session."""
|
|
||||||
started_at: int
|
|
||||||
last_activity_at: int
|
|
||||||
|
|
||||||
|
|
||||||
class 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: Pipeline query ID
|
|
||||||
plugin_identity: Plugin identifier (author/name) of the runner
|
|
||||||
conversation_id: Conversation ID for history/event access
|
|
||||||
resources: Authorized resources for this run (from AgentResources)
|
|
||||||
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
|
|
||||||
state_policy: State policy from binding (enable_state, state_scopes)
|
|
||||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
|
||||||
status: Session status tracking
|
|
||||||
_authorized_ids: Pre-computed authorized resource IDs for O(1) lookup
|
|
||||||
"""
|
|
||||||
run_id: str
|
|
||||||
runner_id: str
|
|
||||||
query_id: int | None
|
|
||||||
plugin_identity: str # author/name
|
|
||||||
conversation_id: str | None
|
|
||||||
resources: AgentResources
|
|
||||||
permissions: dict[str, list[str]]
|
|
||||||
state_policy: dict[str, typing.Any] # {enable_state: bool, state_scopes: list}
|
|
||||||
state_context: dict[str, typing.Any] # {scope_keys: dict, binding_identity: str, ...}
|
|
||||||
status: AgentRunSessionStatus
|
|
||||||
_authorized_ids: dict[str, set[str]] # Pre-computed sets for O(1) lookup
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunSessionRegistry:
|
|
||||||
"""Registry for active agent run sessions.
|
|
||||||
|
|
||||||
Host-owned registry for tracking active AgentRunner executions.
|
|
||||||
Used by proxy actions in handler.py to validate resource access.
|
|
||||||
|
|
||||||
Key: run_id (UUID from AgentRunContext)
|
|
||||||
Value: AgentRunSession with authorized resources
|
|
||||||
|
|
||||||
Thread-safe via asyncio.Lock.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_sessions: dict[str, AgentRunSession]
|
|
||||||
_lock: asyncio.Lock
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._sessions = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def register(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
runner_id: str,
|
|
||||||
query_id: int | None,
|
|
||||||
plugin_identity: str,
|
|
||||||
resources: AgentResources,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
permissions: dict[str, list[str]] | None = None,
|
|
||||||
state_policy: dict[str, typing.Any] | None = None,
|
|
||||||
state_context: dict[str, typing.Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Register a new agent run session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
runner_id: Runner descriptor ID
|
|
||||||
query_id: Pipeline query ID
|
|
||||||
plugin_identity: Plugin identifier (author/name)
|
|
||||||
resources: Authorized resources for this run
|
|
||||||
conversation_id: Conversation ID for history/event access
|
|
||||||
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
|
|
||||||
state_policy: State policy from binding (enable_state, state_scopes)
|
|
||||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
|
|
||||||
# Normalize permissions to empty dict if None
|
|
||||||
permissions = permissions or {}
|
|
||||||
|
|
||||||
# Normalize state_policy to defaults if None
|
|
||||||
if state_policy is None:
|
|
||||||
state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
|
||||||
|
|
||||||
# Normalize state_context to empty dict if None
|
|
||||||
state_context = state_context or {}
|
|
||||||
|
|
||||||
# Pre-compute authorized resource IDs for O(1) lookup
|
|
||||||
authorized_ids: dict[str, set[str]] = {
|
|
||||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
|
||||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
|
||||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
|
||||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
|
||||||
}
|
|
||||||
|
|
||||||
# NOTE: state_policy and state_context are stored at session top-level,
|
|
||||||
# NOT in resources. Resources should only contain resource authorization info.
|
|
||||||
session: AgentRunSession = {
|
|
||||||
'run_id': run_id,
|
|
||||||
'runner_id': runner_id,
|
|
||||||
'query_id': query_id,
|
|
||||||
'plugin_identity': plugin_identity,
|
|
||||||
'conversation_id': conversation_id,
|
|
||||||
'resources': resources, # Original AgentResources, no state metadata mixed in
|
|
||||||
'permissions': permissions,
|
|
||||||
'state_policy': state_policy,
|
|
||||||
'state_context': state_context,
|
|
||||||
'status': {
|
|
||||||
'started_at': now,
|
|
||||||
'last_activity_at': now,
|
|
||||||
},
|
|
||||||
'_authorized_ids': authorized_ids,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
self._sessions[run_id] = session
|
|
||||||
|
|
||||||
async def unregister(self, run_id: str) -> None:
|
|
||||||
"""Unregister an agent run session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if run_id in self._sessions:
|
|
||||||
del self._sessions[run_id]
|
|
||||||
|
|
||||||
async def get(self, run_id: str) -> AgentRunSession | None:
|
|
||||||
"""Get session by run_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunSession if found, None otherwise
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
return self._sessions.get(run_id)
|
|
||||||
|
|
||||||
async def update_activity(self, run_id: str) -> None:
|
|
||||||
"""Update last activity timestamp for session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
run_id: Unique run identifier
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if run_id in self._sessions:
|
|
||||||
self._sessions[run_id]['status']['last_activity_at'] = int(time.time())
|
|
||||||
|
|
||||||
def is_resource_allowed(
|
|
||||||
self,
|
|
||||||
session: AgentRunSession,
|
|
||||||
resource_type: str,
|
|
||||||
resource_id: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if resource access is allowed for this session.
|
|
||||||
|
|
||||||
Uses pre-computed authorized IDs for O(1) lookup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AgentRunSession to check
|
|
||||||
resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file')
|
|
||||||
resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace', file_key)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if resource is authorized, False otherwise
|
|
||||||
"""
|
|
||||||
authorized_ids = session.get('_authorized_ids', {})
|
|
||||||
|
|
||||||
if resource_type in ('model', 'tool', 'knowledge_base', 'file'):
|
|
||||||
return resource_id in authorized_ids.get(resource_type, set())
|
|
||||||
|
|
||||||
if resource_type == 'storage':
|
|
||||||
storage = session['resources'].get('storage', {})
|
|
||||||
if resource_id == 'plugin':
|
|
||||||
return storage.get('plugin_storage', False)
|
|
||||||
elif resource_id == 'workspace':
|
|
||||||
return storage.get('workspace_storage', False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def list_active_runs(self) -> list[AgentRunSession]:
|
|
||||||
"""List all active run sessions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of active AgentRunSession dicts
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
return list(self._sessions.values())
|
|
||||||
|
|
||||||
async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int:
|
|
||||||
"""Cleanup sessions that have been inactive for too long.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_age_seconds: Maximum inactivity time in seconds (default 1 hour)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sessions cleaned up
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
|
||||||
cleaned = 0
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
stale_run_ids = []
|
|
||||||
for run_id, session in self._sessions.items():
|
|
||||||
last_activity = session['status'].get('last_activity_at', 0)
|
|
||||||
if now - last_activity > max_age_seconds:
|
|
||||||
stale_run_ids.append(run_id)
|
|
||||||
|
|
||||||
for run_id in stale_run_ids:
|
|
||||||
del self._sessions[run_id]
|
|
||||||
cleaned += 1
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
# Global registry instance (singleton)
|
|
||||||
_global_registry: AgentRunSessionRegistry | None = None
|
|
||||||
_global_registry_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_registry() -> AgentRunSessionRegistry:
|
|
||||||
"""Get global session registry instance (thread-safe singleton).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AgentRunSessionRegistry singleton
|
|
||||||
"""
|
|
||||||
global _global_registry
|
|
||||||
with _global_registry_lock:
|
|
||||||
if _global_registry is None:
|
|
||||||
_global_registry = AgentRunSessionRegistry()
|
|
||||||
return _global_registry
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""State scope key helpers for AgentRunner host-owned state."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .descriptor import AgentRunnerDescriptor
|
|
||||||
from .host_models import AgentBinding, AgentEventEnvelope
|
|
||||||
|
|
||||||
|
|
||||||
VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner')
|
|
||||||
|
|
||||||
STATE_KEY_ALIASES = {
|
|
||||||
'conversation_id': 'external.conversation_id',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_state_key(key: str) -> str:
|
|
||||||
"""Map accepted public aliases to protocol state keys."""
|
|
||||||
return STATE_KEY_ALIASES.get(key, key)
|
|
||||||
|
|
||||||
|
|
||||||
def get_binding_identity(binding: AgentBinding) -> str:
|
|
||||||
"""Return the stable binding identity used for state isolation."""
|
|
||||||
if binding.binding_id:
|
|
||||||
return binding.binding_id
|
|
||||||
|
|
||||||
scope = binding.scope
|
|
||||||
if scope.scope_type and scope.scope_id:
|
|
||||||
return f'{scope.scope_type}:{scope.scope_id}'
|
|
||||||
|
|
||||||
return 'unknown_binding'
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_scope_key(
|
|
||||||
scope: str,
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> str | None:
|
|
||||||
"""Build the storage key for one state scope.
|
|
||||||
|
|
||||||
Returns None when the event lacks the identity required by that scope.
|
|
||||||
"""
|
|
||||||
binding_identity = get_binding_identity(binding)
|
|
||||||
|
|
||||||
if scope == 'conversation':
|
|
||||||
if not event.conversation_id:
|
|
||||||
return None
|
|
||||||
parts = [descriptor.id, binding_identity, event.conversation_id]
|
|
||||||
if event.thread_id:
|
|
||||||
parts.append(event.thread_id)
|
|
||||||
return f'conversation:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'actor':
|
|
||||||
if not event.actor or not event.actor.actor_id:
|
|
||||||
return None
|
|
||||||
parts = [
|
|
||||||
descriptor.id,
|
|
||||||
binding_identity,
|
|
||||||
event.actor.actor_type or 'user',
|
|
||||||
event.actor.actor_id,
|
|
||||||
]
|
|
||||||
return f'actor:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'subject':
|
|
||||||
if not event.subject or not event.subject.subject_id:
|
|
||||||
return None
|
|
||||||
parts = [
|
|
||||||
descriptor.id,
|
|
||||||
binding_identity,
|
|
||||||
event.subject.subject_type or 'unknown',
|
|
||||||
event.subject.subject_id,
|
|
||||||
]
|
|
||||||
return f'subject:{":".join(parts)}'
|
|
||||||
|
|
||||||
if scope == 'runner':
|
|
||||||
return f'runner:{descriptor.id}:{binding_identity}'
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_scope_keys(
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Build all available scope keys for an event/binding pair."""
|
|
||||||
scope_keys: dict[str, str] = {}
|
|
||||||
for scope in VALID_STATE_SCOPES:
|
|
||||||
scope_key = build_state_scope_key(scope, event, binding, descriptor)
|
|
||||||
if scope_key:
|
|
||||||
scope_keys[scope] = scope_key
|
|
||||||
return scope_keys
|
|
||||||
|
|
||||||
|
|
||||||
def build_state_context(
|
|
||||||
event: AgentEventEnvelope,
|
|
||||||
binding: AgentBinding,
|
|
||||||
descriptor: AgentRunnerDescriptor,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Build the State API context stored in the run session."""
|
|
||||||
return {
|
|
||||||
'scope_keys': build_state_scope_keys(event, binding, descriptor),
|
|
||||||
'binding_identity': get_binding_identity(binding),
|
|
||||||
'bot_id': event.bot_id,
|
|
||||||
'workspace_id': event.workspace_id,
|
|
||||||
'conversation_id': event.conversation_id,
|
|
||||||
'thread_id': event.thread_id,
|
|
||||||
'actor_type': event.actor.actor_type if event.actor else None,
|
|
||||||
'actor_id': event.actor.actor_id if event.actor else None,
|
|
||||||
'subject_type': event.subject.subject_type if event.subject else None,
|
|
||||||
'subject_id': event.subject.subject_id if event.subject else None,
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Transcript store for writing and querying conversation history."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from ...entity.persistence.transcript import Transcript
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptStore:
|
|
||||||
"""Store for Transcript records.
|
|
||||||
|
|
||||||
Handles writing transcript items and querying them for history API.
|
|
||||||
All methods are async and use the provided database engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
|
|
||||||
# Hard limits
|
|
||||||
MAX_CONTENT_LENGTH = 4000
|
|
||||||
HARD_LIMIT = 100
|
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
|
||||||
self.engine = engine
|
|
||||||
self._session_factory = sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
async def append_transcript(
|
|
||||||
self,
|
|
||||||
transcript_id: str | None,
|
|
||||||
event_id: str,
|
|
||||||
conversation_id: str,
|
|
||||||
role: str,
|
|
||||||
content: str | None = None,
|
|
||||||
content_json: dict[str, typing.Any] | None = None,
|
|
||||||
artifact_refs: list[dict[str, typing.Any]] | None = None,
|
|
||||||
thread_id: str | None = None,
|
|
||||||
item_type: str = "message",
|
|
||||||
run_id: str | None = None,
|
|
||||||
runner_id: str | None = None,
|
|
||||||
metadata: dict[str, typing.Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Append a transcript item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
transcript_id: Unique transcript ID (generated if None)
|
|
||||||
event_id: Source event ID
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
role: Message role (user, assistant, system, tool)
|
|
||||||
content: Text content
|
|
||||||
content_json: Full structured content
|
|
||||||
artifact_refs: Artifact references
|
|
||||||
thread_id: Thread ID
|
|
||||||
item_type: Item type
|
|
||||||
run_id: Run ID that generated this
|
|
||||||
runner_id: Runner ID that generated this
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The transcript_id
|
|
||||||
"""
|
|
||||||
if transcript_id is None:
|
|
||||||
transcript_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Truncate content if too long
|
|
||||||
if content and len(content) > self.MAX_CONTENT_LENGTH:
|
|
||||||
content = content[:self.MAX_CONTENT_LENGTH - 3] + "..."
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
item = Transcript(
|
|
||||||
transcript_id=transcript_id,
|
|
||||||
event_id=event_id,
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
role=role,
|
|
||||||
item_type=item_type,
|
|
||||||
content=content,
|
|
||||||
content_json=json.dumps(content_json) if content_json else None,
|
|
||||||
artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None,
|
|
||||||
seq=0,
|
|
||||||
run_id=run_id,
|
|
||||||
runner_id=runner_id,
|
|
||||||
created_at=datetime.datetime.utcnow(),
|
|
||||||
metadata_json=json.dumps(metadata) if metadata else None,
|
|
||||||
)
|
|
||||||
session.add(item)
|
|
||||||
await session.flush()
|
|
||||||
item.seq = item.id or await self._get_next_seq(conversation_id)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return transcript_id
|
|
||||||
|
|
||||||
async def page_transcript(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
before_seq: int | None = None,
|
|
||||||
after_seq: int | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
direction: str = "backward",
|
|
||||||
include_artifacts: bool = False,
|
|
||||||
) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]:
|
|
||||||
"""Page through transcript items.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
before_seq: Get items before this sequence (backward)
|
|
||||||
after_seq: Get items after this sequence (forward)
|
|
||||||
limit: Maximum items to return (capped at 100)
|
|
||||||
direction: 'backward' (older) or 'forward' (newer)
|
|
||||||
include_artifacts: Include artifact refs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (items, next_seq, prev_seq, has_more)
|
|
||||||
"""
|
|
||||||
limit = min(limit, self.HARD_LIMIT)
|
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(Transcript).where(
|
|
||||||
Transcript.conversation_id == conversation_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if direction == "backward" and before_seq is not None:
|
|
||||||
query = query.where(Transcript.seq < before_seq)
|
|
||||||
query = query.order_by(Transcript.seq.desc())
|
|
||||||
elif direction == "forward" and after_seq is not None:
|
|
||||||
query = query.where(Transcript.seq > after_seq)
|
|
||||||
query = query.order_by(Transcript.seq.asc())
|
|
||||||
else:
|
|
||||||
# Default: most recent items first (backward from latest)
|
|
||||||
query = query.order_by(Transcript.seq.desc())
|
|
||||||
|
|
||||||
query = query.limit(limit + 1)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
items = [self._row_to_dict(row, include_artifacts) for row in rows[:limit]]
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
|
|
||||||
# Calculate cursors
|
|
||||||
next_seq = None
|
|
||||||
prev_seq = None
|
|
||||||
|
|
||||||
if direction == "backward":
|
|
||||||
# Items are in descending order
|
|
||||||
if items:
|
|
||||||
next_seq = items[-1].get('seq') if has_more else None
|
|
||||||
prev_seq = items[0].get('seq')
|
|
||||||
else:
|
|
||||||
# Items are in ascending order
|
|
||||||
if items:
|
|
||||||
next_seq = items[-1].get('seq') if has_more else None
|
|
||||||
prev_seq = items[0].get('seq')
|
|
||||||
|
|
||||||
return items, next_seq, prev_seq, has_more
|
|
||||||
|
|
||||||
async def search_transcript(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
query_text: str,
|
|
||||||
filters: dict[str, typing.Any] | None = None,
|
|
||||||
top_k: int = 10,
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
"""Search transcript items.
|
|
||||||
|
|
||||||
Basic implementation using LIKE filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
query_text: Search query
|
|
||||||
filters: Optional filters
|
|
||||||
top_k: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of matching items
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
query = sqlalchemy.select(Transcript).where(
|
|
||||||
Transcript.conversation_id == conversation_id,
|
|
||||||
Transcript.content.ilike(f"%{query_text}%"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply additional filters
|
|
||||||
if filters:
|
|
||||||
if 'roles' in filters:
|
|
||||||
query = query.where(Transcript.role.in_(filters['roles']))
|
|
||||||
if 'item_types' in filters:
|
|
||||||
query = query.where(Transcript.item_type.in_(filters['item_types']))
|
|
||||||
|
|
||||||
query = query.order_by(Transcript.seq.desc()).limit(top_k)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
return [self._row_to_dict(row, include_artifacts=True) for row in rows]
|
|
||||||
|
|
||||||
async def get_latest_cursor(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get the latest cursor for a conversation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cursor string (seq number), or None if no items
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(Transcript.seq)
|
|
||||||
.where(Transcript.conversation_id == conversation_id)
|
|
||||||
.order_by(Transcript.seq.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = result.scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return str(row)
|
|
||||||
|
|
||||||
async def has_history_before(
|
|
||||||
self,
|
|
||||||
conversation_id: str,
|
|
||||||
seq: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if there is history before a sequence number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conversation_id: Conversation ID
|
|
||||||
seq: Sequence number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if there are items before
|
|
||||||
"""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(Transcript)
|
|
||||||
.where(
|
|
||||||
Transcript.conversation_id == conversation_id,
|
|
||||||
Transcript.seq < seq,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
count = result.scalar()
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def _get_next_seq(self, conversation_id: str) -> int:
|
|
||||||
"""Fallback next sequence number for stores that cannot expose autoincrement IDs."""
|
|
||||||
async with self._session_factory() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.max(Transcript.seq))
|
|
||||||
.where(Transcript.conversation_id == conversation_id)
|
|
||||||
)
|
|
||||||
max_seq = result.scalar()
|
|
||||||
return (max_seq or 0) + 1
|
|
||||||
|
|
||||||
def _row_to_dict(
|
|
||||||
self,
|
|
||||||
row: Transcript,
|
|
||||||
include_artifacts: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Convert a Transcript row to dict."""
|
|
||||||
result = {
|
|
||||||
'transcript_id': row.transcript_id,
|
|
||||||
'event_id': row.event_id,
|
|
||||||
'conversation_id': row.conversation_id,
|
|
||||||
'thread_id': row.thread_id,
|
|
||||||
'role': row.role,
|
|
||||||
'item_type': row.item_type,
|
|
||||||
'content': row.content,
|
|
||||||
'content_json': json.loads(row.content_json) if row.content_json else None,
|
|
||||||
'seq': row.seq,
|
|
||||||
'cursor': str(row.seq),
|
|
||||||
'created_at': int(row.created_at.timestamp()) if row.created_at else None,
|
|
||||||
'metadata': json.loads(row.metadata_json) if row.metadata_json else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_artifacts and row.artifact_refs_json:
|
|
||||||
result['artifact_refs'] = json.loads(row.artifact_refs_json)
|
|
||||||
else:
|
|
||||||
result['artifact_refs'] = []
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import asyncio
|
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -36,640 +35,3 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
# In-memory session store for active registrations
|
|
||||||
_create_app_sessions: dict = {}
|
|
||||||
_SESSION_TTL = 900 # 15 minutes
|
|
||||||
|
|
||||||
def _cleanup_expired_sessions():
|
|
||||||
"""Remove sessions that have exceeded their TTL."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
|
|
||||||
for sid in expired:
|
|
||||||
session = _create_app_sessions.pop(sid, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
|
|
||||||
@self.route('/lark/create-app', methods=['POST'])
|
|
||||||
async def _() -> str:
|
|
||||||
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import lark_oapi as lark
|
|
||||||
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
|
|
||||||
|
|
||||||
_cleanup_expired_sessions()
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
session = {
|
|
||||||
'status': 'pending',
|
|
||||||
'qr_url': None,
|
|
||||||
'expire_at': None,
|
|
||||||
'app_id': None,
|
|
||||||
'app_secret': None,
|
|
||||||
'error': None,
|
|
||||||
'created_at': time.time(),
|
|
||||||
}
|
|
||||||
_create_app_sessions[session_id] = session
|
|
||||||
|
|
||||||
def on_qr_code(info):
|
|
||||||
# May be called from a background thread by the SDK;
|
|
||||||
# use call_soon_threadsafe to safely update session state.
|
|
||||||
def _update():
|
|
||||||
session['qr_url'] = info['url']
|
|
||||||
session['expire_at'] = time.time() + 600 # 10 minutes
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update)
|
|
||||||
|
|
||||||
async def run_registration():
|
|
||||||
try:
|
|
||||||
result = await lark.aregister_app(
|
|
||||||
on_qr_code=on_qr_code,
|
|
||||||
source='langbot',
|
|
||||||
)
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['app_id'] = result['client_id']
|
|
||||||
session['app_secret'] = result['client_secret']
|
|
||||||
except AppAccessDeniedError:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'User denied authorization'
|
|
||||||
except AppExpiredError:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
|
|
||||||
task = asyncio.create_task(run_registration())
|
|
||||||
session['task'] = task
|
|
||||||
|
|
||||||
# Wait for QR code to be ready (max 10 seconds)
|
|
||||||
for _ in range(20):
|
|
||||||
if session['qr_url']:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if not session['qr_url']:
|
|
||||||
task.cancel()
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Timeout waiting for QR code'
|
|
||||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'session_id': session_id,
|
|
||||||
'qr_url': session['qr_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Poll registration status."""
|
|
||||||
session = _create_app_sessions.get(session_id)
|
|
||||||
if not session:
|
|
||||||
return self.http_status(404, -1, 'Session not found')
|
|
||||||
|
|
||||||
data = {'status': session['status']}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
|
||||||
data['app_id'] = session['app_id']
|
|
||||||
data['app_secret'] = session['app_secret']
|
|
||||||
_create_app_sessions.pop(session_id, None)
|
|
||||||
elif session['status'] == 'error':
|
|
||||||
data['error'] = session['error']
|
|
||||||
_create_app_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
|
||||||
|
|
||||||
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Cancel and clean up a registration session."""
|
|
||||||
session = _create_app_sessions.pop(session_id, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
return self.success(data={})
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# WeChat QR Code Login
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_weixin_login_sessions: dict = {}
|
|
||||||
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
|
|
||||||
|
|
||||||
def _cleanup_expired_weixin_sessions():
|
|
||||||
import time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
expired = [
|
|
||||||
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
|
|
||||||
]
|
|
||||||
for sid in expired:
|
|
||||||
session = _weixin_login_sessions.pop(sid, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
|
|
||||||
@self.route('/weixin/login', methods=['POST'])
|
|
||||||
async def _() -> str:
|
|
||||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
|
||||||
|
|
||||||
_cleanup_expired_weixin_sessions()
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
session = {
|
|
||||||
'status': 'pending',
|
|
||||||
'qr_data_url': None,
|
|
||||||
'expire_at': None,
|
|
||||||
'token': None,
|
|
||||||
'base_url': None,
|
|
||||||
'account_id': None,
|
|
||||||
'error': None,
|
|
||||||
'created_at': time.time(),
|
|
||||||
}
|
|
||||||
_weixin_login_sessions[session_id] = session
|
|
||||||
|
|
||||||
client = OpenClawWeixinClient(
|
|
||||||
base_url=DEFAULT_BASE_URL,
|
|
||||||
token='',
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_login():
|
|
||||||
try:
|
|
||||||
import qrcode as qr_lib
|
|
||||||
|
|
||||||
for _attempt in range(3):
|
|
||||||
qr_resp = await client.fetch_qrcode()
|
|
||||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
|
||||||
raise Exception('Failed to get QR code from server')
|
|
||||||
|
|
||||||
# Generate QR code image locally
|
|
||||||
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
|
|
||||||
qr.add_data(qr_resp.qrcode_img_content)
|
|
||||||
qr.make(fit=True)
|
|
||||||
img = qr.make_image(fill_color='black', back_color='white')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
|
||||||
data_url = f'data:image/png;base64,{b64}'
|
|
||||||
|
|
||||||
def _update_qr():
|
|
||||||
session['qr_data_url'] = data_url
|
|
||||||
session['expire_at'] = time.time() + 480 # 8 minutes
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update_qr)
|
|
||||||
|
|
||||||
# Poll for scan status
|
|
||||||
deadline = loop.time() + 180
|
|
||||||
while loop.time() < deadline:
|
|
||||||
try:
|
|
||||||
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
|
|
||||||
except Exception:
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['token'] = status_resp.bot_token
|
|
||||||
session['base_url'] = status_resp.baseurl or client.base_url
|
|
||||||
session['account_id'] = status_resp.ilink_bot_id or ''
|
|
||||||
return
|
|
||||||
|
|
||||||
if status_resp.status == 'expired':
|
|
||||||
break # retry with new QR code
|
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
else:
|
|
||||||
pass # timeout, retry
|
|
||||||
|
|
||||||
# All retries exhausted
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code login failed: max retries exceeded'
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
finally:
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
task = asyncio.create_task(run_login())
|
|
||||||
session['task'] = task
|
|
||||||
|
|
||||||
# Wait for QR code to be ready (max 10 seconds)
|
|
||||||
for _ in range(20):
|
|
||||||
if session['qr_data_url']:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if not session['qr_data_url']:
|
|
||||||
task.cancel()
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Timeout waiting for QR code'
|
|
||||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'session_id': session_id,
|
|
||||||
'qr_data_url': session['qr_data_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Poll WeChat login status."""
|
|
||||||
session = _weixin_login_sessions.get(session_id)
|
|
||||||
if not session:
|
|
||||||
return self.http_status(404, -1, 'Session not found')
|
|
||||||
|
|
||||||
data = {'status': session['status']}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
|
||||||
data['token'] = session['token']
|
|
||||||
data['base_url'] = session['base_url']
|
|
||||||
data['account_id'] = session['account_id']
|
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
|
||||||
elif session['status'] == 'error':
|
|
||||||
data['error'] = session['error']
|
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
|
||||||
|
|
||||||
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Cancel and clean up a WeChat login session."""
|
|
||||||
session = _weixin_login_sessions.pop(session_id, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
return self.success(data={})
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# DingTalk Device Flow QR Code Login
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_dingtalk_sessions: dict = {}
|
|
||||||
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
|
|
||||||
|
|
||||||
def _cleanup_expired_dingtalk_sessions():
|
|
||||||
import time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
expired = [
|
|
||||||
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
|
|
||||||
]
|
|
||||||
for sid in expired:
|
|
||||||
session = _dingtalk_sessions.pop(sid, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
|
|
||||||
@self.route('/dingtalk/create-app', methods=['POST'])
|
|
||||||
async def _() -> str:
|
|
||||||
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
|
|
||||||
|
|
||||||
_cleanup_expired_dingtalk_sessions()
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
session = {
|
|
||||||
'status': 'pending',
|
|
||||||
'qr_url': None,
|
|
||||||
'expire_at': None,
|
|
||||||
'client_id': None,
|
|
||||||
'client_secret': None,
|
|
||||||
'error': None,
|
|
||||||
'created_at': time.time(),
|
|
||||||
'device_code': None,
|
|
||||||
'interval': 5,
|
|
||||||
}
|
|
||||||
_dingtalk_sessions[session_id] = session
|
|
||||||
|
|
||||||
async def run_device_flow():
|
|
||||||
try:
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as http:
|
|
||||||
# Step 1: Init — get nonce
|
|
||||||
async with http.post(
|
|
||||||
f'{DINGTALK_BASE_URL}/app/registration/init',
|
|
||||||
json={'source': 'langbot'},
|
|
||||||
) as resp:
|
|
||||||
try:
|
|
||||||
data = await resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Invalid response from DingTalk service'
|
|
||||||
return
|
|
||||||
if data.get('errcode', -1) != 0:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = data.get('errmsg', 'Failed to init')
|
|
||||||
return
|
|
||||||
nonce = data['nonce']
|
|
||||||
|
|
||||||
# Step 2: Begin — get device_code + QR URL
|
|
||||||
async with http.post(
|
|
||||||
f'{DINGTALK_BASE_URL}/app/registration/begin',
|
|
||||||
json={'nonce': nonce},
|
|
||||||
) as resp:
|
|
||||||
try:
|
|
||||||
data = await resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Invalid response from DingTalk service'
|
|
||||||
return
|
|
||||||
if data.get('errcode', -1) != 0:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = data.get('errmsg', 'Failed to begin authorization')
|
|
||||||
return
|
|
||||||
|
|
||||||
device_code = data['device_code']
|
|
||||||
verification_uri_complete = data.get('verification_uri_complete', '')
|
|
||||||
expires_in = data.get('expires_in', 7200)
|
|
||||||
interval = data.get('interval', 5)
|
|
||||||
|
|
||||||
session['device_code'] = device_code
|
|
||||||
session['interval'] = interval
|
|
||||||
session['qr_url'] = verification_uri_complete
|
|
||||||
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
# Step 3: Poll for authorization result
|
|
||||||
deadline = time.time() + expires_in
|
|
||||||
while time.time() < deadline:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
|
|
||||||
async with http.post(
|
|
||||||
f'{DINGTALK_BASE_URL}/app/registration/poll',
|
|
||||||
json={'device_code': device_code},
|
|
||||||
) as poll_resp:
|
|
||||||
try:
|
|
||||||
poll_data = await poll_resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if poll_data.get('errcode', -1) != 0:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = poll_data.get('errmsg', 'Poll failed')
|
|
||||||
return
|
|
||||||
|
|
||||||
status = poll_data.get('status', '')
|
|
||||||
|
|
||||||
if status == 'SUCCESS':
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['client_id'] = poll_data.get('client_id', '')
|
|
||||||
session['client_secret'] = poll_data.get('client_secret', '')
|
|
||||||
return
|
|
||||||
elif status == 'FAIL':
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
|
|
||||||
return
|
|
||||||
elif status == 'EXPIRED':
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
return
|
|
||||||
# status == 'WAITING': continue polling
|
|
||||||
|
|
||||||
# Timeout
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
|
|
||||||
task = asyncio.create_task(run_device_flow())
|
|
||||||
session['task'] = task
|
|
||||||
|
|
||||||
# Wait for QR code to be ready (max 10 seconds)
|
|
||||||
for _ in range(20):
|
|
||||||
if session['qr_url'] or session['error']:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if session['error']:
|
|
||||||
task.cancel()
|
|
||||||
return self.http_status(502, -1, session['error'])
|
|
||||||
|
|
||||||
if not session['qr_url']:
|
|
||||||
task.cancel()
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Timeout waiting for QR code'
|
|
||||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'session_id': session_id,
|
|
||||||
'qr_url': session['qr_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Poll DingTalk Device Flow status."""
|
|
||||||
_cleanup_expired_dingtalk_sessions()
|
|
||||||
session = _dingtalk_sessions.get(session_id)
|
|
||||||
if not session:
|
|
||||||
return self.http_status(404, -1, 'Session not found')
|
|
||||||
|
|
||||||
data = {'status': session['status']}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
|
||||||
data['client_id'] = session['client_id']
|
|
||||||
data['client_secret'] = session['client_secret']
|
|
||||||
_dingtalk_sessions.pop(session_id, None)
|
|
||||||
elif session['status'] == 'error':
|
|
||||||
data['error'] = session['error']
|
|
||||||
_dingtalk_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
|
||||||
|
|
||||||
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Cancel and clean up a DingTalk Device Flow session."""
|
|
||||||
session = _dingtalk_sessions.pop(session_id, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
return self.success(data={})
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# WeComBot QR Code One-Click Create
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_wecombot_sessions: dict = {}
|
|
||||||
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
|
|
||||||
|
|
||||||
def _cleanup_expired_wecombot_sessions():
|
|
||||||
import time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
expired = [
|
|
||||||
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
|
|
||||||
]
|
|
||||||
for sid in expired:
|
|
||||||
session = _wecombot_sessions.pop(sid, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
|
|
||||||
@self.route('/wecombot/create-bot', methods=['POST'])
|
|
||||||
async def _() -> str:
|
|
||||||
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
|
|
||||||
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
|
|
||||||
|
|
||||||
_cleanup_expired_wecombot_sessions()
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
session = {
|
|
||||||
'status': 'pending',
|
|
||||||
'qr_url': None,
|
|
||||||
'expire_at': None,
|
|
||||||
'botid': None,
|
|
||||||
'secret': None,
|
|
||||||
'error': None,
|
|
||||||
'created_at': time.time(),
|
|
||||||
'scode': None,
|
|
||||||
'task': None,
|
|
||||||
}
|
|
||||||
_wecombot_sessions[session_id] = session
|
|
||||||
|
|
||||||
async def run_qr_flow():
|
|
||||||
try:
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as http:
|
|
||||||
# Step 1: Generate QR code
|
|
||||||
async with http.get(
|
|
||||||
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
|
||||||
) as resp:
|
|
||||||
try:
|
|
||||||
data = await resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Invalid response from WeCom service'
|
|
||||||
return
|
|
||||||
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = data.get('errmsg', 'Failed to generate QR code')
|
|
||||||
return
|
|
||||||
|
|
||||||
scode = data['data']['scode']
|
|
||||||
auth_url = data['data']['auth_url']
|
|
||||||
|
|
||||||
session['scode'] = scode
|
|
||||||
session['qr_url'] = auth_url
|
|
||||||
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
# Step 2: Poll for scan result
|
|
||||||
deadline = time.time() + _WECOMBOT_SESSION_TTL
|
|
||||||
while time.time() < deadline:
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
|
|
||||||
async with http.get(
|
|
||||||
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
|
||||||
) as poll_resp:
|
|
||||||
try:
|
|
||||||
poll_data = await poll_resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
status = poll_data.get('data', {}).get('status', '')
|
|
||||||
if status == 'success':
|
|
||||||
bot_info = poll_data.get('data', {}).get('bot_info', {})
|
|
||||||
if bot_info.get('botid') and bot_info.get('secret'):
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['botid'] = bot_info['botid']
|
|
||||||
session['secret'] = bot_info['secret']
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Scan succeeded but bot info is incomplete'
|
|
||||||
return
|
|
||||||
|
|
||||||
# Timeout
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
|
|
||||||
task = asyncio.create_task(run_qr_flow())
|
|
||||||
session['task'] = task
|
|
||||||
|
|
||||||
# Wait for QR code to be ready (max 10 seconds)
|
|
||||||
for _ in range(20):
|
|
||||||
if session['qr_url'] or session['error']:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if session['error']:
|
|
||||||
task.cancel()
|
|
||||||
return self.http_status(502, -1, session['error'])
|
|
||||||
|
|
||||||
if not session['qr_url']:
|
|
||||||
task.cancel()
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Timeout waiting for QR code'
|
|
||||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'session_id': session_id,
|
|
||||||
'qr_url': session['qr_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Poll WeComBot creation status."""
|
|
||||||
_cleanup_expired_wecombot_sessions()
|
|
||||||
session = _wecombot_sessions.get(session_id)
|
|
||||||
if not session:
|
|
||||||
return self.http_status(404, -1, 'Session not found')
|
|
||||||
|
|
||||||
data = {'status': session['status']}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
|
||||||
data['botid'] = session['botid']
|
|
||||||
data['secret'] = session['secret']
|
|
||||||
_wecombot_sessions.pop(session_id, None)
|
|
||||||
elif session['status'] == 'error':
|
|
||||||
data['error'] = session['error']
|
|
||||||
_wecombot_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
|
||||||
|
|
||||||
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Cancel and clean up a WeComBot creation session."""
|
|
||||||
session = _wecombot_sessions.pop(session_id, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
return self.success(data={})
|
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import httpx
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
from .....entity.persistence import plugin as persistence_plugin
|
|
||||||
from .. import group
|
from .. import group
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
@@ -41,16 +39,6 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
|||||||
return f'assets/{normalized}'
|
return f'assets/{normalized}'
|
||||||
|
|
||||||
|
|
||||||
def _get_request_origin() -> str:
|
|
||||||
"""Return the public request origin, respecting reverse-proxy headers."""
|
|
||||||
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
|
|
||||||
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
|
|
||||||
|
|
||||||
scheme = forwarded_proto or quart.request.scheme
|
|
||||||
host = forwarded_host or quart.request.host
|
|
||||||
return f'{scheme}://{host}'
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
async def _check_extensions_limit(self) -> str | None:
|
async def _check_extensions_limit(self) -> str | None:
|
||||||
@@ -150,15 +138,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
|
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
return self.success(data={'config': plugin['plugin_config']})
|
||||||
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
|
||||||
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
|
||||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
|
||||||
)
|
|
||||||
persisted_config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
config = persisted_config if persisted_config is not None else plugin['plugin_config']
|
|
||||||
return self.success(data={'config': config})
|
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
@@ -209,7 +189,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||||
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||||
if mime_type and mime_type.startswith('text/html'):
|
if mime_type and mime_type.startswith('text/html'):
|
||||||
origin = _get_request_origin()
|
origin = f'{quart.request.scheme}://{quart.request.host}'
|
||||||
resp.headers['Content-Security-Policy'] = (
|
resp.headers['Content-Security-Policy'] = (
|
||||||
f'default-src {origin}; '
|
f'default-src {origin}; '
|
||||||
f"script-src {origin} 'unsafe-inline'; "
|
f"script-src {origin} 'unsafe-inline'; "
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(3, str(e))
|
return self.fail(3, str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
|
||||||
return self.fail(1, str(e))
|
return self.fail(1, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class ApiKeyService:
|
|||||||
|
|
||||||
async def verify_api_key(self, key: str) -> bool:
|
async def verify_api_key(self, key: str) -> bool:
|
||||||
"""Verify if an API key is valid"""
|
"""Verify if an API key is valid"""
|
||||||
if not isinstance(key, str) or not key.startswith('lbk_'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import sqlalchemy
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
|
from ....discover import engine
|
||||||
from ....entity.persistence import bot as persistence_bot
|
from ....entity.persistence import bot as persistence_bot
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
@@ -17,6 +18,24 @@ class BotService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
|
||||||
|
"""Return the discovered platform adapter component for an adapter name."""
|
||||||
|
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
|
||||||
|
if component.metadata.name == adapter_name:
|
||||||
|
return component
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
|
||||||
|
"""Whether the adapter manifest declares a generated webhook URL config item."""
|
||||||
|
component = self._get_adapter_component(adapter_name)
|
||||||
|
if component is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for config_item in component.spec.get('config', []):
|
||||||
|
if config_item.get('type') == 'webhook-url':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
||||||
"""获取所有机器人"""
|
"""获取所有机器人"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||||||
@@ -58,17 +77,10 @@ class BotService:
|
|||||||
if runtime_bot is not None:
|
if runtime_bot is not None:
|
||||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||||
|
|
||||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
# Webhook URL for adapters that declare a generated webhook config item.
|
||||||
if persistence_bot['adapter'] in [
|
# This is manifest-driven so EBA adapters do not need to be mirrored in a
|
||||||
'wecom',
|
# second hard-coded list.
|
||||||
'wecombot',
|
if self._adapter_declares_webhook_url(persistence_bot['adapter']):
|
||||||
'officialaccount',
|
|
||||||
'qqofficial',
|
|
||||||
'slack',
|
|
||||||
'wecomcs',
|
|
||||||
'LINE',
|
|
||||||
'lark',
|
|
||||||
]:
|
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
webhook_url = f'/bots/{bot_uuid}'
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
@@ -99,11 +111,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# bind the most recently updated pipeline if any exist
|
# checkout the default pipeline
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
persistence_pipeline.LegacyPipeline.is_default == True
|
||||||
.limit(1)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
@@ -120,26 +132,24 @@ class BotService:
|
|||||||
|
|
||||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||||
"""Update bot"""
|
"""Update bot"""
|
||||||
update_data = bot_data.copy()
|
if 'uuid' in bot_data:
|
||||||
|
del bot_data['uuid']
|
||||||
if 'uuid' in update_data:
|
|
||||||
del update_data['uuid']
|
|
||||||
|
|
||||||
# set use_pipeline_name
|
# set use_pipeline_name
|
||||||
if 'use_pipeline_uuid' in update_data:
|
if 'use_pipeline_uuid' in bot_data:
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
update_data['use_pipeline_name'] = pipeline.name
|
bot_data['use_pipeline_name'] = pipeline.name
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
raise Exception('Pipeline not found')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||||
|
|
||||||
|
|||||||
@@ -31,126 +31,15 @@ class KnowledgeService:
|
|||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
raise ValueError('knowledge_engine_plugin_id is required')
|
raise ValueError('knowledge_engine_plugin_id is required')
|
||||||
|
|
||||||
creation_settings = kb_data.get('creation_settings', {})
|
|
||||||
retrieval_settings = kb_data.get('retrieval_settings', {})
|
|
||||||
|
|
||||||
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
|
||||||
await self._validate_schema_required_fields(
|
|
||||||
knowledge_engine_plugin_id,
|
|
||||||
creation_settings,
|
|
||||||
retrieval_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||||
name=kb_data.get('name', 'Untitled'),
|
name=kb_data.get('name', 'Untitled'),
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||||
creation_settings=creation_settings,
|
creation_settings=kb_data.get('creation_settings', {}),
|
||||||
retrieval_settings=retrieval_settings,
|
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
||||||
description=kb_data.get('description', ''),
|
description=kb_data.get('description', ''),
|
||||||
)
|
)
|
||||||
return kb.uuid
|
return kb.uuid
|
||||||
|
|
||||||
async def _validate_schema_required_fields(
|
|
||||||
self,
|
|
||||||
plugin_id: str,
|
|
||||||
creation_settings: dict,
|
|
||||||
retrieval_settings: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
|
|
||||||
|
|
||||||
This is a business-agnostic validation that checks all fields marked as
|
|
||||||
required in the plugin's schema, regardless of field type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_id: Knowledge Engine plugin ID.
|
|
||||||
creation_settings: User-provided creation settings.
|
|
||||||
retrieval_settings: User-provided retrieval settings.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If any required field is missing or empty.
|
|
||||||
"""
|
|
||||||
# Validate creation_schema
|
|
||||||
try:
|
|
||||||
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
|
||||||
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
|
||||||
|
|
||||||
# Validate retrieval_schema
|
|
||||||
try:
|
|
||||||
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
|
||||||
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
|
|
||||||
|
|
||||||
def _check_required_fields(
|
|
||||||
self,
|
|
||||||
schema: dict | list,
|
|
||||||
settings: dict,
|
|
||||||
context: str,
|
|
||||||
) -> None:
|
|
||||||
"""Check required fields in schema against provided settings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema: Plugin-defined schema (can be list or dict with 'schema' key).
|
|
||||||
settings: User-provided settings values.
|
|
||||||
context: Context name for error messages (e.g., 'creation_settings').
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If a required field is missing or empty.
|
|
||||||
"""
|
|
||||||
if not schema:
|
|
||||||
return
|
|
||||||
|
|
||||||
# schema can be a list directly, or a dict with 'schema' key
|
|
||||||
items = schema if isinstance(schema, list) else schema.get('schema', [])
|
|
||||||
if not items:
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
field_name = item.get('name')
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_required = item.get('required', False)
|
|
||||||
if not is_required:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check show_if condition - if field is conditionally shown, only validate when condition is met
|
|
||||||
show_if = item.get('show_if')
|
|
||||||
if show_if:
|
|
||||||
depend_field = show_if.get('field')
|
|
||||||
operator = show_if.get('operator')
|
|
||||||
expected_value = show_if.get('value')
|
|
||||||
|
|
||||||
if depend_field and operator:
|
|
||||||
depend_value = settings.get(depend_field)
|
|
||||||
# If show_if condition is not met, skip validation for this field
|
|
||||||
if operator == 'eq' and depend_value != expected_value:
|
|
||||||
continue
|
|
||||||
if operator == 'neq' and depend_value == expected_value:
|
|
||||||
continue
|
|
||||||
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = settings.get(field_name)
|
|
||||||
|
|
||||||
# Validate required field has a non-empty value
|
|
||||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
|
||||||
# Get field label for friendly error message
|
|
||||||
label = item.get('label', {})
|
|
||||||
field_label = (
|
|
||||||
label.get('en_US', field_name)
|
|
||||||
or label.get('zh_Hans', field_name)
|
|
||||||
or label.get('zh_Hant', field_name)
|
|
||||||
or field_name
|
|
||||||
)
|
|
||||||
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
|
||||||
|
|
||||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
# Filter to only mutable fields
|
# Filter to only mutable fields
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from ....core import app
|
|||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....provider.modelmgr import requester as model_requester
|
from ....provider.modelmgr import requester as model_requester
|
||||||
from ....agent.runner.config_migration import ConfigMigration
|
|
||||||
from ....agent.runner import config_schema
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
||||||
@@ -42,40 +40,6 @@ class LLMModelsService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
async def _get_runner_descriptor(self, runner_id: str):
|
|
||||||
registry = getattr(self.ap, 'agent_runner_registry', None)
|
|
||||||
if registry is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return await registry.get(runner_id, bound_plugins=None)
|
|
||||||
except Exception as e:
|
|
||||||
logger = getattr(self.ap, 'logger', None)
|
|
||||||
if logger:
|
|
||||||
logger.warning(f'Failed to load AgentRunner descriptor while setting default model: {e}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _auto_set_default_pipeline_llm_model(self, pipeline: persistence_pipeline.LegacyPipeline, model_uuid: str):
|
|
||||||
pipeline_config = pipeline.config
|
|
||||||
if not isinstance(pipeline_config, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
|
||||||
if not runner_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
descriptor = await self._get_runner_descriptor(runner_id)
|
|
||||||
if descriptor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
ai_config = pipeline_config.setdefault('ai', {})
|
|
||||||
runner_configs = ai_config.setdefault('runner_config', {})
|
|
||||||
runner_config = runner_configs.setdefault(runner_id, {})
|
|
||||||
|
|
||||||
if not config_schema.set_empty_llm_model_selection(descriptor, runner_config, model_uuid):
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config})
|
|
||||||
|
|
||||||
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
|
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
|
||||||
"""Get all LLM models with provider info"""
|
"""Get all LLM models with provider info"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
|
||||||
@@ -145,6 +109,7 @@ class LLMModelsService:
|
|||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
|
|
||||||
if auto_set_to_default_pipeline:
|
if auto_set_to_default_pipeline:
|
||||||
|
# set the default pipeline model to this model
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
persistence_pipeline.LegacyPipeline.is_default == True
|
||||||
@@ -152,7 +117,15 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
await self._auto_set_default_pipeline_llm_model(pipeline, model_data['uuid'])
|
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||||
|
if not model_config.get('primary', ''):
|
||||||
|
pipeline_config = pipeline.config
|
||||||
|
pipeline_config['ai']['local-agent']['model'] = {
|
||||||
|
'primary': model_data['uuid'],
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
pipeline_data = {'config': pipeline_config}
|
||||||
|
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,17 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import typing
|
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
# Prefer the official local-agent plugin when it is installed. This is not a
|
|
||||||
# built-in fallback: when no AgentRunner plugin is available, the default
|
|
||||||
# pipeline stays unbound so the UI can guide users to install a runner.
|
|
||||||
PREFERRED_DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default'
|
|
||||||
|
|
||||||
|
|
||||||
default_stage_order = [
|
default_stage_order = [
|
||||||
'GroupRespondRuleCheckStage', # 群响应规则检查
|
'GroupRespondRuleCheckStage', # 群响应规则检查
|
||||||
'BanSessionCheckStage', # 封禁会话检查
|
'BanSessionCheckStage', # 封禁会话检查
|
||||||
'PreContentFilterStage', # 内容过滤前置阶段
|
'PreContentFilterStage', # 内容过滤前置阶段
|
||||||
'PreProcessor', # 预处理器
|
'PreProcessor', # 预处理器
|
||||||
|
'ConversationMessageTruncator', # 会话消息截断器
|
||||||
'RequireRateLimitOccupancy', # 请求速率限制占用
|
'RequireRateLimitOccupancy', # 请求速率限制占用
|
||||||
'MessageProcessor', # 处理器
|
'MessageProcessor', # 处理器
|
||||||
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
'ReleaseRateLimitOccupancy', # 释放速率限制占用
|
||||||
@@ -35,108 +30,11 @@ class PipelineService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
def _get_default_values_from_schema(self, config_schema: list[dict[str, typing.Any]]) -> dict[str, typing.Any]:
|
|
||||||
"""Build runner config defaults from a DynamicForm schema."""
|
|
||||||
defaults: dict[str, typing.Any] = {}
|
|
||||||
for item in config_schema:
|
|
||||||
name = item.get('name')
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if 'default' in item:
|
|
||||||
defaults[name] = item['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
async def get_default_pipeline_config(self) -> dict[str, typing.Any]:
|
|
||||||
"""Get the default pipeline config, rendering runner defaults from installed plugins."""
|
|
||||||
from ....utils import paths as path_utils
|
|
||||||
|
|
||||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
|
||||||
with open(template_path, 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
agent_runner_registry = getattr(self.ap, 'agent_runner_registry', None)
|
|
||||||
if agent_runner_registry is None:
|
|
||||||
return config
|
|
||||||
|
|
||||||
try:
|
|
||||||
runners = await agent_runner_registry.list_runners(bound_plugins=None)
|
|
||||||
except Exception as e:
|
|
||||||
logger = getattr(self.ap, 'logger', None)
|
|
||||||
if logger:
|
|
||||||
logger.warning(f'Failed to load plugin agent runners for default pipeline config: {e}')
|
|
||||||
return config
|
|
||||||
|
|
||||||
if not runners:
|
|
||||||
return config
|
|
||||||
|
|
||||||
selected_runner = next(
|
|
||||||
(runner for runner in runners if runner.id == PREFERRED_DEFAULT_RUNNER_ID),
|
|
||||||
runners[0],
|
|
||||||
)
|
|
||||||
ai_config = config.setdefault('ai', {})
|
|
||||||
runner_config = ai_config.setdefault('runner', {})
|
|
||||||
runner_config['id'] = selected_runner.id
|
|
||||||
runner_config.setdefault('expire-time', 0)
|
|
||||||
|
|
||||||
ai_config['runner_config'] = {
|
|
||||||
selected_runner.id: self._get_default_values_from_schema(selected_runner.config_schema),
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
async def get_pipeline_metadata(self) -> list[dict]:
|
async def get_pipeline_metadata(self) -> list[dict]:
|
||||||
"""Get pipeline metadata with dynamically loaded plugin runners from registry"""
|
|
||||||
import copy
|
|
||||||
|
|
||||||
# Deep copy AI metadata to avoid modifying the original
|
|
||||||
ai_metadata = copy.deepcopy(self.ap.pipeline_config_meta_ai)
|
|
||||||
|
|
||||||
# Find the runner stage
|
|
||||||
runner_stage = None
|
|
||||||
for stage in ai_metadata.get('stages', []):
|
|
||||||
if stage.get('name') == 'runner':
|
|
||||||
runner_stage = stage
|
|
||||||
break
|
|
||||||
|
|
||||||
if runner_stage:
|
|
||||||
# Find the runner select config (now uses 'id' field)
|
|
||||||
for config_item in runner_stage.get('config', []):
|
|
||||||
if config_item.get('name') == 'id':
|
|
||||||
# Get plugin agent runners from registry
|
|
||||||
try:
|
|
||||||
(
|
|
||||||
runner_options,
|
|
||||||
runner_stages,
|
|
||||||
) = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline()
|
|
||||||
|
|
||||||
# Replace options entirely with registry options
|
|
||||||
# Only installed/available runners should be shown
|
|
||||||
config_item['options'] = runner_options
|
|
||||||
|
|
||||||
# Prefer the official local-agent plugin when installed; otherwise use the first
|
|
||||||
# discoverable runner. If no runner is available, leave the default unset so the
|
|
||||||
# UI can recommend installing an AgentRunner plugin, similar to the RAG flow.
|
|
||||||
if runner_options and 'default' not in config_item:
|
|
||||||
default_option = next(
|
|
||||||
(option for option in runner_options if option['name'] == PREFERRED_DEFAULT_RUNNER_ID),
|
|
||||||
runner_options[0],
|
|
||||||
)
|
|
||||||
config_item['default'] = default_option['name']
|
|
||||||
|
|
||||||
# Add corresponding stage configuration for each runner
|
|
||||||
for stage_config in runner_stages:
|
|
||||||
# Avoid duplicate stages
|
|
||||||
existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])}
|
|
||||||
if stage_config['name'] not in existing_stage_names:
|
|
||||||
ai_metadata['stages'].append(stage_config)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}')
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
self.ap.pipeline_config_meta_trigger,
|
self.ap.pipeline_config_meta_trigger,
|
||||||
self.ap.pipeline_config_meta_safety,
|
self.ap.pipeline_config_meta_safety,
|
||||||
ai_metadata,
|
self.ap.pipeline_config_meta_ai,
|
||||||
self.ap.pipeline_config_meta_output,
|
self.ap.pipeline_config_meta_output,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -176,6 +74,8 @@ class PipelineService:
|
|||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||||
|
|
||||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||||
|
from ....utils import paths as path_utils
|
||||||
|
|
||||||
# Check limitation
|
# Check limitation
|
||||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||||
max_pipelines = limitation.get('max_pipelines', -1)
|
max_pipelines = limitation.get('max_pipelines', -1)
|
||||||
@@ -189,7 +89,9 @@ class PipelineService:
|
|||||||
pipeline_data['stages'] = default_stage_order.copy()
|
pipeline_data['stages'] = default_stage_order.copy()
|
||||||
pipeline_data['is_default'] = default
|
pipeline_data['is_default'] = default
|
||||||
|
|
||||||
pipeline_data['config'] = await self.get_default_pipeline_config()
|
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
||||||
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
|
pipeline_data['config'] = json.load(f)
|
||||||
|
|
||||||
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
||||||
if 'extensions_preferences' not in pipeline_data:
|
if 'extensions_preferences' not in pipeline_data:
|
||||||
@@ -211,15 +113,14 @@ class PipelineService:
|
|||||||
return pipeline_data['uuid']
|
return pipeline_data['uuid']
|
||||||
|
|
||||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||||
from ....agent.runner.config_migration import ConfigMigration
|
if 'uuid' in pipeline_data:
|
||||||
|
del pipeline_data['uuid']
|
||||||
pipeline_data = pipeline_data.copy()
|
if 'for_version' in pipeline_data:
|
||||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
del pipeline_data['for_version']
|
||||||
pipeline_data.pop(protected_field, None)
|
if 'stages' in pipeline_data:
|
||||||
|
del pipeline_data['stages']
|
||||||
# Migrate config to new format before saving
|
if 'is_default' in pipeline_data:
|
||||||
if 'config' in pipeline_data:
|
del pipeline_data['is_default']
|
||||||
pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -17,24 +17,6 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
|
||||||
if api_keys is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
|
|
||||||
normalized_keys = []
|
|
||||||
seen_keys = set()
|
|
||||||
|
|
||||||
for raw_key in raw_keys:
|
|
||||||
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
|
|
||||||
if not normalized_key or normalized_key in seen_keys:
|
|
||||||
continue
|
|
||||||
normalized_keys.append(normalized_key)
|
|
||||||
seen_keys.add(normalized_key)
|
|
||||||
|
|
||||||
return normalized_keys
|
|
||||||
|
|
||||||
async def get_providers(self) -> list[dict]:
|
async def get_providers(self) -> list[dict]:
|
||||||
"""Get all providers"""
|
"""Get all providers"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||||
@@ -77,7 +59,6 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, provider_data: dict) -> str:
|
async def create_provider(self, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -91,8 +72,6 @@ class ModelProviderService:
|
|||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
if 'uuid' in provider_data:
|
if 'uuid' in provider_data:
|
||||||
del provider_data['uuid']
|
del provider_data['uuid']
|
||||||
if 'api_keys' in provider_data:
|
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
@@ -162,8 +141,6 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
api_keys = self._normalize_api_keys(api_keys)
|
|
||||||
|
|
||||||
# Try to find existing provider with same config
|
# Try to find existing provider with same config
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
@@ -191,7 +168,7 @@ class ModelProviderService:
|
|||||||
'name': provider_name,
|
'name': provider_name,
|
||||||
'requester': requester,
|
'requester': requester,
|
||||||
'base_url': base_url,
|
'base_url': base_url,
|
||||||
'api_keys': api_keys,
|
'api_keys': api_keys or [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,7 +177,7 @@ class ModelProviderService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||||
.values(api_keys=self._normalize_api_keys(api_key))
|
.values(api_keys=[api_key])
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..platform import botmgr as im_mgr
|
from ..platform import botmgr as im_mgr
|
||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
@@ -45,9 +44,6 @@ from ..vector import mgr as vectordb_mgr
|
|||||||
from ..telemetry import telemetry as telemetry_module
|
from ..telemetry import telemetry as telemetry_module
|
||||||
from ..survey import manager as survey_module
|
from ..survey import manager as survey_module
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator
|
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
"""Runtime application object and context"""
|
"""Runtime application object and context"""
|
||||||
@@ -162,11 +158,6 @@ class Application:
|
|||||||
|
|
||||||
maintenance_service: maintenance_service.MaintenanceService = None
|
maintenance_service: maintenance_service.MaintenanceService = None
|
||||||
|
|
||||||
# Agent runner subsystem
|
|
||||||
agent_runner_registry: AgentRunnerRegistry = None
|
|
||||||
|
|
||||||
agent_run_orchestrator: AgentRunOrchestrator = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,12 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
|||||||
|
|
||||||
|
|
||||||
async def main(loop: asyncio.AbstractEventLoop):
|
async def main(loop: asyncio.AbstractEventLoop):
|
||||||
app_inst: app.Application | None = None
|
|
||||||
try:
|
try:
|
||||||
# Hang system signal processing
|
# Hang system signal processing
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
if app_inst is not None:
|
app_inst.dispose()
|
||||||
app_inst.dispose()
|
|
||||||
print('[Signal] Program exit.')
|
print('[Signal] Program exit.')
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|||||||
22
src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py
Normal file
22
src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class('msg-truncator-cfg-migration', 9)
|
||||||
|
class MsgTruncatorConfigMigration(migration.Migration):
|
||||||
|
"""迁移"""
|
||||||
|
|
||||||
|
async def need_migrate(self) -> bool:
|
||||||
|
"""判断当前环境是否需要运行此迁移"""
|
||||||
|
return 'msg-truncate' not in self.ap.pipeline_cfg.data
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""执行迁移"""
|
||||||
|
|
||||||
|
self.ap.pipeline_cfg.data['msg-truncate'] = {
|
||||||
|
'method': 'round',
|
||||||
|
'round': {'max-round': 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.pipeline_cfg.dump_config()
|
||||||
@@ -36,7 +36,6 @@ from ...vector import mgr as vectordb_mgr
|
|||||||
from .. import taskmgr
|
from .. import taskmgr
|
||||||
from ...telemetry import telemetry as telemetry_module
|
from ...telemetry import telemetry as telemetry_module
|
||||||
from ...survey import manager as survey_module
|
from ...survey import manager as survey_module
|
||||||
from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator
|
|
||||||
|
|
||||||
|
|
||||||
@stage.stage_class('BuildAppStage')
|
@stage.stage_class('BuildAppStage')
|
||||||
@@ -180,12 +179,5 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
ap.plugin_connector = plugin_connector_inst
|
ap.plugin_connector = plugin_connector_inst
|
||||||
|
|
||||||
# Initialize agent runner subsystem
|
|
||||||
agent_runner_registry_inst = AgentRunnerRegistry(ap)
|
|
||||||
ap.agent_runner_registry = agent_runner_registry_inst
|
|
||||||
|
|
||||||
agent_run_orchestrator_inst = AgentRunOrchestrator(ap, agent_runner_registry_inst)
|
|
||||||
ap.agent_run_orchestrator = agent_run_orchestrator_inst
|
|
||||||
|
|
||||||
ctrl = controller.Controller(ap)
|
ctrl = controller.Controller(ap)
|
||||||
ap.ctrl = ctrl
|
ap.ctrl = ctrl
|
||||||
|
|||||||
@@ -241,12 +241,14 @@ class ComponentDiscoveryEngine:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for file in importutil.list_resource_files(path):
|
for file in importutil.list_resource_files(path):
|
||||||
if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')):
|
file_path = os.path.join(path, file)
|
||||||
comp = self.load_component_manifest(os.path.join(path, file), owner, no_save)
|
is_dir = importutil.is_resource_dir(file_path)
|
||||||
|
if (not is_dir) and (file.endswith('.yaml') or file.endswith('.yml')):
|
||||||
|
comp = self.load_component_manifest(file_path, owner, no_save)
|
||||||
if comp is not None:
|
if comp is not None:
|
||||||
components.append(comp)
|
components.append(comp)
|
||||||
elif os.path.isdir(os.path.join(path, file)):
|
elif is_dir:
|
||||||
recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1)
|
recursive_load_component_manifests_in_dir(file_path, depth + 1)
|
||||||
|
|
||||||
recursive_load_component_manifests_in_dir(path)
|
recursive_load_component_manifests_in_dir(path)
|
||||||
return components
|
return components
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Agent runner state persistence entity for host-owned state."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerState(Base):
|
|
||||||
"""AgentRunnerState stores host-owned state for AgentRunner protocol.
|
|
||||||
|
|
||||||
State is:
|
|
||||||
- Host-owned: Managed by LangBot, not by plugin instances
|
|
||||||
- Scope-isolated: Separated by runner_id + binding_identity + scope
|
|
||||||
- Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes)
|
|
||||||
|
|
||||||
Scope key design:
|
|
||||||
- conversation: runner_id + binding_id + conversation_id [+ thread_id]
|
|
||||||
- actor: runner_id + binding_id + actor_type + actor_id
|
|
||||||
- subject: runner_id + binding_id + subject_type + subject_id
|
|
||||||
- runner: runner_id + binding_id
|
|
||||||
|
|
||||||
This table is the production store for AgentRunner state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'agent_runner_state'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
# Identity
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Runner descriptor ID (plugin:author/name/runner)."""
|
|
||||||
|
|
||||||
binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Binding identity for isolation (binding_id or scope_type:scope_id)."""
|
|
||||||
|
|
||||||
scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
|
||||||
"""State scope: 'conversation', 'actor', 'subject', or 'runner'."""
|
|
||||||
|
|
||||||
scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False, index=True)
|
|
||||||
"""Full scope key for unique lookup (includes all identity parts)."""
|
|
||||||
|
|
||||||
state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
"""State key within scope (should use namespace prefix like external.*)."""
|
|
||||||
|
|
||||||
value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""State value as JSON string (size-limited by host)."""
|
|
||||||
|
|
||||||
# Context fields for querying/filtering
|
|
||||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Bot UUID if applicable."""
|
|
||||||
|
|
||||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Workspace ID for multi-tenant."""
|
|
||||||
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Conversation ID for conversation scope."""
|
|
||||||
|
|
||||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Thread ID for thread-scoped conversation state."""
|
|
||||||
|
|
||||||
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Actor type for actor scope."""
|
|
||||||
|
|
||||||
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Actor ID for actor scope."""
|
|
||||||
|
|
||||||
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Subject type for subject scope."""
|
|
||||||
|
|
||||||
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Subject ID for subject scope."""
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this state entry was created."""
|
|
||||||
|
|
||||||
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
|
||||||
"""When this state entry was last updated."""
|
|
||||||
|
|
||||||
# Unique constraint: scope_key + state_key
|
|
||||||
__table_args__ = (
|
|
||||||
sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'),
|
|
||||||
sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'),
|
|
||||||
sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'),
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Artifact persistence entity for Host-owned artifact store."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AgentArtifact(Base):
|
|
||||||
"""AgentArtifact stores metadata for large files, images, tool results, etc.
|
|
||||||
|
|
||||||
This table only stores metadata. The actual blob content is stored in
|
|
||||||
BinaryStorage or external storage, referenced by storage_key.
|
|
||||||
|
|
||||||
Artifacts are accessed via artifact_metadata and artifact_read APIs
|
|
||||||
with run_id authorization.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'agent_artifact'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
artifact_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
|
||||||
"""Unique artifact identifier."""
|
|
||||||
|
|
||||||
artifact_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
"""Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc."""
|
|
||||||
|
|
||||||
mime_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""MIME type of the content."""
|
|
||||||
|
|
||||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Original file name (if applicable)."""
|
|
||||||
|
|
||||||
size_bytes = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
|
|
||||||
"""Size in bytes."""
|
|
||||||
|
|
||||||
sha256 = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
|
||||||
"""SHA256 hash of content (for integrity verification)."""
|
|
||||||
|
|
||||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
"""Source of artifact: 'platform', 'runner', 'tool', 'system'."""
|
|
||||||
|
|
||||||
# Storage reference (points to BinaryStorage or external storage)
|
|
||||||
storage_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Key in BinaryStorage or external storage reference."""
|
|
||||||
|
|
||||||
storage_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='binary_storage')
|
|
||||||
"""Storage type: 'binary_storage', 'file', 'url', etc."""
|
|
||||||
|
|
||||||
# Context
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Conversation this artifact belongs to."""
|
|
||||||
|
|
||||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Run ID that created this artifact."""
|
|
||||||
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Runner ID that created this artifact."""
|
|
||||||
|
|
||||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Bot UUID that handled this artifact."""
|
|
||||||
|
|
||||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Workspace ID for multi-tenant deployments."""
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this artifact was created."""
|
|
||||||
|
|
||||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
"""When this artifact expires (optional)."""
|
|
||||||
|
|
||||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Additional metadata as JSON string."""
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""EventLog persistence entity for storing auditable event facts."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class EventLog(Base):
|
|
||||||
"""EventLog stores auditable event records for AgentRunner.
|
|
||||||
|
|
||||||
This is the fact source for events - messages, tool calls, system events, etc.
|
|
||||||
Large payloads are stored separately as artifacts; this table stores
|
|
||||||
references and summaries.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'event_log'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
|
||||||
"""Unique event identifier."""
|
|
||||||
|
|
||||||
event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
|
|
||||||
"""Event type (message.received, tool.call.started, etc.)."""
|
|
||||||
|
|
||||||
event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
"""When the event occurred."""
|
|
||||||
|
|
||||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
"""Event source (platform, webui, api, scheduler, system, pipeline_adapter)."""
|
|
||||||
|
|
||||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Bot UUID that handled this event."""
|
|
||||||
|
|
||||||
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Workspace ID for multi-tenant deployments."""
|
|
||||||
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Conversation ID this event belongs to."""
|
|
||||||
|
|
||||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Thread ID if platform supports threads."""
|
|
||||||
|
|
||||||
# Actor information
|
|
||||||
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Actor type (user, system, runner)."""
|
|
||||||
|
|
||||||
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Actor identifier."""
|
|
||||||
|
|
||||||
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Actor display name."""
|
|
||||||
|
|
||||||
# Subject information
|
|
||||||
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
"""Subject type (message, tool_call, artifact)."""
|
|
||||||
|
|
||||||
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Subject identifier."""
|
|
||||||
|
|
||||||
# Input information
|
|
||||||
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Brief summary of input (truncated text, max 1000 chars)."""
|
|
||||||
|
|
||||||
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
|
|
||||||
|
|
||||||
# Raw event reference
|
|
||||||
raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Reference to raw event payload in ArtifactStore."""
|
|
||||||
|
|
||||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Run ID that processed this event."""
|
|
||||||
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Runner ID that processed this event."""
|
|
||||||
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this record was created."""
|
|
||||||
|
|
||||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Additional metadata as JSON string."""
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""Transcript persistence entity for conversation history projection."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(Base):
|
|
||||||
"""Transcript stores conversation-oriented message projection for history API.
|
|
||||||
|
|
||||||
This is a projection of EventLog, optimized for agent history retrieval.
|
|
||||||
It includes message content and artifact refs, but not raw platform payloads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'transcript'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
"""Auto-increment ID for sequencing."""
|
|
||||||
|
|
||||||
transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
|
||||||
"""Unique transcript item identifier."""
|
|
||||||
|
|
||||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Reference to the source event in EventLog."""
|
|
||||||
|
|
||||||
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""Conversation this item belongs to."""
|
|
||||||
|
|
||||||
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Thread ID if platform supports threads."""
|
|
||||||
|
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
"""Message role: 'user', 'assistant', 'system', or 'tool'."""
|
|
||||||
|
|
||||||
item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message')
|
|
||||||
"""Item type: 'message', 'tool_call', 'tool_result', 'system'."""
|
|
||||||
|
|
||||||
# Content
|
|
||||||
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
|
|
||||||
|
|
||||||
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Full structured content as JSON string (Message model dump)."""
|
|
||||||
|
|
||||||
# Artifact references
|
|
||||||
artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Artifact references as JSON string (list of ArtifactRef)."""
|
|
||||||
|
|
||||||
# Sequence for cursor-based pagination
|
|
||||||
seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, index=True)
|
|
||||||
"""Monotonic cursor sequence for pagination."""
|
|
||||||
|
|
||||||
# Context
|
|
||||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
"""Run ID that generated this item (for assistant messages)."""
|
|
||||||
|
|
||||||
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Runner ID that generated this item."""
|
|
||||||
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
"""When this item was created."""
|
|
||||||
|
|
||||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'),
|
|
||||||
sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'),
|
|
||||||
)
|
|
||||||
@@ -13,28 +13,6 @@ from sqlalchemy.engine import Connection
|
|||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
# Import all ORM models so they are registered with Base.metadata
|
|
||||||
# This is required for autogenerate to detect model changes
|
|
||||||
from langbot.pkg.entity.persistence import (
|
|
||||||
agent_runner_state,
|
|
||||||
apikey,
|
|
||||||
artifact,
|
|
||||||
bot,
|
|
||||||
bstorage,
|
|
||||||
event_log,
|
|
||||||
mcp,
|
|
||||||
metadata,
|
|
||||||
model,
|
|
||||||
monitoring,
|
|
||||||
pipeline,
|
|
||||||
plugin,
|
|
||||||
rag,
|
|
||||||
transcript,
|
|
||||||
user,
|
|
||||||
vector,
|
|
||||||
webhook,
|
|
||||||
)
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
"""Migrate pipeline config to new runner format
|
|
||||||
|
|
||||||
Revision ID: 0004_migrate_runner_config
|
|
||||||
Revises: 0003_add_rerank_models
|
|
||||||
Create Date: 2026-05-10
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0004_migrate_runner_config'
|
|
||||||
down_revision = '0003_add_rerank_models'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
# Mapping from old built-in runner names to official plugin runner IDs
|
|
||||||
OLD_RUNNER_TO_PLUGIN_RUNNER_ID = {
|
|
||||||
'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',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_plugin_runner_id(runner_id: str) -> bool:
|
|
||||||
"""Check if runner ID is in plugin:* format."""
|
|
||||||
return runner_id.startswith('plugin:')
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_runner_config_for_migration(runner_id: str, runner_config: dict) -> dict:
|
|
||||||
"""Normalize released legacy runner fields before storing binding config."""
|
|
||||||
normalized = dict(runner_config)
|
|
||||||
|
|
||||||
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
|
|
||||||
legacy_kb = normalized.pop('knowledge-base', None)
|
|
||||||
if 'knowledge-bases' not in normalized:
|
|
||||||
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
|
|
||||||
normalized['knowledge-bases'] = [legacy_kb]
|
|
||||||
elif legacy_kb is not None:
|
|
||||||
normalized['knowledge-bases'] = []
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_pipeline_config(config: dict) -> dict:
|
|
||||||
"""Migrate pipeline config to new format."""
|
|
||||||
new_config = dict(config)
|
|
||||||
ai_config = new_config.get('ai', {})
|
|
||||||
if not ai_config:
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
runner_config = ai_config.get('runner', {})
|
|
||||||
runner_configs = ai_config.get('runner_config', {})
|
|
||||||
|
|
||||||
# Check for new format first
|
|
||||||
runner_id = runner_config.get('id')
|
|
||||||
if runner_id and is_plugin_runner_id(runner_id):
|
|
||||||
if runner_id in runner_configs:
|
|
||||||
runner_configs[runner_id] = normalize_runner_config_for_migration(
|
|
||||||
runner_id,
|
|
||||||
runner_configs[runner_id],
|
|
||||||
)
|
|
||||||
ai_config['runner_config'] = runner_configs
|
|
||||||
new_config['ai'] = ai_config
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
# Check for old format
|
|
||||||
old_runner_name = runner_config.get('runner')
|
|
||||||
if old_runner_name:
|
|
||||||
# Map to new runner ID
|
|
||||||
if is_plugin_runner_id(old_runner_name):
|
|
||||||
runner_id = old_runner_name
|
|
||||||
else:
|
|
||||||
runner_id = OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(old_runner_name, old_runner_name)
|
|
||||||
|
|
||||||
# Set new format
|
|
||||||
runner_config['id'] = runner_id
|
|
||||||
|
|
||||||
# Remove old runner field if it's a mapped built-in runner
|
|
||||||
if old_runner_name in OLD_RUNNER_TO_PLUGIN_RUNNER_ID:
|
|
||||||
del runner_config['runner']
|
|
||||||
|
|
||||||
# Migrate runner-specific config and remove old config blocks
|
|
||||||
if old_runner_name in ai_config:
|
|
||||||
old_runner_config = ai_config[old_runner_name]
|
|
||||||
if old_runner_config:
|
|
||||||
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, old_runner_config)
|
|
||||||
# Remove old config block after migration
|
|
||||||
del ai_config[old_runner_name]
|
|
||||||
|
|
||||||
# Also check if runner_id has config under other old name formats
|
|
||||||
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
|
|
||||||
if mapped_id == runner_id and old_name in ai_config:
|
|
||||||
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, ai_config[old_name])
|
|
||||||
# Remove old config block after migration
|
|
||||||
del ai_config[old_name]
|
|
||||||
|
|
||||||
# Update configs
|
|
||||||
ai_config['runner'] = runner_config
|
|
||||||
ai_config['runner_config'] = runner_configs
|
|
||||||
new_config['ai'] = ai_config
|
|
||||||
|
|
||||||
return new_config
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Migrate existing pipeline configs to new runner format."""
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
|
|
||||||
# Check if pipelines table exists (may not exist in fresh install)
|
|
||||||
if 'pipelines' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get all pipelines
|
|
||||||
result = conn.execute(sa.text('SELECT uuid, config FROM pipelines'))
|
|
||||||
pipelines = result.fetchall()
|
|
||||||
|
|
||||||
for pipeline_uuid, config_json in pipelines:
|
|
||||||
if not config_json:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = json.loads(config_json)
|
|
||||||
migrated_config = migrate_pipeline_config(config)
|
|
||||||
|
|
||||||
# Only update if config changed
|
|
||||||
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
|
|
||||||
conn.execute(
|
|
||||||
sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
|
|
||||||
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Skip invalid configs
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade is not supported for data migration."""
|
|
||||||
# No downgrade - keep configs in new format
|
|
||||||
pass
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""add_event_log_and_transcript_tables
|
|
||||||
|
|
||||||
Revision ID: 58846a8d7a81
|
|
||||||
Revises: 0004_migrate_runner_config
|
|
||||||
Create Date: 2026-05-23 15:41:47.030841
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = '58846a8d7a81'
|
|
||||||
down_revision = '0004_migrate_runner_config'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create event_log table
|
|
||||||
op.create_table(
|
|
||||||
'event_log',
|
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column('event_id', sa.String(255), nullable=False, unique=True),
|
|
||||||
sa.Column('event_type', sa.String(100), nullable=False),
|
|
||||||
sa.Column('event_time', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('source', sa.String(50), nullable=False),
|
|
||||||
sa.Column('bot_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('conversation_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('thread_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('actor_type', sa.String(50), nullable=True),
|
|
||||||
sa.Column('actor_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('actor_name', sa.String(255), nullable=True),
|
|
||||||
sa.Column('subject_type', sa.String(50), nullable=True),
|
|
||||||
sa.Column('subject_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('input_summary', sa.Text(), nullable=True),
|
|
||||||
sa.Column('input_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('raw_ref', sa.String(255), nullable=True),
|
|
||||||
sa.Column('run_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
|
||||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for event_log
|
|
||||||
with op.batch_alter_table('event_log', schema=None) as batch_op:
|
|
||||||
batch_op.create_index('ix_event_log_event_id', ['event_id'], unique=True)
|
|
||||||
batch_op.create_index('ix_event_log_event_type', ['event_type'], unique=False)
|
|
||||||
batch_op.create_index('ix_event_log_bot_id', ['bot_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_event_log_conversation_id', ['conversation_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_event_log_run_id', ['run_id'], unique=False)
|
|
||||||
|
|
||||||
# Create transcript table
|
|
||||||
op.create_table(
|
|
||||||
'transcript',
|
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column('transcript_id', sa.String(255), nullable=False, unique=True),
|
|
||||||
sa.Column('event_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('conversation_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('thread_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('role', sa.String(50), nullable=False),
|
|
||||||
sa.Column('item_type', sa.String(50), nullable=False, server_default='message'),
|
|
||||||
sa.Column('content', sa.Text(), nullable=True),
|
|
||||||
sa.Column('content_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('artifact_refs_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('seq', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('run_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
|
||||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for transcript
|
|
||||||
with op.batch_alter_table('transcript', schema=None) as batch_op:
|
|
||||||
batch_op.create_index('ix_transcript_transcript_id', ['transcript_id'], unique=True)
|
|
||||||
batch_op.create_index('ix_transcript_event_id', ['event_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_transcript_conversation_id', ['conversation_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_transcript_conversation_seq', ['conversation_id', 'seq'], unique=False)
|
|
||||||
batch_op.create_index('ix_transcript_conversation_created', ['conversation_id', 'created_at'], unique=False)
|
|
||||||
batch_op.create_index('ix_transcript_run_id', ['run_id'], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop transcript table
|
|
||||||
with op.batch_alter_table('transcript', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index('ix_transcript_run_id')
|
|
||||||
batch_op.drop_index('ix_transcript_conversation_created')
|
|
||||||
batch_op.drop_index('ix_transcript_conversation_seq')
|
|
||||||
batch_op.drop_index('ix_transcript_conversation_id')
|
|
||||||
batch_op.drop_index('ix_transcript_event_id')
|
|
||||||
batch_op.drop_index('ix_transcript_transcript_id')
|
|
||||||
|
|
||||||
op.drop_table('transcript')
|
|
||||||
|
|
||||||
# Drop event_log table
|
|
||||||
with op.batch_alter_table('event_log', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index('ix_event_log_run_id')
|
|
||||||
batch_op.drop_index('ix_event_log_conversation_id')
|
|
||||||
batch_op.drop_index('ix_event_log_bot_id')
|
|
||||||
batch_op.drop_index('ix_event_log_event_type')
|
|
||||||
batch_op.drop_index('ix_event_log_event_id')
|
|
||||||
|
|
||||||
op.drop_table('event_log')
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Alembic script.py.mako — template for auto-generated revisions
|
|
||||||
"""add agent_runner_state table for host-owned persistent state
|
|
||||||
|
|
||||||
Revision ID: 6dfd3dd7f0c7
|
|
||||||
Revises: a1b2c3d4e5f6
|
|
||||||
Create Date: 2026-05-23 19:49:08.529110
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = '6dfd3dd7f0c7'
|
|
||||||
down_revision = 'a1b2c3d4e5f6'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('agent_runner_state',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('runner_id', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('binding_identity', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('scope', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('scope_key', sa.String(length=512), nullable=False),
|
|
||||||
sa.Column('state_key', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('value_json', sa.Text(), nullable=True),
|
|
||||||
sa.Column('bot_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('workspace_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('conversation_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('thread_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('actor_type', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('actor_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('subject_type', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('subject_id', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key')
|
|
||||||
)
|
|
||||||
with op.batch_alter_table('agent_runner_state', schema=None) as batch_op:
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_actor_id'), ['actor_id'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_binding_identity'), ['binding_identity'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_bot_id'), ['bot_id'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_conversation_id'), ['conversation_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_agent_runner_state_runner_binding', ['runner_id', 'binding_identity'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_runner_id'), ['runner_id'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_scope'), ['scope'], unique=False)
|
|
||||||
batch_op.create_index(batch_op.f('ix_agent_runner_state_scope_key'), ['scope_key'], unique=False)
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('agent_runner_state', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_scope_key'))
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_scope'))
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_runner_id'))
|
|
||||||
batch_op.drop_index('ix_agent_runner_state_runner_binding')
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_conversation_id'))
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_bot_id'))
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_binding_identity'))
|
|
||||||
batch_op.drop_index(batch_op.f('ix_agent_runner_state_actor_id'))
|
|
||||||
|
|
||||||
op.drop_table('agent_runner_state')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""add_agent_artifact_table
|
|
||||||
|
|
||||||
Revision ID: a1b2c3d4e5f6
|
|
||||||
Revises: 58846a8d7a81
|
|
||||||
Create Date: 2026-05-23 20:00:00.000000
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers
|
|
||||||
revision = 'a1b2c3d4e5f6'
|
|
||||||
down_revision = '58846a8d7a81'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create agent_artifact table
|
|
||||||
op.create_table(
|
|
||||||
'agent_artifact',
|
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
||||||
sa.Column('artifact_id', sa.String(255), nullable=False, unique=True),
|
|
||||||
sa.Column('artifact_type', sa.String(50), nullable=False),
|
|
||||||
sa.Column('mime_type', sa.String(255), nullable=True),
|
|
||||||
sa.Column('name', sa.String(255), nullable=True),
|
|
||||||
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column('sha256', sa.String(64), nullable=True),
|
|
||||||
sa.Column('source', sa.String(50), nullable=False),
|
|
||||||
sa.Column('storage_key', sa.String(255), nullable=True),
|
|
||||||
sa.Column('storage_type', sa.String(50), nullable=False, server_default='binary_storage'),
|
|
||||||
sa.Column('conversation_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('run_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('runner_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('bot_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('workspace_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
|
||||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for agent_artifact
|
|
||||||
with op.batch_alter_table('agent_artifact', schema=None) as batch_op:
|
|
||||||
batch_op.create_index('ix_agent_artifact_artifact_id', ['artifact_id'], unique=True)
|
|
||||||
batch_op.create_index('ix_agent_artifact_conversation_id', ['conversation_id'], unique=False)
|
|
||||||
batch_op.create_index('ix_agent_artifact_run_id', ['run_id'], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop agent_artifact table
|
|
||||||
with op.batch_alter_table('agent_artifact', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index('ix_agent_artifact_run_id')
|
|
||||||
batch_op.drop_index('ix_agent_artifact_conversation_id')
|
|
||||||
batch_op.drop_index('ix_agent_artifact_artifact_id')
|
|
||||||
|
|
||||||
op.drop_table('agent_artifact')
|
|
||||||
@@ -118,6 +118,9 @@ class DBMigrateV3Config(migration.DBMigration):
|
|||||||
'runner': self.ap.provider_cfg.data['runner'],
|
'runner': self.ap.provider_cfg.data['runner'],
|
||||||
}
|
}
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_uuid
|
pipeline_config['ai']['local-agent']['model'] = model_uuid
|
||||||
|
pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][
|
||||||
|
'max-round'
|
||||||
|
]
|
||||||
|
|
||||||
pipeline_config['ai']['local-agent']['prompt'] = [
|
pipeline_config['ai']['local-agent']['prompt'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ class MessageAggregator:
|
|||||||
message_chain=merged_chain,
|
message_chain=merged_chain,
|
||||||
adapter=base_msg.adapter,
|
adapter=base_msg.adapter,
|
||||||
pipeline_uuid=base_msg.pipeline_uuid,
|
pipeline_uuid=base_msg.pipeline_uuid,
|
||||||
routed_by_rule=any(msg.routed_by_rule for msg in messages),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def flush_all(self) -> None:
|
async def flush_all(self) -> None:
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
if not query.resp_message_chain:
|
|
||||||
self.ap.logger.debug('Response message chain is empty, skip long message processing.')
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
|
||||||
|
|
||||||
# 检查是否包含非 Plain 组件
|
# 检查是否包含非 Plain 组件
|
||||||
contains_non_plain = False
|
contains_non_plain = False
|
||||||
|
|
||||||
|
|||||||
35
src/langbot/pkg/pipeline/msgtrun/msgtrun.py
Normal file
35
src/langbot/pkg/pipeline/msgtrun/msgtrun.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import stage, entities
|
||||||
|
from . import truncator
|
||||||
|
from ...utils import importutil
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
from . import truncators
|
||||||
|
|
||||||
|
importutil.import_modules_in_pkg(truncators)
|
||||||
|
|
||||||
|
|
||||||
|
@stage.stage_class('ConversationMessageTruncator')
|
||||||
|
class ConversationMessageTruncator(stage.PipelineStage):
|
||||||
|
"""Conversation message truncator
|
||||||
|
|
||||||
|
Used to truncate the conversation message chain to adapt to the LLM message length limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
trun: truncator.Truncator
|
||||||
|
|
||||||
|
async def initialize(self, pipeline_config: dict):
|
||||||
|
use_method = 'round'
|
||||||
|
|
||||||
|
for trun in truncator.preregistered_truncators:
|
||||||
|
if trun.name == use_method:
|
||||||
|
self.trun = trun(self.ap)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unknown truncator: {use_method}')
|
||||||
|
|
||||||
|
async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:
|
||||||
|
"""处理"""
|
||||||
|
query = await self.trun.truncate(query)
|
||||||
|
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
56
src/langbot/pkg/pipeline/msgtrun/truncator.py
Normal file
56
src/langbot/pkg/pipeline/msgtrun/truncator.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
import abc
|
||||||
|
|
||||||
|
from ...core import app
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
|
||||||
|
preregistered_truncators: list[typing.Type[Truncator]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def truncator_class(
|
||||||
|
name: str,
|
||||||
|
) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]:
|
||||||
|
"""截断器类装饰器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): 截断器名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]:
|
||||||
|
assert issubclass(cls, Truncator)
|
||||||
|
|
||||||
|
cls.name = name
|
||||||
|
|
||||||
|
preregistered_truncators.append(cls)
|
||||||
|
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class Truncator(abc.ABC):
|
||||||
|
"""消息截断器基类"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application):
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
||||||
|
"""截断
|
||||||
|
|
||||||
|
一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。
|
||||||
|
请勿操作其他字段。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
30
src/langbot/pkg/pipeline/msgtrun/truncators/round.py
Normal file
30
src/langbot/pkg/pipeline/msgtrun/truncators/round.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import truncator
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
|
||||||
|
|
||||||
|
@truncator.truncator_class('round')
|
||||||
|
class RoundTruncator(truncator.Truncator):
|
||||||
|
"""Truncate the conversation message chain to adapt to the LLM message length limit."""
|
||||||
|
|
||||||
|
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
||||||
|
"""截断"""
|
||||||
|
max_round = query.pipeline_config['ai']['local-agent']['max-round']
|
||||||
|
|
||||||
|
temp_messages = []
|
||||||
|
|
||||||
|
current_round = 0
|
||||||
|
|
||||||
|
# Traverse from back to front
|
||||||
|
for msg in query.messages[::-1]:
|
||||||
|
if current_round < max_round:
|
||||||
|
temp_messages.append(msg)
|
||||||
|
if msg.role == 'user':
|
||||||
|
current_round += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
query.messages = temp_messages[::-1]
|
||||||
|
|
||||||
|
return query
|
||||||
@@ -28,6 +28,7 @@ from . import (
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
|
msgtrun,
|
||||||
)
|
)
|
||||||
|
|
||||||
importutil.import_modules_in_pkgs(
|
importutil.import_modules_in_pkgs(
|
||||||
@@ -41,6 +42,7 @@ importutil.import_modules_in_pkgs(
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
|
msgtrun,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,9 +438,6 @@ class PipelineManager:
|
|||||||
# initialize stage containers according to pipeline_entity.stages
|
# initialize stage containers according to pipeline_entity.stages
|
||||||
stage_containers: list[StageInstContainer] = []
|
stage_containers: list[StageInstContainer] = []
|
||||||
for stage_name in pipeline_entity.stages:
|
for stage_name in pipeline_entity.stages:
|
||||||
if stage_name not in self.stage_dict:
|
|
||||||
self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping')
|
|
||||||
continue
|
|
||||||
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
|
stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))
|
||||||
|
|
||||||
for stage_container in stage_containers:
|
for stage_container in stage_containers:
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ class QueryPool:
|
|||||||
self.cached_queries[query_id] = query
|
self.cached_queries[query_id] = query
|
||||||
self.query_id_counter += 1
|
self.query_id_counter += 1
|
||||||
self.condition.notify_all()
|
self.condition.notify_all()
|
||||||
return query
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.pool_lock.acquire()
|
await self.pool_lock.acquire()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user