mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
108 Commits
copilot/bu
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac52f3b9b | ||
|
|
9fbc2432e0 | ||
|
|
0b83b0c623 | ||
|
|
95b859c55d | ||
|
|
768d52f509 | ||
|
|
9e9bfbfb3d | ||
|
|
471d9d68b2 | ||
|
|
58e4b35770 | ||
|
|
056e62aa03 | ||
|
|
9330a684fe | ||
|
|
90dffa7cd8 | ||
|
|
ea6c8fba57 | ||
|
|
ce007c49c8 | ||
|
|
4e68a93df7 | ||
|
|
7247d8f221 | ||
|
|
e0e321251e | ||
|
|
8db23bf950 | ||
|
|
8063303cfa | ||
|
|
094b87e578 | ||
|
|
26923c66c0 | ||
|
|
146694539e | ||
|
|
7d6f635664 | ||
|
|
641b15c74d | ||
|
|
0cf29930a8 | ||
|
|
927388c1f7 | ||
|
|
760baa24a3 | ||
|
|
036affe01f | ||
|
|
19557c3227 | ||
|
|
b9ecb27560 | ||
|
|
b96dd8edc7 | ||
|
|
423fa0f942 | ||
|
|
948591d439 | ||
|
|
ac3989d3ba | ||
|
|
1e5acb947b | ||
|
|
74b829a288 | ||
|
|
6e982ff49d | ||
|
|
b220cf02e5 | ||
|
|
66eaa99887 | ||
|
|
5aaa422250 | ||
|
|
b7dcda8b23 | ||
|
|
3c58b9141b | ||
|
|
ddbf390d56 | ||
|
|
767137aaa0 | ||
|
|
acb2ce6a40 | ||
|
|
67784708d6 | ||
|
|
1bd9c334aa | ||
|
|
17bbc8bf10 | ||
|
|
4a4c0921a4 | ||
|
|
e425cf079a | ||
|
|
245e798b79 | ||
|
|
27fdccce16 | ||
|
|
484643c0ee | ||
|
|
ec61459619 | ||
|
|
66ef744447 | ||
|
|
10d3a9cc92 | ||
|
|
885320e9ae | ||
|
|
ed02ac4710 | ||
|
|
e4841edbaf | ||
|
|
ef7a06b0db | ||
|
|
6fe20c1812 | ||
|
|
9e8c8f79df | ||
|
|
01d06898fb | ||
|
|
0a669c7016 | ||
|
|
b251fc4b89 | ||
|
|
075c85e2bc | ||
|
|
62b63ca2ca | ||
|
|
3680a80248 | ||
|
|
6713b57d01 | ||
|
|
ea13ef87f2 | ||
|
|
59bd581e88 | ||
|
|
cba83a62e8 | ||
|
|
f412127fb0 | ||
|
|
5273bbb23f | ||
|
|
0ceab3f6a5 | ||
|
|
aedc097188 | ||
|
|
18b27dd9ef | ||
|
|
3f50a56623 | ||
|
|
1fcdbd472f | ||
|
|
547006cb4a | ||
|
|
92bf9a7ea5 | ||
|
|
832efb4069 | ||
|
|
8f1847d480 | ||
|
|
fe619e415f | ||
|
|
0154ea6cd3 | ||
|
|
8db55267d8 | ||
|
|
b9662250a6 | ||
|
|
d9378c3a88 | ||
|
|
86a4d1bf0b | ||
|
|
ce6e79db8e | ||
|
|
d53e2cb9a0 | ||
|
|
c1168745b7 | ||
|
|
69b87a0d8a | ||
|
|
6637b153f1 | ||
|
|
e768fc6116 | ||
|
|
2442d3bf52 | ||
|
|
42d78817f4 | ||
|
|
4b9f25a05d | ||
|
|
d1f0e07cc0 | ||
|
|
78e55509ae | ||
|
|
2c28635a39 | ||
|
|
5f3cecfbe2 | ||
|
|
12df9d6ee9 | ||
|
|
195f6efeff | ||
|
|
564d829e25 | ||
|
|
58c1916712 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b |
109
.github/workflows/run-tests.yml
vendored
109
.github/workflows/run-tests.yml
vendored
@@ -4,25 +4,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- '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:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- '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: Run Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -39,28 +43,13 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
uses: astral-sh/setup-uv@v4
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --dev
|
||||||
uv sync --dev
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit + smoke tests
|
||||||
run: |
|
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
||||||
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()
|
||||||
@@ -69,3 +58,79 @@ 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,11 +9,13 @@ 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:
|
||||||
@@ -34,52 +36,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Test Alembic upgrade (SQLite)
|
- name: Run SQLite migration tests
|
||||||
run: |
|
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
||||||
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)
|
||||||
@@ -114,58 +72,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Test Alembic upgrade (PostgreSQL)
|
- name: Run PostgreSQL migration tests
|
||||||
run: |
|
env:
|
||||||
uv run python -c "
|
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
||||||
import asyncio
|
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
||||||
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())
|
|
||||||
"
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,6 +47,7 @@ plugins.bak
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
|
testsdk/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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/
|
||||||
80
README.md
80
README.md
@@ -84,45 +84,48 @@ docker compose up -d
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Official |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Official |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Official |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Official |
|
||||||
| QQ | ✅ | Personal & Official API |
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Official |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Official |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Official |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Supported LLMs & Integrations
|
## Supported LLMs & Integrations
|
||||||
|
|
||||||
| Provider | Type | Status |
|
| Provider | Type | Status |
|
||||||
|----------|------|--------|
|
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
@@ -130,22 +133,23 @@ docker compose up -d
|
|||||||
|
|
||||||
## Why LangBot?
|
## Why LangBot?
|
||||||
|
|
||||||
| Use Case | How LangBot Helps |
|
| Use Case | How LangBot Helps |
|
||||||
|----------|-------------------|
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||||
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||||
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||||
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
## Live Demo
|
||||||
|
|
||||||
**Try it now:** https://demo.langbot.dev/
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
- Email: `demo@langbot.app`
|
- Email: `demo@langbot.app`
|
||||||
- Password: `langbot123456`
|
- Password: `langbot123456`
|
||||||
|
|
||||||
*Note: Public demo environment. Do not enter sensitive information.*
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_CN.md
18
README_CN.md
@@ -87,13 +87,16 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | |
|
| 飞书 | ✅ | 官方 |
|
||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| KOOK | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +127,7 @@ docker compose up -d
|
|||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
|||||||
19
README_ES.md
19
README_ES.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Oficial |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Oficial |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Oficial |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Oficial |
|
||||||
| QQ | ✅ | Personal y API Oficial |
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Oficial |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Oficial |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Oficial |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
19
README_FR.md
19
README_FR.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Officiel |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Officiel |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Officiel |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Officiel |
|
||||||
| QQ | ✅ | Personnel & API Officielle |
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Officiel |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Officiel |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Officiel |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_JP.md
21
README_JP.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| QQ | ✅ | 個人 & 公式API |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
19
README_KO.md
19
README_KO.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 공식 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 공식 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 공식 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 공식 |
|
||||||
| QQ | ✅ | 개인 및 공식 API |
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 공식 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 공식 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 공식 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
19
README_RU.md
19
README_RU.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Официальный |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Официальный |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Официальный |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Официальный |
|
||||||
| QQ | ✅ | Личный и официальный API |
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Официальный |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Официальный |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Официальный |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
19
README_TW.md
19
README_TW.md
@@ -85,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 飛書 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 釘釘 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| KOOK | ✅ | |
|
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|
||||||
|
|||||||
19
README_VI.md
19
README_VI.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Chính thức |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Chính thức |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Chính thức |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Chính thức |
|
||||||
| QQ | ✅ | Cá nhân & API chính thức |
|
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||||
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Chính thức |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Chính thức |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Chính thức |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
335
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
335
docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# 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。
|
||||||
237
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal file
237
docs/agent-runner-pluginization/EVENT_BASED_AGENT.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# 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。
|
||||||
427
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
427
docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# 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 集成
|
||||||
|
- 平台动作执行器
|
||||||
552
docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md
Normal file
552
docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
# 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 预留,不执行平台动作。
|
||||||
329
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
329
docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# 官方 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 保持一致;代码结构不需要相同。
|
||||||
245
docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md
Normal file
245
docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# 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 已完成。
|
||||||
157
docs/agent-runner-pluginization/PROGRESS.md
Normal file
157
docs/agent-runner-pluginization/PROGRESS.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 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) — 具体实施细节
|
||||||
702
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
702
docs/agent-runner-pluginization/PROTOCOL_V1.md
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
# 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。
|
||||||
125
docs/agent-runner-pluginization/README.md
Normal file
125
docs/agent-runner-pluginization/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 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 能力层之上。
|
||||||
225
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
225
docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# 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 是核心前端的一部分,还是完全由管理插件提供。
|
||||||
73
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
73
docs/agent-runner-pluginization/SECURITY_HARDENING.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 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 的完整实现。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.6"
|
version = "4.9.7"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -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.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"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,6 +35,7 @@ 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",
|
||||||
@@ -69,9 +70,10 @@ 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.8",
|
"langbot-plugin==0.3.11",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
@@ -103,6 +105,9 @@ 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"
|
||||||
@@ -120,6 +125,7 @@ package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/
|
|||||||
|
|
||||||
[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",
|
||||||
@@ -220,4 +226,3 @@ skip-magic-trailing-comma = false
|
|||||||
|
|
||||||
# Like Black, automatically detect the appropriate line ending.
|
# Like Black, automatically detect the appropriate line ending.
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ 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]
|
||||||
|
|||||||
65
scripts/test-coverage.sh
Executable file
65
scripts/test-coverage.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/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"
|
||||||
16
scripts/test-integration-fast.sh
Executable file
16
scripts/test-integration-fast.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/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 ==="
|
||||||
36
scripts/test-quick.sh
Executable file
36
scripts/test-quick.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/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.6'
|
__version__ = '4.9.7'
|
||||||
|
|||||||
@@ -481,6 +481,12 @@ class DingTalkClient:
|
|||||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||||
card_data['content'] = ''
|
card_data['content'] = ''
|
||||||
|
|
||||||
|
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||||
|
if incoming_message.message_type == 'text':
|
||||||
|
card_data['query'] = incoming_message.get_text_list()[0]
|
||||||
|
else:
|
||||||
|
card_data['query'] = '...'
|
||||||
|
|
||||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||||
# print(card_instance)
|
# print(card_instance)
|
||||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
from .qqofficialevent import QQOfficialEvent
|
from .qqofficialevent import QQOfficialEvent
|
||||||
import json
|
import json
|
||||||
@@ -32,6 +34,8 @@ class QQOfficialClient:
|
|||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._msg_seq_counter = 0
|
||||||
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
"""检查access_token是否存在"""
|
"""检查access_token是否存在"""
|
||||||
@@ -50,18 +54,18 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
try:
|
response = await client.post(url, json=params, headers=headers)
|
||||||
response = await client.post(url, json=params, headers=headers)
|
if response.status_code != 200:
|
||||||
if response.status_code == 200:
|
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
access_token = response_data.get('access_token')
|
access_token = response_data.get('access_token')
|
||||||
expires_in = int(response_data.get('expires_in', 7200))
|
expires_in = int(response_data.get('expires_in', 7200))
|
||||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||||
if access_token:
|
if access_token:
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
except Exception as e:
|
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
else:
|
||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception('Failed to get access_token: no access_token in response')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
@@ -87,10 +91,10 @@ class QQOfficialClient:
|
|||||||
try:
|
try:
|
||||||
body = await req.get_data()
|
body = await req.get_data()
|
||||||
|
|
||||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||||
|
|
||||||
if not body or len(body) == 0:
|
if not body or len(body) == 0:
|
||||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
await self.logger.info('Received empty body, might be health check or GET request')
|
||||||
return {'code': 0, 'message': 'ok'}, 200
|
return {'code': 0, 'message': 'ok'}, 200
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
@@ -111,7 +115,6 @@ class QQOfficialClient:
|
|||||||
return {'code': 0, 'message': 'success'}
|
return {'code': 0, 'message': 'success'}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
|
|
||||||
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||||
return {'error': str(e)}, 400
|
return {'error': str(e)}, 400
|
||||||
|
|
||||||
@@ -139,21 +142,24 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||||
"""获取消息"""
|
"""获取消息"""
|
||||||
|
d = msg.get('d', {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return {}
|
||||||
message_data = {
|
message_data = {
|
||||||
't': msg.get('t', {}),
|
't': msg.get('t', {}),
|
||||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
'timestamp': d.get('timestamp', {}),
|
||||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
'd_author_id': d.get('author', {}).get('id', {}),
|
||||||
'content': msg.get('d', {}).get('content', {}),
|
'content': d.get('content', {}),
|
||||||
'd_id': msg.get('d', {}).get('id', {}),
|
'd_id': d.get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
'channel_id': d.get('channel_id', {}),
|
||||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
'username': d.get('author', {}).get('username', {}),
|
||||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
'guild_id': d.get('guild_id', {}),
|
||||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
'member_openid': d.get('author', {}).get('openid', {}),
|
||||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
'group_openid': d.get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = msg.get('d', {}).get('attachments', [])
|
attachments = d.get('attachments', [])
|
||||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||||
image_attachments_type = [
|
image_attachments_type = [
|
||||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||||
@@ -192,7 +198,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||||
@@ -215,7 +221,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||||
raise Exception(response.read().decode())
|
raise Exception(response.read().decode())
|
||||||
|
|
||||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||||
@@ -238,7 +244,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||||
@@ -261,9 +267,224 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
|
# ---- 富媒体消息 ----
|
||||||
|
|
||||||
|
# 媒体文件类型
|
||||||
|
MEDIA_TYPE_IMAGE = 1
|
||||||
|
MEDIA_TYPE_VIDEO = 2
|
||||||
|
MEDIA_TYPE_VOICE = 3
|
||||||
|
MEDIA_TYPE_FILE = 4
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_type: int,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""上传媒体文件,返回 file_info。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_type: 'c2c' | 'group'
|
||||||
|
target_id: 用户 openid 或群 openid
|
||||||
|
file_type: 1=图片, 2=视频, 3=语音, 4=文件
|
||||||
|
file_url: 在线 URL(与 file_data 二选一)
|
||||||
|
file_data: base64 编码的文件数据或 data URL(与 file_url 二选一)
|
||||||
|
file_name: 文件名(file_type=4 时必填)
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/files'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/files'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'file_type': file_type,
|
||||||
|
'srv_send_msg': False,
|
||||||
|
}
|
||||||
|
if file_url:
|
||||||
|
body['url'] = file_url
|
||||||
|
elif file_data:
|
||||||
|
# 处理 data URL 格式: data:image/png;base64,xxxxx
|
||||||
|
if file_data.startswith('data:'):
|
||||||
|
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
body['file_data'] = match.group(1)
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
raise ValueError('file_url or file_data is required')
|
||||||
|
|
||||||
|
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||||
|
body['file_name'] = file_name
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
file_info = data.get('file_info', '')
|
||||||
|
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||||
|
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _send_media_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_info: str,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送富媒体消息(msg_type=7)"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
self._msg_seq_counter += 1
|
||||||
|
msg_seq = self._msg_seq_counter
|
||||||
|
body = {
|
||||||
|
'msg_type': 7,
|
||||||
|
'media': {'file_info': file_info},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if content:
|
||||||
|
body['content'] = content
|
||||||
|
if msg_id:
|
||||||
|
body['msg_id'] = msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def send_image_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送图片消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_IMAGE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
|
||||||
|
|
||||||
|
async def send_voice_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送语音消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_VOICE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_file_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送文件消息(含视频)"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_FILE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_stream_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
event_id: str,
|
||||||
|
msg_id: str,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
index: int = 0,
|
||||||
|
stream_msg_id: str = None,
|
||||||
|
input_state: int = 1,
|
||||||
|
):
|
||||||
|
"""发送流式消息(C2C 私聊)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_state: 1=生成中, 10=生成结束
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
|
||||||
|
body = {
|
||||||
|
'input_mode': 'replace',
|
||||||
|
'input_state': input_state,
|
||||||
|
'content_type': 'markdown',
|
||||||
|
'content_raw': content,
|
||||||
|
'event_id': event_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
'index': index,
|
||||||
|
}
|
||||||
|
if stream_msg_id:
|
||||||
|
body['stream_msg_id'] = stream_msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
async def is_token_expired(self):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
@@ -292,3 +513,325 @@ class QQOfficialClient:
|
|||||||
'signature': signature,
|
'signature': signature,
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# ---- WebSocket Gateway ----
|
||||||
|
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||||
|
|
||||||
|
INTENT_GUILDS = 1 << 0
|
||||||
|
INTENT_GUILD_MEMBERS = 1 << 1
|
||||||
|
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
|
||||||
|
INTENT_DIRECT_MESSAGE = 1 << 12
|
||||||
|
INTENT_GROUP_AND_C2C = 1 << 25
|
||||||
|
INTENT_INTERACTION = 1 << 26
|
||||||
|
|
||||||
|
FULL_INTENTS = (
|
||||||
|
INTENT_GUILDS
|
||||||
|
| INTENT_GUILD_MEMBERS
|
||||||
|
| INTENT_PUBLIC_GUILD_MESSAGES
|
||||||
|
| INTENT_DIRECT_MESSAGE
|
||||||
|
| INTENT_GROUP_AND_C2C
|
||||||
|
| INTENT_INTERACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gateway_url(self) -> str:
|
||||||
|
"""获取 WebSocket 网关地址"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/gateway'
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
}
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
ws_url = data.get('url', '')
|
||||||
|
if not ws_url:
|
||||||
|
raise Exception('Gateway URL is empty')
|
||||||
|
return ws_url
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _background_token_refresh(self):
|
||||||
|
"""在 token 到期前主动刷新"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if self.access_token_expiry_time:
|
||||||
|
remain = self.access_token_expiry_time - time.time()
|
||||||
|
if remain > 120:
|
||||||
|
await asyncio.sleep(remain - 60)
|
||||||
|
continue
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
if await self.check_access_token():
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
else:
|
||||||
|
await self.get_access_token()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
|
||||||
|
on_ready: 连接就绪 (收到 READY) 时的回调
|
||||||
|
on_error: 发生错误时的回调
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
reconnect_attempts = 0
|
||||||
|
max_reconnect_attempts = 100
|
||||||
|
backoff_delays = [1, 2, 5, 10, 30, 60]
|
||||||
|
rate_limit_delay = 60
|
||||||
|
|
||||||
|
# Cancel previous token refresh task if any
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = None
|
||||||
|
|
||||||
|
while reconnect_attempts <= max_reconnect_attempts:
|
||||||
|
heartbeat_interval = 45000
|
||||||
|
should_refresh_token = False
|
||||||
|
ws = None
|
||||||
|
heartbeat_task = None
|
||||||
|
|
||||||
|
# Refresh token if needed
|
||||||
|
if should_refresh_token:
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws_url = await self.get_gateway_url()
|
||||||
|
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
await self.logger.error(f'Failed to get gateway URL: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
|
||||||
|
delay = rate_limit_delay
|
||||||
|
else:
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.logger.info('Connecting to WebSocket gateway...')
|
||||||
|
ws = await websockets.connect(ws_url)
|
||||||
|
await self.logger.info('WebSocket connected')
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw_msg in ws:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_msg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = payload.get('op')
|
||||||
|
d = payload.get('d', {})
|
||||||
|
s = payload.get('s')
|
||||||
|
t = payload.get('t')
|
||||||
|
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
d = {}
|
||||||
|
|
||||||
|
if op == 10: # Hello
|
||||||
|
heartbeat_interval = d.get('heartbeat_interval', 45000)
|
||||||
|
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
|
||||||
|
|
||||||
|
# Send Identify or Resume
|
||||||
|
if session_id and last_seq > 0:
|
||||||
|
resume_payload = {
|
||||||
|
'op': 6,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'session_id': session_id,
|
||||||
|
'seq': last_seq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(resume_payload))
|
||||||
|
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
|
||||||
|
else:
|
||||||
|
identify_payload = {
|
||||||
|
'op': 2,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'intents': self.FULL_INTENTS,
|
||||||
|
'shard': [0, 1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(identify_payload))
|
||||||
|
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
async def _heartbeat_loop(conn, interval_ms):
|
||||||
|
interval_sec = interval_ms / 1000.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_sec)
|
||||||
|
try:
|
||||||
|
hb_payload = {'op': 1, 'd': last_seq}
|
||||||
|
await conn.send(json.dumps(hb_payload))
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
|
||||||
|
|
||||||
|
elif op == 0: # Dispatch
|
||||||
|
if s is not None:
|
||||||
|
last_seq = s
|
||||||
|
|
||||||
|
if t == 'READY':
|
||||||
|
session_id = d.get('session_id', '')
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info(f'READY, session_id={session_id}')
|
||||||
|
if on_ready:
|
||||||
|
try:
|
||||||
|
result = on_ready()
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Track token refresh task to avoid leaks
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
|
||||||
|
|
||||||
|
elif t == 'RESUMED':
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info('RESUMED')
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
|
if on_event:
|
||||||
|
try:
|
||||||
|
result = on_event(t, d)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
elif op == 11: # Heartbeat ACK
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == 7: # Reconnect
|
||||||
|
await self.logger.info('Received Reconnect directive')
|
||||||
|
break
|
||||||
|
|
||||||
|
elif op == 9: # Invalid Session
|
||||||
|
can_resume = d.get('can_resume', False)
|
||||||
|
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
|
||||||
|
if not can_resume:
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Connection closed normally (end of async for)
|
||||||
|
try:
|
||||||
|
close_code = ws.close_code
|
||||||
|
close_reason = ws.close_reason or ''
|
||||||
|
except Exception:
|
||||||
|
close_code = None
|
||||||
|
close_reason = ''
|
||||||
|
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
|
||||||
|
|
||||||
|
if close_code == 4004:
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code in (4006, 4007, 4009):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code == 4008:
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = rate_limit_delay
|
||||||
|
await self.logger.info(
|
||||||
|
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
elif close_code in (4914, 4915):
|
||||||
|
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, err)
|
||||||
|
return
|
||||||
|
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
|
||||||
|
if close_code == 1000:
|
||||||
|
return
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
|
||||||
|
finally:
|
||||||
|
if heartbeat_task:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if ws:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we reach here, we need to reconnect
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if reconnect_attempts > max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
|
||||||
|
return
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def _safe_callback(self, callback, *args):
|
||||||
|
"""Safely invoke a callback, handling both sync and async functions."""
|
||||||
|
try:
|
||||||
|
result = callback(*args)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway_loop(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""持续重连的网关循环。"""
|
||||||
|
await self.connect_gateway(on_event, on_ready, on_error)
|
||||||
|
|||||||
37
src/langbot/pkg/agent/__init__.py
Normal file
37
src/langbot/pkg/agent/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Agent runner subsystem for LangBot."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .runner.descriptor import AgentRunnerDescriptor
|
||||||
|
from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id
|
||||||
|
from .runner.errors import (
|
||||||
|
AgentRunnerError,
|
||||||
|
RunnerNotFoundError,
|
||||||
|
RunnerNotAuthorizedError,
|
||||||
|
RunnerProtocolError,
|
||||||
|
RunnerExecutionError,
|
||||||
|
)
|
||||||
|
from .runner.registry import AgentRunnerRegistry
|
||||||
|
from .runner.context_builder import AgentRunContextBuilder
|
||||||
|
from .runner.resource_builder import AgentResourceBuilder
|
||||||
|
from .runner.result_normalizer import AgentResultNormalizer
|
||||||
|
from .runner.orchestrator import AgentRunOrchestrator
|
||||||
|
from .runner.config_migration import ConfigMigration
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AgentRunnerDescriptor',
|
||||||
|
'parse_runner_id',
|
||||||
|
'format_runner_id',
|
||||||
|
'is_plugin_runner_id',
|
||||||
|
'RunnerIdParts',
|
||||||
|
'AgentRunnerError',
|
||||||
|
'RunnerNotFoundError',
|
||||||
|
'RunnerNotAuthorizedError',
|
||||||
|
'RunnerProtocolError',
|
||||||
|
'RunnerExecutionError',
|
||||||
|
'AgentRunnerRegistry',
|
||||||
|
'AgentRunContextBuilder',
|
||||||
|
'AgentResourceBuilder',
|
||||||
|
'AgentResultNormalizer',
|
||||||
|
'AgentRunOrchestrator',
|
||||||
|
'ConfigMigration',
|
||||||
|
]
|
||||||
52
src/langbot/pkg/agent/runner/__init__.py
Normal file
52
src/langbot/pkg/agent/runner/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""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',
|
||||||
|
]
|
||||||
300
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
300
src/langbot/pkg/agent/runner/artifact_store.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""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 {},
|
||||||
|
}
|
||||||
230
src/langbot/pkg/agent/runner/config_migration.py
Normal file
230
src/langbot/pkg/agent/runner/config_migration.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""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
|
||||||
208
src/langbot/pkg/agent/runner/config_schema.py
Normal file
208
src/langbot/pkg/agent/runner/config_schema.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""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
|
||||||
427
src/langbot/pkg/agent/runner/context_builder.py
Normal file
427
src/langbot/pkg/agent/runner/context_builder.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""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,
|
||||||
|
},
|
||||||
|
}
|
||||||
72
src/langbot/pkg/agent/runner/descriptor.py
Normal file
72
src/langbot/pkg/agent/runner/descriptor.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""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)
|
||||||
37
src/langbot/pkg/agent/runner/errors.py
Normal file
37
src/langbot/pkg/agent/runner/errors.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Agent runner errors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunnerError(Exception):
|
||||||
|
"""Base error for agent runner operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerNotFoundError(AgentRunnerError):
|
||||||
|
"""Runner not found in registry."""
|
||||||
|
def __init__(self, runner_id: str):
|
||||||
|
self.runner_id = runner_id
|
||||||
|
super().__init__(f'Agent runner not found: {runner_id}')
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerNotAuthorizedError(AgentRunnerError):
|
||||||
|
"""Runner not authorized for this 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}')
|
||||||
255
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
255
src/langbot/pkg/agent/runner/event_log_store.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""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 {},
|
||||||
|
}
|
||||||
25
src/langbot/pkg/agent/runner/events.py
Normal file
25
src/langbot/pkg/agent/runner/events.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Canonical AgentRunner event names reserved for future EBA integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE_RECEIVED = 'message.received'
|
||||||
|
"""A normal message entered the current Pipeline."""
|
||||||
|
|
||||||
|
MESSAGE_RECALLED = 'message.recalled'
|
||||||
|
"""A platform message was recalled or deleted."""
|
||||||
|
|
||||||
|
GROUP_MEMBER_JOINED = 'group.member_joined'
|
||||||
|
"""A new member joined a group/channel conversation."""
|
||||||
|
|
||||||
|
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
|
||||||
|
"""A new friend/contact request was received."""
|
||||||
|
|
||||||
|
|
||||||
|
RESERVED_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
MESSAGE_RECEIVED,
|
||||||
|
MESSAGE_RECALLED,
|
||||||
|
GROUP_MEMBER_JOINED,
|
||||||
|
FRIEND_REQUEST_RECEIVED,
|
||||||
|
}
|
||||||
|
)
|
||||||
172
src/langbot/pkg/agent/runner/host_models.py
Normal file
172
src/langbot/pkg/agent/runner/host_models.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""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)."""
|
||||||
91
src/langbot/pkg/agent/runner/id.py
Normal file
91
src/langbot/pkg/agent/runner/id.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Agent runner ID parsing and formatting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class RunnerIdParts:
|
||||||
|
"""Parsed runner ID components."""
|
||||||
|
source: str # 'plugin' (future: 'builtin')
|
||||||
|
plugin_author: str
|
||||||
|
plugin_name: str
|
||||||
|
runner_name: str
|
||||||
|
|
||||||
|
def to_plugin_id(self) -> str:
|
||||||
|
"""Return plugin identifier as author/name."""
|
||||||
|
return f'{self.plugin_author}/{self.plugin_name}'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_runner_id(runner_id: str) -> RunnerIdParts:
|
||||||
|
"""Parse runner ID string into components.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RunnerIdParts with parsed components
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If runner_id format is invalid
|
||||||
|
"""
|
||||||
|
if runner_id.startswith('plugin:'):
|
||||||
|
parts = runner_id[7:].split('/')
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid plugin runner ID format: {runner_id}. '
|
||||||
|
f'Expected: plugin:author/plugin_name/runner_name'
|
||||||
|
)
|
||||||
|
plugin_author, plugin_name, runner_name = parts
|
||||||
|
if not plugin_author or not plugin_name or not runner_name:
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid plugin runner ID: {runner_id}. '
|
||||||
|
f'author, plugin_name, and runner_name must be non-empty'
|
||||||
|
)
|
||||||
|
return RunnerIdParts(
|
||||||
|
source='plugin',
|
||||||
|
plugin_author=plugin_author,
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
runner_name=runner_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Only plugin runner IDs are valid at the protocol boundary.
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid runner ID format: {runner_id}. '
|
||||||
|
f'Expected: plugin:author/plugin_name/runner_name'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_runner_id(
|
||||||
|
source: str,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
runner_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""Format runner ID from components.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Runner source ('plugin')
|
||||||
|
plugin_author: Plugin author
|
||||||
|
plugin_name: Plugin name
|
||||||
|
runner_name: Runner component name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Runner ID string
|
||||||
|
"""
|
||||||
|
if source == 'plugin':
|
||||||
|
return f'plugin:{plugin_author}/{plugin_name}/{runner_name}'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Invalid runner source: {source}')
|
||||||
|
|
||||||
|
|
||||||
|
def is_plugin_runner_id(runner_id: str) -> bool:
|
||||||
|
"""Check if runner ID is a plugin runner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
runner_id: Runner ID string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if runner ID starts with 'plugin:'
|
||||||
|
"""
|
||||||
|
return runner_id.startswith('plugin:')
|
||||||
884
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
884
src/langbot/pkg/agent/runner/orchestrator.py
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
"""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,
|
||||||
|
},
|
||||||
|
)
|
||||||
431
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
431
src/langbot/pkg/agent/runner/persistent_state_store.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"""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
|
||||||
626
src/langbot/pkg/agent/runner/pipeline_adapter.py
Normal file
626
src/langbot/pkg/agent/runner/pipeline_adapter.py
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
"""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
|
||||||
293
src/langbot/pkg/agent/runner/registry.py
Normal file
293
src/langbot/pkg/agent/runner/registry.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""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
|
||||||
268
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
268
src/langbot/pkg/agent/runner/resource_builder.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""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}')
|
||||||
193
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
193
src/langbot/pkg/agent/runner/result_normalizer.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""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}')
|
||||||
250
src/langbot/pkg/agent/runner/session_registry.py
Normal file
250
src/langbot/pkg/agent/runner/session_registry.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""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
|
||||||
113
src/langbot/pkg/agent/runner/state_scope.py
Normal file
113
src/langbot/pkg/agent/runner/state_scope.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
290
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
290
src/langbot/pkg/agent/runner/transcript_store.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""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
|
||||||
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""Embed widget routes - serve embeddable chat widget for external websites.
|
||||||
|
|
||||||
|
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
||||||
|
internal pipeline identifiers are never exposed to end-users. Each handler
|
||||||
|
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
||||||
|
the bound pipeline_uuid for internal routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......utils import paths
|
||||||
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache the widget template content
|
||||||
|
_widget_template_cache: str | None = None
|
||||||
|
_logo_bytes_cache: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_uuid(s: str) -> bool:
|
||||||
|
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_widget_template() -> str:
|
||||||
|
"""Load and cache the widget JS template."""
|
||||||
|
global _widget_template_cache
|
||||||
|
if _widget_template_cache is None:
|
||||||
|
template_path = paths.get_resource_path('templates/embed/widget.js')
|
||||||
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
|
_widget_template_cache = f.read()
|
||||||
|
return _widget_template_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _get_logo_bytes() -> bytes:
|
||||||
|
"""Load and cache the logo image."""
|
||||||
|
global _logo_bytes_cache
|
||||||
|
if _logo_bytes_cache is None:
|
||||||
|
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
||||||
|
with open(logo_path, 'rb') as f:
|
||||||
|
_logo_bytes_cache = f.read()
|
||||||
|
return _logo_bytes_cache
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('embed', '/api/v1/embed')
|
||||||
|
class EmbedRouterGroup(group.RouterGroup):
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_bot(self, bot_uuid: str):
|
||||||
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||||
|
|
||||||
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||||
|
``web_page_bot``, is disabled, or has no pipeline bound.
|
||||||
|
"""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if (
|
||||||
|
bot.bot_entity.uuid == bot_uuid
|
||||||
|
and bot.bot_entity.adapter == 'web_page_bot'
|
||||||
|
and bot.bot_entity.enable
|
||||||
|
and bot.bot_entity.use_pipeline_uuid
|
||||||
|
):
|
||||||
|
return bot, bot.bot_entity.use_pipeline_uuid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
||||||
|
return bot.bot_entity.adapter_config
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
return True
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
return False
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
ts_str, mac = token.split('.', 1)
|
||||||
|
ts = float(ts_str)
|
||||||
|
if time.time() - ts > 86400:
|
||||||
|
return False
|
||||||
|
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(mac, expected_mac)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- routes --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def verify_turnstile(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
token = data.get('token')
|
||||||
|
if not token:
|
||||||
|
return self.http_status(400, -1, 'Token is required')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
ts = time.time()
|
||||||
|
return self.success(data={'token': f'{ts}.dummy'})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data={'secret': secret, 'response': token},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
return self.http_status(403, -1, 'Turnstile verification failed')
|
||||||
|
|
||||||
|
ts = time.time()
|
||||||
|
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
session_token = f'{ts}.{mac}'
|
||||||
|
|
||||||
|
return self.success(data={'token': session_token})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_widget(bot_uuid: str) -> quart.Response:
|
||||||
|
"""Serve the embed widget JavaScript with injected configuration."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return quart.Response(
|
||||||
|
'// Bot not found or not available', status=404, content_type='application/javascript'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
template = _get_widget_template()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
||||||
|
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||||
|
if webhook_prefix:
|
||||||
|
base_url = webhook_prefix.rstrip('/')
|
||||||
|
|
||||||
|
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
site_key = config.get('turnstile_site_key', '')
|
||||||
|
locale = config.get('language', 'en_US') or 'en_US'
|
||||||
|
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
||||||
|
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
||||||
|
|
||||||
|
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=300'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_logo() -> quart.Response:
|
||||||
|
"""Serve the LangBot logo for the embed widget."""
|
||||||
|
try:
|
||||||
|
logo_data = _get_logo_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('', status=404)
|
||||||
|
|
||||||
|
response = quart.Response(logo_data, content_type='image/webp')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def submit_feedback(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
message_id = data.get('message_id', '')
|
||||||
|
feedback_type = data.get('feedback_type')
|
||||||
|
|
||||||
|
if feedback_type not in (1, 2, 3):
|
||||||
|
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
||||||
|
|
||||||
|
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
bot_id=runtime_bot.bot_entity.uuid,
|
||||||
|
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
||||||
|
pipeline_id=pipeline_uuid,
|
||||||
|
message_id=str(message_id),
|
||||||
|
platform='web_page_bot',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'feedback_id': feedback_id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
# -- Embed WebSocket endpoint ----------------------------------------
|
||||||
|
|
||||||
|
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||||
|
async def embed_websocket_connect(bot_uuid: str):
|
||||||
|
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = await ws_connection_manager.add_connection(
|
||||||
|
websocket=quart.websocket._get_current_object(),
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
|
)
|
||||||
|
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'type': 'connected',
|
||||||
|
'connection_id': connection.connection_id,
|
||||||
|
'bot_uuid': bot_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'timestamp': connection.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'Embed WebSocket connected: {connection.connection_id} '
|
||||||
|
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
||||||
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(receive_task, send_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket task error: {e}')
|
||||||
|
finally:
|
||||||
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||||
|
try:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- WebSocket receive/send helpers --------------------------------------
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
message = await quart.websocket.receive()
|
||||||
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
await connection.send_queue.put(
|
||||||
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
elif message_type == 'message':
|
||||||
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
elif message_type == 'disconnect':
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
async def _handle_send(self, connection):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
|
await quart.websocket.send(json.dumps(message))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
@@ -43,6 +43,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
||||||
|
owner_bot = self._find_owner_bot(pipeline_uuid)
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
websocket=quart.websocket._get_current_object(),
|
websocket=quart.websocket._get_current_object(),
|
||||||
@@ -70,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -178,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
def _find_owner_bot(self, pipeline_uuid: str):
|
||||||
|
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
||||||
|
return bot
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -203,7 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -35,3 +36,640 @@ 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={})
|
||||||
|
|||||||
@@ -6,11 +6,50 @@ import re
|
|||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
# Resolve the built-in page SDK JS from the langbot_plugin package
|
||||||
|
_PAGE_SDK_PATH = None
|
||||||
|
try:
|
||||||
|
import langbot_plugin.assets as _assets_pkg
|
||||||
|
|
||||||
|
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
|
||||||
|
if os.path.exists(_candidate):
|
||||||
|
_PAGE_SDK_PATH = _candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
||||||
|
filepath = filepath.replace('\\', '/')
|
||||||
|
if filepath.startswith('/'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(filepath)
|
||||||
|
if normalized == '.' or normalized.startswith('../') or normalized == '..':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized.startswith('components/pages/'):
|
||||||
|
return 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):
|
||||||
@@ -27,6 +66,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
"""Serve the built-in LangBot page SDK JavaScript."""
|
||||||
|
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
|
||||||
|
with open(_PAGE_SDK_PATH, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
return quart.Response(content, mimetype='application/javascript')
|
||||||
|
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
|
||||||
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
plugins = await self.ap.plugin_connector.list_plugins()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
@@ -102,7 +150,15 @@ 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':
|
||||||
return self.success(data={'config': plugin['plugin_config']})
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -135,15 +191,62 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(icon_data, mimetype=mime_type)
|
return quart.Response(icon_data, mimetype=mime_type)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/assets/<filepath>',
|
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.NONE,
|
auth_type=group.AuthType.NONE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
asset_path = _normalize_plugin_asset_path(filepath)
|
||||||
|
if asset_path is None:
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
|
|
||||||
|
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||||
|
if not asset_data.get('asset_base64'):
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||||
mime_type = asset_data['mime_type']
|
mime_type = asset_data['mime_type']
|
||||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
resp = quart.Response(asset_bytes, mimetype=mime_type)
|
||||||
|
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||||
|
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||||
|
if mime_type and mime_type.startswith('text/html'):
|
||||||
|
origin = _get_request_origin()
|
||||||
|
resp.headers['Content-Security-Policy'] = (
|
||||||
|
f'default-src {origin}; '
|
||||||
|
f"script-src {origin} 'unsafe-inline'; "
|
||||||
|
f"style-src {origin} 'unsafe-inline'; "
|
||||||
|
f'img-src {origin} data:; '
|
||||||
|
f'connect-src {origin}; '
|
||||||
|
"frame-src 'none'; "
|
||||||
|
"object-src 'none'"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/page-api',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(author: str, plugin_name: str) -> str:
|
||||||
|
"""Forward a page API request to the plugin."""
|
||||||
|
data = await quart.request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
|
|
||||||
|
page_id = data.get('page_id', '')
|
||||||
|
endpoint = data.get('endpoint', '')
|
||||||
|
method = data.get('method', 'POST')
|
||||||
|
body = data.get('body')
|
||||||
|
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
|
||||||
|
return self.http_status(400, -1, 'invalid page api request')
|
||||||
|
if not endpoint.startswith('/') or '..' in endpoint:
|
||||||
|
return self.http_status(400, -1, 'invalid endpoint')
|
||||||
|
|
||||||
|
result = await self.ap.plugin_connector.handle_page_api(
|
||||||
|
author, plugin_name, page_id, endpoint, method.upper(), body
|
||||||
|
)
|
||||||
|
if result.get('error'):
|
||||||
|
return self.http_status(400, -1, result['error'])
|
||||||
|
return self.success(data=result.get('data'))
|
||||||
|
|
||||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
|
|||||||
@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
|
||||||
|
class RerankModelsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
|
if provider_uuid:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
|
||||||
|
return self.success(data={'uuid': model_uuid})
|
||||||
|
|
||||||
|
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
return self.http_status(404, -1, 'model not found')
|
||||||
|
|
||||||
|
return self.success(data={'model': model})
|
||||||
|
elif quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
elif quart.request.method == 'DELETE':
|
||||||
|
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'providers': providers})
|
return self.success(data={'providers': providers})
|
||||||
elif quart.request.method == 'POST':
|
elif quart.request.method == 'POST':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'provider': provider})
|
return self.success(data={'provider': provider})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=task.to_dict())
|
return self.success(data=task.to_dict())
|
||||||
|
|
||||||
|
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||||
|
|
||||||
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
if not constants.debug_mode:
|
if not constants.debug_mode:
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ 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,6 +52,9 @@ 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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# checkout the default pipeline
|
# bind the most recently updated pipeline if any exist
|
||||||
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)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
@@ -120,24 +120,26 @@ 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"""
|
||||||
if 'uuid' in bot_data:
|
update_data = bot_data.copy()
|
||||||
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 bot_data:
|
if 'use_pipeline_uuid' in update_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 == bot_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
update_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(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(update_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,15 +31,126 @@ 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=kb_data.get('creation_settings', {}),
|
creation_settings=creation_settings,
|
||||||
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
retrieval_settings=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
|
||||||
|
|||||||
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....entity.persistence import bstorage as persistence_bstorage
|
||||||
|
from ....entity.persistence import monitoring as persistence_monitoring
|
||||||
|
|
||||||
|
|
||||||
|
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceService:
|
||||||
|
"""Storage maintenance and diagnostics."""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def cleanup_expired_files(self) -> dict[str, int]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
||||||
|
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_storage_analysis(self) -> dict[str, Any]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
database_cfg = self.ap.instance_config.data.get('database', {})
|
||||||
|
database_type = database_cfg.get('use', 'sqlite')
|
||||||
|
database_path = (
|
||||||
|
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
||||||
|
)
|
||||||
|
roots: list[tuple[str, Path | None]] = [
|
||||||
|
('database', database_path),
|
||||||
|
('logs', Path('data/logs')),
|
||||||
|
('storage', Path('data/storage')),
|
||||||
|
('vector_store', Path('data/chroma')),
|
||||||
|
('plugins', Path('data/plugins')),
|
||||||
|
('mcp', Path('data/mcp')),
|
||||||
|
('temp', Path('data/temp')),
|
||||||
|
]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for key, path in roots:
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'path': str(path) if path else '',
|
||||||
|
'exists': path.exists() if path else False,
|
||||||
|
'size_bytes': self._path_size(path) if path else 0,
|
||||||
|
'file_count': self._file_count(path) if path else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monitoring_counts = await self._monitoring_counts()
|
||||||
|
binary_storage = await self._binary_storage_stats()
|
||||||
|
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
||||||
|
log_candidates = self._expired_log_candidates(log_retention_days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
'cleanup_policy': {
|
||||||
|
'uploaded_file_retention_days': upload_retention_days,
|
||||||
|
'log_retention_days': log_retention_days,
|
||||||
|
},
|
||||||
|
'sections': sections,
|
||||||
|
'database': {
|
||||||
|
'type': database_type,
|
||||||
|
'monitoring_counts': monitoring_counts,
|
||||||
|
'binary_storage': binary_storage,
|
||||||
|
},
|
||||||
|
'cleanup_candidates': {
|
||||||
|
'uploaded_files': upload_candidates,
|
||||||
|
'log_files': log_candidates,
|
||||||
|
},
|
||||||
|
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
provider_name = provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
return self._expired_local_upload_candidates(retention_days)
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
candidates = await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
await provider.delete(item['key'])
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
||||||
|
candidates = []
|
||||||
|
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
||||||
|
|
||||||
|
for page in paginator.paginate(Bucket=provider.bucket_name):
|
||||||
|
for obj in page.get('Contents', []):
|
||||||
|
key = obj.get('Key', '')
|
||||||
|
last_modified = obj.get('LastModified')
|
||||||
|
if not self._is_uploaded_file_key(key):
|
||||||
|
continue
|
||||||
|
if last_modified and last_modified < cutoff:
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'size_bytes': obj.get('Size', 0),
|
||||||
|
'modified_at': last_modified.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _cleanup_expired_log_files(self, retention_days: int) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for item in self._expired_log_candidates(retention_days, include_paths=True):
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _expired_local_upload_candidates(
|
||||||
|
self, retention_days: int, include_paths: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
storage_root = Path('data/storage')
|
||||||
|
if not storage_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
||||||
|
candidates = []
|
||||||
|
for entry in storage_root.iterdir():
|
||||||
|
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
if stat.st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
'key': entry.name,
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
||||||
|
log_root = Path('data/logs')
|
||||||
|
if not log_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
|
||||||
|
candidates = []
|
||||||
|
for entry in log_root.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
match = LOG_FILE_PATTERN.match(entry.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_date = datetime.date.fromisoformat(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if file_date >= cutoff_date:
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
item = {
|
||||||
|
'name': entry.name,
|
||||||
|
'date': file_date.isoformat(),
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _is_uploaded_file_key(self, key: str) -> bool:
|
||||||
|
return '/' not in key and not key.startswith('plugin_config_')
|
||||||
|
|
||||||
|
async def _monitoring_counts(self) -> dict[str, int]:
|
||||||
|
tables = {
|
||||||
|
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||||
|
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||||
|
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
|
'errors': persistence_monitoring.MonitoringError.id,
|
||||||
|
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
||||||
|
}
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for key, column in tables.items():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
||||||
|
counts[key] = result.scalar() or 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
async def _binary_storage_stats(self) -> dict[str, Any]:
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
||||||
|
)
|
||||||
|
size_bytes = None
|
||||||
|
try:
|
||||||
|
size_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
||||||
|
)
|
||||||
|
size_bytes = size_result.scalar() or 0
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'count': count_result.scalar() or 0,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _path_size(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return path.stat().st_size
|
||||||
|
total = 0
|
||||||
|
for root, _, files in os.walk(path):
|
||||||
|
for file_name in files:
|
||||||
|
file_path = Path(root) / file_name
|
||||||
|
try:
|
||||||
|
total += file_path.stat().st_size
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _file_count(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return 1
|
||||||
|
count = 0
|
||||||
|
for _, _, files in os.walk(path):
|
||||||
|
count += len(files)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
@@ -9,6 +9,8 @@ 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:
|
||||||
@@ -23,12 +25,57 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
|||||||
return provider_dict
|
return provider_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||||
|
"""Return model data for rebuilding runtime models after an update.
|
||||||
|
|
||||||
|
Update payloads intentionally omit uuid before writing to the database.
|
||||||
|
Runtime model entities still need the stable uuid so pipeline configs can
|
||||||
|
resolve the in-memory model immediately after an edit, without requiring a
|
||||||
|
process restart.
|
||||||
|
"""
|
||||||
|
return {**model_data, 'uuid': model_uuid}
|
||||||
|
|
||||||
|
|
||||||
class LLMModelsService:
|
class LLMModelsService:
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
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))
|
||||||
@@ -98,7 +145,6 @@ 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
|
||||||
@@ -106,15 +152,7 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
await self._auto_set_default_pipeline_llm_model(pipeline, model_data['uuid'])
|
||||||
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']
|
||||||
|
|
||||||
@@ -173,7 +211,7 @@ class LLMModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
persistence_model.LLMModel(**model_data),
|
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
@@ -334,7 +372,7 @@ class EmbeddingModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||||
persistence_model.EmbeddingModel(**model_data),
|
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||||
@@ -367,3 +405,162 @@ class EmbeddingModelsService:
|
|||||||
input_text=['Hello, world!'],
|
input_text=['Hello, world!'],
|
||||||
extra_args={},
|
extra_args={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModelsService:
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def get_rerank_models(self) -> list[dict]:
|
||||||
|
"""Get all rerank models with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||||
|
models = result.all()
|
||||||
|
|
||||||
|
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.ModelProvider)
|
||||||
|
)
|
||||||
|
providers = {p.uuid: p for p in providers_result.all()}
|
||||||
|
|
||||||
|
models_list = []
|
||||||
|
for model in models:
|
||||||
|
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||||
|
provider = providers.get(model.provider_uuid)
|
||||||
|
if provider:
|
||||||
|
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||||
|
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||||
|
models_list.append(model_dict)
|
||||||
|
|
||||||
|
return models_list
|
||||||
|
|
||||||
|
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||||
|
"""Get rerank models by provider UUID"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
models = result.all()
|
||||||
|
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
|
|
||||||
|
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
|
"""Create a new rerank model"""
|
||||||
|
if not preserve_uuid:
|
||||||
|
model_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if 'provider' in model_data:
|
||||||
|
provider_data = model_data.pop('provider')
|
||||||
|
if provider_data.get('uuid'):
|
||||||
|
model_data['provider_uuid'] = provider_data['uuid']
|
||||||
|
else:
|
||||||
|
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||||
|
requester=provider_data.get('requester', ''),
|
||||||
|
base_url=provider_data.get('base_url', ''),
|
||||||
|
api_keys=provider_data.get('api_keys', []),
|
||||||
|
)
|
||||||
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||||
|
if runtime_provider is None:
|
||||||
|
raise Exception('provider not found')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**model_data),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
return model_data['uuid']
|
||||||
|
|
||||||
|
async def get_rerank_model(self, model_uuid: str) -> dict | None:
|
||||||
|
"""Get a single rerank model with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
model = result.first()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||||
|
|
||||||
|
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
|
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = provider_result.first()
|
||||||
|
if provider:
|
||||||
|
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||||
|
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||||
|
|
||||||
|
return model_dict
|
||||||
|
|
||||||
|
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Update an existing rerank model"""
|
||||||
|
if 'uuid' in model_data:
|
||||||
|
del model_data['uuid']
|
||||||
|
|
||||||
|
if 'provider' in model_data:
|
||||||
|
provider_data = model_data.pop('provider')
|
||||||
|
if provider_data.get('uuid'):
|
||||||
|
model_data['provider_uuid'] = provider_data['uuid']
|
||||||
|
else:
|
||||||
|
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||||
|
requester=provider_data.get('requester', ''),
|
||||||
|
base_url=provider_data.get('base_url', ''),
|
||||||
|
api_keys=provider_data.get('api_keys', []),
|
||||||
|
)
|
||||||
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_model.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
.values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||||
|
if runtime_provider is None:
|
||||||
|
raise Exception('provider not found')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
async def delete_rerank_model(self, model_uuid: str) -> None:
|
||||||
|
"""Delete a rerank model"""
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Test a rerank model"""
|
||||||
|
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
|
||||||
|
|
||||||
|
if model_uuid != '_':
|
||||||
|
for model in self.ap.model_mgr.rerank_models:
|
||||||
|
if model.model_entity.uuid == model_uuid:
|
||||||
|
runtime_rerank_model = model
|
||||||
|
break
|
||||||
|
if runtime_rerank_model is None:
|
||||||
|
raise Exception('model not found')
|
||||||
|
else:
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
|
||||||
|
|
||||||
|
await runtime_rerank_model.provider.invoke_rerank(
|
||||||
|
model=runtime_rerank_model,
|
||||||
|
query='What is artificial intelligence?',
|
||||||
|
documents=[
|
||||||
|
'Artificial intelligence is a branch of computer science.',
|
||||||
|
'The weather is nice today.',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,55 +18,119 @@ class MonitoringService:
|
|||||||
|
|
||||||
# ========== Cleanup Methods ==========
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
||||||
"""Delete monitoring records older than the specified retention period.
|
"""Delete monitoring records older than the specified retention period.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
retention_days: Number of days to retain records.
|
retention_days: Number of days to retain records.
|
||||||
|
batch_size: Maximum rows to delete per table batch.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict mapping table name to the number of deleted rows.
|
A dict mapping table name to the number of deleted rows.
|
||||||
"""
|
"""
|
||||||
|
if retention_days < 1:
|
||||||
|
raise ValueError('retention_days must be >= 1')
|
||||||
|
if batch_size < 1:
|
||||||
|
raise ValueError('batch_size must be >= 1')
|
||||||
|
|
||||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||||
days=retention_days
|
days=retention_days
|
||||||
)
|
)
|
||||||
|
|
||||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
||||||
(
|
(
|
||||||
'monitoring_messages',
|
'monitoring_messages',
|
||||||
persistence_monitoring.MonitoringMessage,
|
persistence_monitoring.MonitoringMessage,
|
||||||
persistence_monitoring.MonitoringMessage.timestamp,
|
persistence_monitoring.MonitoringMessage.timestamp,
|
||||||
|
persistence_monitoring.MonitoringMessage.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_llm_calls',
|
'monitoring_llm_calls',
|
||||||
persistence_monitoring.MonitoringLLMCall,
|
persistence_monitoring.MonitoringLLMCall,
|
||||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringLLMCall.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_embedding_calls',
|
'monitoring_embedding_calls',
|
||||||
persistence_monitoring.MonitoringEmbeddingCall,
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_errors',
|
'monitoring_errors',
|
||||||
persistence_monitoring.MonitoringError,
|
persistence_monitoring.MonitoringError,
|
||||||
persistence_monitoring.MonitoringError.timestamp,
|
persistence_monitoring.MonitoringError.timestamp,
|
||||||
|
persistence_monitoring.MonitoringError.id,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_sessions',
|
'monitoring_sessions',
|
||||||
persistence_monitoring.MonitoringSession,
|
persistence_monitoring.MonitoringSession,
|
||||||
persistence_monitoring.MonitoringSession.last_activity,
|
persistence_monitoring.MonitoringSession.last_activity,
|
||||||
|
persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_feedback',
|
||||||
|
persistence_monitoring.MonitoringFeedback,
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp,
|
||||||
|
persistence_monitoring.MonitoringFeedback.id,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
deleted_counts: dict[str, int] = {}
|
deleted_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for table_name, model_cls, ts_column in tables_and_columns:
|
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
||||||
deleted_counts[table_name] = result.rowcount
|
model_cls=model_cls,
|
||||||
|
ts_column=ts_column,
|
||||||
|
pk_column=pk_column,
|
||||||
|
cutoff=cutoff,
|
||||||
|
batch_size=batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sum(deleted_counts.values()) > 0:
|
||||||
|
await self._release_sqlite_space()
|
||||||
|
|
||||||
return deleted_counts
|
return deleted_counts
|
||||||
|
|
||||||
|
async def _delete_expired_in_batches(
|
||||||
|
self,
|
||||||
|
model_cls: type,
|
||||||
|
ts_column: sqlalchemy.Column,
|
||||||
|
pk_column: sqlalchemy.Column,
|
||||||
|
cutoff: datetime.datetime,
|
||||||
|
batch_size: int,
|
||||||
|
) -> int:
|
||||||
|
deleted_total = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
select_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
|
||||||
|
)
|
||||||
|
pk_values = list(select_result.scalars().all())
|
||||||
|
if not pk_values:
|
||||||
|
break
|
||||||
|
|
||||||
|
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
|
||||||
|
)
|
||||||
|
deleted = delete_result.rowcount or 0
|
||||||
|
deleted_total += deleted
|
||||||
|
|
||||||
|
if len(pk_values) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
return deleted_total
|
||||||
|
|
||||||
|
async def _release_sqlite_space(self) -> None:
|
||||||
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||||
|
if database_type != 'sqlite':
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
|
||||||
|
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ 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', # 释放速率限制占用
|
||||||
@@ -30,11 +35,108 @@ 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,
|
||||||
self.ap.pipeline_config_meta_ai,
|
ai_metadata,
|
||||||
self.ap.pipeline_config_meta_output,
|
self.ap.pipeline_config_meta_output,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -74,8 +176,6 @@ 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)
|
||||||
@@ -89,9 +189,7 @@ 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
|
||||||
|
|
||||||
template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
|
pipeline_data['config'] = await self.get_default_pipeline_config()
|
||||||
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:
|
||||||
@@ -113,14 +211,15 @@ 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:
|
||||||
if 'uuid' in pipeline_data:
|
from ....agent.runner.config_migration import ConfigMigration
|
||||||
del pipeline_data['uuid']
|
|
||||||
if 'for_version' in pipeline_data:
|
pipeline_data = pipeline_data.copy()
|
||||||
del pipeline_data['for_version']
|
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||||
if 'stages' in pipeline_data:
|
pipeline_data.pop(protected_field, None)
|
||||||
del pipeline_data['stages']
|
|
||||||
if 'is_default' in pipeline_data:
|
# Migrate config to new format before saving
|
||||||
del pipeline_data['is_default']
|
if 'config' in pipeline_data:
|
||||||
|
pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ 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))
|
||||||
@@ -59,6 +77,7 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -72,6 +91,8 @@ 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)
|
||||||
@@ -98,6 +119,14 @@ class ModelProviderService:
|
|||||||
if embedding_result.first() is not None:
|
if embedding_result.first() is not None:
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||||
|
|
||||||
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if rerank_result.first() is not None:
|
||||||
|
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
@@ -122,10 +151,19 @@ class ModelProviderService:
|
|||||||
)
|
)
|
||||||
embedding_count = embedding_result.scalar() or 0
|
embedding_count = embedding_result.scalar() or 0
|
||||||
|
|
||||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count())
|
||||||
|
.select_from(persistence_model.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
|
||||||
|
)
|
||||||
|
rerank_count = rerank_result.scalar() or 0
|
||||||
|
|
||||||
|
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
|
||||||
|
|
||||||
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(
|
||||||
@@ -153,7 +191,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 or [],
|
'api_keys': api_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +200,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=[api_key])
|
.values(api_keys=self._normalize_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')
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class SpaceService:
|
|||||||
space_url = space_config['url']
|
space_url = space_config['url']
|
||||||
|
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
||||||
@@ -31,6 +32,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
|
from ..api.http.service import maintenance as maintenance_service
|
||||||
|
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
@@ -43,6 +45,9 @@ 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"""
|
||||||
@@ -133,6 +138,8 @@ class Application:
|
|||||||
|
|
||||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||||
|
|
||||||
|
rerank_models_service: model_service.RerankModelsService = None
|
||||||
|
|
||||||
provider_service: provider_service.ModelProviderService = None
|
provider_service: provider_service.ModelProviderService = None
|
||||||
|
|
||||||
pipeline_service: pipeline_service.PipelineService = None
|
pipeline_service: pipeline_service.PipelineService = None
|
||||||
@@ -153,6 +160,13 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = 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
|
||||||
|
|
||||||
@@ -192,14 +206,30 @@ class Application:
|
|||||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||||
if auto_cleanup_cfg.get('enabled', True):
|
if auto_cleanup_cfg.get('enabled', True):
|
||||||
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
retention_days = self._get_positive_int_config(
|
||||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
auto_cleanup_cfg.get('retention_days', 30),
|
||||||
|
default=30,
|
||||||
|
name='monitoring.auto_cleanup.retention_days',
|
||||||
|
)
|
||||||
|
delete_batch_size = self._get_positive_int_config(
|
||||||
|
auto_cleanup_cfg.get('delete_batch_size', 1000),
|
||||||
|
default=1000,
|
||||||
|
name='monitoring.auto_cleanup.delete_batch_size',
|
||||||
|
)
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
auto_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='monitoring.auto_cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
async def monitoring_cleanup_loop():
|
async def monitoring_cleanup_loop():
|
||||||
check_interval_seconds = check_interval_hours * 3600
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||||
|
retention_days,
|
||||||
|
batch_size=delete_batch_size,
|
||||||
|
)
|
||||||
total_deleted = sum(deleted.values())
|
total_deleted = sum(deleted.values())
|
||||||
if total_deleted > 0:
|
if total_deleted > 0:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -216,6 +246,33 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start storage/log maintenance task if enabled
|
||||||
|
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
storage_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='storage.cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def storage_cleanup_loop():
|
||||||
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await self.maintenance_service.cleanup_expired_files()
|
||||||
|
total_deleted = sum(deleted.values())
|
||||||
|
if total_deleted > 0:
|
||||||
|
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Storage maintenance error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
storage_cleanup_loop(),
|
||||||
|
name='storage-maintenance',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
@@ -230,6 +287,28 @@ class Application:
|
|||||||
self.logger.error(f'Application runtime fatal exception: {e}')
|
self.logger.error(f'Application runtime fatal exception: {e}')
|
||||||
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _get_positive_int_config(self, value, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _get_positive_float_config(self, value, default: float, name: str) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed <= 0:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
def dispose(self):
|
def dispose(self):
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ 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):
|
||||||
app_inst.dispose()
|
if app_inst is not None:
|
||||||
|
app_inst.dispose()
|
||||||
print('[Signal] Program exit.')
|
print('[Signal] Program exit.')
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class('msg-truncator-cfg-migration', 9)
|
|
||||||
class MsgTruncatorConfigMigration(migration.Migration):
|
|
||||||
"""迁移"""
|
|
||||||
|
|
||||||
async def need_migrate(self) -> bool:
|
|
||||||
"""判断当前环境是否需要运行此迁移"""
|
|
||||||
return 'msg-truncate' not in self.ap.pipeline_cfg.data
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
"""执行迁移"""
|
|
||||||
|
|
||||||
self.ap.pipeline_cfg.data['msg-truncate'] = {
|
|
||||||
'method': 'round',
|
|
||||||
'round': {'max-round': 10},
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.pipeline_cfg.dump_config()
|
|
||||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_service
|
from ...api.http.service import monitoring as monitoring_service
|
||||||
|
from ...api.http.service import maintenance as maintenance_service
|
||||||
from ...discover import engine as discover_engine
|
from ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -35,6 +36,7 @@ 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')
|
||||||
@@ -61,6 +63,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||||
ap.embedding_models_service = embedding_models_service_inst
|
ap.embedding_models_service = embedding_models_service_inst
|
||||||
|
|
||||||
|
rerank_models_service_inst = model_service.RerankModelsService(ap)
|
||||||
|
ap.rerank_models_service = rerank_models_service_inst
|
||||||
|
|
||||||
provider_service_inst = provider_service.ModelProviderService(ap)
|
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||||
ap.provider_service = provider_service_inst
|
ap.provider_service = provider_service_inst
|
||||||
|
|
||||||
@@ -164,6 +169,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
ap.monitoring_service = monitoring_service_inst
|
||||||
|
|
||||||
|
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
|
||||||
|
ap.maintenance_service = maintenance_service_inst
|
||||||
|
|
||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
@@ -172,5 +180,12 @@ 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
from . import entities as core_entities
|
from . import entities as core_entities
|
||||||
@@ -119,6 +120,7 @@ class TaskWrapper:
|
|||||||
self.label = label if label != '' else name
|
self.label = label if label != '' else name
|
||||||
self.task.set_name(name)
|
self.task.set_name(name)
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
def assume_exception(self):
|
def assume_exception(self):
|
||||||
try:
|
try:
|
||||||
@@ -154,6 +156,7 @@ class TaskWrapper:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'label': self.label,
|
'label': self.label,
|
||||||
'scopes': [scope.value for scope in self.scopes],
|
'scopes': [scope.value for scope in self.scopes],
|
||||||
|
'created_at': self.created_at,
|
||||||
'task_context': self.task_context.to_dict(),
|
'task_context': self.task_context.to_dict(),
|
||||||
'runtime': {
|
'runtime': {
|
||||||
'done': self.task.done(),
|
'done': self.task.done(),
|
||||||
@@ -193,6 +196,8 @@ class AsyncTaskManager:
|
|||||||
) -> TaskWrapper:
|
) -> TaskWrapper:
|
||||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||||
self.tasks.append(wrapper)
|
self.tasks.append(wrapper)
|
||||||
|
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||||
|
self._prune_completed_tasks()
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def create_user_task(
|
def create_user_task(
|
||||||
@@ -226,6 +231,15 @@ class AsyncTaskManager:
|
|||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
completed = sum(1 for t in self.tasks if t.task.done())
|
||||||
|
return {
|
||||||
|
'total': len(self.tasks),
|
||||||
|
'running': len(self.tasks) - completed,
|
||||||
|
'completed': completed,
|
||||||
|
'id_index': TaskWrapper._id_index,
|
||||||
|
}
|
||||||
|
|
||||||
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
||||||
for t in self.tasks:
|
for t in self.tasks:
|
||||||
if t.id == id:
|
if t.id == id:
|
||||||
@@ -243,3 +257,27 @@ class AsyncTaskManager:
|
|||||||
if not wrapper.task.done():
|
if not wrapper.task.done():
|
||||||
wrapper.task.cancel()
|
wrapper.task.cancel()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _prune_completed_tasks(self):
|
||||||
|
completed_limit = (
|
||||||
|
self.ap.instance_config.data.get('system', {})
|
||||||
|
.get('task_retention', {})
|
||||||
|
.get(
|
||||||
|
'completed_limit',
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed_limit = int(completed_limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
completed_limit = 200
|
||||||
|
if completed_limit < 1:
|
||||||
|
completed_limit = 1
|
||||||
|
|
||||||
|
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
|
||||||
|
overflow = len(completed_tasks) - completed_limit
|
||||||
|
if overflow <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
|
||||||
|
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]
|
||||||
|
|||||||
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal file
88
src/langbot/pkg/entity/persistence/agent_runner_state.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Agent runner state persistence entity for host-owned state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunnerState(Base):
|
||||||
|
"""AgentRunnerState stores host-owned state for AgentRunner protocol.
|
||||||
|
|
||||||
|
State is:
|
||||||
|
- Host-owned: Managed by LangBot, not by plugin instances
|
||||||
|
- Scope-isolated: Separated by runner_id + binding_identity + scope
|
||||||
|
- Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes)
|
||||||
|
|
||||||
|
Scope key design:
|
||||||
|
- conversation: runner_id + binding_id + conversation_id [+ thread_id]
|
||||||
|
- actor: runner_id + binding_id + actor_type + actor_id
|
||||||
|
- subject: runner_id + binding_id + subject_type + subject_id
|
||||||
|
- runner: runner_id + binding_id
|
||||||
|
|
||||||
|
This table is the production store for AgentRunner state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'agent_runner_state'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
"""Auto-increment ID for sequencing."""
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
"""Runner descriptor ID (plugin:author/name/runner)."""
|
||||||
|
|
||||||
|
binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
"""Binding identity for isolation (binding_id or scope_type:scope_id)."""
|
||||||
|
|
||||||
|
scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
||||||
|
"""State scope: 'conversation', 'actor', 'subject', or 'runner'."""
|
||||||
|
|
||||||
|
scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False, 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'),
|
||||||
|
)
|
||||||
77
src/langbot/pkg/entity/persistence/artifact.py
Normal file
77
src/langbot/pkg/entity/persistence/artifact.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Artifact persistence entity for Host-owned artifact store."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentArtifact(Base):
|
||||||
|
"""AgentArtifact stores metadata for large files, images, tool results, etc.
|
||||||
|
|
||||||
|
This table only stores metadata. The actual blob content is stored in
|
||||||
|
BinaryStorage or external storage, referenced by storage_key.
|
||||||
|
|
||||||
|
Artifacts are accessed via artifact_metadata and artifact_read APIs
|
||||||
|
with run_id authorization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'agent_artifact'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
"""Auto-increment ID for sequencing."""
|
||||||
|
|
||||||
|
artifact_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
"""Unique artifact identifier."""
|
||||||
|
|
||||||
|
artifact_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||||
|
"""Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc."""
|
||||||
|
|
||||||
|
mime_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""MIME type of the content."""
|
||||||
|
|
||||||
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Original file name (if applicable)."""
|
||||||
|
|
||||||
|
size_bytes = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
|
||||||
|
"""Size in bytes."""
|
||||||
|
|
||||||
|
sha256 = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||||
|
"""SHA256 hash of content (for integrity verification)."""
|
||||||
|
|
||||||
|
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||||
|
"""Source of artifact: 'platform', 'runner', 'tool', 'system'."""
|
||||||
|
|
||||||
|
# Storage reference (points to BinaryStorage or external storage)
|
||||||
|
storage_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Key in BinaryStorage or external storage reference."""
|
||||||
|
|
||||||
|
storage_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='binary_storage')
|
||||||
|
"""Storage type: 'binary_storage', 'file', 'url', etc."""
|
||||||
|
|
||||||
|
# Context
|
||||||
|
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
"""Conversation this artifact belongs to."""
|
||||||
|
|
||||||
|
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
"""Run ID that created this artifact."""
|
||||||
|
|
||||||
|
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Runner ID that created this artifact."""
|
||||||
|
|
||||||
|
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Bot UUID that handled this artifact."""
|
||||||
|
|
||||||
|
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Workspace ID for multi-tenant deployments."""
|
||||||
|
|
||||||
|
# Lifecycle
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||||
|
"""When this artifact was created."""
|
||||||
|
|
||||||
|
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
"""When this artifact expires (optional)."""
|
||||||
|
|
||||||
|
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
"""Additional metadata as JSON string."""
|
||||||
85
src/langbot/pkg/entity/persistence/event_log.py
Normal file
85
src/langbot/pkg/entity/persistence/event_log.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""EventLog persistence entity for storing auditable event facts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog(Base):
|
||||||
|
"""EventLog stores auditable event records for AgentRunner.
|
||||||
|
|
||||||
|
This is the fact source for events - messages, tool calls, system events, etc.
|
||||||
|
Large payloads are stored separately as artifacts; this table stores
|
||||||
|
references and summaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'event_log'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
"""Auto-increment ID for sequencing."""
|
||||||
|
|
||||||
|
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
"""Unique event identifier."""
|
||||||
|
|
||||||
|
event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
|
||||||
|
"""Event type (message.received, tool.call.started, etc.)."""
|
||||||
|
|
||||||
|
event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
"""When the event occurred."""
|
||||||
|
|
||||||
|
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||||
|
"""Event source (platform, webui, api, scheduler, system, pipeline_adapter)."""
|
||||||
|
|
||||||
|
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
"""Bot UUID that handled this event."""
|
||||||
|
|
||||||
|
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Workspace ID for multi-tenant deployments."""
|
||||||
|
|
||||||
|
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
"""Conversation ID this event belongs to."""
|
||||||
|
|
||||||
|
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Thread ID if platform supports threads."""
|
||||||
|
|
||||||
|
# Actor information
|
||||||
|
actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||||
|
"""Actor type (user, system, runner)."""
|
||||||
|
|
||||||
|
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Actor identifier."""
|
||||||
|
|
||||||
|
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Actor display name."""
|
||||||
|
|
||||||
|
# Subject information
|
||||||
|
subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||||
|
"""Subject type (message, tool_call, artifact)."""
|
||||||
|
|
||||||
|
subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Subject identifier."""
|
||||||
|
|
||||||
|
# Input information
|
||||||
|
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
"""Brief summary of input (truncated text, max 1000 chars)."""
|
||||||
|
|
||||||
|
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
|
||||||
|
|
||||||
|
# Raw event reference
|
||||||
|
raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Reference to raw event payload in ArtifactStore."""
|
||||||
|
|
||||||
|
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
"""Run ID that processed this event."""
|
||||||
|
|
||||||
|
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Runner ID that processed this event."""
|
||||||
|
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||||
|
"""When this record was created."""
|
||||||
|
|
||||||
|
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
"""Additional metadata as JSON string."""
|
||||||
@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
|
|||||||
server_default=sqlalchemy.func.now(),
|
server_default=sqlalchemy.func.now(),
|
||||||
onupdate=sqlalchemy.func.now(),
|
onupdate=sqlalchemy.func.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModel(Base):
|
||||||
|
"""Rerank model"""
|
||||||
|
|
||||||
|
__tablename__ = 'rerank_models'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
updated_at = sqlalchemy.Column(
|
||||||
|
sqlalchemy.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.func.now(),
|
||||||
|
onupdate=sqlalchemy.func.now(),
|
||||||
|
)
|
||||||
|
|||||||
72
src/langbot/pkg/entity/persistence/transcript.py
Normal file
72
src/langbot/pkg/entity/persistence/transcript.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""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,6 +13,28 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add rerank_models table
|
||||||
|
|
||||||
|
Revision ID: 0003_add_rerank_models
|
||||||
|
Revises: 0002_sample
|
||||||
|
Create Date: 2026-04-19
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0003_add_rerank_models'
|
||||||
|
down_revision = '0002_sample'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if table already exists (may have been created by create_all())
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'rerank_models' not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'rerank_models',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
|
||||||
|
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('rerank_models')
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""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')
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 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 ###
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""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,9 +118,6 @@ 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,6 +275,7 @@ 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,6 +76,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import stage, entities
|
|
||||||
from . import truncator
|
|
||||||
from ...utils import importutil
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
from . import truncators
|
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(truncators)
|
|
||||||
|
|
||||||
|
|
||||||
@stage.stage_class('ConversationMessageTruncator')
|
|
||||||
class ConversationMessageTruncator(stage.PipelineStage):
|
|
||||||
"""Conversation message truncator
|
|
||||||
|
|
||||||
Used to truncate the conversation message chain to adapt to the LLM message length limit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
trun: truncator.Truncator
|
|
||||||
|
|
||||||
async def initialize(self, pipeline_config: dict):
|
|
||||||
use_method = 'round'
|
|
||||||
|
|
||||||
for trun in truncator.preregistered_truncators:
|
|
||||||
if trun.name == use_method:
|
|
||||||
self.trun = trun(self.ap)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Unknown truncator: {use_method}')
|
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult:
|
|
||||||
"""处理"""
|
|
||||||
query = await self.trun.truncate(query)
|
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import abc
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
|
|
||||||
preregistered_truncators: list[typing.Type[Truncator]] = []
|
|
||||||
|
|
||||||
|
|
||||||
def truncator_class(
|
|
||||||
name: str,
|
|
||||||
) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]:
|
|
||||||
"""截断器类装饰器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): 截断器名称
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]:
|
|
||||||
assert issubclass(cls, Truncator)
|
|
||||||
|
|
||||||
cls.name = name
|
|
||||||
|
|
||||||
preregistered_truncators.append(cls)
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
class Truncator(abc.ABC):
|
|
||||||
"""消息截断器基类"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
|
||||||
"""截断
|
|
||||||
|
|
||||||
一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。
|
|
||||||
请勿操作其他字段。
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import truncator
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
|
|
||||||
|
|
||||||
@truncator.truncator_class('round')
|
|
||||||
class RoundTruncator(truncator.Truncator):
|
|
||||||
"""Truncate the conversation message chain to adapt to the LLM message length limit."""
|
|
||||||
|
|
||||||
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
|
|
||||||
"""截断"""
|
|
||||||
max_round = query.pipeline_config['ai']['local-agent']['max-round']
|
|
||||||
|
|
||||||
temp_messages = []
|
|
||||||
|
|
||||||
current_round = 0
|
|
||||||
|
|
||||||
# Traverse from back to front
|
|
||||||
for msg in query.messages[::-1]:
|
|
||||||
if current_round < max_round:
|
|
||||||
temp_messages.append(msg)
|
|
||||||
if msg.role == 'user':
|
|
||||||
current_round += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
query.messages = temp_messages[::-1]
|
|
||||||
|
|
||||||
return query
|
|
||||||
@@ -28,7 +28,6 @@ from . import (
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
msgtrun,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
importutil.import_modules_in_pkgs(
|
importutil.import_modules_in_pkgs(
|
||||||
@@ -42,7 +41,6 @@ importutil.import_modules_in_pkgs(
|
|||||||
wrapper,
|
wrapper,
|
||||||
preproc,
|
preproc,
|
||||||
ratelimit,
|
ratelimit,
|
||||||
msgtrun,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -438,6 +436,9 @@ 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,6 +63,7 @@ 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()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import typing
|
||||||
|
|
||||||
from .. import stage, entities
|
from .. import stage, entities
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
@@ -9,6 +10,14 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
|
||||||
|
from ...agent.runner.descriptor import AgentRunnerDescriptor
|
||||||
|
from ...agent.runner.config_migration import ConfigMigration
|
||||||
|
from ...agent.runner import config_schema
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROMPT_CONFIG = [
|
||||||
|
{'role': 'system', 'content': 'You are a helpful assistant.'},
|
||||||
|
]
|
||||||
|
|
||||||
@stage.stage_class('PreProcessor')
|
@stage.stage_class('PreProcessor')
|
||||||
class PreProcessor(stage.PipelineStage):
|
class PreProcessor(stage.PipelineStage):
|
||||||
@@ -25,70 +34,147 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
- use_funcs
|
- use_funcs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def _get_runner_descriptor(
|
||||||
|
self,
|
||||||
|
runner_id: str | None,
|
||||||
|
bound_plugins: list[str] | None,
|
||||||
|
) -> AgentRunnerDescriptor | None:
|
||||||
|
if not runner_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
registry = getattr(self.ap, 'agent_runner_registry', None)
|
||||||
|
if registry is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await registry.get(runner_id, bound_plugins)
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_llm_model(
|
||||||
|
self,
|
||||||
|
primary_uuid: str,
|
||||||
|
) -> typing.Any | None:
|
||||||
|
if primary_uuid in config_schema.NONE_SENTINELS:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_fallback_models(self, fallback_uuids: list[str]) -> list[str]:
|
||||||
|
valid_fallbacks = []
|
||||||
|
for fallback_uuid in fallback_uuids:
|
||||||
|
if fallback_uuid in config_schema.NONE_SENTINELS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.get_model_by_uuid(fallback_uuid)
|
||||||
|
valid_fallbacks.append(fallback_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping')
|
||||||
|
return valid_fallbacks
|
||||||
|
|
||||||
|
def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||||
|
if descriptor is None:
|
||||||
|
return True
|
||||||
|
return descriptor.capabilities.get('multimodal_input', False)
|
||||||
|
|
||||||
|
def _model_supports_vision(self, llm_model: typing.Any | None) -> bool:
|
||||||
|
if not llm_model:
|
||||||
|
return False
|
||||||
|
abilities = getattr(getattr(llm_model, 'model_entity', None), 'abilities', [])
|
||||||
|
return 'vision' in abilities
|
||||||
|
|
||||||
|
def _should_keep_image_inputs(
|
||||||
|
self,
|
||||||
|
descriptor: AgentRunnerDescriptor | None,
|
||||||
|
uses_host_models: bool,
|
||||||
|
llm_model: typing.Any | None,
|
||||||
|
) -> bool:
|
||||||
|
if not self._runner_accepts_multimodal_input(descriptor):
|
||||||
|
return False
|
||||||
|
if uses_host_models:
|
||||||
|
return self._model_supports_vision(llm_model)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _strip_images_from_history(self, query: pipeline_query.Query) -> None:
|
||||||
|
for msg in query.messages:
|
||||||
|
if isinstance(msg.content, list):
|
||||||
|
msg.content = [elem for elem in msg.content if elem.type != 'image_url']
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
stage_inst_name: str,
|
stage_inst_name: str,
|
||||||
) -> entities.StageProcessResult:
|
) -> entities.StageProcessResult:
|
||||||
"""Process"""
|
"""Process"""
|
||||||
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
# Resolve runner ID using ConfigMigration (supports both new and old formats)
|
||||||
|
runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
|
||||||
|
|
||||||
|
# Get runner config from ai.runner_config[runner_id].
|
||||||
|
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
|
||||||
|
query.variables = query.variables or {}
|
||||||
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
|
descriptor = await self._get_runner_descriptor(runner_id, bound_plugins)
|
||||||
|
|
||||||
session = await self.ap.sess_mgr.get_session(query)
|
session = await self.ap.sess_mgr.get_session(query)
|
||||||
|
|
||||||
# When not local-agent, llm_model is None
|
uses_host_models = config_schema.uses_host_models(descriptor)
|
||||||
llm_model = None
|
llm_model = None
|
||||||
if selected_runner == 'local-agent':
|
if uses_host_models:
|
||||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||||
# but handle legacy plain string for backward compatibility
|
llm_model = await self._resolve_llm_model(primary_uuid)
|
||||||
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
valid_fallbacks = await self._resolve_fallback_models(fallback_uuids)
|
||||||
if isinstance(model_config, str):
|
if valid_fallbacks:
|
||||||
# Legacy format: plain UUID string
|
query.variables['_fallback_model_uuids'] = valid_fallbacks
|
||||||
primary_uuid = model_config
|
|
||||||
fallback_uuids = []
|
|
||||||
else:
|
|
||||||
primary_uuid = model_config.get('primary', '')
|
|
||||||
fallback_uuids = model_config.get('fallbacks', [])
|
|
||||||
|
|
||||||
if primary_uuid:
|
prompt_config = config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG)
|
||||||
try:
|
|
||||||
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
|
||||||
except ValueError:
|
|
||||||
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
|
||||||
|
|
||||||
# Resolve fallback model UUIDs
|
|
||||||
if fallback_uuids:
|
|
||||||
valid_fallbacks = []
|
|
||||||
for fb_uuid in fallback_uuids:
|
|
||||||
try:
|
|
||||||
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
|
||||||
valid_fallbacks.append(fb_uuid)
|
|
||||||
except ValueError:
|
|
||||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
|
||||||
if valid_fallbacks:
|
|
||||||
query.variables['_fallback_model_uuids'] = valid_fallbacks
|
|
||||||
|
|
||||||
conversation = await self.ap.sess_mgr.get_conversation(
|
conversation = await self.ap.sess_mgr.get_conversation(
|
||||||
query,
|
query,
|
||||||
session,
|
session,
|
||||||
query.pipeline_config['ai']['local-agent']['prompt'],
|
prompt_config,
|
||||||
query.pipeline_uuid,
|
query.pipeline_uuid,
|
||||||
query.bot_uuid,
|
query.bot_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Expire externally managed conversation ids after the conversation has
|
||||||
|
# been idle for longer than the configured conversation expire time.
|
||||||
|
# The idle window is measured from the last preprocess/update time, not
|
||||||
|
# from the conversation creation time.
|
||||||
|
conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
if conversation_expire_time is not None and conversation_expire_time > 0:
|
||||||
|
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||||
|
if last_update_time is not None:
|
||||||
|
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
|
||||||
|
if conversation_idle_time > conversation_expire_time:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
|
||||||
|
)
|
||||||
|
conversation.uuid = None
|
||||||
|
|
||||||
|
# Treat every preprocess pass as a conversation activity update. This
|
||||||
|
# makes future expiry checks use the latest incoming message/preprocess
|
||||||
|
# time instead of the first message/creation time.
|
||||||
|
conversation.update_time = now
|
||||||
|
|
||||||
# 设置query
|
# 设置query
|
||||||
query.session = session
|
query.session = session
|
||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
query.messages = conversation.messages.copy()
|
query.messages = conversation.messages.copy()
|
||||||
|
|
||||||
if selected_runner == 'local-agent':
|
if uses_host_models:
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
if llm_model:
|
if llm_model:
|
||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
if config_schema.uses_host_tools(descriptor) and llm_model.model_entity.abilities.__contains__(
|
||||||
# Get bound plugins and MCP servers for filtering tools
|
'func_call'
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
):
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
@@ -97,10 +183,18 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
# If primary model doesn't support func_call but fallback models exist,
|
# If primary model doesn't support func_call but fallback models exist,
|
||||||
# load tools anyway since fallback models may support them
|
# load tools anyway since fallback models may support them
|
||||||
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
if (
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
config_schema.uses_host_tools(descriptor)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
and not query.use_funcs
|
||||||
|
and query.variables.get('_fallback_model_uuids')
|
||||||
|
):
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
elif config_schema.uses_host_tools(descriptor):
|
||||||
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
|
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
@@ -125,32 +219,21 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
}
|
}
|
||||||
query.variables.update(variables)
|
query.variables.update(variables)
|
||||||
|
|
||||||
# Check if this model supports vision, if not, remove all images
|
keep_image_inputs = self._should_keep_image_inputs(descriptor, uses_host_models, llm_model)
|
||||||
# TODO this checking should be performed in runner, and in this stage, the image should be reserved
|
if not keep_image_inputs:
|
||||||
if (
|
self._strip_images_from_history(query)
|
||||||
selected_runner == 'local-agent'
|
|
||||||
and llm_model
|
|
||||||
and not llm_model.model_entity.abilities.__contains__('vision')
|
|
||||||
):
|
|
||||||
for msg in query.messages:
|
|
||||||
if isinstance(msg.content, list):
|
|
||||||
for me in msg.content:
|
|
||||||
if me.type == 'image_url':
|
|
||||||
msg.content.remove(me)
|
|
||||||
|
|
||||||
content_list: list[provider_message.ContentElement] = []
|
content_list: list[provider_message.ContentElement] = []
|
||||||
|
|
||||||
plain_text = ''
|
plain_text = ''
|
||||||
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
quote_msg = query.pipeline_config['trigger'].get('misc', {}).get('combine-quote-message', False)
|
||||||
|
|
||||||
for me in query.message_chain:
|
for me in query.message_chain:
|
||||||
if isinstance(me, platform_message.Plain):
|
if isinstance(me, platform_message.Plain):
|
||||||
content_list.append(provider_message.ContentElement.from_text(me.text))
|
content_list.append(provider_message.ContentElement.from_text(me.text))
|
||||||
plain_text += me.text
|
plain_text += me.text
|
||||||
elif isinstance(me, platform_message.Image):
|
elif isinstance(me, platform_message.Image):
|
||||||
if selected_runner != 'local-agent' or (
|
if keep_image_inputs:
|
||||||
llm_model and llm_model.model_entity.abilities.__contains__('vision')
|
|
||||||
):
|
|
||||||
if me.base64 is not None:
|
if me.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||||
elif isinstance(me, platform_message.Voice):
|
elif isinstance(me, platform_message.Voice):
|
||||||
@@ -160,19 +243,23 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
elif isinstance(me, platform_message.File):
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
if me.base64:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
|
||||||
|
elif me.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
if isinstance(msg, platform_message.Plain):
|
if isinstance(msg, platform_message.Plain):
|
||||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||||
elif isinstance(msg, platform_message.Image):
|
elif isinstance(msg, platform_message.Image):
|
||||||
if selected_runner != 'local-agent' or (
|
if keep_image_inputs:
|
||||||
llm_model and llm_model.model_entity.abilities.__contains__('vision')
|
|
||||||
):
|
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
elif isinstance(msg, platform_message.File):
|
elif isinstance(msg, platform_message.File):
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
if msg.base64:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
elif isinstance(msg, platform_message.Voice):
|
elif isinstance(msg, platform_message.Voice):
|
||||||
if msg.base64:
|
if msg.base64:
|
||||||
content_list.append(
|
content_list.append(
|
||||||
@@ -185,14 +272,12 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
query.user_message = provider_message.Message(role='user', content=content_list)
|
query.user_message = provider_message.Message(role='user', content=content_list)
|
||||||
|
|
||||||
# Extract knowledge base UUIDs into query variables so plugins can modify them
|
# Extract configured KB UUIDs into query variables so PromptPreProcessing
|
||||||
# during PromptPreProcessing before the runner performs retrieval.
|
# plugins can still adjust the authorized retrieval set before run_agent.
|
||||||
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
query.variables['_knowledge_base_uuids'] = config_schema.extract_knowledge_base_uuids(
|
||||||
if not kb_uuids:
|
descriptor,
|
||||||
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
runner_config,
|
||||||
if old_kb_uuid and old_kb_uuid != '__none__':
|
)
|
||||||
kb_uuids = [old_kb_uuid]
|
|
||||||
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
|
|
||||||
|
|
||||||
# =========== 触发事件 PromptPreProcessing
|
# =========== 触发事件 PromptPreProcessing
|
||||||
|
|
||||||
|
|||||||
@@ -9,29 +9,28 @@ from datetime import datetime
|
|||||||
|
|
||||||
from .. import handler
|
from .. import handler
|
||||||
from ... import entities
|
from ... import entities
|
||||||
from ....provider import runner as runner_module
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ....utils import importutil, constants, runner as runner_utils
|
from ....utils import constants, runner as runner_utils
|
||||||
from ....provider import runners
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(runners)
|
|
||||||
|
|
||||||
|
|
||||||
class ChatMessageHandler(handler.MessageHandler):
|
class ChatMessageHandler(handler.MessageHandler):
|
||||||
|
"""Chat message handler using AgentRunOrchestrator.
|
||||||
|
|
||||||
|
This handler delegates all runner execution to the agent_run_orchestrator,
|
||||||
|
which resolves runner ID, builds context, invokes plugin runtime,
|
||||||
|
and normalizes results.
|
||||||
|
"""
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
|
) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
|
||||||
"""处理"""
|
"""Handle chat message by delegating to AgentRunOrchestrator."""
|
||||||
# 调API
|
# Trigger plugin event
|
||||||
# 生成器
|
|
||||||
|
|
||||||
# 触发插件事件
|
|
||||||
event_class = (
|
event_class = (
|
||||||
events.PersonNormalMessageReceived
|
events.PersonNormalMessageReceived
|
||||||
if query.launcher_type == provider_session.LauncherTypes.PERSON
|
if query.launcher_type == provider_session.LauncherTypes.PERSON
|
||||||
@@ -52,7 +51,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||||
|
|
||||||
is_create_card = False # 判断下是否需要创建流式卡片
|
is_create_card = False # Track if streaming card was created
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
if event_ctx.event.reply_message_chain is not None:
|
if event_ctx.event.reply_message_chain is not None:
|
||||||
@@ -83,83 +82,85 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
is_stream = False
|
is_stream = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for r in runner_module.preregistered_runners:
|
|
||||||
if r.name == query.pipeline_config['ai']['runner']['runner']:
|
|
||||||
runner = r(self.ap, query.pipeline_config)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
|
||||||
# Mark start time for telemetry
|
# Mark start time for telemetry
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
|
|
||||||
if is_stream:
|
# Create a single resp_message_id for the entire streaming response
|
||||||
resp_message_id = uuid.uuid4()
|
resp_message_id = uuid.uuid4()
|
||||||
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
|
||||||
|
|
||||||
async for result in runner.run(query):
|
# Use AgentRunOrchestrator to run the agent
|
||||||
result.resp_message_id = str(resp_message_id)
|
# This replaces direct runner lookup and PluginAgentRunnerWrapper
|
||||||
|
async for result in self.ap.agent_run_orchestrator.run_from_query(query):
|
||||||
|
result.resp_message_id = str(resp_message_id)
|
||||||
|
|
||||||
|
# For streaming mode, pop previous response before adding new chunk
|
||||||
|
# This allows incremental card updates
|
||||||
|
if is_stream:
|
||||||
if query.resp_messages:
|
if query.resp_messages:
|
||||||
query.resp_messages.pop()
|
query.resp_messages.pop()
|
||||||
if query.resp_message_chain:
|
if query.resp_message_chain:
|
||||||
query.resp_message_chain.pop()
|
query.resp_message_chain.pop()
|
||||||
# 此时连接外部 AI 服务正常,创建卡片
|
|
||||||
if not is_create_card: # 只有不是第一次才创建卡片
|
|
||||||
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
|
||||||
is_create_card = True
|
|
||||||
query.resp_messages.append(result)
|
|
||||||
|
|
||||||
chunk_count += 1
|
# Create streaming card on first result (connection established)
|
||||||
# Only log every 10th chunk to reduce excessive logging during streaming
|
if is_stream and not is_create_card:
|
||||||
# This prevents memory overflow from thousands of log entries per conversation
|
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
||||||
# First chunk uses INFO level to confirm connection establishment
|
is_create_card = True
|
||||||
if chunk_count == 1:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
|
|
||||||
)
|
|
||||||
elif chunk_count % 10 == 0:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.content is not None:
|
query.resp_messages.append(result)
|
||||||
text_length += len(result.content)
|
|
||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
|
||||||
|
|
||||||
# Log final summary after streaming completes
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
async for result in runner.run(query):
|
|
||||||
query.resp_messages.append(result)
|
|
||||||
|
|
||||||
|
# Logging (reduce verbosity for streaming chunks)
|
||||||
|
if not is_stream:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
|
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.content is not None:
|
if result.content is not None:
|
||||||
text_length += len(result.content)
|
text_length += len(result.content)
|
||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# Log final summary after streaming completes
|
||||||
|
if is_stream:
|
||||||
|
chunk_count = len(query.resp_messages)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update conversation history
|
||||||
query.session.using_conversation.messages.append(query.user_message)
|
query.session.using_conversation.messages.append(query.user_message)
|
||||||
|
|
||||||
query.session.using_conversation.messages.extend(query.resp_messages)
|
query.session.using_conversation.messages.extend(query.resp_messages)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Import orchestrator errors for specific handling
|
||||||
|
from ....agent.runner.errors import (
|
||||||
|
RunnerNotFoundError,
|
||||||
|
RunnerNotAuthorizedError,
|
||||||
|
RunnerExecutionError,
|
||||||
|
)
|
||||||
|
|
||||||
error_info = f'{traceback.format_exc()}'
|
error_info = f'{traceback.format_exc()}'
|
||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
# Handle specific runner errors with appropriate messages
|
||||||
|
if isinstance(e, RunnerNotFoundError):
|
||||||
|
user_notice = f'Agent runner not found: {e.runner_id}'
|
||||||
|
elif isinstance(e, RunnerNotAuthorizedError):
|
||||||
|
user_notice = 'Agent runner not authorized for this pipeline'
|
||||||
|
elif isinstance(e, RunnerExecutionError):
|
||||||
|
if e.retryable:
|
||||||
|
user_notice = 'Agent runner temporarily unavailable. Please try again.'
|
||||||
|
else:
|
||||||
|
user_notice = 'Agent runner execution failed.'
|
||||||
|
else:
|
||||||
|
# Use existing exception handling
|
||||||
|
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||||
|
|
||||||
if exception_handling == 'show-error':
|
if exception_handling == 'show-error':
|
||||||
user_notice = f'{e}'
|
user_notice = f'{e}'
|
||||||
elif exception_handling == 'show-hint':
|
elif exception_handling == 'show-hint':
|
||||||
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
||||||
else: # hide
|
else: # hide
|
||||||
user_notice = None
|
user_notice = None
|
||||||
|
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
result_type=entities.ResultType.INTERRUPT,
|
result_type=entities.ResultType.INTERRUPT,
|
||||||
@@ -169,7 +170,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
|
# Telemetry reporting
|
||||||
try:
|
try:
|
||||||
end_ts = time.time()
|
end_ts = time.time()
|
||||||
duration_ms = None
|
duration_ms = None
|
||||||
@@ -177,16 +178,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
duration_ms = int((end_ts - start_ts) * 1000)
|
duration_ms = int((end_ts - start_ts) * 1000)
|
||||||
|
|
||||||
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
|
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
|
||||||
runner_name = (
|
|
||||||
query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')
|
|
||||||
if query.pipeline_config
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Model name if using localagent
|
# Use orchestrator to resolve runner ID for telemetry
|
||||||
|
runner_name = self.ap.agent_run_orchestrator.resolve_runner_id_for_telemetry(query)
|
||||||
|
|
||||||
|
# Model name if available
|
||||||
model_name = None
|
model_name = None
|
||||||
try:
|
try:
|
||||||
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
|
if getattr(query, 'use_llm_model_uuid', None):
|
||||||
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
if m and getattr(m, 'model_entity', None):
|
if m and getattr(m, 'model_entity', None):
|
||||||
model_name = getattr(m.model_entity, 'name', None)
|
model_name = getattr(m.model_entity, 'name', None)
|
||||||
@@ -196,7 +195,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
|
||||||
runner_category = runner_utils.get_runner_category_from_runner(
|
runner_category = runner_utils.get_runner_category_from_runner(
|
||||||
runner_name, runner, query.pipeline_config
|
runner_name, None, query.pipeline_config
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -214,7 +213,6 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
|
|
||||||
await self.ap.telemetry.start_send_task(payload)
|
await self.ap.telemetry.start_send_task(payload)
|
||||||
|
|
||||||
# Trigger survey event on first successful non-WebSocket response
|
# Trigger survey event on first successful non-WebSocket response
|
||||||
@@ -222,5 +220,4 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
if self.ap.survey:
|
if self.ap.survey:
|
||||||
await self.ap.survey.trigger_event('first_bot_response_success')
|
await self.ap.survey.trigger_event('first_bot_response_success')
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Ensure telemetry issues do not affect normal flow
|
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||||
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
|
||||||
@@ -523,7 +523,7 @@ class PlatformManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def remove_bot(self, bot_uuid: str):
|
async def remove_bot(self, bot_uuid: str):
|
||||||
for bot in self.bots:
|
for bot in self.bots[:]:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
if bot.enable:
|
if bot.enable:
|
||||||
await bot.shutdown()
|
await bot.shutdown()
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
description:
|
||||||
|
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
|
||||||
|
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
|
||||||
|
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: dingtalk
|
||||||
|
required: false
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
@@ -40,6 +52,10 @@ spec:
|
|||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
zh_Hant: 機器人代碼
|
||||||
|
description:
|
||||||
|
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
|
||||||
|
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
|
||||||
|
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -1025,7 +1025,90 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
||||||
|
|
||||||
|
# Map standard target_type to Feishu receive_id_type
|
||||||
|
if target_type == 'person':
|
||||||
|
receive_id_type = 'open_id'
|
||||||
|
elif target_type == 'group':
|
||||||
|
receive_id_type = 'chat_id'
|
||||||
|
else:
|
||||||
|
receive_id_type = target_type
|
||||||
|
|
||||||
|
# Send text message if there are text elements
|
||||||
|
if text_elements:
|
||||||
|
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||||
|
|
||||||
|
if needs_post:
|
||||||
|
msg_type = 'post'
|
||||||
|
final_content = json.dumps(
|
||||||
|
{
|
||||||
|
'zh_Hans': {
|
||||||
|
'title': '',
|
||||||
|
'content': text_elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg_type = 'text'
|
||||||
|
parts = []
|
||||||
|
for paragraph in text_elements:
|
||||||
|
para_text = ''.join(ele.get('text', '') for ele in paragraph)
|
||||||
|
if para_text:
|
||||||
|
parts.append(para_text)
|
||||||
|
final_content = json.dumps({'text': '\n\n'.join(parts)})
|
||||||
|
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(final_content)
|
||||||
|
.msg_type(msg_type)
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send media messages separately (image, audio, file, etc.)
|
||||||
|
for media in media_items:
|
||||||
|
request: CreateMessageRequest = (
|
||||||
|
CreateMessageRequest.builder()
|
||||||
|
.receive_id_type(receive_id_type)
|
||||||
|
.request_body(
|
||||||
|
CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(target_id)
|
||||||
|
.content(json.dumps(media['content']))
|
||||||
|
.msg_type(media['msg_type'])
|
||||||
|
.uuid(str(uuid.uuid4()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app_access_token = self.get_app_access_token()
|
||||||
|
req_opt: RequestOption = (
|
||||||
|
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
||||||
|
)
|
||||||
|
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
raise Exception(
|
||||||
|
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
is_stream = False
|
is_stream = False
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-create
|
||||||
|
label:
|
||||||
|
en_US: One-Click Create App
|
||||||
|
zh_Hans: 一键创建应用
|
||||||
|
zh_Hant: 一鍵建立應用
|
||||||
|
ja_JP: ワンクリックでアプリ作成
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
||||||
|
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
||||||
|
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
||||||
|
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: feishu
|
||||||
|
required: false
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user