mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 10:17:13 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0135ae31b8 | |||
| c222321f2d |
@@ -7,42 +7,23 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-dev-image:
|
build-dev-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# 如果是tag则跳过
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Generate Tag
|
||||||
uses: docker/setup-buildx-action@v3
|
id: generate_tag
|
||||||
|
|
||||||
- name: Generate image metadata
|
|
||||||
id: image
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
# 获取分支名称,把/替换为-
|
||||||
branch_tag="${GITHUB_REF#refs/heads/}"
|
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
||||||
branch_tag="${branch_tag//\//-}"
|
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
||||||
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
- name: Login to Registry
|
||||||
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build Docker Image
|
||||||
- name: Login to Docker Hub
|
run: |
|
||||||
uses: docker/login-action@v3
|
docker buildx create --name mybuilder --use
|
||||||
with:
|
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build and push immutable Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
|
||||||
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS node
|
FROM node:22-alpine AS node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -f /tmp/nodesource_setup.sh \
|
&& rm -f /tmp/nodesource_setup.sh \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync --extra seekdb \
|
&& uv sync \
|
||||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### One-Click Cloud Deploy
|
### One-Click Cloud Deploy
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 一键云部署
|
### 一键云部署
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Despliegue en la Nube con un Clic
|
### Despliegue en la Nube con un Clic
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Déploiement Cloud en un Clic
|
### Déploiement Cloud en un Clic
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### ワンクリッククラウドデプロイ
|
### ワンクリッククラウドデプロイ
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 원클릭 클라우드 배포
|
### 원클릭 클라우드 배포
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Облачное развертывание одним кликом
|
### Облачное развертывание одним кликом
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 一鍵雲端部署
|
### 一鍵雲端部署
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Triển khai đám mây một cú nhấp
|
### Triển khai đám mây một cú nhấp
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional shared control-plane secret used to authenticate both the RPC
|
# Shared control-plane secret used to authenticate both the RPC socket
|
||||||
# socket and managed-process relay. Leave unset on both OSS services, or
|
# and managed-process relay. Generate once (for example with
|
||||||
# generate one with ``openssl rand -hex 32`` and set the same value on
|
# ``openssl rand -hex 32``) and export it before enabling this profile.
|
||||||
# both ends. Strongly recommended when the deployment is Internet-accessible.
|
# An empty value is accepted by Compose so Box can remain optional, but
|
||||||
|
# the Box runtime itself fails closed when the profile is started.
|
||||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||||
# Box has its own process-wide blocking-work budget.
|
# Box has its own process-wide blocking-work budget.
|
||||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||||
@@ -78,9 +79,8 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# When set, this must match langbot_box. If both ends leave it unset,
|
# Must match the value supplied to langbot_box. The token is sent only
|
||||||
# OSS permits the connection without token authentication. The token is
|
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||||
# sent only in WebSocket handshake headers, never in URLs or payloads.
|
|
||||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||||
# Core process-wide blocking-work admission. These are native config
|
# Core process-wide blocking-work admission. These are native config
|
||||||
# overrides and are persisted with the effective data/config.yaml.
|
# overrides and are persisted with the effective data/config.yaml.
|
||||||
|
|||||||
@@ -10,19 +10,6 @@ uvx langbot
|
|||||||
|
|
||||||
This will automatically download and run the latest version of LangBot.
|
This will automatically download and run the latest version of LangBot.
|
||||||
|
|
||||||
SeekDB support is optional and is not installed by the command above. If you
|
|
||||||
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
|
||||||
run LangBot with the `seekdb` extra:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uvx --from 'langbot[seekdb]@latest' langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
The extra includes native dependencies whose supported operating systems may
|
|
||||||
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
|
||||||
require macOS 15 or later. The default Chroma backend does not have this
|
|
||||||
requirement.
|
|
||||||
|
|
||||||
## Install with pip/uv
|
## Install with pip/uv
|
||||||
|
|
||||||
You can also install LangBot as a regular Python package:
|
You can also install LangBot as a regular Python package:
|
||||||
@@ -33,10 +20,6 @@ pip install langbot
|
|||||||
|
|
||||||
# Using uv
|
# Using uv
|
||||||
uv pip install langbot
|
uv pip install langbot
|
||||||
|
|
||||||
# Include optional SeekDB support
|
|
||||||
pip install 'langbot[seekdb]'
|
|
||||||
# or: uv pip install 'langbot[seekdb]'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run it:
|
Then run it:
|
||||||
@@ -118,7 +101,7 @@ uvx langbot
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
- Python 3.11 or higher (lower than Python 4)
|
- Python 3.10.1 or higher
|
||||||
- Operating System: Linux, macOS, or Windows
|
- Operating System: Linux, macOS, or Windows
|
||||||
|
|
||||||
## Differences from Source Installation
|
## Differences from Source Installation
|
||||||
|
|||||||
+43
-34
@@ -16,20 +16,12 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||||
Chroma by default and does not install `pyseekdb` or its native bindings.
|
|
||||||
|
|
||||||
Choose the command that matches how you run LangBot:
|
If you need to install it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PyPI / uvx
|
pip install pyseekdb
|
||||||
uvx --from 'langbot[seekdb]@latest' langbot
|
|
||||||
|
|
||||||
# Installed package
|
|
||||||
pip install 'langbot[seekdb]'
|
|
||||||
|
|
||||||
# Source checkout
|
|
||||||
uv sync --extra seekdb
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Platform Compatibility
|
## ⚠️ Platform Compatibility
|
||||||
@@ -38,36 +30,31 @@ uv sync --extra seekdb
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||||
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||||
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||||
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
|
||||||
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
|
||||||
|
|
||||||
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||||
force-install or retag a wheel built for a newer macOS release: the bundled
|
|
||||||
binaries also declare macOS 15 as their minimum deployment target.
|
|
||||||
|
|
||||||
### Server Mode (Docker)
|
### Server Mode (Docker)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full Docker support |
|
| Linux | ✅ Supported | Full Docker support |
|
||||||
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||||
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||||
|
|
||||||
|
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||||
|
- Using ChromaDB or Qdrant as alternatives
|
||||||
|
- Connecting to a remote SeekDB server on Linux if available
|
||||||
|
|
||||||
### Server Mode (Remote Connection)
|
### Server Mode (Remote Connection)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||||
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
|
||||||
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
|
||||||
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
|
|
||||||
|
|
||||||
Remote server mode does not use embedded storage at runtime. However, whether
|
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||||
the Python client can be installed still depends on `pyseekdb`'s package
|
|
||||||
metadata for the current platform.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -183,23 +170,22 @@ Key methods:
|
|||||||
|
|
||||||
### Import Error
|
### Import Error
|
||||||
|
|
||||||
If you see: `SeekDB support is not installed`
|
If you see: `ImportError: pyseekdb is not installed`
|
||||||
|
|
||||||
Solution:
|
Solution:
|
||||||
```bash
|
```bash
|
||||||
uv sync --extra seekdb
|
pip install pyseekdb
|
||||||
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedded Mode Is Unavailable on the Current Platform
|
### Embedded Mode Error on macOS/Windows
|
||||||
|
|
||||||
**Error**:
|
**Error**:
|
||||||
```
|
```
|
||||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||||
|
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||||
CPU architecture, Python version, and macOS deployment target.
|
|
||||||
|
|
||||||
**Solution**: Use server mode instead:
|
**Solution**: Use server mode instead:
|
||||||
1. Deploy SeekDB on a Linux server or VM
|
1. Deploy SeekDB on a Linux server or VM
|
||||||
@@ -222,6 +208,29 @@ vdb:
|
|||||||
use: chroma # or qdrant
|
use: chroma # or qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Container Fails on macOS
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||||
|
# Container exits immediately with code 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error in logs**:
|
||||||
|
```
|
||||||
|
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||||
|
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||||
|
|
||||||
|
**Status**: Under investigation by OceanBase team.
|
||||||
|
|
||||||
|
**Workaround Options**:
|
||||||
|
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||||
|
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||||
|
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||||
|
|
||||||
### Connection Error (Server Mode)
|
### Connection Error (Server Mode)
|
||||||
|
|
||||||
If SeekDB server is not reachable, check:
|
If SeekDB server is not reachable, check:
|
||||||
|
|||||||
+3
-7
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.8"
|
version = "4.10.7"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,8 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"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)",
|
||||||
"langbot-plugin==0.5.5",
|
"pyseekdb==1.1.0.post3",
|
||||||
|
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -107,11 +108,6 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
seekdb = [
|
|
||||||
"pyseekdb==1.1.0.post3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
|||||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
CMD_SEND_MSG = 'aibot_send_msg'
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
|
|
||||||
# command names below match the WeCom AI Bot long-connection protocol.
|
|
||||||
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
|
|
||||||
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
|
|
||||||
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
|
|
||||||
|
|
||||||
# Default upload chunk size: 512 KB before base64 encoding.
|
|
||||||
_UPLOAD_CHUNK_SIZE = 512 * 1024
|
|
||||||
|
|
||||||
_DEDUP_CACHE_MAX = 4096
|
_DEDUP_CACHE_MAX = 4096
|
||||||
_STREAM_CACHE_MAX = 1024
|
_STREAM_CACHE_MAX = 1024
|
||||||
@@ -503,145 +495,6 @@ class WecomBotWsClient:
|
|||||||
body['chatid'] = chat_id
|
body['chatid'] = chat_id
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Media upload (image / voice / file)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def upload_media(
|
|
||||||
self,
|
|
||||||
data: bytes,
|
|
||||||
filename: str = 'attachment',
|
|
||||||
media_type: str = 'file',
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
|
|
||||||
|
|
||||||
Implements the three-step protocol documented for the WeCom
|
|
||||||
AI Bot:
|
|
||||||
|
|
||||||
1. ``aibot_upload_media_init`` — declare media type, file name,
|
|
||||||
size, MD5 and chunk count; receive ``upload_id``.
|
|
||||||
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
|
|
||||||
bytes) until done; receive per-chunk ACK.
|
|
||||||
3. ``aibot_upload_media_finish`` — finalize the upload; receive
|
|
||||||
``media_id``.
|
|
||||||
|
|
||||||
Returns a dict with the final ``media_id`` (and the raw
|
|
||||||
``finish`` ACK) on success, or ``None`` on any failure. The
|
|
||||||
caller is expected to ignore the result and continue
|
|
||||||
gracefully — the framework will keep working without media
|
|
||||||
delivery.
|
|
||||||
"""
|
|
||||||
import base64 as _b64
|
|
||||||
import hashlib as _hl
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
file_size = len(data)
|
|
||||||
file_md5 = _hl.md5(data).hexdigest()
|
|
||||||
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
|
|
||||||
if total_chunks == 0:
|
|
||||||
total_chunks = 1
|
|
||||||
|
|
||||||
# Step 1: init.
|
|
||||||
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
|
|
||||||
init_body = {
|
|
||||||
'type': media_type,
|
|
||||||
'filename': filename,
|
|
||||||
'total_size': file_size,
|
|
||||||
'total_chunks': total_chunks,
|
|
||||||
'md5': file_md5,
|
|
||||||
}
|
|
||||||
init_ack = await self._send_reply(
|
|
||||||
init_req_id,
|
|
||||||
init_body,
|
|
||||||
cmd=CMD_UPLOAD_INIT,
|
|
||||||
)
|
|
||||||
if not init_ack or init_ack.get('errcode', 0) != 0:
|
|
||||||
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
|
|
||||||
return None
|
|
||||||
upload_id = (
|
|
||||||
init_ack.get('upload_id')
|
|
||||||
or init_ack.get('body', {}).get('upload_id')
|
|
||||||
or init_ack.get('data', {}).get('upload_id')
|
|
||||||
)
|
|
||||||
if not upload_id:
|
|
||||||
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Step 2: chunks.
|
|
||||||
for index in range(total_chunks):
|
|
||||||
start = index * _UPLOAD_CHUNK_SIZE
|
|
||||||
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
|
|
||||||
chunk_bytes = data[start:end]
|
|
||||||
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
|
|
||||||
chunk_body = {
|
|
||||||
'upload_id': upload_id,
|
|
||||||
'chunk_index': index,
|
|
||||||
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
|
|
||||||
}
|
|
||||||
chunk_ack = await self._send_reply(
|
|
||||||
chunk_req_id,
|
|
||||||
chunk_body,
|
|
||||||
cmd=CMD_UPLOAD_CHUNK,
|
|
||||||
)
|
|
||||||
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
|
|
||||||
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Step 3: finish.
|
|
||||||
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
|
|
||||||
finish_body = {'upload_id': upload_id}
|
|
||||||
finish_ack = await self._send_reply(
|
|
||||||
finish_req_id,
|
|
||||||
finish_body,
|
|
||||||
cmd=CMD_UPLOAD_FINISH,
|
|
||||||
)
|
|
||||||
if not finish_ack or finish_ack.get('errcode', 0) != 0:
|
|
||||||
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
media_id = (
|
|
||||||
finish_ack.get('media_id')
|
|
||||||
or finish_ack.get('body', {}).get('media_id')
|
|
||||||
or finish_ack.get('data', {}).get('media_id')
|
|
||||||
)
|
|
||||||
if not media_id:
|
|
||||||
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
|
|
||||||
return None
|
|
||||||
return {'media_id': media_id, 'ack': finish_ack}
|
|
||||||
|
|
||||||
async def _reply_media(
|
|
||||||
self,
|
|
||||||
req_id: str,
|
|
||||||
media_id: str,
|
|
||||||
kind: str,
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""Send a media reply (image / voice / file) referencing *media_id*.
|
|
||||||
|
|
||||||
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
|
|
||||||
the standard ``aibot_respond_msg`` command with a per-kind
|
|
||||||
body key (matches the convention documented for the WeCom
|
|
||||||
AI Bot SDK).
|
|
||||||
"""
|
|
||||||
if kind not in {'image', 'voice', 'file'}:
|
|
||||||
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
|
|
||||||
return None
|
|
||||||
body = {
|
|
||||||
'msgtype': kind,
|
|
||||||
kind: {'media_id': media_id},
|
|
||||||
}
|
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
|
|
||||||
|
|
||||||
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
|
|
||||||
return await self._reply_media(req_id, media_id, 'image')
|
|
||||||
|
|
||||||
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
|
|
||||||
return await self._reply_media(req_id, media_id, 'file')
|
|
||||||
|
|
||||||
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
|
|
||||||
return await self._reply_media(req_id, media_id, 'voice')
|
|
||||||
|
|
||||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
"""Push a streaming chunk for a given message ID.
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
|||||||
@@ -1210,9 +1210,8 @@ class BoxService:
|
|||||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||||
|
|
||||||
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
||||||
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
only reliably transfers small files. The host path is preferred.
|
||||||
base64 payload can be far larger than the 4000-char display limit.
|
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -1266,22 +1265,14 @@ class BoxService:
|
|||||||
' break\n'
|
' break\n'
|
||||||
'print(json.dumps(out))\n'
|
'print(json.dumps(out))\n'
|
||||||
)
|
)
|
||||||
spec_payload: dict = {
|
result = await self.execute_tool(
|
||||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||||
'timeout_sec': 120,
|
query,
|
||||||
'session_id': self.resolve_box_session_id(query),
|
)
|
||||||
}
|
if not result.get('ok'):
|
||||||
if 'extra_mounts' not in spec_payload:
|
|
||||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
|
||||||
try:
|
|
||||||
spec = self.build_spec(spec_payload)
|
|
||||||
result = await self.client.execute(spec)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
if not result.ok:
|
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from ..api.http.context import ExecutionContext
|
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import operator
|
from . import operator
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
@@ -67,14 +66,7 @@ class CommandManager:
|
|||||||
|
|
||||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||||
if require_context is not None:
|
if require_context is not None:
|
||||||
result = require_context(
|
result = require_context(context)
|
||||||
ExecutionContext(
|
|
||||||
instance_uuid=context.instance_uuid,
|
|
||||||
workspace_uuid=context.workspace_uuid,
|
|
||||||
placement_generation=context.placement_generation,
|
|
||||||
query_uuid=context.query_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
|
|||||||
@@ -301,36 +301,11 @@ class Application:
|
|||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _initialize_plugin_runtime(self) -> None:
|
|
||||||
try:
|
|
||||||
await self.plugin_connector.initialize()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
|
||||||
self.plugin_connector.schedule_reconnect()
|
|
||||||
|
|
||||||
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
|
||||||
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
|
||||||
if task is not None and not task.done():
|
|
||||||
return task
|
|
||||||
# This is application lifecycle work, not a request side effect. It must
|
|
||||||
# not wait on PersistenceManager's after-commit gate at boot.
|
|
||||||
task = asyncio.create_task(
|
|
||||||
self._initialize_plugin_runtime(),
|
|
||||||
name='plugin-runtime-initialization',
|
|
||||||
)
|
|
||||||
self._plugin_runtime_initialization_task = task
|
|
||||||
return task
|
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
self.event_loop_monitor.start()
|
self.event_loop_monitor.start()
|
||||||
try:
|
try:
|
||||||
if (
|
if self.directory_projection_service is not None:
|
||||||
self.directory_projection_service is not None
|
self.task_mgr.create_task(
|
||||||
and getattr(self, 'directory_projection_task', None) is None
|
|
||||||
):
|
|
||||||
self.directory_projection_task = self.task_mgr.create_task(
|
|
||||||
self.directory_projection_service.run(),
|
self.directory_projection_service.run(),
|
||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
@@ -347,6 +322,7 @@ class Application:
|
|||||||
name='cloud-manifest-refresh',
|
name='cloud-manifest-refresh',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
await self.plugin_connector.initialize_plugins()
|
||||||
|
|
||||||
# 后续可能会允许动态重启其他任务
|
# 后续可能会允许动态重启其他任务
|
||||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -372,7 +348,6 @@ class Application:
|
|||||||
name='http-api-controller',
|
name='http-api-controller',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
self._start_plugin_runtime_initialization()
|
|
||||||
|
|
||||||
# Telemetry instance heartbeat (startup + daily); respects
|
# Telemetry instance heartbeat (startup + daily); respects
|
||||||
# space.disable_telemetry via TelemetryManager.send().
|
# space.disable_telemetry via TelemetryManager.send().
|
||||||
@@ -554,11 +529,6 @@ class Application:
|
|||||||
|
|
||||||
if self.task_mgr is not None:
|
if self.task_mgr is not None:
|
||||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||||
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
|
||||||
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
|
||||||
plugin_runtime_task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await plugin_runtime_task
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.event_loop_monitor.stop()
|
await self.event_loop_monitor.stop()
|
||||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app, entities as core_entities
|
from .. import stage, app
|
||||||
from ...utils import version, proxy, constants
|
from ...utils import version, proxy, constants
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
@@ -292,17 +292,14 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
connector.schedule_reconnect()
|
connector.schedule_reconnect()
|
||||||
|
|
||||||
if ap.directory_projection_service is not None:
|
|
||||||
# Keep the projection fresh while shared Runtime cold restore runs.
|
|
||||||
# BuildApp initializes the connector before Application.run() starts
|
|
||||||
# its long-lived tasks, so start the single refresh task here.
|
|
||||||
ap.directory_projection_task = ap.task_mgr.create_task(
|
|
||||||
ap.directory_projection_service.run(),
|
|
||||||
name='cloud-directory-projection',
|
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||||
|
try:
|
||||||
|
await plugin_connector_inst.initialize()
|
||||||
|
except Exception as exc:
|
||||||
|
# Keep the API/UI available while an external or managed runtime is
|
||||||
|
# starting, then recover in the background with bounded backoff.
|
||||||
|
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||||
|
plugin_connector_inst.schedule_reconnect()
|
||||||
ap.plugin_connector = plugin_connector_inst
|
ap.plugin_connector = plugin_connector_inst
|
||||||
workspace_service_inst.release_startup_execution_bindings()
|
workspace_service_inst.release_startup_execution_bindings()
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ class PersistenceManager:
|
|||||||
await self._validate_cloud_runtime()
|
await self._validate_cloud_runtime()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._enable_sqlite_foreign_keys()
|
||||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||||
async with self._release_migration_lock():
|
async with self._release_migration_lock():
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
@@ -184,7 +185,6 @@ class PersistenceManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
await self._enable_sqlite_foreign_keys_after_migration()
|
|
||||||
|
|
||||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
@@ -373,17 +373,6 @@ class PersistenceManager:
|
|||||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||||
self._oss_tenant_scope_listener_installed = True
|
self._oss_tenant_scope_listener_installed = True
|
||||||
|
|
||||||
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
|
||||||
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
|
||||||
engine = self.get_db_engine()
|
|
||||||
if engine.dialect.name != 'sqlite':
|
|
||||||
return
|
|
||||||
await engine.dispose()
|
|
||||||
self._enable_sqlite_foreign_keys()
|
|
||||||
# Dispose again so every runtime connection is opened through the new
|
|
||||||
# listener instead of reusing a pre-migration pooled connection.
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
def _enable_sqlite_foreign_keys(self) -> None:
|
def _enable_sqlite_foreign_keys(self) -> None:
|
||||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||||
engine = self.get_db_engine()
|
engine = self.get_db_engine()
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -118,19 +117,8 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
def _fsync_file(path: pathlib.Path) -> None:
|
||||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
|
||||||
descriptor: int | None = None
|
|
||||||
for attempt in range(reopen_attempts):
|
|
||||||
try:
|
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
|
||||||
break
|
|
||||||
except FileNotFoundError:
|
|
||||||
if attempt + 1 >= reopen_attempts:
|
|
||||||
raise
|
|
||||||
time.sleep(0.05)
|
|
||||||
assert descriptor is not None
|
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -158,18 +158,6 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
result_type=entities.ResultType.CONTINUE,
|
result_type=entities.ResultType.CONTINUE,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
)
|
)
|
||||||
elif (
|
|
||||||
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
|
||||||
):
|
|
||||||
# Final streaming chunk with no text content but
|
|
||||||
# possibly carrying sandbox outbox attachments.
|
|
||||||
reply_chain = platform_message.MessageChain([])
|
|
||||||
await self._append_outbound_attachments(query, reply_chain)
|
|
||||||
query.resp_message_chain.append(reply_chain)
|
|
||||||
yield entities.StageProcessResult(
|
|
||||||
result_type=entities.ResultType.CONTINUE,
|
|
||||||
new_query=query,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||||
function_names = [tc.function.name for tc in result.tool_calls]
|
function_names = [tc.function.name for tc in result.tool_calls]
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import base64
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
@@ -26,24 +24,11 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
|||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
"""Convert a MessageChain into a list of component dicts.
|
content = ''
|
||||||
|
|
||||||
Each dict has a ``type`` key (``'text'``, ``'image'``,
|
|
||||||
``'voice'``, ``'file'``). Text items carry ``text``; media
|
|
||||||
items carry ``base64`` (may include a ``data:...;base64,``
|
|
||||||
prefix) and optionally ``name``.
|
|
||||||
"""
|
|
||||||
items: list[dict] = []
|
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
items.append({'type': 'text', 'text': msg.text})
|
content += msg.text
|
||||||
elif type(msg) is platform_message.Image:
|
return content
|
||||||
items.append({'type': 'image', 'base64': msg.base64 or ''})
|
|
||||||
elif type(msg) is platform_message.Voice:
|
|
||||||
items.append({'type': 'voice', 'base64': msg.base64 or ''})
|
|
||||||
elif type(msg) is platform_message.File:
|
|
||||||
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
|
|
||||||
return items
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
||||||
@@ -377,76 +362,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _join_text_components(items: list[dict]) -> str:
|
|
||||||
"""Concatenate ``text`` items in order, leaving media items alone."""
|
|
||||||
return ''.join(item['text'] for item in items if item.get('type') == 'text')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _iter_media_components(items: list[dict]):
|
|
||||||
"""Yield non-text items in order."""
|
|
||||||
for item in items:
|
|
||||||
if item.get('type') in {'image', 'voice', 'file'}:
|
|
||||||
yield item
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _send_media(
|
|
||||||
bot,
|
|
||||||
req_id: str,
|
|
||||||
item: dict,
|
|
||||||
) -> bool:
|
|
||||||
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
|
|
||||||
|
|
||||||
Returns True on success. Falls back to a no-op (with a warning log)
|
|
||||||
if the SDK does not yet implement ``upload_media`` /
|
|
||||||
``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
|
|
||||||
will keep working, just without image delivery.
|
|
||||||
"""
|
|
||||||
kind = item.get('type')
|
|
||||||
upload = getattr(bot, 'upload_media', None)
|
|
||||||
if upload is None:
|
|
||||||
return False
|
|
||||||
b64_text = item.get('base64') or ''
|
|
||||||
if not b64_text:
|
|
||||||
return False
|
|
||||||
if b64_text.startswith('data:') and ',' in b64_text:
|
|
||||||
b64_text = b64_text.split(',', 1)[1]
|
|
||||||
try:
|
|
||||||
data = base64.b64decode(b64_text, validate=False)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
if not data:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
media_id = getattr(upload_result, 'media_id', None) or (
|
|
||||||
isinstance(upload_result, dict) and upload_result.get('media_id')
|
|
||||||
)
|
|
||||||
if not media_id:
|
|
||||||
return False
|
|
||||||
reply_fn = {
|
|
||||||
'image': getattr(bot, 'reply_image', None),
|
|
||||||
'file': getattr(bot, 'reply_file', None),
|
|
||||||
'voice': getattr(bot, 'reply_voice', None),
|
|
||||||
}.get(kind)
|
|
||||||
if reply_fn is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
await reply_fn(req_id, media_id)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
items = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
text = self._join_text_components(items)
|
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
@@ -460,7 +382,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
chat_id = str(message_source.sender.id)
|
chat_id = str(message_source.sender.id)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(chat_id, text)
|
await self.bot.send_message(chat_id, content)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(
|
await self.logger.error(
|
||||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||||
@@ -474,15 +396,12 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
if text:
|
if req_id:
|
||||||
if req_id:
|
await self.bot.reply_text(req_id, content)
|
||||||
await self.bot.reply_text(req_id, text)
|
else:
|
||||||
else:
|
await self.bot.set_message(event.message_id, content)
|
||||||
await self.bot.set_message(event.message_id, text)
|
|
||||||
for item in self._iter_media_components(items):
|
|
||||||
await self._send_media(self.bot, req_id, item)
|
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, text)
|
await self.bot.set_message(event.message_id, content)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -492,8 +411,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
items = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
text = self._join_text_components(items)
|
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
# Synthetic events (e.g. button-click triggered form resume) have
|
# Synthetic events (e.g. button-click triggered form resume) have
|
||||||
@@ -502,7 +420,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# of the stream/reply path.
|
# of the stream/reply path.
|
||||||
spo = message_source.source_platform_object
|
spo = message_source.source_platform_object
|
||||||
if spo is None:
|
if spo is None:
|
||||||
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
|
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
||||||
|
|
||||||
msg_id = spo.message_id
|
msg_id = spo.message_id
|
||||||
|
|
||||||
@@ -534,7 +452,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data.get('actions', []) or [],
|
form_data.get('actions', []) or [],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback = text or '(人工输入)'
|
fallback = content or '(人工输入)'
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
@@ -545,22 +463,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return {'stream': False, 'form': True, 'fallback': True}
|
return {'stream': False, 'form': True, 'fallback': True}
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '')
|
req_id = event.get('req_id', '')
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, text)
|
await self.bot.reply_text(req_id, content)
|
||||||
if is_final:
|
|
||||||
event = message_source.source_platform_object
|
|
||||||
req_id = event.get('req_id', '')
|
|
||||||
for item in self._iter_media_components(items):
|
|
||||||
await self._send_media(self.bot, req_id, item)
|
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
else:
|
else:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
await self.bot.set_message(msg_id, text)
|
await self.bot.set_message(msg_id, content)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
@@ -714,9 +627,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
items = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
text = self._join_text_components(items)
|
await self.bot.send_message(target_id, content)
|
||||||
await self.bot.send_message(target_id, text)
|
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -701,13 +701,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
}
|
}
|
||||||
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
||||||
|
|
||||||
reconcile_timeout_seconds = max(
|
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
|
||||||
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
|
||||||
)
|
|
||||||
result = await runtime_handler.reconcile_plugin_installations(
|
|
||||||
tuple(self._known_desired_states.values()),
|
|
||||||
timeout=reconcile_timeout_seconds,
|
|
||||||
)
|
|
||||||
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
||||||
self._record_reconcile_failures(self._known_desired_states, result)
|
self._record_reconcile_failures(self._known_desired_states, result)
|
||||||
|
|
||||||
@@ -742,13 +736,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if state.binding.installation_uuid in all_states:
|
if state.binding.installation_uuid in all_states:
|
||||||
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
||||||
all_states[state.binding.installation_uuid] = state
|
all_states[state.binding.installation_uuid] = state
|
||||||
reconcile_timeout_seconds = max(
|
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
|
||||||
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
|
||||||
)
|
|
||||||
result = await runtime_handler.reconcile_plugin_installations(
|
|
||||||
tuple(all_states.values()),
|
|
||||||
timeout=reconcile_timeout_seconds,
|
|
||||||
)
|
|
||||||
await self._repair_reconcile_missing_artifacts(all_states, result)
|
await self._repair_reconcile_missing_artifacts(all_states, result)
|
||||||
self._record_reconcile_failures(all_states, result)
|
self._record_reconcile_failures(all_states, result)
|
||||||
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import traceback
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import sqlalchemy.dialects.postgresql
|
|
||||||
import sqlalchemy.dialects.sqlite
|
|
||||||
|
|
||||||
from langbot_plugin.runtime.io import handler
|
from langbot_plugin.runtime.io import handler
|
||||||
from langbot_plugin.runtime.io.connection import Connection
|
from langbot_plugin.runtime.io.connection import Connection
|
||||||
@@ -433,19 +431,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||||
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _legacy_binary_storage_key(
|
|
||||||
action_context: ActionContext,
|
|
||||||
*,
|
|
||||||
owner_type: str,
|
|
||||||
owner: str,
|
|
||||||
key: str,
|
|
||||||
) -> str:
|
|
||||||
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
|
|
||||||
|
|
||||||
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
|
|
||||||
return f'{owner_type}:{legacy_owner}:{key}'
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _binary_storage_key(
|
def _binary_storage_key(
|
||||||
cls,
|
cls,
|
||||||
@@ -911,82 +896,25 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
)
|
)
|
||||||
storage = result.first()
|
|
||||||
if storage is None:
|
|
||||||
legacy_key = self._legacy_binary_storage_key(
|
|
||||||
action_context,
|
|
||||||
owner_type=owner_type,
|
|
||||||
owner=owner,
|
|
||||||
key=key,
|
|
||||||
)
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
)
|
|
||||||
storage = result.first()
|
|
||||||
if storage is not None:
|
|
||||||
update_result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
.values(unique_key=unique_key, value=value)
|
|
||||||
)
|
|
||||||
if update_result.rowcount:
|
|
||||||
return handler.ActionResponse.success(data={})
|
|
||||||
canonical_update = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
.values(value=value)
|
|
||||||
)
|
|
||||||
if canonical_update.rowcount:
|
|
||||||
return handler.ActionResponse.success(data={})
|
|
||||||
storage = None
|
|
||||||
|
|
||||||
if storage is not None:
|
if result.first() is not None:
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
.values(value=value)
|
.values(value=value)
|
||||||
)
|
)
|
||||||
return handler.ActionResponse.success(data={})
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
||||||
insert = {
|
workspace_uuid=action_context.workspace_uuid,
|
||||||
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
unique_key=unique_key,
|
||||||
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
key=key,
|
||||||
}.get(dialect_name)
|
owner_type=owner_type,
|
||||||
if insert is None:
|
owner=owner,
|
||||||
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
value=value,
|
||||||
await self.ap.persistence_mgr.execute_async(
|
)
|
||||||
insert(persistence_bstorage.BinaryStorage)
|
|
||||||
.values(
|
|
||||||
workspace_uuid=action_context.workspace_uuid,
|
|
||||||
unique_key=unique_key,
|
|
||||||
key=key,
|
|
||||||
owner_type=owner_type,
|
|
||||||
owner=owner,
|
|
||||||
value=value,
|
|
||||||
)
|
)
|
||||||
.on_conflict_do_update(
|
|
||||||
index_elements=['workspace_uuid', 'unique_key'],
|
|
||||||
set_={'value': value},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={},
|
data={},
|
||||||
@@ -1018,29 +946,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
storage = result.first()
|
storage = result.first()
|
||||||
if storage is None:
|
|
||||||
legacy_key = self._legacy_binary_storage_key(
|
|
||||||
action_context,
|
|
||||||
owner_type=owner_type,
|
|
||||||
owner=owner,
|
|
||||||
key=key,
|
|
||||||
)
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
)
|
|
||||||
storage = result.first()
|
|
||||||
if storage is None:
|
|
||||||
retry_result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
|
||||||
)
|
|
||||||
storage = retry_result.first()
|
|
||||||
if storage is None:
|
if storage is None:
|
||||||
return handler.ActionResponse.error(
|
return handler.ActionResponse.error(
|
||||||
message=f'Storage with key {key} not found',
|
message=f'Storage with key {key} not found',
|
||||||
@@ -1076,19 +981,10 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
legacy_key = self._legacy_binary_storage_key(
|
|
||||||
action_context,
|
|
||||||
owner_type=owner_type,
|
|
||||||
owner=owner,
|
|
||||||
key=key,
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
|
||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
@@ -1116,7 +1012,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'keys': list(dict.fromkeys(result.scalars().all())),
|
'keys': result.scalars().all(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1677,15 +1573,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
async def reconcile_plugin_installations(
|
async def reconcile_plugin_installations(
|
||||||
self,
|
self,
|
||||||
installations: tuple[PluginInstallationDesiredState, ...],
|
installations: tuple[PluginInstallationDesiredState, ...],
|
||||||
*,
|
|
||||||
timeout: float = 300,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
request = ReconcilePluginInstallationsRequest(installations=installations)
|
request = ReconcilePluginInstallationsRequest(installations=installations)
|
||||||
with self.installation_scope(None):
|
with self.installation_scope(None):
|
||||||
return await self.call_action(
|
return await self.call_action(
|
||||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
timeout=timeout,
|
timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def apply_plugin_installation(
|
async def apply_plugin_installation(
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
|||||||
try:
|
try:
|
||||||
import pyseekdb
|
import pyseekdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||||
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
|
||||||
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
|
||||||
)
|
|
||||||
|
|
||||||
self._embedding_function = pyseekdb.get_default_embedding_function()
|
self._embedding_function = pyseekdb.get_default_embedding_function()
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
def __init__(self, ap: app.Application):
|
||||||
if not SEEKDB_AVAILABLE:
|
if not SEEKDB_AVAILABLE:
|
||||||
raise ImportError(
|
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||||
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
|
||||||
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||||
|
|||||||
@@ -181,11 +181,6 @@ vdb:
|
|||||||
host: localhost
|
host: localhost
|
||||||
port: 6333
|
port: 6333
|
||||||
api_key: ''
|
api_key: ''
|
||||||
# SeekDB is optional. Native/package installs need the `seekdb` extra:
|
|
||||||
# `uv sync --extra seekdb` (source) or
|
|
||||||
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
|
|
||||||
# The official Docker image already includes it.
|
|
||||||
# Embedded-mode platform support depends on the native pylibseekdb wheels.
|
|
||||||
seekdb:
|
seekdb:
|
||||||
mode: embedded # 'embedded' or 'server'
|
mode: embedded # 'embedded' or 'server'
|
||||||
# Embedded mode options:
|
# Embedded mode options:
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
sa.Column('key', sa.String(255), nullable=False),
|
sa.Column('key', sa.String(255), nullable=False),
|
||||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||||
sa.Column('owner', sa.String(255), nullable=False),
|
sa.Column('owner', sa.String(255), nullable=False),
|
||||||
sa.Column('value', sa.LargeBinary, nullable=False),
|
|
||||||
)
|
)
|
||||||
mcp_servers = _uuid_table(
|
mcp_servers = _uuid_table(
|
||||||
metadata,
|
metadata,
|
||||||
@@ -211,13 +210,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
||||||
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
binary_storages.insert().values(
|
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
||||||
unique_key='plugin:demo:key',
|
|
||||||
key='key',
|
|
||||||
owner_type='plugin',
|
|
||||||
owner='demo',
|
|
||||||
value=b'legacy-plugin-value',
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
||||||
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||||
|
|||||||
@@ -76,26 +76,6 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
|||||||
)
|
)
|
||||||
assert legacy_kb['collection_id'] == 'collection-1'
|
assert legacy_kb['collection_id'] == 'collection-1'
|
||||||
assert legacy_kb['legacy_vector_collection'] == 1
|
assert legacy_kb['legacy_vector_collection'] == 1
|
||||||
legacy_binary_storage = (
|
|
||||||
(
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
|
|
||||||
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
assert legacy_binary_storage == {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'unique_key': 'plugin:demo:key',
|
|
||||||
'key': 'key',
|
|
||||||
'owner_type': 'plugin',
|
|
||||||
'owner': 'demo',
|
|
||||||
'value': b'legacy-plugin-value',
|
|
||||||
}
|
|
||||||
assert (
|
assert (
|
||||||
await conn.scalar(
|
await conn.scalar(
|
||||||
sa.text(
|
sa.text(
|
||||||
@@ -229,8 +209,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
'INSERT INTO binary_storages '
|
'INSERT INTO binary_storages '
|
||||||
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
||||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': second_workspace_uuid},
|
{'workspace_uuid': second_workspace_uuid},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ import pytest
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
from langbot.pkg.persistence import alembic_runner
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
from .resource_migration_support import create_legacy_resource_schema
|
from .resource_migration_support import create_legacy_resource_schema
|
||||||
@@ -106,31 +105,3 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
|
||||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
|
||||||
real_open = os.open
|
|
||||||
transient_failures = 0
|
|
||||||
|
|
||||||
def transient_open(path, flags, *args, **kwargs):
|
|
||||||
nonlocal transient_failures
|
|
||||||
candidate = pathlib.Path(path)
|
|
||||||
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
|
||||||
transient_failures += 1
|
|
||||||
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
|
||||||
return real_open(path, flags, *args, **kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
|
||||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
||||||
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
|
||||||
|
|
||||||
await _manager(engine)._run_alembic_migrations()
|
|
||||||
|
|
||||||
assert transient_failures == 1
|
|
||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
|
||||||
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|||||||
@@ -179,17 +179,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
)
|
)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(schema.create_all)
|
await conn.run_sync(schema.create_all)
|
||||||
|
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
|
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
|
|
||||||
),
|
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||||
)
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
|
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
||||||
{'uuid': old_workspace_uuid},
|
{'uuid': old_workspace_uuid},
|
||||||
)
|
)
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||||
@@ -197,8 +193,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
await run_alembic_upgrade(engine, 'head')
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
||||||
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -415,45 +411,6 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
database_path = tmp_path / 'startup-foreign-keys.db'
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
|
||||||
try:
|
|
||||||
await _create_legacy_schema(engine)
|
|
||||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
|
|
||||||
application = type('Application', (), {})()
|
|
||||||
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
|
|
||||||
application.instance_config = type(
|
|
||||||
'InstanceConfig',
|
|
||||||
(),
|
|
||||||
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
|
|
||||||
)()
|
|
||||||
manager = PersistenceManager(application)
|
|
||||||
|
|
||||||
await manager.initialize()
|
|
||||||
try:
|
|
||||||
async with manager.get_db_engine().connect() as conn:
|
|
||||||
workspace = (
|
|
||||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
|
||||||
)
|
|
||||||
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
|
|
||||||
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
|
|
||||||
|
|
||||||
assert workspace['created_by_account_uuid'] == membership['account_uuid']
|
|
||||||
assert membership['role'] == 'owner'
|
|
||||||
assert membership['status'] == 'active'
|
|
||||||
assert foreign_keys == 1
|
|
||||||
finally:
|
|
||||||
await manager.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -468,7 +425,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
assert instance_uuid
|
assert instance_uuid
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
@@ -476,7 +433,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
)
|
)
|
||||||
@@ -485,16 +442,12 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||||
assert (
|
assert await conn.scalar(
|
||||||
await conn.scalar(
|
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
) == expected_uuid
|
||||||
)
|
assert await conn.scalar(
|
||||||
== expected_uuid
|
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
||||||
)
|
) == expected_uuid
|
||||||
assert (
|
|
||||||
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
|
||||||
== expected_uuid
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -2163,38 +2163,25 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_client_execute(spec):
|
async def fake_execute_tool(parameters, q):
|
||||||
cmd = spec.cmd
|
calls.append(parameters['command'])
|
||||||
calls.append(cmd)
|
if 'os.scandir' in parameters['command']:
|
||||||
if 'os.scandir' in cmd:
|
return {
|
||||||
return BoxExecutionResult(
|
'ok': True,
|
||||||
session_id='s',
|
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||||
backend_name='test',
|
'stderr': '',
|
||||||
status=BoxExecutionStatus.COMPLETED,
|
}
|
||||||
exit_code=0,
|
|
||||||
stdout='[{"name": "out.png", "b64": "QUJD"}]',
|
|
||||||
duration_ms=10,
|
|
||||||
)
|
|
||||||
# the rm -rf cleanup call
|
# the rm -rf cleanup call
|
||||||
return BoxExecutionResult(
|
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||||
session_id='s',
|
|
||||||
backend_name='test',
|
|
||||||
status=BoxExecutionStatus.COMPLETED,
|
|
||||||
exit_code=0,
|
|
||||||
stdout='',
|
|
||||||
duration_ms=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
|
||||||
|
|
||||||
attachments = await service.collect_outbound_attachments(query)
|
attachments = await service.collect_outbound_attachments(query)
|
||||||
assert len(attachments) == 1
|
assert len(attachments) == 1
|
||||||
assert attachments[0]['type'] == 'Image'
|
assert attachments[0]['type'] == 'Image'
|
||||||
assert attachments[0]['name'] == 'out.png'
|
assert attachments[0]['name'] == 'out.png'
|
||||||
# cleanup (rm -rf) must have been issued after a successful collection
|
# cleanup (rm -rf) must have been issued after a successful collection
|
||||||
service.execute_tool.assert_awaited_once()
|
assert any('rm -rf' in c for c in calls)
|
||||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_collect_outbound_empty_still_clears(self):
|
async def test_collect_outbound_empty_still_clears(self):
|
||||||
@@ -2206,33 +2193,16 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_client_execute(spec):
|
async def fake_execute_tool(parameters, q):
|
||||||
cmd = spec.cmd
|
calls.append(parameters['command'])
|
||||||
calls.append(cmd)
|
if 'os.scandir' in parameters['command']:
|
||||||
if 'os.scandir' in cmd:
|
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||||
return BoxExecutionResult(
|
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||||
session_id='s',
|
|
||||||
backend_name='test',
|
|
||||||
status=BoxExecutionStatus.COMPLETED,
|
|
||||||
exit_code=0,
|
|
||||||
stdout='[]',
|
|
||||||
duration_ms=10,
|
|
||||||
)
|
|
||||||
return BoxExecutionResult(
|
|
||||||
session_id='s',
|
|
||||||
backend_name='test',
|
|
||||||
status=BoxExecutionStatus.COMPLETED,
|
|
||||||
exit_code=0,
|
|
||||||
stdout='',
|
|
||||||
duration_ms=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
|
||||||
assert await service.collect_outbound_attachments(query) == []
|
assert await service.collect_outbound_attachments(query) == []
|
||||||
# cleanup (rm -rf) is issued unconditionally now
|
# cleanup (rm -rf) is issued unconditionally now
|
||||||
service.execute_tool.assert_awaited_once()
|
assert any('rm -rf' in c for c in calls)
|
||||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_passthrough_noop_when_unavailable(self):
|
async def test_passthrough_noop_when_unavailable(self):
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
|
|
||||||
from langbot.pkg.command import operator
|
from langbot.pkg.command import operator
|
||||||
from langbot.pkg.command.cmdmgr import CommandManager
|
from langbot.pkg.command.cmdmgr import CommandManager
|
||||||
from langbot.pkg.api.http.context import ExecutionContext
|
|
||||||
from tests.factories import FakeApp, command_query
|
from tests.factories import FakeApp, command_query
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
@@ -394,32 +393,6 @@ class TestCommandManagerInternalExecute:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].text == 'plugin response'
|
assert results[0].text == 'plugin response'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execute_selects_workspace_with_trusted_context(self):
|
|
||||||
"""Plugin command discovery receives the typed runtime scope."""
|
|
||||||
|
|
||||||
fake_app = FakeApp()
|
|
||||||
mgr = CommandManager(fake_app)
|
|
||||||
mgr.cmd_list = []
|
|
||||||
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
|
||||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
|
||||||
|
|
||||||
ctx = self._create_context(command='help')
|
|
||||||
ctx.instance_uuid = 'instance-a'
|
|
||||||
ctx.workspace_uuid = 'workspace-a'
|
|
||||||
ctx.placement_generation = 4
|
|
||||||
ctx.query_uuid = 'query-a'
|
|
||||||
|
|
||||||
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
|
||||||
pass
|
|
||||||
|
|
||||||
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
|
||||||
assert isinstance(selected, ExecutionContext)
|
|
||||||
assert selected.instance_uuid == 'instance-a'
|
|
||||||
assert selected.workspace_uuid == 'workspace-a'
|
|
||||||
assert selected.placement_generation == 4
|
|
||||||
assert selected.query_uuid == 'query-a'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_with_bound_plugins(self):
|
async def test_execute_with_bound_plugins(self):
|
||||||
"""_execute passes bound_plugins to plugin connector."""
|
"""_execute passes bound_plugins to plugin connector."""
|
||||||
|
|||||||
@@ -144,39 +144,3 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
|||||||
assert stats['models']['providers'] == 1
|
assert stats['models']['providers'] == 1
|
||||||
assert stats['runtimes']['plugin_installations'] == 1
|
assert stats['runtimes']['plugin_installations'] == 1
|
||||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
|
||||||
app = Application()
|
|
||||||
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
|
||||||
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
|
||||||
|
|
||||||
task = app._start_plugin_runtime_initialization()
|
|
||||||
await task
|
|
||||||
|
|
||||||
app.plugin_connector.initialize.assert_awaited_once_with()
|
|
||||||
app.task_mgr.create_task.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
|
||||||
app = Application()
|
|
||||||
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
|
||||||
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
|
||||||
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
|
||||||
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
|
||||||
app.platform_mgr = None
|
|
||||||
app.tool_mgr = None
|
|
||||||
app.model_mgr = None
|
|
||||||
app.box_service = None
|
|
||||||
app.plugin_connector = None
|
|
||||||
app.telemetry = None
|
|
||||||
app.vector_db_mgr = None
|
|
||||||
app.storage_mgr = None
|
|
||||||
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
|
||||||
app.deployment = None
|
|
||||||
|
|
||||||
await app.shutdown()
|
|
||||||
|
|
||||||
assert app._plugin_runtime_initialization_task.cancelled()
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import langbot.pkg.core.app # noqa: F401
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
|
|
||||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
|
|
||||||
|
|
||||||
|
|
||||||
class Logger:
|
|
||||||
def __init__(self):
|
|
||||||
self.warnings = []
|
|
||||||
self.errors = []
|
|
||||||
|
|
||||||
async def warning(self, message):
|
|
||||||
self.warnings.append(message)
|
|
||||||
|
|
||||||
async def error(self, message):
|
|
||||||
self.errors.append(message)
|
|
||||||
|
|
||||||
async def info(self, message):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class UploadClient(WecomBotWsClient):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(bot_id='bot', secret='secret', logger=Logger())
|
|
||||||
self.frames = []
|
|
||||||
|
|
||||||
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
|
|
||||||
self.frames.append((cmd, body))
|
|
||||||
if cmd == 'aibot_upload_media_init':
|
|
||||||
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
|
|
||||||
if cmd == 'aibot_upload_media_finish':
|
|
||||||
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
|
|
||||||
return {'errcode': 0}
|
|
||||||
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
async def upload_media(self, data, filename='attachment', media_type='file'):
|
|
||||||
self.calls.append(('upload_media', media_type, filename, data))
|
|
||||||
return {'media_id': 'media-1'}
|
|
||||||
|
|
||||||
async def reply_text(self, req_id, content):
|
|
||||||
self.calls.append(('reply_text', req_id, content))
|
|
||||||
|
|
||||||
async def reply_image(self, req_id, media_id):
|
|
||||||
self.calls.append(('reply_image', req_id, media_id))
|
|
||||||
|
|
||||||
async def send_message(self, target_id, content):
|
|
||||||
self.calls.append(('send_message', target_id, content))
|
|
||||||
|
|
||||||
|
|
||||||
def make_adapter(bot):
|
|
||||||
return WecomBotAdapter.model_construct(
|
|
||||||
bot=bot,
|
|
||||||
config={'enable-webhook': False},
|
|
||||||
logger=Logger(),
|
|
||||||
message_converter=WecomBotMessageConverter(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ws_client_upload_media_uses_chunk_protocol():
|
|
||||||
client = UploadClient()
|
|
||||||
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
|
|
||||||
|
|
||||||
upload_result = await client.upload_media(data, 'image.png', media_type='image')
|
|
||||||
|
|
||||||
assert upload_result['media_id'] == 'media-1'
|
|
||||||
assert [cmd for cmd, _ in client.frames] == [
|
|
||||||
'aibot_upload_media_init',
|
|
||||||
'aibot_upload_media_chunk',
|
|
||||||
'aibot_upload_media_chunk',
|
|
||||||
'aibot_upload_media_finish',
|
|
||||||
]
|
|
||||||
init_body = client.frames[0][1]
|
|
||||||
assert init_body['type'] == 'image'
|
|
||||||
assert init_body['filename'] == 'image.png'
|
|
||||||
assert init_body['total_size'] == len(data)
|
|
||||||
assert init_body['total_chunks'] == 2
|
|
||||||
assert client.frames[1][1]['chunk_index'] == 0
|
|
||||||
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
|
|
||||||
assert client.frames[2][1]['chunk_index'] == 1
|
|
||||||
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reply_message_uploads_and_replies_image_media():
|
|
||||||
bot = Bot()
|
|
||||||
adapter = make_adapter(bot)
|
|
||||||
png_data = b'\x89PNG\r\n\x1a\nimage'
|
|
||||||
image_b64 = base64.b64encode(png_data).decode('utf-8')
|
|
||||||
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
|
|
||||||
|
|
||||||
items = await WecomBotMessageConverter.yiri2target(chain)
|
|
||||||
await adapter._send_media(bot, 'req-1', items[0])
|
|
||||||
|
|
||||||
assert bot.calls == [
|
|
||||||
('upload_media', 'image', 'attachment.image', png_data),
|
|
||||||
('reply_image', 'req-1', 'media-1'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_sends_text_and_skips_proactive_image():
|
|
||||||
bot = Bot()
|
|
||||||
adapter = make_adapter(bot)
|
|
||||||
jpg_data = b'\xff\xd8\xffimage'
|
|
||||||
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
|
|
||||||
chain = platform_message.MessageChain(
|
|
||||||
[
|
|
||||||
platform_message.Plain(text='before'),
|
|
||||||
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
|
|
||||||
platform_message.Plain(text='after'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await adapter.send_message('group', 'chat-1', chain)
|
|
||||||
|
|
||||||
assert bot.calls == [
|
|
||||||
('send_message', 'chat-1', 'beforeafter'),
|
|
||||||
]
|
|
||||||
@@ -107,19 +107,6 @@ def shared_connector(
|
|||||||
return connector
|
return connector
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
|
||||||
binding = execution_binding("workspace-a")
|
|
||||||
setting = plugin_setting("01", "a" * 64)
|
|
||||||
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
|
||||||
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
|
||||||
connector.handler = runtime_handler()
|
|
||||||
|
|
||||||
await connector._prepare_connected_runtime()
|
|
||||||
|
|
||||||
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||||
binding_a = execution_binding('workspace-a')
|
binding_a = execution_binding('workspace-a')
|
||||||
@@ -163,7 +150,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
|||||||
|
|
||||||
assert connector._workspace_installations == {}
|
assert connector._workspace_installations == {}
|
||||||
assert connector._known_desired_states == {}
|
assert connector._known_desired_states == {}
|
||||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -81,18 +81,6 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
|||||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
|
||||||
runtime_handler = make_handler(SimpleNamespace())
|
|
||||||
runtime_handler.call_action = AsyncMock(return_value={})
|
|
||||||
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
|
||||||
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
|
||||||
|
|
||||||
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
|
||||||
|
|
||||||
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandlerQueryVariables:
|
class TestHandlerQueryVariables:
|
||||||
"""Tests for handler query variable logic."""
|
"""Tests for handler query variable logic."""
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ class TestSetBinaryStorage:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mock_app.persistence_mgr = Mock()
|
mock_app.persistence_mgr = Mock()
|
||||||
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
|
||||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||||
mock_app.logger = Mock()
|
mock_app.logger = Mock()
|
||||||
return mock_app
|
return mock_app
|
||||||
@@ -271,8 +270,8 @@ class TestSetBinaryStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
assert app.persistence_mgr.execute_async.await_count == 3
|
assert app.persistence_mgr.execute_async.await_count == 2
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||||
assert insert_params['unique_key'] == canonical_binary_key(
|
assert insert_params['unique_key'] == canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
@@ -302,69 +301,6 @@ class TestSetBinaryStorage:
|
|||||||
assert expected_key in update_params.values()
|
assert expected_key in update_params.values()
|
||||||
assert update_params['value'] == b'new'
|
assert update_params['value'] == b'new'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_adopts_legacy_storage_before_updating(self, app):
|
|
||||||
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
|
||||||
adopted = SimpleNamespace(rowcount=1)
|
|
||||||
app.persistence_mgr.execute_async.side_effect = [
|
|
||||||
make_result(),
|
|
||||||
make_result(legacy_storage),
|
|
||||||
adopted,
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
assert app.persistence_mgr.execute_async.await_count == 3
|
|
||||||
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
|
||||||
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
|
||||||
assert expected_key in adoption_params.values()
|
|
||||||
assert adoption_params['value'] == b'new'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
|
||||||
lost_race = SimpleNamespace(rowcount=0)
|
|
||||||
canonical_winner = SimpleNamespace(rowcount=1)
|
|
||||||
app.persistence_mgr.execute_async.side_effect = [
|
|
||||||
make_result(),
|
|
||||||
make_result(legacy_storage),
|
|
||||||
lost_race,
|
|
||||||
canonical_winner,
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
assert app.persistence_mgr.execute_async.await_count == 4
|
|
||||||
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
|
||||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
|
||||||
assert winner_update['value'] == b'new'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
|
||||||
lost_race = SimpleNamespace(rowcount=0)
|
|
||||||
app.persistence_mgr.execute_async.side_effect = [
|
|
||||||
make_result(),
|
|
||||||
make_result(legacy_storage),
|
|
||||||
lost_race,
|
|
||||||
SimpleNamespace(rowcount=0),
|
|
||||||
make_result(),
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
assert app.persistence_mgr.execute_async.await_count == 5
|
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
|
||||||
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
|
||||||
assert insert_params['value'] == b'new'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||||
@@ -589,46 +525,6 @@ class TestGetBinaryStorage:
|
|||||||
in statement_params.values()
|
in statement_params.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
legacy_storage = SimpleNamespace(
|
|
||||||
unique_key='plugin:test-author/test-plugin:test-key',
|
|
||||||
value=b'legacy bytes',
|
|
||||||
)
|
|
||||||
app.persistence_mgr.execute_async.side_effect = [
|
|
||||||
make_result(),
|
|
||||||
make_result(legacy_storage),
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
|
||||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
|
||||||
assert app.persistence_mgr.execute_async.await_count == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
|
||||||
app.persistence_mgr.execute_async.side_effect = [
|
|
||||||
make_result(),
|
|
||||||
make_result(),
|
|
||||||
make_result(canonical_storage),
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
|
||||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
|
||||||
assert app.persistence_mgr.execute_async.await_count == 3
|
|
||||||
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
|
||||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_error_when_not_found(self, app):
|
async def test_returns_error_when_not_found(self, app):
|
||||||
"""Missing binary storage rows return an error response."""
|
"""Missing binary storage rows return an error response."""
|
||||||
@@ -671,47 +567,21 @@ class TestDeleteAndListBinaryStorage:
|
|||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
flat_values = [
|
assert 'workspace-a' in statement_params.values()
|
||||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
|
||||||
]
|
|
||||||
assert 'workspace-a' in flat_values
|
|
||||||
assert (
|
assert (
|
||||||
canonical_binary_key(
|
canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
'test-author/test-plugin',
|
'test-author/test-plugin',
|
||||||
'test-key',
|
'test-key',
|
||||||
)
|
)
|
||||||
in flat_values
|
in statement_params.values()
|
||||||
)
|
)
|
||||||
assert 'forged-owner' not in flat_values
|
assert 'forged-owner' not in statement_params.values()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
|
||||||
runtime_handler = make_handler(app)
|
|
||||||
|
|
||||||
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
|
||||||
{
|
|
||||||
'key': 'test-key',
|
|
||||||
'owner_type': 'plugin',
|
|
||||||
'owner': 'forged-owner',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.code == 0
|
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
|
||||||
values = [
|
|
||||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
|
||||||
]
|
|
||||||
assert 'workspace-a' in values
|
|
||||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
|
||||||
assert 'plugin:test-author/test-plugin:test-key' in values
|
|
||||||
assert 'test-author/test-plugin' in values
|
|
||||||
assert 'forged-owner' not in values
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||||
result = Mock()
|
result = Mock()
|
||||||
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
result.scalars.return_value.all.return_value = ['first', 'second']
|
||||||
app.persistence_mgr.execute_async.return_value = result
|
app.persistence_mgr.execute_async.return_value = result
|
||||||
runtime_handler = make_handler(app)
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
|
|
||||||
project_root = Path(__file__).resolve().parents[2]
|
|
||||||
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
|
|
||||||
pyproject = tomllib.load(pyproject_file)
|
|
||||||
|
|
||||||
project = pyproject['project']
|
|
||||||
base_dependencies = project['dependencies']
|
|
||||||
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
|
|
||||||
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.utils.import_isolation import isolated_sys_modules
|
|
||||||
|
|
||||||
|
|
||||||
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
|
|
||||||
|
|
||||||
|
|
||||||
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
|
|
||||||
module_name = 'langbot.pkg.vector.vdbs.seekdb'
|
|
||||||
|
|
||||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
|
||||||
seekdb_module = importlib.import_module(module_name)
|
|
||||||
|
|
||||||
assert seekdb_module.SEEKDB_AVAILABLE is False
|
|
||||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
|
||||||
seekdb_module.SeekDBVectorDatabase(MagicMock())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
|
|
||||||
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
|
|
||||||
|
|
||||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
|
||||||
seekdb_embedding_module = importlib.import_module(module_name)
|
|
||||||
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
|
|
||||||
|
|
||||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
|
||||||
await requester.initialize()
|
|
||||||
@@ -9,10 +9,10 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
||||||
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
|
|
||||||
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
|
||||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||||
|
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1018,7 +1018,7 @@ name = "cuda-bindings"
|
|||||||
version = "13.3.1"
|
version = "13.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-pathfinder" },
|
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
||||||
@@ -1051,34 +1051,34 @@ wheels = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
cudart = [
|
cudart = [
|
||||||
{ name = "nvidia-cuda-runtime" },
|
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufft = [
|
cufft = [
|
||||||
{ name = "nvidia-cufft" },
|
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufile = [
|
cufile = [
|
||||||
{ name = "nvidia-cufile" },
|
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cupti = [
|
cupti = [
|
||||||
{ name = "nvidia-cuda-cupti" },
|
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
curand = [
|
curand = [
|
||||||
{ name = "nvidia-curand" },
|
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusolver = [
|
cusolver = [
|
||||||
{ name = "nvidia-cusolver" },
|
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusparse = [
|
cusparse = [
|
||||||
{ name = "nvidia-cusparse" },
|
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvjitlink = [
|
nvjitlink = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvrtc = [
|
nvrtc = [
|
||||||
{ name = "nvidia-cuda-nvrtc" },
|
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvtx = [
|
nvtx = [
|
||||||
{ name = "nvidia-nvtx" },
|
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.8"
|
version = "4.10.7"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2063,6 +2063,7 @@ dependencies = [
|
|||||||
{ name = "pymilvus" },
|
{ name = "pymilvus" },
|
||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
{ name = "pypdf2" },
|
{ name = "pypdf2" },
|
||||||
|
{ name = "pyseekdb" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "python-socks" },
|
{ name = "python-socks" },
|
||||||
@@ -2088,11 +2089,6 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
seekdb = [
|
|
||||||
{ name = "pyseekdb" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "moto" },
|
{ name = "moto" },
|
||||||
@@ -2129,7 +2125,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2157,7 +2153,7 @@ requires-dist = [
|
|||||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
||||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||||
@@ -2182,7 +2178,6 @@ requires-dist = [
|
|||||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "websockets", specifier = ">=15.0.1" },
|
{ name = "websockets", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
provides-extras = ["seekdb"]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -2196,8 +2191,8 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.5"
|
version = "0.5.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19#555a58e5db3de28e977b08dd4cd116b332848a19" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -2217,10 +2212,6 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain"
|
name = "langchain"
|
||||||
@@ -3247,7 +3238,7 @@ name = "nvidia-cublas"
|
|||||||
version = "13.1.1.3"
|
version = "13.1.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cuda-nvrtc" },
|
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||||
@@ -3286,7 +3277,7 @@ name = "nvidia-cudnn-cu13"
|
|||||||
version = "9.20.0.48"
|
version = "9.20.0.48"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
||||||
@@ -3298,7 +3289,7 @@ name = "nvidia-cufft"
|
|||||||
version = "12.0.0.61"
|
version = "12.0.0.61"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||||
@@ -3328,9 +3319,9 @@ name = "nvidia-cusolver"
|
|||||||
version = "12.0.4.66"
|
version = "12.0.4.66"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-cusparse" },
|
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||||
@@ -3342,7 +3333,7 @@ name = "nvidia-cusparse"
|
|||||||
version = "12.6.3.3"
|
version = "12.6.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||||
@@ -5172,10 +5163,10 @@ name = "scikit-learn"
|
|||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib" },
|
{ name = "joblib", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "threadpoolctl" },
|
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5222,7 +5213,7 @@ name = "scipy"
|
|||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5293,14 +5284,14 @@ name = "sentence-transformers"
|
|||||||
version = "5.2.3"
|
version = "5.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scikit-learn" },
|
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "torch" },
|
{ name = "torch", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "transformers" },
|
{ name = "transformers", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5673,21 +5664,21 @@ name = "torch"
|
|||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
|
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
|
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "filelock" },
|
{ name = "filelock", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "fsspec" },
|
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "networkx" },
|
{ name = "networkx", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "setuptools" },
|
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "sympy" },
|
{ name = "sympy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "triton", marker = "sys_platform == 'linux'" },
|
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
|
||||||
@@ -5729,15 +5720,15 @@ name = "transformers"
|
|||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "regex" },
|
{ name = "regex", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "safetensors" },
|
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "typer" },
|
{ name = "typer", marker = "python_full_version >= '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5998,9 +5989,9 @@ name = "valkey-glide"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio", marker = "sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
Reference in New Issue
Block a user