Merge origin/main into feature/chatmail-channel
Resolved conflicts in: - pkg/channels/manager.go: Added Chatmail, VK, and TeamsWebhook channel initialization - pkg/config/config.go: Added ChatmailConfig, VKConfig, and TeamsWebhookConfig types Both feature branches' changes have been preserved.
This commit is contained in:
commit
7bed1ff298
245 changed files with 28856 additions and 2165 deletions
6
.github/workflows/create_dmg.yml
vendored
6
.github/workflows/create_dmg.yml
vendored
|
|
@ -54,9 +54,9 @@ jobs:
|
|||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||
"build/PicoClaw Launcher.app"
|
||||
|
||||
# 6. 上传文件到 GitHub Artifacts (供你下载)
|
||||
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: macos-dmg-${{ matrix.arch }}
|
||||
path: dist/*.dmg
|
||||
path: dist/*.dmg
|
||||
|
|
|
|||
7
.github/workflows/pr.yml
vendored
7
.github/workflows/pr.yml
vendored
|
|
@ -41,10 +41,11 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
|
||||
- name: Run Govulncheck
|
||||
uses: golang/govulncheck-action@v1
|
||||
with:
|
||||
go-package: ./...
|
||||
run: govulncheck -C . -format text ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -67,3 +67,5 @@ web/backend/dist/*
|
|||
.claude/
|
||||
|
||||
docker/data
|
||||
|
||||
.omc/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ linters:
|
|||
- exhaustruct
|
||||
- funcorder
|
||||
- gochecknoglobals
|
||||
- gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting)
|
||||
- godot
|
||||
- intrange
|
||||
- ireturn
|
||||
|
|
|
|||
55
Makefile
55
Makefile
|
|
@ -5,6 +5,7 @@ BINARY_NAME=picoclaw
|
|||
BUILD_DIR=build
|
||||
CMD_DIR=cmd/$(BINARY_NAME)
|
||||
MAIN_GO=$(CMD_DIR)/main.go
|
||||
EXT=
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
|
|
@ -69,9 +70,11 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
|
|||
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
|
||||
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
||||
|
||||
LNCMD=ln -sf
|
||||
|
||||
# OS detection
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
UNAME_S?=$(shell uname -s)
|
||||
UNAME_M?=$(shell uname -m)
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
|
|
@ -103,7 +106,20 @@ else ifeq ($(UNAME_S),Darwin)
|
|||
endif
|
||||
else
|
||||
PLATFORM=$(UNAME_S)
|
||||
ARCH=$(UNAME_M)
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH?=amd64
|
||||
else
|
||||
ARCH?=$(UNAME_M)
|
||||
endif
|
||||
# Detect Windows (Git Bash / MSYS2)
|
||||
IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no)))
|
||||
ifeq ($(IS_WINDOWS),yes)
|
||||
EXT=.exe
|
||||
LNCMD=cp
|
||||
else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows
|
||||
EXT=.exe
|
||||
endif
|
||||
|
||||
endif
|
||||
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
|
|
@ -120,23 +136,23 @@ generate:
|
|||
|
||||
## build: Build the picoclaw binary for current platform
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
||||
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
|
||||
|
||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
|
||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
||||
WEB_GO='$(WEB_GO)' \
|
||||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||
LDFLAGS='$(LDFLAGS)'
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
||||
|
||||
build-launcher-frontend:
|
||||
@$(MAKE) -C web build-frontend
|
||||
|
|
@ -333,6 +349,25 @@ build-macos-app:build-launcher
|
|||
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
|
||||
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||
|
||||
## mem: Build membench, download LOCOMO data (if needed), run benchmark, and show results
|
||||
mem:
|
||||
@echo "Building membench..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build -o $(BUILD_DIR)/membench ./cmd/membench
|
||||
@echo "Build complete: $(BUILD_DIR)/membench"
|
||||
@if [ ! -f $(BUILD_DIR)/memdata/locomo10.json ]; then \
|
||||
echo "Downloading LOCOMO dataset..."; \
|
||||
mkdir -p $(BUILD_DIR)/memdata; \
|
||||
curl -sfL "https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json" \
|
||||
-o $(BUILD_DIR)/memdata/locomo10.json && [ -s $(BUILD_DIR)/memdata/locomo10.json ] || { echo "Error: LOCOMO download failed"; exit 1; }; \
|
||||
echo "Download complete"; \
|
||||
else \
|
||||
echo "LOCOMO dataset already exists, skipping download"; \
|
||||
fi
|
||||
@echo "Running benchmark..."
|
||||
@rm -rf $(BUILD_DIR)/memout
|
||||
@$(BUILD_DIR)/membench run --data $(BUILD_DIR)/memdata --out $(BUILD_DIR)/memout --budget 4000
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "picoclaw Makefile"
|
||||
|
|
|
|||
27
README.fr.md
27
README.fr.md
|
|
@ -306,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p
|
|||
|
||||
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
||||
|
||||
**Option 1 : Termux (disponible maintenant)**
|
||||
**Option 1 : Installation APK**
|
||||
|
||||
Aperçu :
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
|
||||
|
||||
**Option 2 : Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
||||
|
||||
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
|
||||
2. Exécutez les commandes suivantes :
|
||||
|
|
@ -323,13 +341,6 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Option 2 : Installation APK**
|
||||
|
||||
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
||||
|
||||
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
||||
|
||||
**1. Initialiser**
|
||||
|
|
|
|||
27
README.id.md
27
README.id.md
|
|
@ -303,7 +303,25 @@ Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io
|
|||
|
||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||
|
||||
**Opsi 1: Termux (tersedia sekarang)**
|
||||
**Opsi 1: Instal APK**
|
||||
|
||||
Pratinjau:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
|
||||
|
||||
**Opsi 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||
|
||||
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||
2. Jalankan perintah berikut:
|
||||
|
|
@ -320,13 +338,6 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opsi 2: Instal APK**
|
||||
|
||||
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||
|
||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||
|
||||
**1. Inisialisasi**
|
||||
|
|
|
|||
27
README.it.md
27
README.it.md
|
|
@ -303,7 +303,25 @@ Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.
|
|||
|
||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||
|
||||
**Opzione 1: Termux (disponibile ora)**
|
||||
**Opzione 1: Installazione APK**
|
||||
|
||||
Anteprima:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
|
||||
|
||||
**Opzione 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||
|
||||
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
||||
2. Esegui i seguenti comandi:
|
||||
|
|
@ -320,13 +338,6 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opzione 2: Installazione APK**
|
||||
|
||||
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||
|
||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||
|
||||
**1. Inizializza**
|
||||
|
|
|
|||
27
README.ja.md
27
README.ja.md
|
|
@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i
|
|||
|
||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
||||
|
||||
**オプション 1: Termux(現在利用可能)**
|
||||
**オプション 1: APK インストール**
|
||||
|
||||
プレビュー:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
|
||||
|
||||
**オプション 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
||||
|
||||
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
|
||||
2. 以下のコマンドを実行:
|
||||
|
|
@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**オプション 2: APK インストール**
|
||||
|
||||
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
||||
|
||||
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||
|
||||
**1. 初期化**
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -303,7 +303,25 @@ For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io)
|
|||
|
||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
||||
|
||||
**Option 1: Termux (available now)**
|
||||
**Option 1: APK Install**
|
||||
|
||||
Preview:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
|
||||
|
||||
**Option 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||
|
||||
1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
|
||||
2. Run the following commands:
|
||||
|
|
@ -320,13 +338,6 @@ Then follow the Terminal Launcher section below to complete configuration.
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Option 2: APK Install**
|
||||
|
||||
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||
|
||||
For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
|
||||
|
||||
**1. Initialize**
|
||||
|
|
@ -443,7 +454,7 @@ For full provider configuration details, see [Providers & Models](docs/providers
|
|||
|
||||
## 💬 Channels (Chat Apps)
|
||||
|
||||
Talk to your PicoClaw through 17+ messaging platforms:
|
||||
Talk to your PicoClaw through 18+ messaging platforms:
|
||||
|
||||
| Channel | Setup | Protocol | Docs |
|
||||
|---------|-------|----------|------|
|
||||
|
|
@ -458,6 +469,7 @@ Talk to your PicoClaw through 17+ messaging platforms:
|
|||
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
|
||||
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
|
||||
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||
|
|
|
|||
27
README.my.md
27
README.my.md
|
|
@ -300,7 +300,25 @@ Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw
|
|||
|
||||
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
|
||||
|
||||
**Pilihan 1: Termux (tersedia sekarang)**
|
||||
**Pilihan 1: Pasang APK**
|
||||
|
||||
Pratonton:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
|
||||
|
||||
**Pilihan 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
|
||||
|
||||
1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||
2. Jalankan arahan berikut:
|
||||
|
|
@ -317,13 +335,6 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
|
||||
|
||||
**Pilihan 2: Pasang APK**
|
||||
|
||||
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
|
||||
|
||||
<details>
|
||||
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
|
||||
|
||||
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
|
||||
|
||||
**1. Mulakan**
|
||||
|
|
|
|||
|
|
@ -303,7 +303,25 @@ Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoc
|
|||
|
||||
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
||||
|
||||
**Opção 1: Termux (disponível agora)**
|
||||
**Opção 1: Instalação via APK**
|
||||
|
||||
Pré-visualização:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
|
||||
|
||||
**Opção 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
||||
|
||||
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
|
||||
2. Execute os seguintes comandos:
|
||||
|
|
@ -320,13 +338,6 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opção 2: Instalação via APK**
|
||||
|
||||
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
||||
|
||||
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
||||
|
||||
**1. Inicializar**
|
||||
|
|
|
|||
27
README.vi.md
27
README.vi.md
|
|
@ -303,7 +303,25 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch
|
|||
|
||||
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
||||
|
||||
**Tùy chọn 1: Termux (có sẵn ngay)**
|
||||
**Tùy chọn 1: Cài đặt APK**
|
||||
|
||||
Xem trước:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
|
||||
|
||||
**Tùy chọn 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
||||
|
||||
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
|
||||
2. Chạy các lệnh sau:
|
||||
|
|
@ -320,13 +338,6 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Tùy chọn 2: Cài đặt APK**
|
||||
|
||||
Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
||||
|
||||
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
||||
|
||||
**1. Khởi tạo**
|
||||
|
|
|
|||
30
README.zh.md
30
README.zh.md
|
|
@ -303,7 +303,25 @@ picoclaw-launcher-tui
|
|||
|
||||
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
||||
|
||||
**方式一:Termux(现已可用)**
|
||||
**方式一:APK 安装**
|
||||
|
||||
预览:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
|
||||
|
||||
**方式二:Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
||||
|
||||
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
|
||||
2. 执行以下命令:
|
||||
|
|
@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布
|
|||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**方式二:APK 安装**
|
||||
|
||||
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
||||
|
||||
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||
|
||||
**1. 初始化**
|
||||
|
|
@ -437,7 +448,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
|||
|
||||
## 💬 Channels(聊天应用)
|
||||
|
||||
通过 17+ 消息平台与你的 PicoClaw 对话:
|
||||
通过 18+ 消息平台与你的 PicoClaw 对话:
|
||||
|
||||
| Channel | 配置难度 | 协议 | 文档 |
|
||||
|---------|----------|------|------|
|
||||
|
|
@ -452,6 +463,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
|||
| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
|
||||
| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) |
|
||||
| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) |
|
||||
| **VK** | 简单(群组 token) | Long Poll | [指南](docs/channels/vk/README.md) |
|
||||
| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
|
||||
| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |
|
||||
|
|
|
|||
BIN
assets/fui_log_page.jpg
Normal file
BIN
assets/fui_log_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/fui_main_page.jpg
Normal file
BIN
assets/fui_main_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/fui_setting_page.jpg
Normal file
BIN
assets/fui_setting_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/fui_web_page.jpg
Normal file
BIN
assets/fui_web_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 362 KiB |
366
cmd/membench/eval.go
Normal file
366
cmd/membench/eval.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
// EvalResult holds per-sample evaluation results for one mode.
|
||||
type EvalResult struct {
|
||||
Mode string `json:"mode"`
|
||||
SampleID string `json:"sampleId"`
|
||||
QAResults []QAResult `json:"qaResults"`
|
||||
Agg AggMetrics `json:"aggregated"`
|
||||
}
|
||||
|
||||
// QAResult holds metrics for a single QA pair.
|
||||
type QAResult struct {
|
||||
Question string `json:"question"`
|
||||
Category int `json:"category"`
|
||||
GoldAnswer string `json:"goldAnswer"`
|
||||
TokenF1 float64 `json:"tokenF1"`
|
||||
HitRate float64 `json:"hitRate"`
|
||||
}
|
||||
|
||||
// AggMetrics holds aggregated evaluation metrics.
|
||||
type AggMetrics struct {
|
||||
OverallF1 float64 `json:"overallF1"`
|
||||
OverallHitRate float64 `json:"overallHitRate"`
|
||||
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||
TotalQuestions int `json:"totalQuestions"`
|
||||
}
|
||||
|
||||
// CatMetrics holds metrics for a single category.
|
||||
type CatMetrics struct {
|
||||
F1 float64 `json:"f1"`
|
||||
HitRate float64 `json:"hitRate"`
|
||||
QuestionCount int `json:"questionCount"`
|
||||
}
|
||||
|
||||
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||
func EvalLegacy(
|
||||
ctx context.Context,
|
||||
samples []LocomoSample,
|
||||
legacy *LegacyStore,
|
||||
budgetTokens int,
|
||||
) []EvalResult {
|
||||
results := make([]EvalResult, 0, len(samples))
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
history := legacy.GetHistory(sample.SampleID)
|
||||
|
||||
// Convert messages to content strings
|
||||
allContent := make([]string, 0, len(history))
|
||||
for _, msg := range history {
|
||||
allContent = append(allContent, msg.Content)
|
||||
}
|
||||
|
||||
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||
for qi := range sample.QA {
|
||||
qa := &sample.QA[qi]
|
||||
// Budget truncate the full history
|
||||
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||
context := StringListToContent(truncated)
|
||||
|
||||
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: f1,
|
||||
HitRate: hitRate,
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, EvalResult{
|
||||
Mode: "legacy",
|
||||
SampleID: sample.SampleID,
|
||||
QAResults: qaResults,
|
||||
Agg: aggregateMetrics(qaResults),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// EvalSeahorse evaluates using seahorse short memory (per-keyword search + expand).
|
||||
func EvalSeahorse(
|
||||
ctx context.Context,
|
||||
samples []LocomoSample,
|
||||
ir *SeahorseIngestResult,
|
||||
budgetTokens int,
|
||||
) []EvalResult {
|
||||
store := ir.Engine.GetRetrieval().Store()
|
||||
retrieval := ir.Engine.GetRetrieval()
|
||||
|
||||
results := make([]EvalResult, 0, len(samples))
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
convID, ok := ir.ConvMap[sample.SampleID]
|
||||
if !ok {
|
||||
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||
continue
|
||||
}
|
||||
|
||||
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||
for qi := range sample.QA {
|
||||
qa := &sample.QA[qi]
|
||||
keywords := ExtractKeywords(qa.Question)
|
||||
|
||||
// Search each keyword individually and union results,
|
||||
// tracking best BM25 rank per message for relevance sorting.
|
||||
bestRank := map[int64]float64{}
|
||||
for _, kw := range keywords {
|
||||
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||
Pattern: kw,
|
||||
ConversationID: convID,
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("WARN: search failed for keyword %q: %v", kw, err)
|
||||
continue
|
||||
}
|
||||
for _, sr := range searchResults {
|
||||
if sr.MessageID > 0 {
|
||||
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||
bestRank[sr.MessageID] = sr.Rank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort messageIDs by rank ascending (best/most-negative first).
|
||||
// BudgetTruncate walks from the front, keeping best-ranked messages.
|
||||
// Note: SQLite FTS5 bm25() returns negative values where more
|
||||
// negative = better match.
|
||||
messageIDs := make([]int64, 0, len(bestRank))
|
||||
for id := range bestRank {
|
||||
messageIDs = append(messageIDs, id)
|
||||
}
|
||||
sort.Slice(messageIDs, func(i, j int) bool {
|
||||
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||
})
|
||||
|
||||
// Expand messages to get full content
|
||||
var contentParts []string
|
||||
if len(messageIDs) > 0 {
|
||||
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||
if err != nil {
|
||||
log.Printf("WARN: expand failed for sample %s: %v", sample.SampleID, err)
|
||||
} else {
|
||||
for _, msg := range expandResult.Messages {
|
||||
contentParts = append(contentParts, msg.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(contentParts) == 0 {
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: 0.0,
|
||||
HitRate: 0.0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Budget truncate (drop worst-ranked)
|
||||
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||
context := StringListToContent(truncated)
|
||||
|
||||
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: f1,
|
||||
HitRate: hitRate,
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, EvalResult{
|
||||
Mode: "seahorse",
|
||||
SampleID: sample.SampleID,
|
||||
QAResults: qaResults,
|
||||
Agg: aggregateMetrics(qaResults),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// aggregateMetrics computes overall and per-category metrics.
|
||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||
byCat := map[int]*CatMetrics{}
|
||||
totalF1 := 0.0
|
||||
totalHitRate := 0.0
|
||||
for _, qr := range qaResults {
|
||||
totalF1 += qr.TokenF1
|
||||
totalHitRate += qr.HitRate
|
||||
cat, ok := byCat[qr.Category]
|
||||
if !ok {
|
||||
cat = &CatMetrics{}
|
||||
byCat[qr.Category] = cat
|
||||
}
|
||||
cat.F1 += qr.TokenF1
|
||||
cat.HitRate += qr.HitRate
|
||||
cat.QuestionCount++
|
||||
}
|
||||
n := len(qaResults)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
}
|
||||
agg := AggMetrics{
|
||||
OverallF1: totalF1 / float64(n),
|
||||
OverallHitRate: totalHitRate / float64(n),
|
||||
ByCategory: byCat,
|
||||
TotalQuestions: len(qaResults),
|
||||
}
|
||||
for _, cat := range agg.ByCategory {
|
||||
if cat.QuestionCount > 0 {
|
||||
cat.F1 /= float64(cat.QuestionCount)
|
||||
cat.HitRate /= float64(cat.QuestionCount)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// SaveResults writes per-sample eval results to JSON files.
|
||||
func SaveResults(results []EvalResult, outDir string) error {
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
path := filepath.Join(outDir, fmt.Sprintf("eval_%s_%s.json", r.Mode, r.SampleID))
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal result: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write result: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAggregated writes a combined results.json with all modes.
|
||||
func SaveAggregated(results []EvalResult, outDir string) error {
|
||||
byMode := map[string][]EvalResult{}
|
||||
for _, r := range results {
|
||||
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||
}
|
||||
|
||||
aggMap := map[string]AggMetrics{}
|
||||
for mode, modeResults := range byMode {
|
||||
aggMap[mode] = computeModeAgg(modeResults)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(aggMap, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(outDir, "results.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// computeModeAgg aggregates results for a single mode using weighted averaging
|
||||
// (weighted by question count per sample). All modes must have the same Mode field.
|
||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||
for _, r := range results {
|
||||
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
|
||||
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||
for cat, cm := range r.Agg.ByCategory {
|
||||
existing, ok := agg.ByCategory[cat]
|
||||
if !ok {
|
||||
existing = &CatMetrics{}
|
||||
agg.ByCategory[cat] = existing
|
||||
}
|
||||
existing.F1 += cm.F1 * float64(cm.QuestionCount)
|
||||
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||
existing.QuestionCount += cm.QuestionCount
|
||||
}
|
||||
}
|
||||
if agg.TotalQuestions > 0 {
|
||||
agg.OverallF1 /= float64(agg.TotalQuestions)
|
||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||
}
|
||||
for _, cat := range agg.ByCategory {
|
||||
if cat.QuestionCount > 0 {
|
||||
cat.F1 /= float64(cat.QuestionCount)
|
||||
cat.HitRate /= float64(cat.QuestionCount)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// printSection prints a single comparison table section.
|
||||
func printSection(title string, results []EvalResult) {
|
||||
fmt.Printf("\n--- %s ---\n", title)
|
||||
byMode := map[string][]EvalResult{}
|
||||
for _, r := range results {
|
||||
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||
}
|
||||
|
||||
modes := map[string]AggMetrics{}
|
||||
for mode, modeResults := range byMode {
|
||||
modes[mode] = computeModeAgg(modeResults)
|
||||
}
|
||||
|
||||
modeKeys := make([]string, 0, len(modes))
|
||||
for k := range modes {
|
||||
modeKeys = append(modeKeys, k)
|
||||
}
|
||||
sort.Strings(modeKeys)
|
||||
|
||||
// Collect all category keys across modes
|
||||
catSet := map[int]bool{}
|
||||
for _, agg := range modes {
|
||||
for cat := range agg.ByCategory {
|
||||
catSet[cat] = true
|
||||
}
|
||||
}
|
||||
cats := make([]int, 0, len(catSet))
|
||||
for cat := range catSet {
|
||||
cats = append(cats, cat)
|
||||
}
|
||||
sort.Ints(cats)
|
||||
|
||||
fmt.Printf("%-10s %-8s %-8s", "Mode", "HitRate", "F1")
|
||||
for _, cat := range cats {
|
||||
fmt.Printf(" %-7s", fmt.Sprintf("C%d", cat))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(strings.Repeat("-", 10+8+8+7*len(cats)+8))
|
||||
|
||||
for _, mode := range modeKeys {
|
||||
agg := modes[mode]
|
||||
fmt.Printf("%-10s %-8.4f %-8.4f", mode, agg.OverallHitRate, agg.OverallF1)
|
||||
for _, cat := range cats {
|
||||
if cm, ok := agg.ByCategory[cat]; ok {
|
||||
fmt.Printf(" %-7.4f", cm.HitRate)
|
||||
} else {
|
||||
fmt.Printf(" %-7s", "N/A")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||
printSection("No LLM generation", results)
|
||||
if len(llmResults) > 0 {
|
||||
printSection("With LLM", llmResults)
|
||||
}
|
||||
}
|
||||
104
cmd/membench/eval_test.go
Normal file
104
cmd/membench/eval_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeModeAggAllCategories(t *testing.T) {
|
||||
results := []EvalResult{
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "s1",
|
||||
QAResults: []QAResult{
|
||||
{Category: 1, TokenF1: 0.5, HitRate: 0.8},
|
||||
{Category: 2, TokenF1: 0.3, HitRate: 0.6},
|
||||
{Category: 3, TokenF1: 0.1, HitRate: 0.4},
|
||||
{Category: 4, TokenF1: 0.7, HitRate: 0.9},
|
||||
{Category: 5, TokenF1: 0.2, HitRate: 0.1},
|
||||
},
|
||||
},
|
||||
}
|
||||
for i := range results {
|
||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||
}
|
||||
|
||||
got := computeModeAgg(results)
|
||||
|
||||
// Should have all 5 categories
|
||||
for cat := 1; cat <= 5; cat++ {
|
||||
cm, ok := got.ByCategory[cat]
|
||||
if !ok {
|
||||
t.Errorf("ByCategory missing category %d", cat)
|
||||
continue
|
||||
}
|
||||
if cm.QuestionCount != 1 {
|
||||
t.Errorf("ByCategory[%d].QuestionCount = %d, want 1", cat, cm.QuestionCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify specific F1 values per category
|
||||
wantF1 := map[int]float64{1: 0.5, 2: 0.3, 3: 0.1, 4: 0.7, 5: 0.2}
|
||||
for cat, want := range wantF1 {
|
||||
if cm, ok := got.ByCategory[cat]; ok {
|
||||
if math.Abs(cm.F1-want) > 1e-9 {
|
||||
t.Errorf("ByCategory[%d].F1 = %.4f, want %.4f", cat, cm.F1, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeModeAgg(t *testing.T) {
|
||||
// Two samples with different question counts:
|
||||
// sample-a: 2 questions, F1 = [0.4, 0.6] → avg 0.5
|
||||
// sample-b: 8 questions, F1 = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] → avg 0.1
|
||||
//
|
||||
// Unweighted (PrintComparison bug): (0.5 + 0.1) / 2 = 0.3
|
||||
// Weighted (correct): (0.4+0.6 + 0.1*8) / 10 = 1.8 / 10 = 0.18
|
||||
results := []EvalResult{
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "sample-a",
|
||||
QAResults: []QAResult{
|
||||
{TokenF1: 0.4, HitRate: 0.5},
|
||||
{TokenF1: 0.6, HitRate: 0.7},
|
||||
},
|
||||
},
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "sample-b",
|
||||
QAResults: []QAResult{
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Compute per-sample aggregates
|
||||
for i := range results {
|
||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||
}
|
||||
|
||||
got := computeModeAgg(results)
|
||||
|
||||
// Weighted: (0.4+0.6+0.1*8) / 10 = 1.8/10 = 0.18
|
||||
wantF1 := 0.18
|
||||
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||
t.Errorf("OverallF1 = %.6f, want %.6f (weighted average)", got.OverallF1, wantF1)
|
||||
}
|
||||
|
||||
// Weighted: (0.5+0.7+0.2*8) / 10 = 2.8/10 = 0.28
|
||||
wantRecall := 0.28
|
||||
if math.Abs(got.OverallHitRate-wantRecall) > 1e-9 {
|
||||
t.Errorf("OverallHitRate = %.6f, want %.6f (weighted average)", got.OverallHitRate, wantRecall)
|
||||
}
|
||||
|
||||
if got.TotalQuestions != 10 {
|
||||
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||
}
|
||||
}
|
||||
85
cmd/membench/ingest.go
Normal file
85
cmd/membench/ingest.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
// ConvMap stores the mapping from sampleID to seahorse ConversationID.
|
||||
type ConvMap map[string]int64
|
||||
|
||||
// SeahorseIngestResult holds the results of ingesting into seahorse.
|
||||
type SeahorseIngestResult struct {
|
||||
Engine *seahorse.Engine
|
||||
ConvMap ConvMap // sampleID → conversationID
|
||||
}
|
||||
|
||||
// IngestSeahorse loads all LOCOMO samples into a seahorse Engine.
|
||||
// Returns the engine and a mapping from sampleID to conversationID for scoped retrieval.
|
||||
func IngestSeahorse(ctx context.Context, samples []LocomoSample, dbPath string) (*SeahorseIngestResult, error) {
|
||||
noopFn := func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||
DBPath: dbPath,
|
||||
}, noopFn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create seahorse engine: %w", err)
|
||||
}
|
||||
|
||||
store := engine.GetRetrieval().Store()
|
||||
convMap := make(ConvMap)
|
||||
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
sessionKey := "locomo-" + sample.SampleID
|
||||
|
||||
// Check if conversation already exists (idempotent)
|
||||
existing, _ := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||
if existing != nil {
|
||||
convMap[sample.SampleID] = existing.ConversationID
|
||||
log.Printf("Skipping existing sample %s: convID=%d", sample.SampleID, existing.ConversationID)
|
||||
continue
|
||||
}
|
||||
|
||||
turns := GetTurns(sample)
|
||||
|
||||
// Convert turns to seahorse messages
|
||||
msgs := make([]seahorse.Message, 0, len(turns))
|
||||
for _, turn := range turns {
|
||||
content := turn.Speaker + ": " + turn.Text
|
||||
msgs = append(msgs, seahorse.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
TokenCount: len(turn.Text) / 4,
|
||||
})
|
||||
}
|
||||
|
||||
// Ingest all turns for this sample
|
||||
_, err := engine.Ingest(ctx, sessionKey, msgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingest sample %s: %w", sample.SampleID, err)
|
||||
}
|
||||
|
||||
// Get the conversation ID for scoped retrieval
|
||||
conv, err := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get conversation for %s: %w", sample.SampleID, err)
|
||||
}
|
||||
if conv == nil {
|
||||
return nil, fmt.Errorf("conversation not found for %s after ingest", sample.SampleID)
|
||||
}
|
||||
convMap[sample.SampleID] = conv.ConversationID
|
||||
log.Printf("Ingested sample %s: %d turns, convID=%d", sample.SampleID, len(turns), conv.ConversationID)
|
||||
}
|
||||
|
||||
log.Printf("Seahorse ingestion complete: %d samples, %d conversations", len(samples), len(convMap))
|
||||
return &SeahorseIngestResult{
|
||||
Engine: engine,
|
||||
ConvMap: convMap,
|
||||
}, nil
|
||||
}
|
||||
79
cmd/membench/ingest_test.go
Normal file
79
cmd/membench/ingest_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
func TestIngestSeahorseIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Minimal test data
|
||||
samples := []LocomoSample{
|
||||
{
|
||||
SampleID: "test-1",
|
||||
Conversation: map[string]json.RawMessage{
|
||||
"session_1": json.RawMessage(`[
|
||||
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message"},
|
||||
{"speaker":"B","dia_id":"D1:2","text":"another message for testing purposes"}
|
||||
]`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// First ingestion
|
||||
result1, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first ingest failed: %v", err)
|
||||
}
|
||||
convCount1 := len(result1.ConvMap)
|
||||
result1.Engine.Close()
|
||||
|
||||
// Second ingestion on same DB — should reuse existing data
|
||||
result2, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest failed: %v", err)
|
||||
}
|
||||
defer result2.Engine.Close()
|
||||
|
||||
// ConvMap should have same number of entries (no duplicates)
|
||||
if len(result2.ConvMap) != convCount1 {
|
||||
t.Errorf("second ingest convMap has %d entries, want %d (same as first)",
|
||||
len(result2.ConvMap), convCount1)
|
||||
}
|
||||
|
||||
// Verify conversation IDs are the same (reused, not new ones)
|
||||
for id, cid1 := range result1.ConvMap {
|
||||
cid2, ok := result2.ConvMap[id]
|
||||
if !ok {
|
||||
t.Errorf("sample %s missing from second ConvMap", id)
|
||||
continue
|
||||
}
|
||||
if cid2 != cid1 {
|
||||
t.Errorf("sample %s: second ingest got convID %d, want %d (reused)", id, cid2, cid1)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no duplicate messages by counting
|
||||
store := result2.Engine.GetRetrieval().Store()
|
||||
for _, convID := range result2.ConvMap {
|
||||
msgs, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||
Pattern: "test",
|
||||
ConversationID: convID,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
// Should find exactly 1 message containing "test" (the first turn)
|
||||
if len(msgs) > 2 {
|
||||
t.Errorf("found %d messages for 'test' in conv %d, expected ≤2 (no duplicates)", len(msgs), convID)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
cmd/membench/legacy_store.go
Normal file
34
cmd/membench/legacy_store.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
)
|
||||
|
||||
// LegacyStore wraps session.SessionManager for legacy baseline.
|
||||
type LegacyStore struct {
|
||||
sm *session.SessionManager
|
||||
}
|
||||
|
||||
// NewLegacyStore creates a new in-memory session manager.
|
||||
func NewLegacyStore() *LegacyStore {
|
||||
return &LegacyStore{
|
||||
sm: session.NewSessionManager(""),
|
||||
}
|
||||
}
|
||||
|
||||
// IngestSample loads all turns from a LOCOMO sample into the legacy session store.
|
||||
func (ls *LegacyStore) IngestSample(sample *LocomoSample) {
|
||||
sessionKey := "locomo-" + sample.SampleID
|
||||
turns := GetTurns(sample)
|
||||
for _, turn := range turns {
|
||||
content := turn.Speaker + ": " + turn.Text
|
||||
ls.sm.AddMessage(sessionKey, "user", content)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHistory returns all messages for a sample's session.
|
||||
func (ls *LegacyStore) GetHistory(sampleID string) []providers.Message {
|
||||
sessionKey := "locomo-" + sampleID
|
||||
return ls.sm.GetHistory(sessionKey)
|
||||
}
|
||||
142
cmd/membench/locomo.go
Normal file
142
cmd/membench/locomo.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocomoSample represents one conversation sample from the LOCOMO dataset.
|
||||
type LocomoSample struct {
|
||||
SampleID string `json:"sample_id"`
|
||||
Conversation map[string]json.RawMessage `json:"conversation"`
|
||||
QA []LocomoQA `json:"qa"`
|
||||
}
|
||||
|
||||
// LocomoTurn represents a single turn in a conversation.
|
||||
type LocomoTurn struct {
|
||||
Speaker string `json:"speaker"`
|
||||
DiaID string `json:"dia_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// LocomoQA represents a question-answer pair with evidence.
|
||||
type LocomoQA struct {
|
||||
Question string `json:"question"`
|
||||
Answer json.RawMessage `json:"answer"` // can be string or int (category 1-4)
|
||||
AdversarialAnswer string `json:"adversarial_answer"` // category 5 only
|
||||
Evidence []string `json:"evidence"`
|
||||
Category int `json:"category"` // 1=single-hop, 2=multi-hop, 3=open-ended, 5=adversarial
|
||||
}
|
||||
|
||||
// AnswerString returns the answer as a string, handling both string and int types.
|
||||
func (qa *LocomoQA) AnswerString() string {
|
||||
// Prefer answer field (category 1-4)
|
||||
if len(qa.Answer) > 0 {
|
||||
var s string
|
||||
if err := json.Unmarshal(qa.Answer, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(qa.Answer, &n); err == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(qa.Answer), `"`)
|
||||
}
|
||||
// Fallback to adversarial_answer (category 5)
|
||||
return qa.AdversarialAnswer
|
||||
}
|
||||
|
||||
// LoadDataset reads all JSON files from dataDir and returns parsed samples.
|
||||
func LoadDataset(dataDir string) ([]LocomoSample, error) {
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read data dir %s: %w", dataDir, err)
|
||||
}
|
||||
|
||||
var samples []LocomoSample
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
|
||||
path := filepath.Join(dataDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
var batch []LocomoSample
|
||||
if err := json.Unmarshal(data, &batch); err != nil {
|
||||
return nil, fmt.Errorf("parse file %s: %w", path, err)
|
||||
}
|
||||
samples = append(samples, batch...)
|
||||
}
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// GetSessionNames returns sorted session keys (session_1, session_2, ...) from conversation.
|
||||
func GetSessionNames(conv map[string]json.RawMessage) []string {
|
||||
var names []string
|
||||
for k := range conv {
|
||||
if strings.HasPrefix(k, "session_") && !strings.Contains(k, "_date_time") {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
ni := sessionNum(names[i])
|
||||
nj := sessionNum(names[j])
|
||||
return ni < nj
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
func sessionNum(key string) int {
|
||||
// "session_1" → 1, "session_10" → 10
|
||||
parts := strings.SplitN(key, "_", 2)
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(parts[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// GetTurns flattens all sessions' turns in chronological order.
|
||||
func GetTurns(sample *LocomoSample) []LocomoTurn {
|
||||
names := GetSessionNames(sample.Conversation)
|
||||
var all []LocomoTurn
|
||||
for _, name := range names {
|
||||
raw, ok := sample.Conversation[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var turns []LocomoTurn
|
||||
if err := json.Unmarshal(raw, &turns); err != nil {
|
||||
log.Printf("WARNING: unmarshal failed for session %q in sample %s: %v", name, sample.SampleID, err)
|
||||
continue
|
||||
}
|
||||
all = append(all, turns...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// GetTurnByDiaID finds a specific turn by dia_id (e.g. "D1:3").
|
||||
func GetTurnByDiaID(sample *LocomoSample, diaID string) *LocomoTurn {
|
||||
turns := GetTurns(sample)
|
||||
for i := range turns {
|
||||
if turns[i].DiaID == diaID {
|
||||
return &turns[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSpeakers returns the two speaker names from conversation metadata.
|
||||
func GetSpeakers(conv map[string]json.RawMessage) (string, string) {
|
||||
var a, b string
|
||||
json.Unmarshal(conv["speaker_a"], &a)
|
||||
json.Unmarshal(conv["speaker_b"], &b)
|
||||
return a, b
|
||||
}
|
||||
67
cmd/membench/locomo_test.go
Normal file
67
cmd/membench/locomo_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnswerString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"string answer",
|
||||
`{"question":"Q","answer":"Paris","evidence":[],"category":1}`,
|
||||
"Paris",
|
||||
},
|
||||
{
|
||||
"int answer",
|
||||
`{"question":"Q","answer":42,"evidence":[],"category":1}`,
|
||||
"42",
|
||||
},
|
||||
{
|
||||
"adversarial answer (category 5)",
|
||||
`{"question":"Q","evidence":[],"category":5,"adversarial_answer":"self-care is important"}`,
|
||||
"self-care is important",
|
||||
},
|
||||
{
|
||||
"both answer and adversarial_answer present",
|
||||
`{"question":"Q","answer":"normal","evidence":[],"category":5,"adversarial_answer":"adversarial"}`,
|
||||
"normal",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var qa LocomoQA
|
||||
if err := json.Unmarshal([]byte(tt.json), &qa); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
got := qa.AnswerString()
|
||||
if got != tt.want {
|
||||
t.Errorf("AnswerString() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionNames(t *testing.T) {
|
||||
conv := map[string]json.RawMessage{
|
||||
"session_2": {},
|
||||
"session_1": {},
|
||||
"session_10": {},
|
||||
"session_1_date_time": {},
|
||||
"speaker_a": {},
|
||||
}
|
||||
names := GetSessionNames(conv)
|
||||
want := []string{"session_1", "session_2", "session_10"}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("got %v, want %v", names, want)
|
||||
}
|
||||
for i, n := range names {
|
||||
if n != want[i] {
|
||||
t.Errorf("names[%d] = %q, want %q", i, n, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
208
cmd/membench/main.go
Normal file
208
cmd/membench/main.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
flagData string
|
||||
flagOut string
|
||||
flagMode string
|
||||
flagBudget int
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Suppress seahorse INFO logs during benchmark
|
||||
logger.SetLevel(logger.WARN)
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "membench",
|
||||
Short: "Memory benchmark tool for picoclaw",
|
||||
}
|
||||
|
||||
ingestCmd := &cobra.Command{
|
||||
Use: "ingest",
|
||||
Short: "Load LOCOMO data into storage backends",
|
||||
RunE: runIngest,
|
||||
}
|
||||
ingestCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
ingestCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
ingestCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to ingest: legacy, seahorse, or all")
|
||||
|
||||
evalCmd := &cobra.Command{
|
||||
Use: "eval",
|
||||
Short: "Run QA evaluation against ingested data",
|
||||
RunE: runEval,
|
||||
}
|
||||
evalCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Output comparison results from evaluation",
|
||||
RunE: runReport,
|
||||
}
|
||||
reportCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Convenience: eval + report (ingestion is done inline)",
|
||||
RunE: runAll,
|
||||
}
|
||||
runCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||
|
||||
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func modesFromFlag() []string {
|
||||
switch strings.ToLower(flagMode) {
|
||||
case "all":
|
||||
return []string{"legacy", "seahorse"}
|
||||
default:
|
||||
return []string{strings.ToLower(flagMode)}
|
||||
}
|
||||
}
|
||||
|
||||
func runIngest(cmd *cobra.Command, args []string) error {
|
||||
if flagData == "" {
|
||||
return fmt.Errorf("--data is required")
|
||||
}
|
||||
modes := modesFromFlag()
|
||||
if len(modes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
samples, err := LoadDataset(flagData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dataset: %w", err)
|
||||
}
|
||||
log.Printf("Loaded %d samples from %s", len(samples), flagData)
|
||||
|
||||
for _, mode := range modes {
|
||||
switch mode {
|
||||
case "legacy":
|
||||
legacy := NewLegacyStore()
|
||||
for i := range samples {
|
||||
legacy.IngestSample(&samples[i])
|
||||
}
|
||||
log.Printf("legacy: ingested %d samples", len(samples))
|
||||
case "seahorse":
|
||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||
if err := os.MkdirAll(flagOut, 0o755); err != nil {
|
||||
return fmt.Errorf("create out dir: %w", err)
|
||||
}
|
||||
_, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingest seahorse: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEval(cmd *cobra.Command, args []string) error {
|
||||
if flagData == "" {
|
||||
return fmt.Errorf("--data is required")
|
||||
}
|
||||
modes := modesFromFlag()
|
||||
if len(modes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
samples, err := LoadDataset(flagData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dataset: %w", err)
|
||||
}
|
||||
log.Printf("Loaded %d samples", len(samples))
|
||||
|
||||
var allResults []EvalResult
|
||||
|
||||
for _, mode := range modes {
|
||||
switch mode {
|
||||
case "legacy":
|
||||
legacy := NewLegacyStore()
|
||||
for i := range samples {
|
||||
legacy.IngestSample(&samples[i])
|
||||
}
|
||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||
allResults = append(allResults, results...)
|
||||
log.Printf("legacy: evaluated %d samples", len(results))
|
||||
case "seahorse":
|
||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingest seahorse: %w", err)
|
||||
}
|
||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||
allResults = append(allResults, results...)
|
||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
if err := SaveResults(allResults, flagOut); err != nil {
|
||||
return fmt.Errorf("save results: %w", err)
|
||||
}
|
||||
if err := SaveAggregated(allResults, flagOut); err != nil {
|
||||
return fmt.Errorf("save aggregated: %w", err)
|
||||
}
|
||||
|
||||
PrintComparison(allResults, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runReport(cmd *cobra.Command, args []string) error {
|
||||
entries, err := os.ReadDir(flagOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read out dir: %w", err)
|
||||
}
|
||||
|
||||
var allResults []EvalResult
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "eval_") && strings.HasSuffix(entry.Name(), ".json") {
|
||||
path := filepath.Join(flagOut, entry.Name())
|
||||
var r EvalResult
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("WARN: read %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
log.Printf("WARN: parse %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
allResults = append(allResults, r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allResults) == 0 {
|
||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||
}
|
||||
|
||||
PrintComparison(allResults, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAll(cmd *cobra.Command, args []string) error {
|
||||
return runEval(cmd, args)
|
||||
}
|
||||
227
cmd/membench/metrics.go
Normal file
227
cmd/membench/metrics.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// diaIDRe matches valid dia_id patterns like "D1:3", "D30:5".
|
||||
var diaIDRe = regexp.MustCompile(`^D(\d+):(\d+)$`)
|
||||
|
||||
// SplitEvidenceIDs splits an evidence string that may contain multiple
|
||||
// semicolon-separated or space-separated dia_ids. Only returns valid IDs.
|
||||
// Example: "D8:6; D9:17" → ["D8:6", "D9:17"]
|
||||
// Example: "D9:1 D4:4 D4:6" → ["D9:1", "D4:4", "D4:6"]
|
||||
func SplitEvidenceIDs(evidence string) []string {
|
||||
if evidence == "" {
|
||||
return nil
|
||||
}
|
||||
// Split on semicolons first, then spaces
|
||||
parts := strings.Split(evidence, ";")
|
||||
var ids []string
|
||||
for _, part := range parts {
|
||||
for _, token := range strings.Fields(strings.TrimSpace(part)) {
|
||||
token = strings.TrimSpace(token)
|
||||
if diaIDRe.MatchString(token) {
|
||||
ids = append(ids, NormalizeDiaID(token))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// NormalizeDiaID strips leading zeros from the number parts of a dia_id.
|
||||
// "D30:05" → "D30:5", "D10:003" → "D10:3"
|
||||
func NormalizeDiaID(id string) string {
|
||||
m := diaIDRe.FindStringSubmatch(id)
|
||||
if m == nil {
|
||||
return id
|
||||
}
|
||||
session, _ := strconv.Atoi(m[1])
|
||||
turn, _ := strconv.Atoi(m[2])
|
||||
return fmt.Sprintf("D%d:%d", session, turn)
|
||||
}
|
||||
|
||||
// stopwords is a fixed English stopword list for deterministic keyword extraction.
|
||||
var stopwords = map[string]struct{}{
|
||||
"a": {}, "an": {}, "the": {},
|
||||
"is": {}, "are": {}, "was": {}, "were": {},
|
||||
"did": {}, "does": {}, "do": {},
|
||||
"when": {}, "where": {}, "what": {}, "who": {},
|
||||
"how": {}, "why": {},
|
||||
"to": {}, "of": {}, "in": {}, "on": {}, "at": {},
|
||||
"for": {}, "and": {}, "or": {}, "but": {}, "not": {},
|
||||
"it": {}, "this": {}, "that": {}, "with": {},
|
||||
"from": {}, "by": {}, "as": {},
|
||||
"if": {}, "then": {}, "than": {}, "so": {},
|
||||
"no": {}, "yes": {},
|
||||
"all": {}, "any": {}, "each": {}, "every": {},
|
||||
"some": {}, "such": {},
|
||||
"about": {}, "into": {}, "over": {},
|
||||
"after": {}, "before": {}, "between": {},
|
||||
"through": {}, "during": {}, "until": {},
|
||||
"would": {}, "could": {}, "should": {},
|
||||
"may": {}, "might": {}, "can": {},
|
||||
"will": {}, "shall": {}, "must": {},
|
||||
"have": {}, "has": {}, "had": {},
|
||||
"been": {}, "being": {}, "be": {},
|
||||
"go": {}, "went": {}, "gone": {},
|
||||
"i": {}, "you": {}, "me": {}, "my": {}, "your": {},
|
||||
"we": {}, "they": {}, "them": {}, "our": {},
|
||||
"its": {}, "their": {}, "he": {}, "she": {},
|
||||
"his": {}, "her": {},
|
||||
}
|
||||
|
||||
// ExtractKeywords removes stopwords and punctuation, returns individual keywords.
|
||||
// Deterministic: uses fixed stopword list, no LLM.
|
||||
func ExtractKeywords(question string) []string {
|
||||
// Lowercase and split on whitespace/punctuation
|
||||
lower := strings.ToLower(question)
|
||||
words := strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
|
||||
var keywords []string
|
||||
for _, w := range words {
|
||||
if w == "" || len(w) < 2 {
|
||||
continue
|
||||
}
|
||||
if _, ok := stopwords[w]; ok {
|
||||
continue
|
||||
}
|
||||
keywords = append(keywords, w)
|
||||
if len(keywords) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
// TokenOverlapF1 computes token-level F1 between prediction and reference.
|
||||
// Both strings are lowercased and split on whitespace.
|
||||
// NOTE: This metric underestimates quality for multi-hop (cat 2) and
|
||||
// open-ended (cat 3) questions where the gold answer uses different phrasing
|
||||
// than the source text. LLM-Judge scoring is a v2 follow-up.
|
||||
func TokenOverlapF1(prediction, reference string) float64 {
|
||||
predTokens := tokenize(prediction)
|
||||
refTokens := tokenize(reference)
|
||||
|
||||
if len(predTokens) == 0 && len(refTokens) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(predTokens) == 0 || len(refTokens) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Count matches
|
||||
refCount := map[string]int{}
|
||||
for _, t := range refTokens {
|
||||
refCount[t]++
|
||||
}
|
||||
|
||||
predCount := map[string]int{}
|
||||
for _, t := range predTokens {
|
||||
predCount[t]++
|
||||
}
|
||||
|
||||
var matches float64
|
||||
for token, pc := range predCount {
|
||||
if rc, ok := refCount[token]; ok {
|
||||
matches += float64(min(pc, rc))
|
||||
}
|
||||
}
|
||||
|
||||
precision := matches / float64(len(predTokens))
|
||||
recall := matches / float64(len(refTokens))
|
||||
|
||||
if precision+recall == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return 2 * precision * recall / (precision + recall)
|
||||
}
|
||||
|
||||
func tokenize(s string) []string {
|
||||
lower := strings.ToLower(s)
|
||||
return strings.Fields(lower)
|
||||
}
|
||||
|
||||
// RecallHitRate computes fraction of evidence IDs found in retrieved content.
|
||||
// For each evidence dia_id, looks up the turn text and checks substring match.
|
||||
// Logs a warning for turns with text < 20 chars (higher false-positive risk).
|
||||
func RecallHitRate(evidenceIDs []string, sample *LocomoSample, retrievedContent string) float64 {
|
||||
if len(evidenceIDs) == 0 {
|
||||
return 1.0 // no evidence required = perfect
|
||||
}
|
||||
|
||||
// Expand any multi-ID evidence entries (e.g. "D8:6; D9:17" or "D9:1 D4:4")
|
||||
var expanded []string
|
||||
for _, id := range evidenceIDs {
|
||||
split := SplitEvidenceIDs(id)
|
||||
if split != nil {
|
||||
expanded = append(expanded, split...)
|
||||
}
|
||||
}
|
||||
if len(expanded) == 0 {
|
||||
log.Printf("WARNING: no valid dia_ids after expanding evidence %v", evidenceIDs)
|
||||
return float64(0) / float64(len(evidenceIDs))
|
||||
}
|
||||
|
||||
// Build turn index once (avoids re-parsing JSON per ID)
|
||||
turns := GetTurns(sample)
|
||||
turnMap := make(map[string]*LocomoTurn, len(turns))
|
||||
for i := range turns {
|
||||
turnMap[turns[i].DiaID] = &turns[i]
|
||||
}
|
||||
|
||||
lowerRetrieved := strings.ToLower(retrievedContent)
|
||||
found := 0
|
||||
resolvable := 0
|
||||
for _, diaID := range expanded {
|
||||
turn, ok := turnMap[diaID]
|
||||
if !ok {
|
||||
log.Printf("WARNING: dia_id %q not found in sample %s", diaID, sample.SampleID)
|
||||
continue
|
||||
}
|
||||
resolvable++
|
||||
if len(turn.Text) < 20 {
|
||||
log.Printf("WARNING: short turn text (%d chars) for dia_id %s: %q",
|
||||
len(turn.Text), diaID, turn.Text)
|
||||
}
|
||||
if strings.Contains(lowerRetrieved, strings.ToLower(turn.Text)) {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if resolvable == 0 {
|
||||
return 0.0 // no resolvable evidence = can't evaluate
|
||||
}
|
||||
return float64(found) / float64(resolvable)
|
||||
}
|
||||
|
||||
// BudgetTruncate truncates messages to fit within a token budget.
|
||||
// Returns the truncated messages and total token count.
|
||||
func BudgetTruncate(messages []string, budgetTokens int) ([]string, int) {
|
||||
var result []string
|
||||
total := 0
|
||||
// Walk from the front (best first) and keep until budget exhausted.
|
||||
for i := 0; i < len(messages); i++ {
|
||||
tokens := len(messages[i]) / 4
|
||||
if total+tokens > budgetTokens && len(result) > 0 {
|
||||
break
|
||||
}
|
||||
result = append(result, messages[i])
|
||||
total += tokens
|
||||
}
|
||||
return result, total
|
||||
}
|
||||
|
||||
// StringListToContent joins a list of strings into a single content string.
|
||||
func StringListToContent(parts []string) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
239
cmd/membench/metrics_test.go
Normal file
239
cmd/membench/metrics_test.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitEvidenceIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"D1:3", []string{"D1:3"}},
|
||||
{"D8:6; D9:17", []string{"D8:6", "D9:17"}},
|
||||
{"D9:1 D4:4 D4:6", []string{"D9:1", "D4:4", "D4:6"}},
|
||||
{"D22:1 D22:2 D9:10 D9:11", []string{"D22:1", "D22:2", "D9:10", "D9:11"}},
|
||||
{"D21:18 D21:22 D11:15 D11:19", []string{"D21:18", "D21:22", "D11:15", "D11:19"}},
|
||||
{"D30:05", []string{"D30:5"}},
|
||||
{"D", nil},
|
||||
{"D:", nil},
|
||||
{"", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := SplitEvidenceIDs(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("SplitEvidenceIDs(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDiaID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"D1:3", "D1:3"},
|
||||
{"D30:05", "D30:5"},
|
||||
{"D10:003", "D10:3"},
|
||||
{"D1:0", "D1:0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeDiaID(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeDiaID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenOverlapF1(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prediction string
|
||||
reference string
|
||||
want float64
|
||||
}{
|
||||
{"exact match", "hello world", "hello world", 1.0},
|
||||
{"no overlap", "foo bar", "baz qux", 0.0},
|
||||
{"empty both", "", "", 1.0},
|
||||
{"empty prediction", "", "hello", 0.0},
|
||||
{"empty reference", "hello", "", 0.0},
|
||||
{"partial overlap", "the cat sat on the mat", "the cat on the floor", 8.0 / 11.0},
|
||||
{"case insensitive", "Hello World", "hello world", 1.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TokenOverlapF1(tt.prediction, tt.reference)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("TokenOverlapF1(%q, %q) = %.4f, want %.4f",
|
||||
tt.prediction, tt.reference, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetTruncate(t *testing.T) {
|
||||
t.Run("within budget returns all", func(t *testing.T) {
|
||||
msgs := []string{"short", "message", "here"}
|
||||
result, total := BudgetTruncate(msgs, 1000)
|
||||
if len(result) != 3 {
|
||||
t.Errorf("expected 3 messages, got %d", len(result))
|
||||
}
|
||||
if total == 0 {
|
||||
t.Error("expected non-zero token count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("over budget keeps best first", func(t *testing.T) {
|
||||
msgs := []string{
|
||||
"best message that is quite long and takes up tokens",
|
||||
"good message also fairly long content",
|
||||
"worst short",
|
||||
}
|
||||
result, _ := BudgetTruncate(msgs, 5) // very small budget
|
||||
if len(result) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
// Best-ranked (first) should be kept
|
||||
if result[0] != "best message that is quite long and takes up tokens" {
|
||||
t.Errorf("expected best message kept first, got %q", result[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("over budget keeps best ranked first", func(t *testing.T) {
|
||||
// Messages are sorted by bm25 rank ascending (best/most-negative first).
|
||||
// When budget is insufficient, BudgetTruncate must keep the front
|
||||
// (best-ranked) messages, not the tail (worst-ranked).
|
||||
msgs := []string{
|
||||
"best ranked message with some content here",
|
||||
"second best message also has content",
|
||||
"third message here too",
|
||||
"worst ranked short",
|
||||
}
|
||||
// Budget only fits ~1 message (~10 tokens per message, budget=12)
|
||||
result, _ := BudgetTruncate(msgs, 12)
|
||||
if len(result) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
if result[0] != "best ranked message with some content here" {
|
||||
t.Errorf("expected best-ranked (first) message kept, got %q", result[0])
|
||||
}
|
||||
// Worst-ranked (last) must NOT appear
|
||||
for _, m := range result {
|
||||
if m == "worst ranked short" {
|
||||
t.Error("worst-ranked message should have been truncated")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves original order", func(t *testing.T) {
|
||||
msgs := []string{"alpha", "beta", "gamma"}
|
||||
result, _ := BudgetTruncate(msgs, 100)
|
||||
for i, got := range result {
|
||||
if got != msgs[i] {
|
||||
t.Errorf("result[%d] = %q, want %q", i, got, msgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
result, total := BudgetTruncate(nil, 100)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 messages, got %d", len(result))
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 tokens, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecallHitRate(t *testing.T) {
|
||||
// Build a sample with known turns
|
||||
sample := &LocomoSample{
|
||||
SampleID: "test-sample",
|
||||
Conversation: map[string]json.RawMessage{
|
||||
"session_1": json.RawMessage(`[
|
||||
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message with enough length"},
|
||||
{"speaker":"B","dia_id":"D1:2","text":"another message for testing recall computation purposes here"},
|
||||
{"speaker":"A","dia_id":"D1:3","text":"third turn with some more content to test"}
|
||||
]`),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("all evidence found", func(t *testing.T) {
|
||||
retrieved := "hello world this is a test message with enough length another message for testing recall computation purposes here"
|
||||
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||
if math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("RecallHitRate all found = %.4f, want 1.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial evidence found", func(t *testing.T) {
|
||||
retrieved := "hello world this is a test message with enough length"
|
||||
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||
if math.Abs(got-0.5) > 1e-9 {
|
||||
t.Errorf("RecallHitRate partial = %.4f, want 0.5", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no evidence required", func(t *testing.T) {
|
||||
got := RecallHitRate(nil, sample, "anything")
|
||||
if got != 1.0 {
|
||||
t.Errorf("RecallHitRate no evidence = %.4f, want 1.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing turn excluded from denominator", func(t *testing.T) {
|
||||
// D1:1 is found, D99:1 does not exist in sample
|
||||
// Should only count resolvable turns in denominator
|
||||
retrieved := "hello world this is a test message with enough length"
|
||||
got := RecallHitRate([]string{"D1:1", "D99:1"}, sample, retrieved)
|
||||
if math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("RecallHitRate missing turn = %.4f, want 1.0 (unresolvable excluded)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"simple", "What is the capital of France", []string{"capital", "france"}},
|
||||
{
|
||||
"stops removed",
|
||||
"Who is the president of the United States",
|
||||
[]string{"president", "united", "states"},
|
||||
},
|
||||
{
|
||||
"max 6 keywords",
|
||||
"one two three four five six seven eight nine ten",
|
||||
[]string{"one", "two", "three", "four", "five", "six"},
|
||||
},
|
||||
{"short words filtered", "I am a go to the store", []string{"am", "store"}},
|
||||
{"empty", "", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractKeywords(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("ExtractKeywords(%q) = %v (len %d), want %v (len %d)",
|
||||
tt.input, got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ import (
|
|||
)
|
||||
|
||||
func NewPicoclawCommand() *cobra.Command {
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
|
|
@ -68,6 +69,21 @@ const (
|
|||
|
||||
func main() {
|
||||
fmt.Printf("%s", banner)
|
||||
|
||||
tz_env := os.Getenv("TZ")
|
||||
if tz_env != "" {
|
||||
fmt.Println("TZ environment:", tz_env)
|
||||
zoneinfo_env := os.Getenv("ZONEINFO")
|
||||
fmt.Println("ZONEINFO environment:", zoneinfo_env)
|
||||
loc, err := time.LoadLocation(tz_env)
|
||||
if err != nil {
|
||||
fmt.Println("Error loading time zone:", err)
|
||||
} else {
|
||||
fmt.Println("Time zone loaded successfully:", loc)
|
||||
time.Local = loc //nolint:gosmopolitan // We intentionally set local timezone from TZ env
|
||||
}
|
||||
}
|
||||
|
||||
cmd := NewPicoclawCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||
|
||||
assert.Equal(t, "picoclaw", cmd.Use)
|
||||
assert.Equal(t, short, cmd.Short)
|
||||
|
|
|
|||
|
|
@ -421,7 +421,8 @@
|
|||
"enabled": true
|
||||
},
|
||||
"read_file": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"mode": "bytes"
|
||||
},
|
||||
"send_tts": {
|
||||
"enabled": false
|
||||
|
|
|
|||
|
|
@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
|
|||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||
|
||||
# Copy binary
|
||||
# Copy binary and first-run entrypoint (same as release image).
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create non-root user and group
|
||||
RUN addgroup -g 1000 picoclaw && \
|
||||
adduser -D -u 1000 -G picoclaw picoclaw
|
||||
|
||||
# Switch to non-root user
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
|||
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-public", "-no-browser"]
|
||||
CMD ["-console", "-public", "-no-browser"]
|
||||
|
|
|
|||
|
|
@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
# Copy default workspace
|
||||
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||
COPY workspace/ /root/.picoclaw/workspace/
|
||||
|
||||
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||
VOLUME /root/.picoclaw/workspace
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
|
|
|
|||
|
|
@ -45,8 +45,11 @@ services:
|
|||
- launcher
|
||||
environment:
|
||||
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||
# Set a fixed dashboard token instead of a random one each restart.
|
||||
# If not set, a random token is generated and printed to the console on startup.
|
||||
#- PICOCLAW_LAUNCHER_TOKEN=your-secret-token-here
|
||||
ports:
|
||||
- "127.0.0.1:18800:18800"
|
||||
- "127.0.0.1:18790:18790"
|
||||
- "18800:18800"
|
||||
- "18790:18790"
|
||||
volumes:
|
||||
- ./data:/root/.picoclaw
|
||||
|
|
|
|||
194
docs/channels/vk/README.md
Normal file
194
docs/channels/vk/README.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# VK (VKontakte)
|
||||
|
||||
The VK channel uses Bots Long Poll API for bot-based communication with VK social network. It supports text messages, media attachments (photos, videos, audio, documents, stickers), and group chat interactions.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"vk": {
|
||||
"enabled": true,
|
||||
"token": "NOT_HERE",
|
||||
"group_id": 123456789,
|
||||
"allow_from": ["123456789"],
|
||||
"group_trigger": {
|
||||
"mention_only": false,
|
||||
"prefixes": ["/bot", "!bot"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | ------ | -------- | ------------------------------------------------------------------ |
|
||||
| enabled | bool | Yes | Whether to enable the VK channel |
|
||||
| token | string | Yes | Set to `NOT_HERE` - token is stored securely (see Token Storage) |
|
||||
| group_id | int | Yes | VK Community ID (Group ID) |
|
||||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||
| group_trigger | object | No | Configuration for group chat triggers |
|
||||
|
||||
### Token Storage
|
||||
|
||||
For security reasons, the VK access token should not be stored directly in the configuration file. Instead:
|
||||
|
||||
1. Set `token` to `"NOT_HERE"` in the configuration
|
||||
2. Store the actual token using one of these methods:
|
||||
- **Environment variable**: Set `PICOCLAW_CHANNELS_VK_TOKEN` environment variable
|
||||
- **Secure storage**: Use PicoClaw's secure token storage mechanism
|
||||
|
||||
Example using environment variable:
|
||||
```bash
|
||||
export PICOCLAW_CHANNELS_VK_TOKEN="vk1.a.abc123..."
|
||||
```
|
||||
|
||||
### Group Trigger Configuration
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | -------- | ------------------------------------------------------------------ |
|
||||
| mention_only | bool | Only respond when bot is mentioned in group chats |
|
||||
| prefixes | []string | List of prefixes that trigger bot response in group chats |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a VK Community
|
||||
|
||||
1. Go to [VK](https://vk.com) and log in
|
||||
2. Create a new community or use an existing one
|
||||
3. Note your Community ID (found in the community URL, e.g., `public123456789`)
|
||||
|
||||
### 2. Enable Messages
|
||||
|
||||
1. Go to your community page
|
||||
2. Click "Manage" → "Messages" → "Community Messages"
|
||||
3. Enable community messages
|
||||
|
||||
### 3. Create Access Token
|
||||
|
||||
1. Go to "Manage" → "API usage" → "Access tokens"
|
||||
2. Click "Create token"
|
||||
3. Select the following permissions:
|
||||
- `messages` - Access to messages
|
||||
- `photos` - Access to photos (optional)
|
||||
- `docs` - Access to documents (optional)
|
||||
4. Copy the generated access token
|
||||
5. Store the token securely (see Token Storage section below)
|
||||
|
||||
### 4. Configure PicoClaw
|
||||
|
||||
1. Add the token to your PicoClaw configuration
|
||||
2. Set the `group_id` to your community ID (numeric value)
|
||||
3. (Optional) Configure `allow_from` to restrict which user IDs can interact
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Message Types
|
||||
|
||||
- **Text messages**: Full support for text messages
|
||||
- **Photos**: Photos are displayed as `[photo]` placeholder
|
||||
- **Videos**: Videos are displayed as `[video]` placeholder
|
||||
- **Audio**: Audio files are displayed as `[audio]` placeholder
|
||||
- **Voice messages**: Voice messages are displayed as `[voice]` placeholder and support transcription
|
||||
- **Documents**: Documents are displayed as `[document: filename]`
|
||||
- **Stickers**: Stickers are displayed as `[sticker]` placeholder
|
||||
|
||||
### Voice Support
|
||||
|
||||
The VK channel supports both voice message reception and text-to-speech capabilities:
|
||||
|
||||
- **ASR (Automatic Speech Recognition)**: Voice messages can be transcribed to text using configured voice models
|
||||
- **TTS (Text-to-Speech)**: Text responses can be converted to voice messages
|
||||
|
||||
To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../providers.md#voice-transcription) for details.
|
||||
|
||||
### Group Chat Support
|
||||
|
||||
The VK channel supports group chats with configurable triggers:
|
||||
|
||||
- **Mention-only mode**: Bot only responds when mentioned
|
||||
- **Prefix mode**: Bot responds to messages starting with specified prefixes
|
||||
- **Permissive mode**: Bot responds to all messages (default)
|
||||
|
||||
### Message Length
|
||||
|
||||
VK has a maximum message length of 4000 characters. PicoClaw automatically splits longer messages into multiple parts.
|
||||
|
||||
## Example Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"vk": {
|
||||
"enabled": true,
|
||||
"token": "NOT_HERE",
|
||||
"group_id": 123456789
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With User Whitelist
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"vk": {
|
||||
"enabled": true,
|
||||
"token": "NOT_HERE",
|
||||
"group_id": 123456789,
|
||||
"allow_from": ["123456789", "987654321"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Group Chat Triggers
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"vk": {
|
||||
"enabled": true,
|
||||
"token": "NOT_HERE",
|
||||
"group_id": 123456789,
|
||||
"group_trigger": {
|
||||
"prefixes": ["/bot", "!bot"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot Not Responding
|
||||
|
||||
1. Check that the access token is valid
|
||||
2. Verify that the `group_id` is correct
|
||||
3. Ensure the user ID is in `allow_from` if configured
|
||||
4. Check PicoClaw logs for error messages
|
||||
|
||||
### Permission Errors
|
||||
|
||||
Make sure the access token has the necessary permissions:
|
||||
- `messages` - Required for sending and receiving messages
|
||||
- `photos` - Optional, for handling photo attachments
|
||||
- `docs` - Optional, for handling document attachments
|
||||
|
||||
### Group Chat Issues
|
||||
|
||||
If the bot doesn't respond in group chats:
|
||||
1. Check `group_trigger` configuration
|
||||
2. Try using a prefix to trigger the bot
|
||||
3. Check if the bot has permission to read group messages
|
||||
|
||||
## API Reference
|
||||
|
||||
The VK channel uses the [VK SDK for Go](https://github.com/SevereCloud/vksdk) library, which supports VK API version 5.199.
|
||||
|
||||
For more information about VK API, see:
|
||||
- [VK API Documentation](https://dev.vk.com/en)
|
||||
- [VK Bots Long Poll API](https://dev.vk.com/en/api/bots-long-poll/getting-started)
|
||||
|
|
@ -301,6 +301,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
|||
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
||||
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
|
||||
|
||||
### Read File Mode
|
||||
|
||||
`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup:
|
||||
|
||||
| Config Key | Type | Default | Description |
|
||||
|------------|------|---------|-------------|
|
||||
| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool |
|
||||
| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` |
|
||||
| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` |
|
||||
|
||||
#### Mode: `bytes`
|
||||
|
||||
Optimized for arbitrary files and binary-safe pagination.
|
||||
|
||||
Parameters:
|
||||
|
||||
* `path` (required): File path
|
||||
* `offset` (optional): Starting byte offset, default `0`
|
||||
* `length` (optional): Maximum number of bytes to read, default `max_read_file_size`
|
||||
|
||||
Use `bytes` when:
|
||||
|
||||
* You may read binary files
|
||||
* You want deterministic byte-range pagination
|
||||
|
||||
#### Mode: `lines`
|
||||
|
||||
Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached.
|
||||
|
||||
Parameters:
|
||||
|
||||
* `path` (required): File path
|
||||
* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1`
|
||||
* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget
|
||||
|
||||
Behavior notes:
|
||||
|
||||
* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes`
|
||||
* Extremely long single lines are truncated rather than skipped
|
||||
|
||||
Use `mode = lines` when:
|
||||
|
||||
* The agent mostly reads text files
|
||||
* You want line-based pagination in prompts and tool calls
|
||||
* You want cleaner chunks for code review, logs, and documentation
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"read_file": {
|
||||
"enabled": true,
|
||||
"mode": "lines",
|
||||
"max_read_file_size": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exec Security
|
||||
|
||||
| Config Key | Type | Default | Description |
|
||||
|
|
|
|||
|
|
@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
|||
}
|
||||
```
|
||||
|
||||
#### Champs d'entrée `model_list`
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent |
|
||||
| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Non | Remplace l'URL de base API par défaut |
|
||||
| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle |
|
||||
| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) |
|
||||
| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) |
|
||||
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
|
||||
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
|
||||
| `rpm` | int | Non | Limite de requêtes par minute |
|
||||
| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
|
||||
| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
|
||||
|
||||
#### Exemples par Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
|
@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -28,6 +28,69 @@ The currently exposed synchronous hook points are:
|
|||
|
||||
Everything else is exposed as read-only events.
|
||||
|
||||
## Hook Actions
|
||||
|
||||
Hooks can return different actions to control the flow:
|
||||
|
||||
| Action | Applicable Stages | Effect |
|
||||
| --- | --- | --- |
|
||||
| `continue` | All interceptors | Pass through without modification |
|
||||
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | Modify request/response and continue |
|
||||
| `respond` | `before_tool` | Return a tool result directly, skip actual tool execution |
|
||||
| `deny_tool` | `before_tool` | Deny tool execution, return error message |
|
||||
| `abort_turn` | All interceptors | Abort the current turn |
|
||||
| `hard_abort` | All interceptors | Force stop the entire agent loop |
|
||||
|
||||
### The `respond` Action
|
||||
|
||||
The `respond` action is special: it allows a `before_tool` hook to provide the tool result directly, skipping the actual tool execution. This is useful for:
|
||||
|
||||
1. **Plugin tool injection**: External hooks can implement tools without registering them in the tool registry
|
||||
2. **Tool result caching**: Return cached results for repeated tool calls
|
||||
3. **Tool mocking**: Return mock results for testing purposes
|
||||
|
||||
When a hook returns `respond` with a `HookResult`, the agent loop:
|
||||
1. Skips the actual tool execution
|
||||
2. Uses the provided result as if the tool had executed
|
||||
3. Continues the turn normally with the result
|
||||
|
||||
Example (Go in-process hook):
|
||||
|
||||
```go
|
||||
func (h *MyHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "my_plugin_tool" {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: "Plugin tool executed successfully",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Example (Python process hook):
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
if tool == "my_plugin_tool":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
`HookManager` sorts hooks like this:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,69 @@
|
|||
|
||||
其余 lifecycle 通过事件形式只读暴露。
|
||||
|
||||
## Hook Actions
|
||||
|
||||
Hook 可以返回不同的 action 来控制流程:
|
||||
|
||||
| Action | 适用阶段 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| `continue` | 所有拦截型 | 放行,不做修改 |
|
||||
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | 改写请求/响应后放行 |
|
||||
| `respond` | `before_tool` | 直接返回工具结果,跳过实际工具执行 |
|
||||
| `deny_tool` | `before_tool` | 拒绝工具执行,返回错误信息 |
|
||||
| `abort_turn` | 所有拦截型 | 中止当前 turn |
|
||||
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
|
||||
|
||||
### `respond` Action
|
||||
|
||||
`respond` action 是特殊的:它允许 `before_tool` hook 直接提供工具结果,跳过实际工具执行。适用于:
|
||||
|
||||
1. **插件工具注入**:外部 hook 可以实现工具,无需在 ToolRegistry 注册
|
||||
2. **工具结果缓存**:对重复调用返回缓存结果
|
||||
3. **工具模拟**:测试时返回模拟结果
|
||||
|
||||
当 hook 返回 `respond` 并携带 `HookResult` 时,agent loop 会:
|
||||
1. 跳过实际工具执行
|
||||
2. 使用提供的结果作为工具执行结果
|
||||
3. 正常继续 turn 流程
|
||||
|
||||
示例(Go 进程内 hook):
|
||||
|
||||
```go
|
||||
func (h *MyHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "my_plugin_tool" {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: "Plugin tool executed successfully",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
```
|
||||
|
||||
示例(Python process hook):
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
if tool == "my_plugin_tool":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
## 执行顺序
|
||||
|
||||
HookManager 的排序规则是:
|
||||
|
|
|
|||
568
docs/hooks/hook-json-protocol.md
Normal file
568
docs/hooks/hook-json-protocol.md
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
# Hook JSON-RPC Protocol Details
|
||||
|
||||
All hooks use `JSON-RPC 2.0` format, with one JSON message per line, transmitted via stdio.
|
||||
|
||||
---
|
||||
|
||||
## Basic Protocol Structure
|
||||
|
||||
### Request (PicoClaw → Hook)
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
|
||||
```
|
||||
|
||||
### Response (Hook → PicoClaw)
|
||||
|
||||
Success:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{...}}
|
||||
```
|
||||
|
||||
Error:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `hook.hello` (Handshake)
|
||||
|
||||
Handshake must be completed at startup, otherwise the hook process will be terminated.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "hook.hello",
|
||||
"params": {
|
||||
"name": "py_review_gate",
|
||||
"version": 1,
|
||||
"modes": ["observe", "tool", "approve"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | hook name (from configuration) |
|
||||
| `version` | protocol version, currently `1` |
|
||||
| `modes` | capability modes supported by the hook |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"name": "python-review-gate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `hook.before_llm`
|
||||
|
||||
Triggered before sending request to LLM. Can be used to inject tools.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "hook.before_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"ParentTurnID": "",
|
||||
"SessionKey": "session-1",
|
||||
"Iteration": 0,
|
||||
"TracePath": "runTurn",
|
||||
"Source": "turn.llm.request"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo text",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1",
|
||||
"graceful_terminal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `meta` | event metadata for tracing |
|
||||
| `model` | requested model name |
|
||||
| `messages` | conversation history |
|
||||
| `tools` | list of available tool definitions |
|
||||
| `options` | LLM parameters (temperature, max_tokens, etc.) |
|
||||
| `channel` | request source channel |
|
||||
| `chat_id` | session ID |
|
||||
|
||||
### Response (Tool Injection Example)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo",
|
||||
"parameters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "Plugin injected tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `action` | decision action (see table below) |
|
||||
| `request` | modified request object |
|
||||
|
||||
---
|
||||
|
||||
## 3. `hook.after_llm`
|
||||
|
||||
Triggered after receiving LLM response. Can modify response content.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "hook.after_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"response": {
|
||||
"role": "assistant",
|
||||
"content": "Hi!",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"arguments": "{\"text\":\"hi\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `hook.before_tool`
|
||||
|
||||
Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "hook.before_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `tool` | tool name |
|
||||
| `arguments` | tool arguments |
|
||||
|
||||
### Response (Modify Arguments)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"call": {
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "modified hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Deny Execution)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "deny_tool",
|
||||
"reason": "Invalid arguments"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Return Result Directly - respond)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "respond",
|
||||
"call": {
|
||||
"tool": "my_plugin_tool",
|
||||
"arguments": {
|
||||
"query": "hello"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `respond` action allows hooks to return tool results directly, skipping actual tool execution. Use cases:
|
||||
1. **Plugin tool injection**: External hooks can implement tools without registering in ToolRegistry
|
||||
2. **Tool result caching**: Return cached results for repeated calls
|
||||
3. **Tool mocking**: Return mock results during testing
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `action` | must be `respond` |
|
||||
| `call` | modified call information (optional) |
|
||||
| `result` | tool result to return directly |
|
||||
|
||||
---
|
||||
|
||||
## 5. `hook.after_tool`
|
||||
|
||||
Triggered after tool execution completes. Can modify the result returned to LLM.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "hook.after_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "echoed: hello",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"async": false,
|
||||
"media": [],
|
||||
"artifact_tags": [],
|
||||
"response_handled": false
|
||||
},
|
||||
"duration": 15000000,
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `result.for_llm` | content returned to LLM |
|
||||
| `result.for_user` | content sent to user |
|
||||
| `result.silent` | whether silent (not sent to user) |
|
||||
| `result.is_error` | whether it's an error |
|
||||
| `result.async` | whether executed asynchronously |
|
||||
| `result.media` | list of media references |
|
||||
| `result.artifact_tags` | local artifact path tags |
|
||||
| `result.response_handled` | whether response has been handled |
|
||||
| `duration` | execution time (nanoseconds) |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `hook.approve_tool`
|
||||
|
||||
Approval hook for deciding whether to allow execution of sensitive tools.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"method": "hook.approve_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "bash",
|
||||
"arguments": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Approved)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Denied)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": false,
|
||||
"reason": "Dangerous command, execution denied"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event` (notification)
|
||||
|
||||
Observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
},
|
||||
"Payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common `Kind` values:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## Action Options
|
||||
|
||||
| action | Applicable hooks | Effect |
|
||||
|--------|-----------------|--------|
|
||||
| `continue` | All interceptor types | Pass through without modification |
|
||||
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | Modify request/response and pass through |
|
||||
| `respond` | `before_tool` | Return tool result directly, skip actual execution. **Note: AfterTool is NOT called (design decision - respond provides final answer).** |
|
||||
| `deny_tool` | `before_tool` | Deny tool execution |
|
||||
| `abort_turn` | All interceptor types | Abort current turn, return error |
|
||||
| `hard_abort` | All interceptor types | Force stop entire agent loop |
|
||||
|
||||
---
|
||||
|
||||
## Complete Flow Example
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
|
||||
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
|
||||
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
|
||||
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}}
|
||||
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Tool Injection via `before_llm` and `before_tool`
|
||||
|
||||
Standard flow for plugin tool injection:
|
||||
|
||||
1. In `before_llm`, inject tool definition to let LLM know the tool is available
|
||||
2. In `before_tool`, use `respond` action to return tool execution result directly
|
||||
|
||||
### `before_llm` Inject Tool Definition
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# Add plugin tool definition
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "Plugin provided tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": "Input content"}
|
||||
},
|
||||
"required": ["input"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params["model"],
|
||||
"messages": params["messages"],
|
||||
"tools": tools,
|
||||
"options": params.get("options", {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` Return Execution Result
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# Implement tool logic here
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
# Return result directly, no need to register in ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"Plugin tool executed successfully, input: {input_text}",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
568
docs/hooks/hook-json-protocol.zh.md
Normal file
568
docs/hooks/hook-json-protocol.zh.md
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
# Hook JSON-RPC 协议详解
|
||||
|
||||
所有 hook 使用 `JSON-RPC 2.0` 格式,每行一个 JSON 消息,通过 stdio 传输。
|
||||
|
||||
---
|
||||
|
||||
## 基础协议结构
|
||||
|
||||
### 请求(PicoClaw → Hook)
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
|
||||
```
|
||||
|
||||
### 响应(Hook → PicoClaw)
|
||||
|
||||
成功:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{...}}
|
||||
```
|
||||
|
||||
错误:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"错误信息"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `hook.hello`(握手)
|
||||
|
||||
启动时必须完成握手,否则 hook 进程会被终止。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "hook.hello",
|
||||
"params": {
|
||||
"name": "py_review_gate",
|
||||
"version": 1,
|
||||
"modes": ["observe", "tool", "approve"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | hook 名称(来自配置) |
|
||||
| `version` | 协议版本,当前为 `1` |
|
||||
| `modes` | hook 支持的能力模式 |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"name": "python-review-gate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `hook.before_llm`
|
||||
|
||||
在发送请求给 LLM 之前触发。可用于注入工具。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "hook.before_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"ParentTurnID": "",
|
||||
"SessionKey": "session-1",
|
||||
"Iteration": 0,
|
||||
"TracePath": "runTurn",
|
||||
"Source": "turn.llm.request"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo text",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1",
|
||||
"graceful_terminal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meta` | 事件元数据,用于追踪 |
|
||||
| `model` | 请求的模型名称 |
|
||||
| `messages` | 对话历史 |
|
||||
| `tools` | 可用工具定义列表 |
|
||||
| `options` | LLM 参数(temperature、max_tokens 等) |
|
||||
| `channel` | 请求来源通道 |
|
||||
| `chat_id` | 会话 ID |
|
||||
|
||||
### 响应(注入工具示例)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo",
|
||||
"parameters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "插件注入的工具",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `action` | 决策动作(见下表) |
|
||||
| `request` | 修改后的请求对象 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `hook.after_llm`
|
||||
|
||||
在收到 LLM 响应后触发。可修改响应内容。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "hook.after_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"response": {
|
||||
"role": "assistant",
|
||||
"content": "Hi!",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"arguments": "{\"text\":\"hi\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `hook.before_tool`
|
||||
|
||||
在执行工具前触发。可修改工具名称和参数,或拒绝执行,或直接返回结果。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "hook.before_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tool` | 工具名称 |
|
||||
| `arguments` | 工具参数 |
|
||||
|
||||
### 响应(改写参数)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"call": {
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "modified hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(拒绝执行)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "deny_tool",
|
||||
"reason": "参数不合法"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(直接返回结果 - respond)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "respond",
|
||||
"call": {
|
||||
"tool": "my_plugin_tool",
|
||||
"arguments": {
|
||||
"query": "hello"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`respond` action 允许 hook 直接返回工具结果,跳过实际工具执行。适用于:
|
||||
1. **插件工具注入**:外部 hook 可实现工具,无需在 ToolRegistry 注册
|
||||
2. **工具结果缓存**:对重复调用返回缓存结果
|
||||
3. **工具模拟**:测试时返回模拟结果
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `action` | 必须为 `respond` |
|
||||
| `call` | 修改后的调用信息(可选) |
|
||||
| `result` | 直接返回的工具结果 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `hook.after_tool`
|
||||
|
||||
在工具执行完成后触发。可修改返回给 LLM 的结果。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "hook.after_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "echoed: hello",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"async": false,
|
||||
"media": [],
|
||||
"artifact_tags": [],
|
||||
"response_handled": false
|
||||
},
|
||||
"duration": 15000000,
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result.for_llm` | 返回给 LLM 的内容 |
|
||||
| `result.for_user` | 发送给用户的内容 |
|
||||
| `result.silent` | 是否静默(不发送给用户) |
|
||||
| `result.is_error` | 是否为错误 |
|
||||
| `result.async` | 是否异步执行 |
|
||||
| `result.media` | 媒体引用列表 |
|
||||
| `result.artifact_tags` | 本地产物路径标签 |
|
||||
| `result.response_handled` | 是否已处理响应 |
|
||||
| `duration` | 执行耗时(纳秒) |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `hook.approve_tool`
|
||||
|
||||
审批型 hook,用于决定是否允许执行敏感工具。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"method": "hook.approve_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "bash",
|
||||
"arguments": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(批准)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(拒绝)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": false,
|
||||
"reason": "危险命令,禁止执行"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event`(notification)
|
||||
|
||||
观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
},
|
||||
"Payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见 `Kind` 值:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## action 可选值
|
||||
|
||||
| action | 适用 hook | 效果 |
|
||||
|--------|----------|------|
|
||||
| `continue` | 所有拦截型 | 放行,不做修改 |
|
||||
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | 改写请求/响应后放行 |
|
||||
| `respond` | `before_tool` | 直接返回工具结果,跳过实际执行 |
|
||||
| `deny_tool` | `before_tool` | 拒绝执行该工具 |
|
||||
| `abort_turn` | 所有拦截型 | 中止当前 turn,返回错误 |
|
||||
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
|
||||
|
||||
---
|
||||
|
||||
## 完整流程示例
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
|
||||
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
|
||||
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
|
||||
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"已列出文件"}}}
|
||||
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通过 `before_llm` 和 `before_tool` 实现插件工具注入
|
||||
|
||||
插件工具注入的标准流程:
|
||||
|
||||
1. 在 `before_llm` 中注入工具定义,让 LLM 知道有这个工具可用
|
||||
2. 在 `before_tool` 中使用 `respond` action 直接返回工具执行结果
|
||||
|
||||
### `before_llm` 注入工具定义
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# 添加插件工具定义
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "插件提供的工具",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": "输入内容"}
|
||||
},
|
||||
"required": ["input"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params["model"],
|
||||
"messages": params["messages"],
|
||||
"tools": tools,
|
||||
"options": params.get("options", {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` 返回执行结果
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# 在这里实现工具逻辑
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
# 直接返回结果,无需在 ToolRegistry 注册
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"插件工具执行成功,输入: {input_text}",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
587
docs/hooks/plugin-tool-injection.md
Normal file
587
docs/hooks/plugin-tool-injection.md
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# Plugin Tool Injection Example
|
||||
|
||||
This document demonstrates how to use PicoClaw's hook system to implement external plugin tool injection, allowing LLM to call tools implemented by external hook processes.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
Through the hook system's `respond` action, external hooks can:
|
||||
|
||||
1. Inject tool **definitions** in `before_llm`, letting LLM know the tool is available
|
||||
2. Return tool **execution results** directly in `before_tool` using `respond` action, skipping ToolRegistry
|
||||
|
||||
This way, external hooks can fully implement plugin tools without registering any tools inside PicoClaw.
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Weather Query Plugin
|
||||
|
||||
Below is a complete Python hook example implementing a weather query plugin tool.
|
||||
|
||||
### 1. Hook Script Implementation
|
||||
|
||||
Save as `/tmp/weather_plugin.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Weather query plugin hook example"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import signal
|
||||
from typing import Any
|
||||
|
||||
# Simulated weather data
|
||||
WEATHER_DATA = {
|
||||
"Beijing": {"temp": 15, "weather": "Sunny", "humidity": 45},
|
||||
"Shanghai": {"temp": 18, "weather": "Cloudy", "humidity": 60},
|
||||
"Guangzhou": {"temp": 25, "weather": "Sunny", "humidity": 70},
|
||||
"Shenzhen": {"temp": 26, "weather": "Cloudy", "humidity": 75},
|
||||
}
|
||||
|
||||
|
||||
def get_weather(city: str) -> dict:
|
||||
"""Get weather data (simulated)"""
|
||||
data = WEATHER_DATA.get(city)
|
||||
if data:
|
||||
return {
|
||||
"for_llm": f"{city} weather: {data['weather']}, temperature {data['temp']}°C, humidity {data['humidity']}%",
|
||||
"for_user": "",
|
||||
"silent": False,
|
||||
"is_error": False,
|
||||
}
|
||||
return {
|
||||
"for_llm": f"Weather data not found for city {city}",
|
||||
"for_user": "",
|
||||
"silent": False,
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
|
||||
def handle_hello(params: dict) -> dict:
|
||||
return {"ok": True, "name": "weather-plugin"}
|
||||
|
||||
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
"""Inject weather query tool definition"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# Add weather query tool
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Query weather information for a specified city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name, e.g.: Beijing, Shanghai, Guangzhou"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params.get("model"),
|
||||
"messages": params.get("messages", []),
|
||||
"tools": tools,
|
||||
"options": params.get("options", {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
"""Handle tool call, return result directly"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
# Use respond action to return result directly, skip ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
# Other tools continue normal flow
|
||||
return {"action": "continue"}
|
||||
|
||||
|
||||
def handle_request(method: str, params: dict) -> dict:
|
||||
if method == "hook.hello":
|
||||
return handle_hello(params)
|
||||
if method == "hook.before_llm":
|
||||
return handle_before_llm(params)
|
||||
if method == "hook.before_tool":
|
||||
return handle_before_tool(params)
|
||||
if method == "hook.after_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_tool":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.approve_tool":
|
||||
return {"approved": True}
|
||||
raise KeyError(f"method not found: {method}")
|
||||
|
||||
|
||||
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
}
|
||||
if error is not None:
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
except KeyError as exc:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
|
||||
signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
|
||||
raise SystemExit(main())
|
||||
```
|
||||
|
||||
### 2. Configure PicoClaw
|
||||
|
||||
Add hook configuration in the config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"weather_plugin": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": ["python3", "/tmp/weather_plugin.py"],
|
||||
"intercept": ["before_llm", "before_tool"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Results
|
||||
|
||||
When user asks "What's the weather in Beijing today?":
|
||||
|
||||
1. PicoClaw sends `hook.before_llm`, hook injects `get_weather` tool definition
|
||||
2. LLM sees tool definition, decides to call `get_weather(city="Beijing")`
|
||||
3. PicoClaw sends `hook.before_tool`, hook uses `respond` action to return weather data
|
||||
4. LLM receives result, replies to user "Beijing is sunny today, temperature 15°C"
|
||||
|
||||
---
|
||||
|
||||
## Flow Diagram
|
||||
|
||||
```
|
||||
User: "What's the weather in Beijing today?"
|
||||
↓
|
||||
PicoClaw
|
||||
↓
|
||||
hook.before_llm
|
||||
↓ (inject get_weather tool definition)
|
||||
LLM request
|
||||
↓
|
||||
LLM decides to call get_weather(city="Beijing")
|
||||
↓
|
||||
hook.before_tool
|
||||
↓ (respond action returns weather data)
|
||||
Return result directly to LLM
|
||||
↓ (skip ToolRegistry)
|
||||
LLM replies: "Beijing is sunny today, temperature 15°C"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Points
|
||||
|
||||
### `before_llm` Inject Tool Definition
|
||||
|
||||
Tool definition follows OpenAI function calling format:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_name",
|
||||
"description": "tool description",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param_name": {
|
||||
"type": "string",
|
||||
"description": "parameter description"
|
||||
}
|
||||
},
|
||||
"required": ["list of required parameters"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` Use respond Action
|
||||
|
||||
`respond` action response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Content returned to LLM",
|
||||
"for_user": "Optional, content sent to user",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"media": ["Optional, media reference list"],
|
||||
"response_handled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `for_llm` | Required, LLM will see this content |
|
||||
| `for_user` | Optional, sent directly to user |
|
||||
| `silent` | When true, not sent to user |
|
||||
| `is_error` | When true, indicates execution failure |
|
||||
| `media` | Optional, media file references (images, files, etc.) |
|
||||
| `response_handled` | When true, indicates user request is handled, turn will end |
|
||||
|
||||
---
|
||||
|
||||
## Media File Handling
|
||||
|
||||
The `respond` action supports returning media files (images, files, etc.). There are two processing modes:
|
||||
|
||||
### 1. Automatic Delivery (`response_handled=true`)
|
||||
|
||||
When `response_handled=true`, media files are automatically sent to the user and the turn ends:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Image sent to user",
|
||||
"for_user": "",
|
||||
"media": ["media://abc123"],
|
||||
"response_handled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use cases:
|
||||
- Image generation plugin directly returning results
|
||||
- File download plugin sending files to user
|
||||
|
||||
### 2. LLM Visible (`response_handled=false`)
|
||||
|
||||
When `response_handled=false`, media references are passed to the LLM, which can see the content in the next request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Image loaded, path: /tmp/image.png [file:/tmp/image.png]",
|
||||
"media": ["media://abc123"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After seeing the content, the LLM can decide:
|
||||
- Use `send_file` tool to send to user
|
||||
- Analyze image content and reply to user
|
||||
- Other processing approaches
|
||||
|
||||
### Media Reference Format
|
||||
|
||||
Media references use the `media://` protocol:
|
||||
|
||||
```
|
||||
media://<store-id>
|
||||
```
|
||||
|
||||
These references are managed by PicoClaw's MediaStore and can be:
|
||||
- Sent to user via channel
|
||||
- Converted to base64 in LLM vision requests
|
||||
|
||||
### Alternative: Use Existing Tools
|
||||
|
||||
If the plugin generates files, you can return the file path and let the LLM call `send_file` or similar tools:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Image generated, saved at /tmp/generated_image.png. Use send_file tool to send to user.",
|
||||
"for_user": "",
|
||||
"silent": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
- More decoupled, LLM decides when to send
|
||||
- Leverages existing tool mechanisms
|
||||
- Supports batch sending, delayed sending, etc.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tool Injection Example
|
||||
|
||||
Multiple tools can be injected simultaneously:
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# Tool 1: Weather query
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Query city weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Tool 2: Calculator
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculate",
|
||||
"description": "Perform mathematical calculations",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "Mathematical expression"}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params.get("model"),
|
||||
"messages": params.get("messages", []),
|
||||
"tools": tools,
|
||||
"options": params.get("options", {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
if tool == "calculate":
|
||||
# Simple calculation example
|
||||
try:
|
||||
expr = args.get("expression", "")
|
||||
result = eval(expr) # Note: needs security handling in actual use
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"Calculation result: {result}",
|
||||
"silent": False,
|
||||
"is_error": False,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"Calculation error: {e}",
|
||||
"silent": False,
|
||||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coexistence with Built-in Tools
|
||||
|
||||
Injected plugin tools coexist with PicoClaw built-in tools:
|
||||
|
||||
- Built-in tools (like `bash`, `read_file`) execute normally through ToolRegistry
|
||||
- Plugin tools return results through hook's `respond` action
|
||||
- `handle_before_tool` only handles plugin tools, other tools return `continue`
|
||||
|
||||
---
|
||||
|
||||
## Go In-Process Hook Example
|
||||
|
||||
If you need to implement plugin tool injection in Go code:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
type WeatherPluginHook struct{}
|
||||
|
||||
func (h *WeatherPluginHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *agent.LLMHookRequest,
|
||||
) (*agent.LLMHookRequest, agent.HookDecision, error) {
|
||||
// Inject tool definition
|
||||
req.Tools = append(req.Tools, agent.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: agent.FunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Query city weather",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
"required": []string{"city"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *WeatherPluginHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
// Set HookResult, use respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: getWeatherData(city),
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func getWeatherData(city string) string {
|
||||
// Implement weather query logic
|
||||
return fmt.Sprintf("%s weather: Sunny, temperature 20°C", city)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Through the hook system's `respond` action, external processes can:
|
||||
|
||||
1. **Inject tool definitions**: Let LLM know new tools are available
|
||||
2. **Provide tool implementation**: Return execution results directly, no need to register in ToolRegistry
|
||||
3. **Coexist with built-in tools**: Does not affect normal operation of PicoClaw's original tools
|
||||
|
||||
This provides a flexible and elegant solution for plugin development.
|
||||
|
||||
---
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### Bypassing Approval Checks
|
||||
|
||||
**Important**: The `respond` action bypasses `ApproveTool` approval checks.
|
||||
|
||||
This means:
|
||||
- A `before_tool` hook can return `respond` for **any tool name**, including sensitive tools (like `bash`)
|
||||
- The tool won't go through the approval process, directly returning the hook-provided result
|
||||
- This is designed for plugin tools but introduces security risks
|
||||
|
||||
### Security Recommendations
|
||||
|
||||
1. **Review hook configuration**: Ensure only trusted hook processes are enabled
|
||||
2. **Limit hook scope**: Add your own security checks in hook implementation
|
||||
3. **Use `deny_tool` for rejection**: Use `deny_tool` action instead of `respond` with error for denying execution
|
||||
|
||||
### Example: Hook-Internal Security Check
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
# Security check: only handle plugin tools
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
# Other tools continue normal flow (will go through approval)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
This ensures the hook only affects plugin tools, not system tool approval flow.
|
||||
587
docs/hooks/plugin-tool-injection.zh.md
Normal file
587
docs/hooks/plugin-tool-injection.zh.md
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# 插件工具注入示例
|
||||
|
||||
本文档展示如何利用 PicoClaw 的 hook 系统实现外部插件工具注入,让 LLM 能调用由外部 hook 进程实现的工具。
|
||||
|
||||
---
|
||||
|
||||
## 核心原理
|
||||
|
||||
通过 hook 系统的 `respond` action,外部 hook 可以:
|
||||
|
||||
1. 在 `before_llm` 中注入工具**定义**,让 LLM 知道有这个工具可用
|
||||
2. 在 `before_tool` 中使用 `respond` action 直接返回工具**执行结果**,跳过 ToolRegistry
|
||||
|
||||
这样,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例:天气查询插件
|
||||
|
||||
下面是一个完整的 Python hook 示例,实现一个天气查询插件工具。
|
||||
|
||||
### 1. Hook 脚本实现
|
||||
|
||||
保存为 `/tmp/weather_plugin.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""天气查询插件 hook 示例"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import signal
|
||||
from typing import Any
|
||||
|
||||
# 模拟天气数据
|
||||
WEATHER_DATA = {
|
||||
"北京": {"temp": 15, "weather": "晴", "humidity": 45},
|
||||
"上海": {"temp": 18, "weather": "多云", "humidity": 60},
|
||||
"广州": {"temp": 25, "weather": "晴", "humidity": 70},
|
||||
"深圳": {"temp": 26, "weather": "多云", "humidity": 75},
|
||||
}
|
||||
|
||||
|
||||
def get_weather(city: str) -> dict:
|
||||
"""获取天气数据(模拟)"""
|
||||
data = WEATHER_DATA.get(city)
|
||||
if data:
|
||||
return {
|
||||
"for_llm": f"{city}天气:{data['weather']},温度{data['temp']}°C,湿度{data['humidity']}%",
|
||||
"for_user": "",
|
||||
"silent": False,
|
||||
"is_error": False,
|
||||
}
|
||||
return {
|
||||
"for_llm": f"未找到城市 {city} 的天气数据",
|
||||
"for_user": "",
|
||||
"silent": False,
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
|
||||
def handle_hello(params: dict) -> dict:
|
||||
return {"ok": True, "name": "weather-plugin"}
|
||||
|
||||
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
"""注入天气查询工具定义"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# 添加天气查询工具
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "查询指定城市的天气信息",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "城市名称,如:北京、上海、广州"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params.get("model"),
|
||||
"messages": params.get("messages", []),
|
||||
"tools": tools,
|
||||
"options": params.get("options", {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
"""处理工具调用,直接返回结果"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
# 使用 respond action 直接返回结果,跳过 ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
# 其他工具继续正常流程
|
||||
return {"action": "continue"}
|
||||
|
||||
|
||||
def handle_request(method: str, params: dict) -> dict:
|
||||
if method == "hook.hello":
|
||||
return handle_hello(params)
|
||||
if method == "hook.before_llm":
|
||||
return handle_before_llm(params)
|
||||
if method == "hook.before_tool":
|
||||
return handle_before_tool(params)
|
||||
if method == "hook.after_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_tool":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.approve_tool":
|
||||
return {"approved": True}
|
||||
raise KeyError(f"method not found: {method}")
|
||||
|
||||
|
||||
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
}
|
||||
if error is not None:
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
except KeyError as exc:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
|
||||
signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
|
||||
raise SystemExit(main())
|
||||
```
|
||||
|
||||
### 2. 配置 PicoClaw
|
||||
|
||||
在配置文件中添加 hook 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"weather_plugin": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": ["python3", "/tmp/weather_plugin.py"],
|
||||
"intercept": ["before_llm", "before_tool"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 测试效果
|
||||
|
||||
当用户问"北京今天天气怎么样?"时:
|
||||
|
||||
1. PicoClaw 发送 `hook.before_llm`,hook 注入 `get_weather` 工具定义
|
||||
2. LLM 看到工具定义,决定调用 `get_weather(city="北京")`
|
||||
3. PicoClaw 发送 `hook.before_tool`,hook 使用 `respond` action 返回天气数据
|
||||
4. LLM 收到结果,回复用户"北京今天晴天,温度15°C"
|
||||
|
||||
---
|
||||
|
||||
## 流程图解
|
||||
|
||||
```
|
||||
用户: "北京今天天气怎么样?"
|
||||
↓
|
||||
PicoClaw
|
||||
↓
|
||||
hook.before_llm
|
||||
↓ (注入 get_weather 工具定义)
|
||||
LLM 请求
|
||||
↓
|
||||
LLM 决定调用 get_weather(city="北京")
|
||||
↓
|
||||
hook.before_tool
|
||||
↓ (respond action 返回天气数据)
|
||||
直接返回结果给 LLM
|
||||
↓ (跳过 ToolRegistry)
|
||||
LLM 回复: "北京今天晴天,温度15°C"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键点说明
|
||||
|
||||
### `before_llm` 注入工具定义
|
||||
|
||||
工具定义遵循 OpenAI function calling 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "工具名称",
|
||||
"description": "工具描述",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"参数名": {
|
||||
"type": "string",
|
||||
"description": "参数描述"
|
||||
}
|
||||
},
|
||||
"required": ["必需参数列表"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` 使用 respond action
|
||||
|
||||
`respond` action 的响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "返回给 LLM 的内容",
|
||||
"for_user": "可选,发送给用户的内容",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"media": ["可选,媒体引用列表"],
|
||||
"response_handled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `for_llm` | 必须,LLM 会看到这个内容 |
|
||||
| `for_user` | 可选,直接发送给用户 |
|
||||
| `silent` | 为 true 时不发送给用户 |
|
||||
| `is_error` | 为 true 时表示执行失败 |
|
||||
| `media` | 可选,媒体文件引用列表(如图片、文件) |
|
||||
| `response_handled` | 为 true 时表示已处理用户请求,轮次将结束 |
|
||||
|
||||
---
|
||||
|
||||
## 媒体文件处理
|
||||
|
||||
`respond` action 支持返回媒体文件(图片、文件等)。有两种处理方式:
|
||||
|
||||
### 1. 自动发送(`response_handled=true`)
|
||||
|
||||
当 `response_handled=true` 时,媒体文件会自动发送给用户,轮次结束:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "图片已发送给用户",
|
||||
"for_user": "",
|
||||
"media": ["media://abc123"],
|
||||
"response_handled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
- 图像生成插件直接返回结果
|
||||
- 文件下载插件发送文件给用户
|
||||
|
||||
### 2. LLM 可见(`response_handled=false`)
|
||||
|
||||
当 `response_handled=false` 时,媒体引用会传递给 LLM,LLM 可以在下一轮请求中看到内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "图片已加载,路径:/tmp/image.png [file:/tmp/image.png]",
|
||||
"media": ["media://abc123"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
LLM 看到内容后,可以自主决定:
|
||||
- 使用 `send_file` 工具发送给用户
|
||||
- 分析图片内容并回复用户
|
||||
- 其他处理方式
|
||||
|
||||
### 媒体引用格式
|
||||
|
||||
媒体引用使用 `media://` 协议:
|
||||
|
||||
```
|
||||
media://<store-id>
|
||||
```
|
||||
|
||||
这些引用由 PicoClaw 的 MediaStore 管理,可以:
|
||||
- 通过 channel 发送给用户
|
||||
- 在 LLM vision 请求中转换为 base64
|
||||
|
||||
### 替代方案:使用现有工具
|
||||
|
||||
如果插件生成文件,可以返回文件路径让 LLM 调用 `send_file` 等工具:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "图片已生成,保存在 /tmp/generated_image.png。使用 send_file 工具发送给用户。",
|
||||
"for_user": "",
|
||||
"silent": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这种方式:
|
||||
- 更解耦,LLM 自主决策发送时机
|
||||
- 利用现有工具机制
|
||||
- 支持批量发送、延迟发送等场景
|
||||
|
||||
---
|
||||
|
||||
## 多工具注入示例
|
||||
|
||||
可以同时注入多个工具:
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# 工具1:天气查询
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "查询城市天气",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "城市名称"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# 工具2:计算器
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculate",
|
||||
"description": "执行数学计算",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "数学表达式"}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params.get("model"),
|
||||
"messages": params.get("messages", []),
|
||||
"tools": tools,
|
||||
"options": params.get("options", {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
if tool == "calculate":
|
||||
# 简单计算示例
|
||||
try:
|
||||
expr = args.get("expression", "")
|
||||
result = eval(expr) # 注意:实际使用时需要安全处理
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"计算结果: {result}",
|
||||
"silent": False,
|
||||
"is_error": False,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"计算错误: {e}",
|
||||
"silent": False,
|
||||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与内置工具共存
|
||||
|
||||
注入的插件工具与 PicoClaw 内置工具共存:
|
||||
|
||||
- 内置工具(如 `bash`、`read_file`)正常通过 ToolRegistry 执行
|
||||
- 插件工具通过 hook 的 `respond` action 返回结果
|
||||
- `handle_before_tool` 中只处理插件工具,其他工具返回 `continue`
|
||||
|
||||
---
|
||||
|
||||
## Go 进程内 Hook 示例
|
||||
|
||||
如果需要在 Go 代码中实现插件工具注入:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
type WeatherPluginHook struct{}
|
||||
|
||||
func (h *WeatherPluginHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *agent.LLMHookRequest,
|
||||
) (*agent.LLMHookRequest, agent.HookDecision, error) {
|
||||
// 注入工具定义
|
||||
req.Tools = append(req.Tools, agent.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: agent.FunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "查询城市天气",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{
|
||||
"type": "string",
|
||||
"description": "城市名称",
|
||||
},
|
||||
},
|
||||
"required": []string{"city"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *WeatherPluginHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
// 设置 HookResult,使用 respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: getWeatherData(city),
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func getWeatherData(city string) string {
|
||||
// 实现天气查询逻辑
|
||||
return fmt.Sprintf("%s天气:晴,温度20°C", city)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
通过 hook 系统的 `respond` action,外部进程可以:
|
||||
|
||||
1. **注入工具定义**:让 LLM 知道有新工具可用
|
||||
2. **提供工具实现**:直接返回执行结果,无需注册到 ToolRegistry
|
||||
3. **与内置工具共存**:不影响 PicoClaw 原有工具的正常运行
|
||||
|
||||
这为插件开发提供了灵活、优雅的解决方案。
|
||||
|
||||
---
|
||||
|
||||
## 安全边界说明
|
||||
|
||||
### 绕过审批检查
|
||||
|
||||
**重要**:`respond` action 会绕过 `ApproveTool` 审批检查。
|
||||
|
||||
这意味着:
|
||||
- `before_tool` hook 可以为**任何工具名称**返回 `respond`,包括敏感工具(如 `bash`)
|
||||
- 工具不会经过审批流程,直接返回 hook 提供的结果
|
||||
- 这是为了支持插件工具而设计,但也带来了安全风险
|
||||
|
||||
### 安全建议
|
||||
|
||||
1. **审查 hook 配置**:确保只有可信的 hook 进程被启用
|
||||
2. **限制 hook 权限**:在 hook 实现中添加自己的安全检查
|
||||
3. **优先使用 `deny_tool`**:对于拒绝执行,使用 `deny_tool` action 而非 `respond` 返回错误
|
||||
|
||||
### 示例:hook 内置安全检查
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
# 安全检查:只处理插件工具
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
# 其他工具继续正常流程(会经过审批)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
这样可以确保 hook 只影响插件工具,不影响系统工具的审批流程。
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
# ⚙️ Guida alla Configurazione
|
||||
|
||||
> Torna al [README](../../README.md)
|
||||
|
||||
## ⚙️ Configurazione
|
||||
|
||||
File di configurazione: `~/.picoclaw/config.json`
|
||||
|
||||
### Variabili d'Ambiente
|
||||
|
||||
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
|
||||
|
||||
| Variabile | Descrizione | Percorso Predefinito |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
|
||||
|
||||
**Esempi:**
|
||||
|
||||
```bash
|
||||
# Esegui picoclaw usando un file di configurazione specifico
|
||||
# Il percorso del workspace verrà letto da quel file di configurazione
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
|
||||
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
|
||||
# Il workspace verrà creato in /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Usa entrambi per un setup completamente personalizzato
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Struttura del Workspace
|
||||
|
||||
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Sessioni di conversazione e cronologia
|
||||
├── memory/ # Memoria a lungo termine (MEMORY.md)
|
||||
├── state/ # Stato persistente (ultimo canale, ecc.)
|
||||
├── cron/ # Database dei job pianificati
|
||||
├── skills/ # Skill personalizzate
|
||||
├── AGENTS.md # Guida al comportamento dell'agent
|
||||
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
|
||||
├── IDENTITY.md # Identità dell'agent
|
||||
├── SOUL.md # Anima dell'agent
|
||||
└── USER.md # Preferenze dell'utente
|
||||
```
|
||||
|
||||
> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
|
||||
|
||||
### Sorgenti delle Skill
|
||||
|
||||
Per impostazione predefinita, le skill vengono caricate da:
|
||||
|
||||
1. `~/.picoclaw/workspace/skills` (workspace)
|
||||
2. `~/.picoclaw/skills` (globale)
|
||||
3. `<current-working-directory>/skills` (builtin)
|
||||
|
||||
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### Politica Unificata di Esecuzione dei Comandi
|
||||
|
||||
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
|
||||
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
|
||||
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
|
||||
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
|
||||
|
||||
### 🔒 Sandbox di Sicurezza
|
||||
|
||||
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
|
||||
|
||||
#### Configurazione Predefinita
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Opzione | Predefinito | Descrizione |
|
||||
| ----------------------- | ----------------------- | ---------------------------------------------------- |
|
||||
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
|
||||
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
|
||||
|
||||
#### Strumenti Protetti
|
||||
|
||||
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
|
||||
|
||||
| Strumento | Funzione | Restrizione |
|
||||
| ------------- | ------------------------- | ---------------------------------------------------- |
|
||||
| `read_file` | Legge file | Solo file all'interno del workspace |
|
||||
| `write_file` | Scrive file | Solo file all'interno del workspace |
|
||||
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
|
||||
| `edit_file` | Modifica file | Solo file all'interno del workspace |
|
||||
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
|
||||
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
|
||||
|
||||
#### Protezione Exec Aggiuntiva
|
||||
|
||||
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
|
||||
* `format`, `mkfs`, `diskpart` — Formattazione del disco
|
||||
* `dd if=` — Imaging del disco
|
||||
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
|
||||
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
### Controllo Accesso ai File
|
||||
|
||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
||||
|--------------------------|------|-------------|-------------|
|
||||
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
|
||||
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
|
||||
|
||||
### Sicurezza Exec
|
||||
|
||||
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|
||||
|--------------------------|------|-------------|-------------|
|
||||
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
|
||||
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
|
||||
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
|
||||
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
|
||||
|
||||
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
|
||||
|
||||
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
|
||||
|
||||
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
|
||||
|
||||
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
|
||||
|
||||
Per ambienti ad alto rischio:
|
||||
|
||||
* Esamina gli script di build prima dell'esecuzione.
|
||||
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
|
||||
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
|
||||
|
||||
#### Esempi di Errore
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
|
||||
|
||||
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
|
||||
|
||||
**Metodo 1: File di configurazione**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Metodo 2: Variabile d'ambiente**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
|
||||
|
||||
#### Coerenza dei Confini di Sicurezza
|
||||
|
||||
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
|
||||
|
||||
| Percorso di esecuzione | Confine di sicurezza |
|
||||
| ---------------------- | --------------------------------- |
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
|
||||
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
|
||||
|
||||
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
|
||||
|
||||
### Heartbeat (Task Periodici)
|
||||
|
||||
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
|
||||
|
||||
#### Task Asincroni con Spawn
|
||||
|
||||
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
```
|
||||
|
|
@ -99,6 +99,24 @@
|
|||
}
|
||||
```
|
||||
|
||||
#### `model_list` エントリフィールド
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|-----------|------|------|------|
|
||||
| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 |
|
||||
| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
|
||||
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
|
||||
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
|
||||
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) |
|
||||
| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
|
||||
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
|
||||
| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
|
||||
| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
|
||||
| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
|
||||
| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
|
||||
| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) |
|
||||
|
||||
#### ベンダー別設定例
|
||||
|
||||
**OpenAI**
|
||||
|
|
@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -108,6 +108,25 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
}
|
||||
```
|
||||
|
||||
#### `model_list` Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||
| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | No | Override the default API endpoint URL |
|
||||
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) |
|
||||
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||
| `extra_body` | object | No | Additional fields to inject into every request body |
|
||||
| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). |
|
||||
| `rpm` | int | No | Per-minute request rate limit |
|
||||
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
|
||||
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
|
||||
|
||||
#### Voice Transcription
|
||||
|
||||
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
||||
|
|
@ -249,6 +268,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
|||
}
|
||||
```
|
||||
|
||||
#### Campos de entrada `model_list`
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|-------|------|-------------|-----------|
|
||||
| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent |
|
||||
| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Não | Substitui a URL base da API padrão |
|
||||
| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo |
|
||||
| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) |
|
||||
| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) |
|
||||
| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
|
||||
| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||
| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição |
|
||||
| `rpm` | int | Não | Limite de requisições por minuto |
|
||||
| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático |
|
||||
| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) |
|
||||
|
||||
#### Exemplos por Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
|
@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
95
docs/rate-limiting.md
Normal file
95
docs/rate-limiting.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Dynamic Rate Limiting
|
||||
|
||||
PicoClaw prevents 429 errors from LLM provider APIs by enforcing configurable per-model request-rate limits **before** sending each request. Unlike the reactive cooldown/fallback system (which activates *after* a 429 is received), rate limiting is **proactive**: it keeps outbound QPS within the provider's free-tier or plan limits.
|
||||
|
||||
## How it works
|
||||
|
||||
### Token-bucket algorithm
|
||||
|
||||
Each rate-limited model gets a token bucket:
|
||||
|
||||
- **Capacity** = `rpm` (burst size equals the per-minute limit)
|
||||
- **Refill rate** = `rpm / 60` tokens per second
|
||||
- Tokens are consumed one per LLM call; if the bucket is empty, the call blocks until a token refills or the request context is cancelled
|
||||
|
||||
### Call chain integration
|
||||
|
||||
```
|
||||
AgentLoop.callLLM()
|
||||
└─ FallbackChain.Execute() ← iterate candidates
|
||||
├─ CooldownTracker.IsAvailable() ← skip if post-429 cooldown active
|
||||
├─ RateLimiterRegistry.Wait() ← NEW: block until token available
|
||||
└─ provider.Chat() ← actual LLM HTTP call
|
||||
```
|
||||
|
||||
The rate limiter runs **after** the cooldown check and **before** the provider call, so:
|
||||
- Candidates already in cooldown are skipped entirely (no token consumed)
|
||||
- Candidates that are available get throttled to the configured RPM
|
||||
|
||||
The same check applies in `ExecuteImage`.
|
||||
|
||||
### Thread safety
|
||||
|
||||
`RateLimiterRegistry` is safe for concurrent use. The per-limiter token bucket uses a fine-grained mutex so concurrent goroutines each acquire their own token independently.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set `rpm` on any model in `model_list`:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o-free
|
||||
model: openai/gpt-4o
|
||||
api_base: https://api.openai.com/v1
|
||||
rpm: 3 # max 3 requests per minute
|
||||
api_keys:
|
||||
- sk-...
|
||||
|
||||
- model_name: claude-haiku
|
||||
model: anthropic/claude-haiku-4-5
|
||||
rpm: 60 # 60 rpm (Anthropic free tier)
|
||||
api_keys:
|
||||
- sk-ant-...
|
||||
|
||||
- model_name: local-llm
|
||||
model: openai/llama3
|
||||
api_base: http://localhost:11434/v1
|
||||
# no rpm → unrestricted
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rpm` | `int` | `0` | Requests per minute. `0` means no limit. |
|
||||
|
||||
### Interaction with fallbacks
|
||||
|
||||
When a model has fallbacks configured, each candidate is rate-limited **independently**:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt4-with-fallback
|
||||
model: openai/gpt-4o
|
||||
rpm: 5
|
||||
fallbacks:
|
||||
- gpt-4o-mini # must also be in model_list; its own rpm applies
|
||||
```
|
||||
|
||||
If the current candidate's bucket is empty and there are more candidates available, PicoClaw skips the locally saturated candidate and tries the next fallback immediately. Only the last remaining candidate waits for a token to refill. If the context deadline is hit while waiting on that last candidate, the wait error propagates.
|
||||
|
||||
For `model_list` aliases that resolve to the same underlying provider/model, rate limiting is keyed by the stable config identity (for example `model_name`) rather than the resolved runtime model string. This preserves distinct RPM settings for multi-key and alias-based configurations.
|
||||
|
||||
### Burst behaviour
|
||||
|
||||
The bucket starts **full** (burst = RPM). For `rpm: 3`, the first 3 requests fire instantly; subsequent requests are spaced ~20 s apart.
|
||||
|
||||
To reduce burstiness for strict APIs, set a lower `rpm` and rely on the steady-state refill.
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `pkg/providers/ratelimiter.go` | `RateLimiter` (token bucket) + `RateLimiterRegistry` |
|
||||
| `pkg/providers/ratelimiter_test.go` | Unit tests for limiter and registry |
|
||||
| `pkg/providers/fallback.go` | `FallbackCandidate.RPM` field; `FallbackChain.rl`; `Wait()` call in `Execute`/`ExecuteImage` |
|
||||
| `pkg/agent/model_resolution.go` | Resolves candidates from `model_list`, preserving stable config identity and propagating `RPM` into `FallbackCandidate` |
|
||||
| `pkg/agent/loop.go` | Build `RateLimiterRegistry`, register all agents' candidates, pass to `NewFallbackChain` |
|
||||
|
|
@ -528,6 +528,9 @@ For example:
|
|||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||
- `PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS=16384`
|
||||
|
||||
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
|
||||
environment variables.
|
||||
|
||||
For MCP tools, `tools.mcp.max_inline_text_chars` controls how much text result is kept inline in model context. The threshold is counted in Unicode characters (Go runes), not bytes. For example, `16384` means up to 16,384 characters inline, which may occupy more than 16 KB for multibyte text such as CJK. Above this threshold, PicoClaw saves the MCP text result as a local artifact in the agent workspace and gives the model a short note plus a structured `[file:...]` artifact path instead of injecting the full payload into context.
|
||||
|
|
|
|||
|
|
@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
|||
}
|
||||
```
|
||||
|
||||
#### Các trường entry `model_list`
|
||||
|
||||
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||
|--------|------|----------|------|
|
||||
| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent |
|
||||
| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) |
|
||||
| `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
|
||||
| `proxy` | string | Không | URL proxy HTTP cho entry model này |
|
||||
| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) |
|
||||
| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) |
|
||||
| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
|
||||
| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
|
||||
| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body |
|
||||
| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút |
|
||||
| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động |
|
||||
| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) |
|
||||
|
||||
#### Ví Dụ Theo Vendor
|
||||
|
||||
**OpenAI**
|
||||
|
|
@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -104,6 +104,25 @@
|
|||
}
|
||||
```
|
||||
|
||||
#### `model_list` 条目字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
|
||||
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
||||
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
|
||||
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) |
|
||||
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
|
||||
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
|
||||
| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
|
||||
| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 |
|
||||
| `custom_headers` | object | 否 | 注入到每个请求中的额外 HTTP 请求头(例如 `{"X-Source":"coding-plan"}`)。若键名与内置请求头同名,会覆盖内置值(如 `Authorization`、`User-Agent`、`Content-Type`、`Accept`)。 |
|
||||
| `rpm` | int | 否 | 每分钟请求速率限制 |
|
||||
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
|
||||
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
|
||||
|
||||
#### 语音转录
|
||||
|
||||
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||
|
|
@ -234,6 +253,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
|||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_keys": ["sk-..."],
|
||||
"user_agent": "MyApp/1.0",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -1,12 +1,14 @@
|
|||
module github.com/sipeed/picoclaw
|
||||
|
||||
go 1.25.8
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||
|
|
@ -29,10 +31,10 @@ require (
|
|||
github.com/mymmrac/telego v1.7.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/pion/rtp v1.8.7
|
||||
github.com/pion/rtp v1.10.1
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
|
|
@ -45,7 +47,7 @@ require (
|
|||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.4
|
||||
modernc.org/sqlite v1.47.0
|
||||
modernc.org/sqlite v1.48.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
|
|
@ -92,6 +94,8 @@ require (
|
|||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
|
|
@ -129,7 +133,7 @@ require (
|
|||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
)
|
||||
|
||||
|
|
|
|||
30
go.sum
30
go.sum
|
|
@ -9,6 +9,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
|
||||
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
|
||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
|
|
@ -19,6 +21,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
|
|
@ -73,7 +77,6 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
|
|||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/jrpc2 v1.3.5 h1:onJko+1u6xoiRph3xwWmfNISR91teCRhbJwSyS9Svzo=
|
||||
github.com/creachadair/jrpc2 v1.3.5/go.mod h1:YXDmS53AavsiytbAwskrczJPcVHvKC9GoyWzwfSQXoE=
|
||||
|
|
@ -116,7 +119,6 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
|||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
|
|
@ -181,11 +183,8 @@ github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJi
|
|||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
|
|
@ -216,12 +215,11 @@ github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa7
|
|||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
|
|
@ -234,9 +232,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
|
|||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
|
|
@ -287,6 +284,10 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
|||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=
|
||||
|
|
@ -319,7 +320,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
|
|
@ -377,11 +377,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
|
@ -466,8 +464,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -602,14 +602,16 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
// Add conversation history
|
||||
messages = append(messages, history...)
|
||||
|
||||
// Add current user message
|
||||
if strings.TrimSpace(currentMessage) != "" {
|
||||
// Add current user message. Media-only turns must still be preserved so
|
||||
// multimodal providers receive the uploaded image even when the user sends
|
||||
// no accompanying text.
|
||||
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
|
||||
msg := providers.Message{
|
||||
Role: "user",
|
||||
Content: currentMessage,
|
||||
}
|
||||
if len(media) > 0 {
|
||||
msg.Media = media
|
||||
msg.Media = append([]string(nil), media...)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tokenizer"
|
||||
)
|
||||
|
||||
// parseTurnBoundaries returns the starting index of each Turn in the history.
|
||||
|
|
@ -86,88 +84,16 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
// estimateMessageTokens estimates the token count for a single message,
|
||||
// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
|
||||
// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
|
||||
func estimateMessageTokens(msg providers.Message) int {
|
||||
contentChars := utf8.RuneCountInString(msg.Content)
|
||||
|
||||
// SystemParts are structured system blocks used for cache-aware adapters.
|
||||
// They carry the same content as Content, but in multiple blocks.
|
||||
// We estimate them as an alternative representation, not additive.
|
||||
systemPartsChars := 0
|
||||
if len(msg.SystemParts) > 0 {
|
||||
for _, part := range msg.SystemParts {
|
||||
systemPartsChars += utf8.RuneCountInString(part.Text)
|
||||
}
|
||||
// Per-part overhead for JSON structure (type, text, cache_control).
|
||||
const perPartOverhead = 20
|
||||
systemPartsChars += len(msg.SystemParts) * perPartOverhead
|
||||
}
|
||||
|
||||
// Use the larger of the two representations to stay conservative.
|
||||
chars := contentChars
|
||||
if systemPartsChars > chars {
|
||||
chars = systemPartsChars
|
||||
}
|
||||
|
||||
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
||||
|
||||
for _, tc := range msg.ToolCalls {
|
||||
chars += len(tc.ID) + len(tc.Type)
|
||||
if tc.Function != nil {
|
||||
// Count function name + arguments (the wire format for most providers).
|
||||
// tc.Name mirrors tc.Function.Name — count only once to avoid double-counting.
|
||||
chars += len(tc.Function.Name) + len(tc.Function.Arguments)
|
||||
} else {
|
||||
// Fallback: some provider formats use top-level Name without Function.
|
||||
chars += len(tc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.ToolCallID != "" {
|
||||
chars += len(msg.ToolCallID)
|
||||
}
|
||||
|
||||
// Per-message overhead for role label, JSON structure, separators.
|
||||
const messageOverhead = 12
|
||||
chars += messageOverhead
|
||||
|
||||
tokens := chars * 2 / 5
|
||||
|
||||
// Media items (images, files) are serialized by provider adapters into
|
||||
// multipart or image_url payloads. Add a fixed per-item token estimate
|
||||
// directly (not through the chars heuristic) since actual cost depends
|
||||
// on resolution and provider-specific image tokenization.
|
||||
const mediaTokensPerItem = 256
|
||||
tokens += len(msg.Media) * mediaTokensPerItem
|
||||
|
||||
return tokens
|
||||
// EstimateMessageTokens estimates the token count for a single message.
|
||||
// Delegates to the shared tokenizer package for consistency across agent and seahorse.
|
||||
func EstimateMessageTokens(msg providers.Message) int {
|
||||
return tokenizer.EstimateMessageTokens(msg)
|
||||
}
|
||||
|
||||
// estimateToolDefsTokens estimates the total token cost of tool definitions
|
||||
// as they appear in the LLM request. Each tool's name, description, and
|
||||
// JSON schema parameters contribute to the context window budget.
|
||||
func estimateToolDefsTokens(defs []providers.ToolDefinition) int {
|
||||
if len(defs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
totalChars := 0
|
||||
for _, d := range defs {
|
||||
totalChars += len(d.Function.Name) + len(d.Function.Description)
|
||||
|
||||
if d.Function.Parameters != nil {
|
||||
if paramJSON, err := json.Marshal(d.Function.Parameters); err == nil {
|
||||
totalChars += len(paramJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-tool overhead: type field, JSON structure, separators.
|
||||
totalChars += 20
|
||||
}
|
||||
|
||||
return totalChars * 2 / 5
|
||||
// EstimateToolDefsTokens estimates the total token cost of tool definitions
|
||||
// as they appear in the LLM request. Delegates to the shared tokenizer package.
|
||||
func EstimateToolDefsTokens(defs []providers.ToolDefinition) int {
|
||||
return tokenizer.EstimateToolDefsTokens(defs)
|
||||
}
|
||||
|
||||
// isOverContextBudget checks whether the assembled messages plus tool definitions
|
||||
|
|
@ -181,10 +107,10 @@ func isOverContextBudget(
|
|||
) bool {
|
||||
msgTokens := 0
|
||||
for _, m := range messages {
|
||||
msgTokens += estimateMessageTokens(m)
|
||||
msgTokens += EstimateMessageTokens(m)
|
||||
}
|
||||
|
||||
toolTokens := estimateToolDefsTokens(toolDefs)
|
||||
toolTokens := EstimateToolDefsTokens(toolDefs)
|
||||
total := msgTokens + toolTokens + maxTokens
|
||||
|
||||
return total > contextWindow
|
||||
|
|
|
|||
|
|
@ -417,9 +417,9 @@ func TestEstimateMessageTokens(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := estimateMessageTokens(tt.msg)
|
||||
got := EstimateMessageTokens(tt.msg)
|
||||
if got < tt.want {
|
||||
t.Errorf("estimateMessageTokens() = %d, want >= %d", got, tt.want)
|
||||
t.Errorf("EstimateMessageTokens() = %d, want >= %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -443,8 +443,8 @@ func TestEstimateMessageTokens_ToolCallsContribute(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
withTCTokens := estimateMessageTokens(withTC)
|
||||
plainTokens := EstimateMessageTokens(plain)
|
||||
withTCTokens := EstimateMessageTokens(withTC)
|
||||
|
||||
if withTCTokens <= plainTokens {
|
||||
t.Errorf("message with ToolCalls (%d tokens) should exceed plain message (%d tokens)",
|
||||
|
|
@ -457,7 +457,7 @@ func TestEstimateMessageTokens_MultibyteContent(t *testing.T) {
|
|||
// but may map to different token counts. The heuristic should still produce
|
||||
// reasonable estimates via RuneCountInString.
|
||||
msg := msgUser("caf\u00e9 na\u00efve r\u00e9sum\u00e9 \u00fcber stra\u00dfe")
|
||||
tokens := estimateMessageTokens(msg)
|
||||
tokens := EstimateMessageTokens(msg)
|
||||
if tokens <= 0 {
|
||||
t.Errorf("multibyte message should produce positive token count, got %d", tokens)
|
||||
}
|
||||
|
|
@ -481,7 +481,7 @@ func TestEstimateMessageTokens_LargeArguments(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
tokens := estimateMessageTokens(msg)
|
||||
tokens := EstimateMessageTokens(msg)
|
||||
// 5000+ chars → at least 2000 tokens with the 2.5 char/token heuristic
|
||||
if tokens < 2000 {
|
||||
t.Errorf("large tool call arguments should produce significant token count, got %d", tokens)
|
||||
|
|
@ -496,8 +496,8 @@ func TestEstimateMessageTokens_ReasoningContent(t *testing.T) {
|
|||
ReasoningContent: strings.Repeat("thinking step ", 200),
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
reasoningTokens := estimateMessageTokens(withReasoning)
|
||||
plainTokens := EstimateMessageTokens(plain)
|
||||
reasoningTokens := EstimateMessageTokens(withReasoning)
|
||||
|
||||
if reasoningTokens <= plainTokens {
|
||||
t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)",
|
||||
|
|
@ -513,8 +513,8 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) {
|
|||
Media: []string{"media://img1.png", "media://img2.png"},
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
mediaTokens := estimateMessageTokens(withMedia)
|
||||
plainTokens := EstimateMessageTokens(plain)
|
||||
mediaTokens := EstimateMessageTokens(withMedia)
|
||||
|
||||
if mediaTokens <= plainTokens {
|
||||
t.Errorf("message with Media (%d tokens) should exceed plain message (%d tokens)",
|
||||
|
|
@ -540,8 +540,8 @@ func TestEstimateMessageTokens_SystemParts(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
partsTokens := estimateMessageTokens(withParts)
|
||||
plainTokens := EstimateMessageTokens(plain)
|
||||
partsTokens := EstimateMessageTokens(withParts)
|
||||
|
||||
if partsTokens <= plainTokens {
|
||||
t.Errorf("system message with SystemParts (%d) should exceed plain message (%d)",
|
||||
|
|
@ -549,7 +549,7 @@ func TestEstimateMessageTokens_SystemParts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- estimateToolDefsTokens tests ---
|
||||
// --- EstimateToolDefsTokens tests ---
|
||||
|
||||
func TestEstimateToolDefsTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -599,9 +599,9 @@ func TestEstimateToolDefsTokens(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := estimateToolDefsTokens(tt.defs)
|
||||
got := EstimateToolDefsTokens(tt.defs)
|
||||
if got < tt.want {
|
||||
t.Errorf("estimateToolDefsTokens() = %d, want >= %d", got, tt.want)
|
||||
t.Errorf("EstimateToolDefsTokens() = %d, want >= %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -624,8 +624,8 @@ func TestEstimateToolDefsTokens_ScalesWithCount(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
one := estimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")})
|
||||
three := estimateToolDefsTokens([]providers.ToolDefinition{
|
||||
one := EstimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")})
|
||||
three := EstimateToolDefsTokens([]providers.ToolDefinition{
|
||||
makeTool("tool_a"), makeTool("tool_b"), makeTool("tool_c"),
|
||||
})
|
||||
|
||||
|
|
@ -770,7 +770,7 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
tokens := estimateMessageTokens(msg)
|
||||
tokens := EstimateMessageTokens(msg)
|
||||
|
||||
// ReasoningContent alone is ~1700 chars → ~680 tokens.
|
||||
// Content + TC + overhead adds more. Should be well above 500.
|
||||
|
|
@ -781,7 +781,7 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) {
|
|||
// Compare without reasoning to ensure it's counted.
|
||||
msgNoReasoning := msg
|
||||
msgNoReasoning.ReasoningContent = ""
|
||||
tokensNoReasoning := estimateMessageTokens(msgNoReasoning)
|
||||
tokensNoReasoning := EstimateMessageTokens(msgNoReasoning)
|
||||
|
||||
if tokens <= tokensNoReasoning {
|
||||
t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning)
|
||||
|
|
|
|||
|
|
@ -707,6 +707,38 @@ func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildMessages_IncludesMediaOnlyCurrentMessage(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, nil)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
msgs := cb.BuildMessages(
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
[]string{"data:image/png;base64,abc123"},
|
||||
"pico",
|
||||
"chat-1",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("len(msgs) = %d, want 2", len(msgs))
|
||||
}
|
||||
|
||||
userMsg := msgs[1]
|
||||
if userMsg.Role != "user" {
|
||||
t.Fatalf("userMsg.Role = %q, want %q", userMsg.Role, "user")
|
||||
}
|
||||
if userMsg.Content != "" {
|
||||
t.Fatalf("userMsg.Content = %q, want empty string", userMsg.Content)
|
||||
}
|
||||
if len(userMsg.Media) != 1 || userMsg.Media[0] != "data:image/png;base64,abc123" {
|
||||
t.Fatalf("userMsg.Media = %#v, want image payload", userMsg.Media)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ func (m *legacyContextManager) summarizeBatch(
|
|||
func (m *legacyContextManager) estimateTokens(messages []providers.Message) int {
|
||||
total := 0
|
||||
for _, msg := range messages {
|
||||
total += estimateMessageTokens(msg)
|
||||
total += EstimateMessageTokens(msg)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type AssembleResponse struct {
|
|||
type CompactRequest struct {
|
||||
SessionKey string // session identifier
|
||||
Reason ContextCompressReason // proactive_budget | llm_retry | summarize
|
||||
Budget int // context window budget (used for retry aggressive compaction)
|
||||
}
|
||||
|
||||
// IngestRequest is the input to Ingest.
|
||||
|
|
|
|||
269
pkg/agent/context_seahorse.go
Normal file
269
pkg/agent/context_seahorse.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/tokenizer"
|
||||
)
|
||||
|
||||
// seahorseContextManager adapts seahorse.Engine to agent.ContextManager.
|
||||
type seahorseContextManager struct {
|
||||
engine *seahorse.Engine
|
||||
sessions session.SessionStore // for startup bootstrap
|
||||
}
|
||||
|
||||
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
||||
func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||
if al == nil {
|
||||
return nil, fmt.Errorf("seahorse: AgentLoop is required")
|
||||
}
|
||||
|
||||
// Resolve workspace for DB path
|
||||
// DB stores session data, so it goes in sessions/ directory
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
dbPath := agent.Workspace + "/sessions/seahorse.db"
|
||||
|
||||
// Create CompleteFn from provider
|
||||
completeFn := providerToCompleteFn(agent.Provider, agent.Model)
|
||||
|
||||
// Create engine
|
||||
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||
DBPath: dbPath,
|
||||
}, completeFn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seahorse: create engine: %w", err)
|
||||
}
|
||||
|
||||
mgr := &seahorseContextManager{
|
||||
engine: engine,
|
||||
sessions: agent.Sessions,
|
||||
}
|
||||
|
||||
// Register seahorse tools with the agent's tool registry
|
||||
retrieval := mgr.engine.GetRetrieval()
|
||||
al.RegisterTool(seahorse.NewGrepTool(retrieval))
|
||||
al.RegisterTool(seahorse.NewExpandTool(retrieval))
|
||||
|
||||
// Bootstrap all existing sessions at startup
|
||||
if agent.Sessions != nil {
|
||||
ctx := context.Background()
|
||||
for _, sessionKey := range agent.Sessions.ListSessions() {
|
||||
mgr.bootstrapSession(ctx, sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
// providerToCompleteFn wraps providers.LLMProvider as a seahorse.CompleteFn.
|
||||
func providerToCompleteFn(provider providers.LLMProvider, model string) seahorse.CompleteFn {
|
||||
return func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
||||
resp, err := provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: prompt}},
|
||||
nil, // no tools for summarization
|
||||
model,
|
||||
map[string]any{
|
||||
"max_tokens": opts.MaxTokens,
|
||||
"temperature": opts.Temperature,
|
||||
"prompt_cache_key": "seahorse",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble builds budget-aware context from seahorse SQLite.
|
||||
func (m *seahorseContextManager) Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("seahorse assemble: nil request")
|
||||
}
|
||||
|
||||
budget := req.Budget
|
||||
if budget <= 0 {
|
||||
budget = 100000
|
||||
}
|
||||
|
||||
// Reserve space for model response (spec lines 1400-1410)
|
||||
effectiveBudget := budget - req.MaxTokens
|
||||
if effectiveBudget <= 0 {
|
||||
// MaxTokens >= budget is a configuration problem
|
||||
// Use 50% as minimum to avoid guaranteed overflow
|
||||
logger.WarnCF("agent", "MaxTokens >= budget, using 50% fallback",
|
||||
map[string]any{"budget": budget, "max_tokens": req.MaxTokens})
|
||||
effectiveBudget = budget / 2
|
||||
}
|
||||
|
||||
result, err := m.engine.Assemble(ctx, req.SessionKey, seahorse.AssembleInput{
|
||||
Budget: effectiveBudget,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seahorse assemble: %w", err)
|
||||
}
|
||||
|
||||
history := seahorseToProviderMessages(result)
|
||||
|
||||
// Summary is already formatted as XML with system prompt addition by assembler
|
||||
return &AssembleResponse{
|
||||
History: history,
|
||||
Summary: result.Summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compact compresses conversation history via seahorse summarization.
|
||||
func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactRequest) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For retry (LLM overflow), use aggressive CompactUntilUnder to guarantee
|
||||
// context shrinks below budget (spec lines ~1410).
|
||||
if req.Reason == ContextCompressReasonRetry && req.Budget > 0 {
|
||||
_, err := m.engine.CompactUntilUnder(ctx, req.SessionKey, req.Budget)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{
|
||||
Force: req.Reason == ContextCompressReasonRetry,
|
||||
Budget: &req.Budget,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Ingest records a message into seahorse SQLite.
|
||||
// All existing sessions are bootstrapped at startup, so this only ingests new messages.
|
||||
func (m *seahorseContextManager) Ingest(ctx context.Context, req *IngestRequest) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := providerToSeahorseMessage(req.Message)
|
||||
_, err := m.engine.Ingest(ctx, req.SessionKey, []seahorse.Message{msg})
|
||||
return err
|
||||
}
|
||||
|
||||
// bootstrapSession reconciles JSONL session history into seahorse SQLite.
|
||||
func (m *seahorseContextManager) bootstrapSession(ctx context.Context, sessionKey string) {
|
||||
if m.sessions == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := m.sessions.GetHistory(sessionKey)
|
||||
if len(history) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert provider messages to seahorse messages
|
||||
msgs := make([]seahorse.Message, len(history))
|
||||
for i, h := range history {
|
||||
msgs[i] = providerToSeahorseMessage(h)
|
||||
}
|
||||
|
||||
if err := m.engine.Bootstrap(ctx, sessionKey, msgs); err != nil {
|
||||
logger.WarnCF("seahorse", "bootstrap", map[string]any{
|
||||
"session": sessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// providerToSeahorseMessage converts a providers.Message to a seahorse.Message.
|
||||
func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
||||
result := seahorse.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
||||
}
|
||||
|
||||
// Convert ToolCalls → MessageParts
|
||||
for _, tc := range msg.ToolCalls {
|
||||
part := seahorse.MessagePart{
|
||||
Type: "tool_use",
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
}
|
||||
|
||||
// Convert tool result
|
||||
if msg.ToolCallID != "" {
|
||||
part := seahorse.MessagePart{
|
||||
Type: "tool_result",
|
||||
ToolCallID: msg.ToolCallID,
|
||||
Text: msg.Content,
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
}
|
||||
|
||||
// Convert media attachments
|
||||
for _, mediaURI := range msg.Media {
|
||||
part := seahorse.MessagePart{
|
||||
Type: "media",
|
||||
MediaURI: mediaURI,
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
||||
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
||||
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
||||
|
||||
// Convert assembled messages (which already include summary XML messages)
|
||||
for _, msg := range result.Messages {
|
||||
pm := protocoltypes.Message{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
}
|
||||
|
||||
// Reconstruct ToolCalls from parts
|
||||
for _, part := range msg.Parts {
|
||||
if part.Type == "tool_use" {
|
||||
pm.ToolCalls = append(pm.ToolCalls, protocoltypes.ToolCall{
|
||||
ID: part.ToolCallID,
|
||||
Type: "function", // Required by OpenAI-compatible APIs (GLM, etc.)
|
||||
Function: &protocoltypes.FunctionCall{
|
||||
Name: part.Name,
|
||||
Arguments: part.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
if part.Type == "tool_result" {
|
||||
pm.ToolCallID = part.ToolCallID
|
||||
if pm.Content == "" && part.Text != "" {
|
||||
pm.Content = part.Text
|
||||
}
|
||||
}
|
||||
if part.Type == "media" && part.MediaURI != "" {
|
||||
pm.Media = append(pm.Media, part.MediaURI)
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, pm)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil {
|
||||
panic(fmt.Sprintf("register seahorse context manager: %v", err))
|
||||
}
|
||||
}
|
||||
1086
pkg/agent/context_seahorse_test.go
Normal file
1086
pkg/agent/context_seahorse_test.go
Normal file
File diff suppressed because it is too large
Load diff
20
pkg/agent/context_seahorse_unsupported.go
Normal file
20
pkg/agent/context_seahorse_unsupported.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build mipsle || netbsd || (freebsd && arm)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// newSeahorseContextManager is unavailable on platforms where modernc sqlite/libc
|
||||
// currently has no stable build path for this project.
|
||||
func newSeahorseContextManager(_ json.RawMessage, _ *AgentLoop) (ContextManager, error) {
|
||||
return nil, fmt.Errorf("seahorse context manager is unavailable on this platform")
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil {
|
||||
panic(fmt.Sprintf("register seahorse context manager: %v", err))
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ import (
|
|||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/isolation"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -90,7 +92,8 @@ type processHookAfterLLMResponse struct {
|
|||
|
||||
type processHookBeforeToolResponse struct {
|
||||
processHookDecisionResponse
|
||||
Call *ToolCallHookRequest `json:"call,omitempty"`
|
||||
Call *ToolCallHookRequest `json:"call,omitempty"`
|
||||
Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action)
|
||||
}
|
||||
|
||||
type processHookAfterToolResponse struct {
|
||||
|
|
@ -120,7 +123,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("create process hook stderr: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
// Route hook subprocess startup through the shared isolation entry point so
|
||||
// process hooks inherit the same isolation behavior as other child processes.
|
||||
if err := isolation.Start(cmd); err != nil {
|
||||
return nil, fmt.Errorf("start process hook: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +246,10 @@ func (ph *ProcessHook) BeforeTool(
|
|||
if resp.Call == nil {
|
||||
resp.Call = call
|
||||
}
|
||||
// If hook returned a Result, carry it in ToolCallHookRequest
|
||||
if resp.Result != nil {
|
||||
resp.Call.HookResult = resp.Result
|
||||
}
|
||||
return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/isolation"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
|
|
@ -178,6 +181,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("linux-only isolation path handling")
|
||||
}
|
||||
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
root := t.TempDir()
|
||||
t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home"))
|
||||
binDir := filepath.Join(root, "bin")
|
||||
hookDir := filepath.Join(root, "hooks")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(hookDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFakeBwrap(t, filepath.Join(binDir, "bwrap"))
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper"))
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Isolation.Enabled = true
|
||||
isolation.Configure(cfg)
|
||||
t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) })
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relHookDir, err := filepath.Rel(cwd, hookDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{
|
||||
Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"},
|
||||
Dir: relHookDir,
|
||||
Env: processHookHelperEnv("rewrite", ""),
|
||||
InterceptLLM: true,
|
||||
})
|
||||
if mountErr != nil {
|
||||
t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-relative",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "hello",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "provider content|ipc" {
|
||||
t.Fatalf("expected process-hooked llm content, got %q", resp)
|
||||
}
|
||||
provider.mu.Lock()
|
||||
lastModel := provider.lastModel
|
||||
provider.mu.Unlock()
|
||||
if lastModel != "process-model" {
|
||||
t.Fatalf("expected process model, got %q", lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func processHookHelperCommand() []string {
|
||||
return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"}
|
||||
}
|
||||
|
|
@ -193,6 +266,59 @@ func processHookHelperEnv(mode, eventLog string) []string {
|
|||
return env
|
||||
}
|
||||
|
||||
func writeFakeBwrap(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
workdir=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
--chdir)
|
||||
workdir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--bind|--ro-bind)
|
||||
shift 3
|
||||
;;
|
||||
--proc|--dev)
|
||||
shift 2
|
||||
;;
|
||||
--die-with-parent|--unshare-ipc)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$workdir" ]; then
|
||||
cd "$workdir"
|
||||
fi
|
||||
exec "$@"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake bwrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func linkTestBinary(t *testing.T, source, target string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(source, target); err == nil {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("read test binary: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o755); err != nil {
|
||||
t.Fatalf("create hook helper binary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForFileContains(t *testing.T, path, substring string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type HookAction string
|
|||
const (
|
||||
HookActionContinue HookAction = "continue"
|
||||
HookActionModify HookAction = "modify"
|
||||
HookActionRespond HookAction = "respond" // Return result directly, skip tool execution. SECURITY: This bypasses ApproveTool checks, allowing hooks to return results for any tool (including sensitive ones like bash) without approval. Use with caution.
|
||||
HookActionDenyTool HookAction = "deny_tool"
|
||||
HookActionAbortTurn HookAction = "abort_turn"
|
||||
HookActionHardAbort HookAction = "hard_abort"
|
||||
|
|
@ -127,11 +128,12 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
|
|||
}
|
||||
|
||||
type ToolCallHookRequest struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
Meta EventMeta `json:"meta"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
HookResult *tools.ToolResult `json:"hook_result,omitempty"` // Result returned directly by hook (for respond action). Media is supported - see Media handling section in docs.
|
||||
}
|
||||
|
||||
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
|
||||
|
|
@ -140,6 +142,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
|
|||
}
|
||||
cloned := *r
|
||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||
cloned.HookResult = cloneToolResult(r.HookResult)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +385,10 @@ func (hm *HookManager) BeforeTool(
|
|||
if next != nil {
|
||||
current = next
|
||||
}
|
||||
case HookActionRespond:
|
||||
// Hook returns result directly, skip tool execution
|
||||
// Carry HookResult in ToolCallHookRequest and return
|
||||
return next, decision
|
||||
case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort:
|
||||
return current, decision
|
||||
default:
|
||||
|
|
@ -793,6 +800,13 @@ func cloneToolResult(result *tools.ToolResult) *tools.ToolResult {
|
|||
if len(result.Media) > 0 {
|
||||
cloned.Media = append([]string(nil), result.Media...)
|
||||
}
|
||||
if len(result.ArtifactTags) > 0 {
|
||||
cloned.ArtifactTags = append([]string(nil), result.ArtifactTags...)
|
||||
}
|
||||
if len(result.Messages) > 0 {
|
||||
cloned.Messages = make([]providers.Message, len(result.Messages))
|
||||
copy(cloned.Messages, result.Messages)
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
|
|
@ -343,3 +345,517 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) {
|
|||
t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// respondHook is a test hook for testing HookActionRespond functionality
|
||||
type respondHook struct {
|
||||
respondTools map[string]bool // tool names to respond to
|
||||
}
|
||||
|
||||
func (h *respondHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, error) {
|
||||
if h.respondTools[call.Tool] {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: "hook-responded: " + call.Tool,
|
||||
ForUser: "",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, HookDecision{Action: HookActionRespond}, nil
|
||||
}
|
||||
return call, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *respondHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, error) {
|
||||
// Should not be called since respond skips tool execution
|
||||
return result, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) {
|
||||
provider := &toolHookProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
if err := al.MountHook(NamedHook("respond-hook", &respondHook{
|
||||
respondTools: map[string]bool{"echo_text": true},
|
||||
})); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response comes from hook, not tool
|
||||
expected := "hook-responded: echo_text"
|
||||
if resp != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, resp)
|
||||
}
|
||||
|
||||
// Verify event stream has ToolExecEnd, not actual tool execution
|
||||
events := collectEventStream(sub.C)
|
||||
endEvt, ok := findEvent(events, EventKindToolExecEnd)
|
||||
if !ok {
|
||||
t.Fatal("expected tool exec end event")
|
||||
}
|
||||
payload, ok := endEvt.Payload.(ToolExecEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
|
||||
}
|
||||
if payload.Tool != "echo_text" {
|
||||
t.Fatalf("expected tool echo_text, got %q", payload.Tool)
|
||||
}
|
||||
if payload.ForLLMLen != len(expected) {
|
||||
t.Fatalf("expected ForLLMLen %d, got %d", len(expected), payload.ForLLMLen)
|
||||
}
|
||||
}
|
||||
|
||||
// denyToolHook tests HookActionDenyTool functionality
|
||||
type denyToolHook struct {
|
||||
denyTools map[string]bool
|
||||
}
|
||||
|
||||
func (h *denyToolHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, error) {
|
||||
if h.denyTools[call.Tool] {
|
||||
return call, HookDecision{Action: HookActionDenyTool, Reason: "tool denied by hook"}, nil
|
||||
}
|
||||
return call, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *denyToolHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, error) {
|
||||
return result, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ToolDenyAction(t *testing.T) {
|
||||
provider := &toolHookProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
if err := al.MountHook(NamedHook("deny-hook", &denyToolHook{
|
||||
denyTools: map[string]bool{"echo_text": true},
|
||||
})); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
|
||||
expected := "Tool execution denied by hook: tool denied by hook"
|
||||
if resp != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookManager_BeforeTool_RespondAction(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
defer hm.Close()
|
||||
|
||||
hook := &respondHook{
|
||||
respondTools: map[string]bool{"test_tool": true},
|
||||
}
|
||||
if err := hm.Mount(NamedHook("respond-test", hook)); err != nil {
|
||||
t.Fatalf("mount hook: %v", err)
|
||||
}
|
||||
|
||||
req := &ToolCallHookRequest{
|
||||
Tool: "test_tool",
|
||||
Arguments: map[string]any{"arg": "value"},
|
||||
}
|
||||
result, decision := hm.BeforeTool(context.Background(), req)
|
||||
|
||||
if decision.Action != HookActionRespond {
|
||||
t.Fatalf("expected action %q, got %q", HookActionRespond, decision.Action)
|
||||
}
|
||||
|
||||
if result.HookResult == nil {
|
||||
t.Fatal("expected HookResult to be set")
|
||||
}
|
||||
if result.HookResult.ForLLM != "hook-responded: test_tool" {
|
||||
t.Fatalf("unexpected HookResult.ForLLM: %q", result.HookResult.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
type respondWithMediaHook struct {
|
||||
respondTools map[string]bool
|
||||
media []string
|
||||
responseHandled bool
|
||||
forLLM string
|
||||
}
|
||||
|
||||
func (h *respondWithMediaHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, error) {
|
||||
if h.respondTools[call.Tool] {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: h.forLLM,
|
||||
ForUser: "media result",
|
||||
Media: h.media,
|
||||
ResponseHandled: h.responseHandled,
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, HookDecision{Action: HookActionRespond}, nil
|
||||
}
|
||||
return call, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *respondWithMediaHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, error) {
|
||||
return result, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
type errorMediaChannel struct {
|
||||
fakeChannel
|
||||
sendErr error
|
||||
}
|
||||
|
||||
func (f *errorMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
||||
return nil, f.sendErr
|
||||
}
|
||||
|
||||
func TestAgentLoop_HookRespond_MediaError(t *testing.T) {
|
||||
provider := &multiToolProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
|
||||
},
|
||||
finalContent: "done",
|
||||
}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
hook := &respondWithMediaHook{
|
||||
respondTools: map[string]bool{"media_tool": true},
|
||||
media: []string{"media://test/image.png"},
|
||||
responseHandled: true,
|
||||
forLLM: "media sent successfully",
|
||||
}
|
||||
if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{
|
||||
sendErr: errors.New("channel unavailable"),
|
||||
})
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
_, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-media-err",
|
||||
Channel: "discord",
|
||||
ChatID: "chat1",
|
||||
UserMessage: "send media",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
endEvt, ok := findEvent(events, EventKindToolExecEnd)
|
||||
if !ok {
|
||||
t.Fatal("expected ToolExecEnd event")
|
||||
}
|
||||
payload, ok := endEvt.Payload.(ToolExecEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
|
||||
}
|
||||
|
||||
if !payload.IsError {
|
||||
t.Fatal("expected IsError=true when SendMedia fails")
|
||||
}
|
||||
|
||||
if payload.ForLLMLen < 30 {
|
||||
t.Fatalf("expected ForLLM to contain error message, got ForLLMLen=%d", payload.ForLLMLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_HookRespond_BusFallback(t *testing.T) {
|
||||
provider := &multiToolProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
|
||||
},
|
||||
finalContent: "done",
|
||||
}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
hook := &respondWithMediaHook{
|
||||
respondTools: map[string]bool{"media_tool": true},
|
||||
media: []string{"media://test/image.png"},
|
||||
responseHandled: true,
|
||||
forLLM: "media queued",
|
||||
}
|
||||
if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-bus-fallback",
|
||||
Channel: "cli",
|
||||
ChatID: "chat1",
|
||||
UserMessage: "send media",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
endEvt, ok := findEvent(events, EventKindToolExecEnd)
|
||||
if !ok {
|
||||
t.Fatal("expected ToolExecEnd event")
|
||||
}
|
||||
payload, ok := endEvt.Payload.(ToolExecEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
|
||||
}
|
||||
|
||||
if payload.IsError {
|
||||
t.Fatal("expected IsError=false for bus fallback (media queued, not delivered)")
|
||||
}
|
||||
|
||||
if resp != "done" {
|
||||
t.Fatalf("expected response 'done', got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type multiToolProvider struct {
|
||||
mu sync.Mutex
|
||||
callCount int
|
||||
toolCalls []providers.ToolCall
|
||||
finalContent string
|
||||
}
|
||||
|
||||
func (p *multiToolProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.callCount++
|
||||
if p.callCount == 1 && len(p.toolCalls) > 0 {
|
||||
return &providers.LLMResponse{
|
||||
ToolCalls: p.toolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: p.finalContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *multiToolProvider) GetDefaultModel() string {
|
||||
return "multi-tool-provider"
|
||||
}
|
||||
|
||||
func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
|
||||
provider := &multiToolProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
|
||||
{ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
|
||||
{ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
|
||||
},
|
||||
finalContent: "done",
|
||||
}
|
||||
al, _, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
tool1ExecCh := make(chan struct{}, 1)
|
||||
al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond, execCh: tool1ExecCh})
|
||||
al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
|
||||
|
||||
hook := &respondHook{
|
||||
respondTools: map[string]bool{"tool_one": true},
|
||||
}
|
||||
if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
|
||||
type result struct {
|
||||
resp string
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"run tools",
|
||||
sessionKey,
|
||||
"cli",
|
||||
"chat1",
|
||||
)
|
||||
resultCh <- result{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := al.InterruptGraceful("stop now"); err != nil {
|
||||
t.Fatalf("InterruptGraceful failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
|
||||
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
|
||||
if len(skippedEvts) < 1 {
|
||||
t.Fatal("expected at least one ToolExecSkipped event after interrupt")
|
||||
}
|
||||
|
||||
for _, evt := range skippedEvts {
|
||||
payload, ok := evt.Payload.(ToolExecSkippedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
|
||||
}
|
||||
if payload.Reason != "graceful interrupt requested" {
|
||||
t.Fatalf("expected skip reason 'graceful interrupt requested', got %q", payload.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
|
||||
provider := &multiToolProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
|
||||
{ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
|
||||
{ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
|
||||
},
|
||||
finalContent: "done",
|
||||
}
|
||||
al, _, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond})
|
||||
al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
|
||||
|
||||
hook := &respondHook{
|
||||
respondTools: map[string]bool{"tool_one": true},
|
||||
}
|
||||
if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
|
||||
type result struct {
|
||||
resp string
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"run tools",
|
||||
sessionKey,
|
||||
"cli",
|
||||
"chat1",
|
||||
)
|
||||
resultCh <- result{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
al.Steer(providers.Message{Role: "user", Content: "change direction"})
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
|
||||
skippedEvts := filterEvents(events, EventKindToolExecSkipped)
|
||||
if len(skippedEvts) < 1 {
|
||||
t.Fatal("expected at least one ToolExecSkipped event after steering")
|
||||
}
|
||||
|
||||
for _, evt := range skippedEvts {
|
||||
payload, ok := evt.Payload.(ToolExecSkippedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
|
||||
}
|
||||
if payload.Reason != "queued user steering message" {
|
||||
t.Fatalf("expected skip reason 'queued user steering message', got %q", payload.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterEvents(events []Event, kind EventKind) []Event {
|
||||
var result []Event
|
||||
for _, evt := range events {
|
||||
if evt.Kind == kind {
|
||||
result = append(result, evt)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/isolation"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
|
|
@ -51,6 +52,10 @@ type AgentInstance struct {
|
|||
// LightProvider is the concrete provider instance for the configured light model.
|
||||
// It is only used when routing selects the light tier for a turn.
|
||||
LightProvider providers.LLMProvider
|
||||
// CandidateProviders maps "provider/model" keys to per-candidate LLMProvider
|
||||
// instances. This allows each fallback model to use its own api_base and api_key
|
||||
// from model_list, instead of inheriting the primary model's provider config.
|
||||
CandidateProviders map[string]providers.LLMProvider
|
||||
}
|
||||
|
||||
// NewAgentInstance creates an agent instance from config.
|
||||
|
|
@ -60,6 +65,12 @@ func NewAgentInstance(
|
|||
cfg *config.Config,
|
||||
provider providers.LLMProvider,
|
||||
) *AgentInstance {
|
||||
if cfg != nil {
|
||||
// Keep the subprocess isolation runtime aligned with the latest loaded config
|
||||
// before any tools or providers start spawning child processes.
|
||||
isolation.Configure(cfg)
|
||||
}
|
||||
|
||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||
os.MkdirAll(workspace, 0o755)
|
||||
|
||||
|
|
@ -77,7 +88,12 @@ func NewAgentInstance(
|
|||
|
||||
if cfg.Tools.IsToolEnabled("read_file") {
|
||||
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||
switch cfg.Tools.ReadFile.EffectiveMode() {
|
||||
case config.ReadFileModeLines:
|
||||
toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||
default:
|
||||
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||
}
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("write_file") {
|
||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||
|
|
@ -170,6 +186,9 @@ func NewAgentInstance(
|
|||
// Resolve fallback candidates
|
||||
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
||||
|
||||
candidateProviders := make(map[string]providers.LLMProvider)
|
||||
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
|
||||
|
||||
// Model routing setup: pre-resolve light model candidates at creation time
|
||||
// to avoid repeated model_list lookups on every incoming message.
|
||||
var router *routing.Router
|
||||
|
|
@ -194,6 +213,7 @@ func NewAgentInstance(
|
|||
})
|
||||
lightCandidates = resolved
|
||||
lightProvider = lp
|
||||
populateCandidateProvidersFromNames(cfg, workspace, []string{rc.LightModel}, candidateProviders)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -225,6 +245,43 @@ func NewAgentInstance(
|
|||
Router: router,
|
||||
LightCandidates: lightCandidates,
|
||||
LightProvider: lightProvider,
|
||||
CandidateProviders: candidateProviders,
|
||||
}
|
||||
}
|
||||
|
||||
// populateCandidateProvidersFromNames resolves each model name (alias or
|
||||
// "provider/model") via resolvedModelConfig and creates a dedicated LLMProvider
|
||||
// for it. This reuses the canonical config resolution path (GetModelConfig) so
|
||||
// alias handling and load-balancing stay consistent with the rest of the codebase.
|
||||
func populateCandidateProvidersFromNames(
|
||||
cfg *config.Config,
|
||||
workspace string,
|
||||
names []string,
|
||||
out map[string]providers.LLMProvider,
|
||||
) {
|
||||
if cfg == nil || len(names) == 0 {
|
||||
return
|
||||
}
|
||||
for _, name := range names {
|
||||
mc, err := resolvedModelConfig(cfg, strings.TrimSpace(name), workspace)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent",
|
||||
"fallback provider: no model_list entry found; will inherit primary provider credentials",
|
||||
map[string]any{"name": name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model))
|
||||
key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID)
|
||||
if _, exists := out[key]; exists {
|
||||
continue
|
||||
}
|
||||
p, _, err := providers.CreateProviderFromConfig(mc)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "fallback provider: failed to create provider",
|
||||
map[string]any{"model": mc.Model, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
out[key] = p
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
||||
|
|
@ -165,6 +166,58 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "glm-4.7",
|
||||
ModelFallbacks: []string{"glm-4.7__key_1"},
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "glm-4.7",
|
||||
Model: "zhipu/glm-4.7",
|
||||
RPM: 1,
|
||||
},
|
||||
{
|
||||
ModelName: "glm-4.7__key_1",
|
||||
Model: "zhipu/glm-4.7",
|
||||
RPM: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||
if len(agent.Candidates) != 2 {
|
||||
t.Fatalf("len(Candidates) = %d, want 2", len(agent.Candidates))
|
||||
}
|
||||
|
||||
first := agent.Candidates[0]
|
||||
second := agent.Candidates[1]
|
||||
if first.Provider != "zhipu" || first.Model != "glm-4.7" {
|
||||
t.Fatalf("first candidate = %s/%s, want zhipu/glm-4.7", first.Provider, first.Model)
|
||||
}
|
||||
if second.Provider != "zhipu" || second.Model != "glm-4.7" {
|
||||
t.Fatalf("second candidate = %s/%s, want zhipu/glm-4.7", second.Provider, second.Model)
|
||||
}
|
||||
if first.IdentityKey != "model_name:glm-4.7" {
|
||||
t.Fatalf("first identity key = %q, want %q", first.IdentityKey, "model_name:glm-4.7")
|
||||
}
|
||||
if second.IdentityKey != "model_name:glm-4.7__key_1" {
|
||||
t.Fatalf("second identity key = %q, want %q", second.IdentityKey, "model_name:glm-4.7__key_1")
|
||||
}
|
||||
if first.RPM != 1 {
|
||||
t.Fatalf("first RPM = %d, want 1", first.RPM)
|
||||
}
|
||||
if second.RPM != 3 {
|
||||
t.Fatalf("second RPM = %d, want 3", second.RPM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
mediaDir := media.TempDir()
|
||||
|
|
@ -248,6 +301,240 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_NilCfgIsNoop verifies that passing a nil
|
||||
// config does not panic and leaves the output map empty.
|
||||
func TestPopulateCandidateProviders_NilCfgIsNoop(t *testing.T) {
|
||||
out := map[string]providers.LLMProvider{}
|
||||
populateCandidateProvidersFromNames(nil, t.TempDir(), []string{"gpt-4o"}, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty map, got %d entries", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_SkipsExistingKeys verifies that a key already
|
||||
// present in the output map is not overwritten.
|
||||
func TestPopulateCandidateProviders_SkipsExistingKeys(t *testing.T) {
|
||||
existing := &mockProvider{}
|
||||
key := providers.ModelKey("openai", "gpt-4o")
|
||||
out := map[string]providers.LLMProvider{key: existing}
|
||||
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("test-key")},
|
||||
},
|
||||
}
|
||||
populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"my-gpt"}, out)
|
||||
|
||||
if out[key] != existing {
|
||||
t.Fatal("existing provider entry was overwritten; expected it to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_ResolvesAlias verifies that a model_name
|
||||
// alias (e.g. "my-gpt") is resolved via GetModelConfig and the provider
|
||||
// is created using the underlying model's config.
|
||||
func TestPopulateCandidateProviders_ResolvesAlias(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
out := map[string]providers.LLMProvider{}
|
||||
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "my-gpt", Model: "openai/gpt-4o", APIBase: "https://api.openai.com/v1", Workspace: workspace},
|
||||
},
|
||||
}
|
||||
populateCandidateProvidersFromNames(cfg, workspace, []string{"my-gpt"}, out)
|
||||
|
||||
key := providers.ModelKey("openai", "gpt-4o")
|
||||
if out[key] == nil {
|
||||
t.Fatalf("expected CandidateProviders[%q] to be populated for alias", key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_ResolvesProtocolPrefix verifies that a
|
||||
// model_list entry using full "provider/model" notation (e.g.
|
||||
// "gemini/gemma-3-27b-it") is matched correctly when referenced by model_name.
|
||||
func TestPopulateCandidateProviders_ResolvesProtocolPrefix(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
out := map[string]providers.LLMProvider{}
|
||||
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "gemma",
|
||||
Model: "gemini/gemma-3-27b-it",
|
||||
APIKeys: config.SimpleSecureStrings("gemini-test-key"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
},
|
||||
}
|
||||
populateCandidateProvidersFromNames(cfg, workspace, []string{"gemma"}, out)
|
||||
|
||||
key := providers.ModelKey("gemini", "gemma-3-27b-it")
|
||||
if out[key] == nil {
|
||||
t.Fatalf("expected CandidateProviders[%q] to be populated for protocol-prefixed model", key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_EmptyNamesIsNoop verifies the early-exit
|
||||
// path when the names slice is empty.
|
||||
func TestPopulateCandidateProviders_EmptyNamesIsNoop(t *testing.T) {
|
||||
out := map[string]providers.LLMProvider{}
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("key")},
|
||||
},
|
||||
}
|
||||
populateCandidateProvidersFromNames(cfg, t.TempDir(), nil, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty map, got %d entries", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_EmptyModelListIsNoop verifies the early-exit
|
||||
// path when model_list is empty — no provider can be created.
|
||||
func TestPopulateCandidateProviders_EmptyModelListIsNoop(t *testing.T) {
|
||||
out := map[string]providers.LLMProvider{}
|
||||
cfg := &config.Config{}
|
||||
populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"gpt-4o"}, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty map, got %d entries", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopulateCandidateProviders_UnmatchedNameIsSkipped verifies that a
|
||||
// name with no matching model_list entry is skipped and does not
|
||||
// cause a panic or leave a nil entry in the map.
|
||||
func TestPopulateCandidateProviders_UnmatchedNameIsSkipped(t *testing.T) {
|
||||
out := map[string]providers.LLMProvider{}
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("key")},
|
||||
},
|
||||
}
|
||||
populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"nonexistent-model"}, out)
|
||||
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected empty map for unmatched name, got %d entries", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks
|
||||
// mirrors the exact scenario from bug #2140: primary model on OpenRouter with
|
||||
// Gemini fallbacks. Each entry must get its own provider instance so that
|
||||
// fallback requests go to the correct API endpoint, not the primary's.
|
||||
func TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "mistral-small-3.1",
|
||||
ModelFallbacks: []string{"gemma-3-27b", "gemini-images"},
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "mistral-small-3.1",
|
||||
Model: "openrouter/mistralai/mistral-small-3.1-24b-instruct:free",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKeys: config.SimpleSecureStrings("sk-or-test"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
{
|
||||
ModelName: "gemma-3-27b",
|
||||
Model: "gemini/gemma-3-27b-it",
|
||||
APIKeys: config.SimpleSecureStrings("AIzaSy-test"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
{
|
||||
ModelName: "gemini-images",
|
||||
Model: "gemini/gemini-2.5-flash-lite",
|
||||
APIKeys: config.SimpleSecureStrings("AIzaSy-test"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
primaryProvider := &mockProvider{}
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, primaryProvider)
|
||||
|
||||
// Only fallback models need entries — the primary uses the injected provider directly.
|
||||
wantKeys := []string{
|
||||
providers.ModelKey("gemini", "gemma-3-27b-it"),
|
||||
providers.ModelKey("gemini", "gemini-2.5-flash-lite"),
|
||||
}
|
||||
|
||||
for _, key := range wantKeys {
|
||||
p, ok := agent.CandidateProviders[key]
|
||||
if !ok {
|
||||
t.Errorf("CandidateProviders missing key %q", key)
|
||||
continue
|
||||
}
|
||||
if p == nil {
|
||||
t.Errorf("CandidateProviders[%q] is nil", key)
|
||||
}
|
||||
// Each fallback must use its own provider, not the injected primary.
|
||||
if p == primaryProvider {
|
||||
t.Errorf(
|
||||
"CandidateProviders[%q] is the same instance as the primary provider; fallback would inherit primary credentials",
|
||||
key,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
t.Logf("CandidateProviders keys present: %v", func() []string {
|
||||
keys := make([]string, 0, len(agent.CandidateProviders))
|
||||
for k := range agent.CandidateProviders {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
ReadFile: config.ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
Mode: config.ReadFileModeLines,
|
||||
MaxReadFileSize: 4096,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||
readTool, ok := agent.Tools.Get("read_file")
|
||||
if !ok {
|
||||
t.Fatal("read_file tool not registered")
|
||||
}
|
||||
|
||||
params := readTool.Parameters()
|
||||
props, _ := params["properties"].(map[string]any)
|
||||
if _, ok := props["start_line"]; !ok {
|
||||
t.Fatalf("expected line-mode schema to expose start_line, got %#v", props)
|
||||
}
|
||||
if _, ok := props["max_lines"]; !ok {
|
||||
t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props)
|
||||
}
|
||||
if _, ok := props["offset"]; ok {
|
||||
t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props)
|
||||
}
|
||||
if _, ok := props["length"]; ok {
|
||||
t.Fatalf("did not expect line-mode schema to expose length, got %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
|
|
|
|||
|
|
@ -119,9 +119,18 @@ func NewAgentLoop(
|
|||
) *AgentLoop {
|
||||
registry := NewAgentRegistry(cfg, provider)
|
||||
|
||||
// Set up shared fallback chain
|
||||
// Set up shared fallback chain with rate limiting.
|
||||
cooldown := providers.NewCooldownTracker()
|
||||
fallbackChain := providers.NewFallbackChain(cooldown)
|
||||
rl := providers.NewRateLimiterRegistry()
|
||||
// Register rate limiters for all agents' candidates so that RPM limits
|
||||
// configured in ModelConfig are enforced before each LLM call.
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
if agent, ok := registry.GetAgent(agentID); ok {
|
||||
rl.RegisterCandidates(agent.Candidates)
|
||||
rl.RegisterCandidates(agent.LightCandidates)
|
||||
}
|
||||
}
|
||||
fallbackChain := providers.NewFallbackChain(cooldown, rl)
|
||||
|
||||
// Create state manager using default agent's workspace for channel recording
|
||||
defaultAgent := registry.GetDefaultAgent()
|
||||
|
|
@ -662,21 +671,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
return
|
||||
}
|
||||
|
||||
alreadySent := false
|
||||
alreadySentToSameChat := false
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent != nil {
|
||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
alreadySent = mt.HasSentInRound()
|
||||
alreadySentToSameChat = mt.HasSentTo(channel, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if alreadySent {
|
||||
if alreadySentToSameChat {
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Skipped outbound (message tool already sent)",
|
||||
map[string]any{"channel": channel},
|
||||
"Skipped outbound (message tool already sent to same chat)",
|
||||
map[string]any{"channel": channel, "chat_id": chatID},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -1032,8 +1041,15 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
al.cfg = cfg
|
||||
al.registry = registry
|
||||
|
||||
// Also update fallback chain with new config
|
||||
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
|
||||
// Also update fallback chain with new config; rebuild rate limiter registry.
|
||||
newRL := providers.NewRateLimiterRegistry()
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
if agent, ok := registry.GetAgent(agentID); ok {
|
||||
newRL.RegisterCandidates(agent.Candidates)
|
||||
newRL.RegisterCandidates(agent.LightCandidates)
|
||||
}
|
||||
}
|
||||
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL)
|
||||
|
||||
al.mu.Unlock()
|
||||
|
||||
|
|
@ -1726,6 +1742,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
|||
if err := al.contextManager.Compact(turnCtx, &CompactRequest{
|
||||
SessionKey: ts.sessionKey,
|
||||
Reason: ContextCompressReasonProactive,
|
||||
Budget: ts.agent.ContextWindow,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Proactive compact failed", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
|
|
@ -1841,6 +1858,7 @@ turnLoop:
|
|||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm)
|
||||
ts.recordPersistedMessage(pm)
|
||||
ts.ingestMessage(turnCtx, al, pm)
|
||||
}
|
||||
logger.InfoCF("agent", "Injected steering message into context",
|
||||
map[string]any{
|
||||
|
|
@ -2002,7 +2020,11 @@ turnLoop:
|
|||
providerCtx,
|
||||
activeCandidates,
|
||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||
return activeProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts)
|
||||
candidateProvider := activeProvider
|
||||
if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok {
|
||||
candidateProvider = cp
|
||||
}
|
||||
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts)
|
||||
},
|
||||
)
|
||||
if fbErr != nil {
|
||||
|
|
@ -2112,6 +2134,7 @@ turnLoop:
|
|||
if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{
|
||||
SessionKey: ts.sessionKey,
|
||||
Reason: ContextCompressReasonRetry,
|
||||
Budget: ts.agent.ContextWindow,
|
||||
}); compactErr != nil {
|
||||
logger.WarnCF("agent", "Context overflow compact failed", map[string]any{
|
||||
"session_key": ts.sessionKey,
|
||||
|
|
@ -2329,6 +2352,236 @@ turnLoop:
|
|||
toolName = toolReq.Tool
|
||||
toolArgs = toolReq.Arguments
|
||||
}
|
||||
case HookActionRespond:
|
||||
// Hook returns result directly, skip tool execution.
|
||||
// SECURITY: This bypasses ApproveTool, allowing hooks to respond
|
||||
// for any tool name without approval. This is intentional for
|
||||
// plugin tools but means a before_tool hook can override even
|
||||
// sensitive tools like bash. Hook configuration should be
|
||||
// carefully reviewed to prevent unauthorized tool execution.
|
||||
if toolReq != nil && toolReq.HookResult != nil {
|
||||
hookResult := toolReq.HookResult
|
||||
|
||||
argsJSON, _ := json.Marshal(toolArgs)
|
||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview),
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"tool": toolName,
|
||||
"iteration": iteration,
|
||||
})
|
||||
|
||||
// Emit ToolExecStart event (same as normal tool execution)
|
||||
al.emitEvent(
|
||||
EventKindToolExecStart,
|
||||
ts.eventMeta("runTurn", "turn.tool.start"),
|
||||
ToolExecStartPayload{
|
||||
Tool: toolName,
|
||||
Arguments: cloneEventArguments(toolArgs),
|
||||
},
|
||||
)
|
||||
|
||||
// Send tool feedback to chat channel if enabled (same as normal tool execution)
|
||||
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
|
||||
ts.channel != "" &&
|
||||
!ts.opts.SuppressToolFeedback {
|
||||
argsJSON, _ := json.Marshal(toolArgs)
|
||||
feedbackPreview := utils.Truncate(
|
||||
string(argsJSON),
|
||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||
)
|
||||
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, feedbackPreview)
|
||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Content: feedbackMsg,
|
||||
})
|
||||
fbCancel()
|
||||
}
|
||||
|
||||
toolDuration := time.Duration(0) // Hook execution time unknown
|
||||
|
||||
// Send ForUser content to user
|
||||
// For ResponseHandled results, send regardless of SendResponse setting,
|
||||
// same as normal tool execution path.
|
||||
shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" &&
|
||||
(ts.opts.SendResponse || hookResult.ResponseHandled)
|
||||
if shouldSendForUser {
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Content: hookResult.ForUser,
|
||||
Metadata: map[string]string{
|
||||
"is_tool_call": "true",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle media from hook result (same as normal tool execution)
|
||||
if len(hookResult.Media) > 0 && hookResult.ResponseHandled {
|
||||
parts := make([]bus.MediaPart, 0, len(hookResult.Media))
|
||||
for _, ref := range hookResult.Media {
|
||||
part := bus.MediaPart{Ref: ref}
|
||||
if al.mediaStore != nil {
|
||||
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||
part.Filename = meta.Filename
|
||||
part.ContentType = meta.ContentType
|
||||
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||
}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
outboundMedia := bus.OutboundMediaMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Parts: parts,
|
||||
}
|
||||
if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) {
|
||||
if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
|
||||
logger.WarnCF("agent", "Failed to deliver hook media",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"tool": toolName,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Same as normal tool execution: notify LLM about delivery failure
|
||||
hookResult.IsError = true
|
||||
hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err)
|
||||
}
|
||||
} else if al.bus != nil {
|
||||
al.bus.PublishOutboundMedia(ctx, outboundMedia)
|
||||
// Same as normal tool execution: bus only queues, media not yet delivered
|
||||
hookResult.ResponseHandled = false
|
||||
}
|
||||
}
|
||||
|
||||
// Track response handling status (same as normal tool execution)
|
||||
if !hookResult.ResponseHandled {
|
||||
allResponsesHandled = false
|
||||
}
|
||||
|
||||
// Build tool message
|
||||
contentForLLM := hookResult.ContentForLLM()
|
||||
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||
}
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: contentForLLM,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
|
||||
// Handle media for LLM vision (same as normal tool execution)
|
||||
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
|
||||
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
|
||||
// Recalculate contentForLLM after adding ArtifactTags
|
||||
contentForLLM = hookResult.ContentForLLM()
|
||||
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||
}
|
||||
toolResultMsg.Content = contentForLLM
|
||||
toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
|
||||
}
|
||||
|
||||
// Emit ToolExecEnd event (after filtering, same as normal tool execution)
|
||||
al.emitEvent(
|
||||
EventKindToolExecEnd,
|
||||
ts.eventMeta("runTurn", "turn.tool.end"),
|
||||
ToolExecEndPayload{
|
||||
Tool: toolName,
|
||||
Duration: toolDuration,
|
||||
ForLLMLen: len(contentForLLM),
|
||||
ForUserLen: len(hookResult.ForUser),
|
||||
IsError: hookResult.IsError,
|
||||
Async: hookResult.Async,
|
||||
},
|
||||
)
|
||||
|
||||
messages = append(messages, toolResultMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
||||
ts.recordPersistedMessage(toolResultMsg)
|
||||
ts.ingestMessage(turnCtx, al, toolResultMsg)
|
||||
}
|
||||
|
||||
// Same as normal tool execution: check for steering/interrupt/SubTurn after each tool
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||
pendingMessages = append(pendingMessages, steerMsgs...)
|
||||
}
|
||||
|
||||
skipReason := ""
|
||||
skipMessage := ""
|
||||
if len(pendingMessages) > 0 {
|
||||
skipReason = "queued user steering message"
|
||||
skipMessage = "Skipped due to queued user message."
|
||||
} else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending {
|
||||
skipReason = "graceful interrupt requested"
|
||||
skipMessage = "Skipped due to graceful interrupt."
|
||||
}
|
||||
|
||||
if skipReason != "" {
|
||||
remaining := len(normalizedToolCalls) - i - 1
|
||||
if remaining > 0 {
|
||||
logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"completed": i + 1,
|
||||
"skipped": remaining,
|
||||
"reason": skipReason,
|
||||
})
|
||||
for j := i + 1; j < len(normalizedToolCalls); j++ {
|
||||
skippedTC := normalizedToolCalls[j]
|
||||
al.emitEvent(
|
||||
EventKindToolExecSkipped,
|
||||
ts.eventMeta("runTurn", "turn.tool.skipped"),
|
||||
ToolExecSkippedPayload{
|
||||
Tool: skippedTC.Name,
|
||||
Reason: skipReason,
|
||||
},
|
||||
)
|
||||
skippedMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: skipMessage,
|
||||
ToolCallID: skippedTC.ID,
|
||||
}
|
||||
messages = append(messages, skippedMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg)
|
||||
ts.recordPersistedMessage(skippedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Also poll for any SubTurn results that arrived during tool execution.
|
||||
if ts.pendingResults != nil {
|
||||
select {
|
||||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
messages = append(messages, msg)
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
default:
|
||||
// No results available
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
// If no HookResult, fall back to continue with warning
|
||||
logger.WarnCF("agent", "Hook returned respond action but no HookResult provided",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"tool": toolName,
|
||||
"action": "respond",
|
||||
})
|
||||
case HookActionDenyTool:
|
||||
allResponsesHandled = false
|
||||
denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason)
|
||||
|
|
@ -2757,7 +3010,7 @@ turnLoop:
|
|||
}
|
||||
}
|
||||
if ts.opts.EnableSummary {
|
||||
al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize})
|
||||
al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow})
|
||||
}
|
||||
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
|
|
@ -2833,6 +3086,7 @@ turnLoop:
|
|||
&CompactRequest{
|
||||
SessionKey: ts.sessionKey,
|
||||
Reason: ContextCompressReasonSummarize,
|
||||
Budget: ts.agent.ContextWindow,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -3229,7 +3483,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
|||
return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
|
||||
}
|
||||
|
||||
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks)
|
||||
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks)
|
||||
if len(nextCandidates) == 0 {
|
||||
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
}
|
||||
|
||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||
mcpTool.SetWorkspace(agent.Workspace)
|
||||
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
|
||||
|
||||
if registerAsHidden {
|
||||
agent.Tools.RegisterHidden(mcpTool)
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,164 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestProcessMessage_FallbackUsesPerCandidateProvider is the loop-level test for
|
||||
// bug #2140. It verifies that when the primary model returns a rate-limit error
|
||||
// the fallback closure routes the retry to the fallback model's own provider
|
||||
// (its own api_base), not back to the primary provider's endpoint.
|
||||
func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
primaryCalls := 0
|
||||
primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
// Return 429 so FallbackChain classifies this as retriable and moves on.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": "rate limit exceeded",
|
||||
"type": "rate_limit_error",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer primaryServer.Close()
|
||||
|
||||
fallbackCalls := 0
|
||||
fallbackServer := newStrictChatCompletionTestServer(
|
||||
t, "fallback", "gemma-3-27b-it", "fallback reply", &fallbackCalls,
|
||||
)
|
||||
defer fallbackServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "mistral-primary",
|
||||
ModelFallbacks: []string{"gemma-fallback"},
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "mistral-primary",
|
||||
Model: "openrouter/mistralai/mistral-small-3.1",
|
||||
APIBase: primaryServer.URL,
|
||||
APIKeys: config.SimpleSecureStrings("primary-key"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
{
|
||||
ModelName: "gemma-fallback",
|
||||
Model: "gemini/gemma-3-27b-it",
|
||||
APIBase: fallbackServer.URL,
|
||||
APIKeys: config.SimpleSecureStrings("fallback-key"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider, _, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "hi",
|
||||
Peer: bus.Peer{Kind: "direct", ID: "user1"},
|
||||
})
|
||||
|
||||
if resp != "fallback reply" {
|
||||
t.Fatalf("response = %q, want %q (fallback provider)", resp, "fallback reply")
|
||||
}
|
||||
if primaryCalls == 0 {
|
||||
t.Fatal("primary server was never called; expected at least one attempt")
|
||||
}
|
||||
if fallbackCalls != 1 {
|
||||
t.Fatalf("fallback server calls = %d, want 1", fallbackCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered verifies
|
||||
// that when a candidate has no model_list entry it is absent from CandidateProviders
|
||||
// and the fallback closure falls back to activeProvider instead of panicking.
|
||||
func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
// Primary server: returns 429 on first call, succeeds on second.
|
||||
// Both the primary and the unregistered fallback share this server
|
||||
// (same api_base) so activeProvider routes both calls here.
|
||||
callCount := 0
|
||||
primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"message": "rate limit", "type": "rate_limit_error"},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Second call (fallback via activeProvider) succeeds.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "active provider reply"}, "finish_reason": "stop"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer primaryServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "primary-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
// No model_list entry for this alias — absent from CandidateProviders.
|
||||
ModelFallbacks: []string{"openrouter/fallback-model"},
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "primary-model",
|
||||
Model: "openrouter/primary-model",
|
||||
APIBase: primaryServer.URL,
|
||||
APIKeys: config.SimpleSecureStrings("primary-key"),
|
||||
Workspace: workspace,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider, _, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
helper := testHelper{al: al}
|
||||
resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "hi",
|
||||
Peer: bus.Peer{Kind: "direct", ID: "user1"},
|
||||
})
|
||||
|
||||
if resp != "active provider reply" {
|
||||
t.Fatalf("response = %q, want %q", resp, "active provider reply")
|
||||
}
|
||||
if callCount < 2 {
|
||||
t.Fatalf("primary server calls = %d, want >= 2 (one 429 + one success via activeProvider)", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
|
|
|
|||
|
|
@ -8,44 +8,102 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) {
|
||||
ensureProtocol := func(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(model, "/") {
|
||||
return model
|
||||
}
|
||||
return "openai/" + model
|
||||
func ensureProtocolModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(model, "/") {
|
||||
return model
|
||||
}
|
||||
return "openai/" + model
|
||||
}
|
||||
|
||||
func modelConfigIdentityKey(mc *config.ModelConfig) string {
|
||||
if mc == nil {
|
||||
return ""
|
||||
}
|
||||
if name := strings.TrimSpace(mc.ModelName); name != "" {
|
||||
return "model_name:" + name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func candidateFromModelConfig(
|
||||
defaultProvider string,
|
||||
mc *config.ModelConfig,
|
||||
) (providers.FallbackCandidate, bool) {
|
||||
if mc == nil {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
return func(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || cfg == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||
return ensureProtocol(mc.Model), true
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||
if fullModel == "" {
|
||||
continue
|
||||
}
|
||||
if fullModel == raw {
|
||||
return ensureProtocol(fullModel), true
|
||||
}
|
||||
_, modelID := providers.ExtractProtocol(fullModel)
|
||||
if modelID == raw {
|
||||
return ensureProtocol(fullModel), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider)
|
||||
if ref == nil {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
return providers.FallbackCandidate{
|
||||
Provider: ref.Provider,
|
||||
Model: ref.Model,
|
||||
RPM: mc.RPM,
|
||||
IdentityKey: modelConfigIdentityKey(mc),
|
||||
}, true
|
||||
}
|
||||
|
||||
func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||
return mc
|
||||
}
|
||||
|
||||
for i := range cfg.ModelList {
|
||||
mc := cfg.ModelList[i]
|
||||
if mc == nil {
|
||||
continue
|
||||
}
|
||||
fullModel := strings.TrimSpace(mc.Model)
|
||||
if fullModel == "" {
|
||||
continue
|
||||
}
|
||||
if fullModel == raw {
|
||||
return mc
|
||||
}
|
||||
_, modelID := providers.ExtractProtocol(fullModel)
|
||||
if modelID == raw {
|
||||
return mc
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveModelCandidate(
|
||||
cfg *config.Config,
|
||||
defaultProvider string,
|
||||
raw string,
|
||||
) (providers.FallbackCandidate, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
if mc := lookupModelConfigByRef(cfg, raw); mc != nil {
|
||||
return candidateFromModelConfig(defaultProvider, mc)
|
||||
}
|
||||
|
||||
ref := providers.ParseModelRef(raw, defaultProvider)
|
||||
if ref == nil {
|
||||
return providers.FallbackCandidate{}, false
|
||||
}
|
||||
|
||||
return providers.FallbackCandidate{
|
||||
Provider: ref.Provider,
|
||||
Model: ref.Model,
|
||||
}, true
|
||||
}
|
||||
|
||||
func resolveModelCandidates(
|
||||
|
|
@ -54,14 +112,29 @@ func resolveModelCandidates(
|
|||
primary string,
|
||||
fallbacks []string,
|
||||
) []providers.FallbackCandidate {
|
||||
return providers.ResolveCandidatesWithLookup(
|
||||
providers.ModelConfig{
|
||||
Primary: primary,
|
||||
Fallbacks: fallbacks,
|
||||
},
|
||||
defaultProvider,
|
||||
buildModelListResolver(cfg),
|
||||
)
|
||||
seen := make(map[string]bool)
|
||||
candidates := make([]providers.FallbackCandidate, 0, 1+len(fallbacks))
|
||||
|
||||
addCandidate := func(raw string) {
|
||||
candidate, ok := resolveModelCandidate(cfg, defaultProvider, raw)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
key := candidate.StableKey()
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
addCandidate(primary)
|
||||
for _, fallback := range fallbacks {
|
||||
addCandidate(fallback)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ type ephemeralSessionStoreIface interface {
|
|||
SetHistory(key string, history []providers.Message)
|
||||
TruncateHistory(key string, keepLast int)
|
||||
Save(key string) error
|
||||
ListSessions() []string
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
|
@ -663,8 +664,9 @@ func (e *ephemeralSessionStore) TruncateHistory(_ string, keepLast int) {
|
|||
e.history = e.history[len(e.history)-keepLast:]
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) Save(_ string) error { return nil }
|
||||
func (e *ephemeralSessionStore) Close() error { return nil }
|
||||
func (e *ephemeralSessionStore) Save(_ string) error { return nil }
|
||||
func (e *ephemeralSessionStore) Close() error { return nil }
|
||||
func (e *ephemeralSessionStore) ListSessions() []string { return nil }
|
||||
|
||||
func (e *ephemeralSessionStore) truncateLocked() {
|
||||
if len(e.history) > maxEphemeralHistorySize {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
|
@ -42,12 +43,18 @@ type FeishuChannel struct {
|
|||
wsClient *larkws.Client
|
||||
tokenCache *tokenCache // custom cache that supports invalidation
|
||||
|
||||
botOpenID atomic.Value // stores string; populated lazily for @mention detection
|
||||
botOpenID atomic.Value // stores string; populated lazily for @mention detection
|
||||
messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message)
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type cachedMessage struct {
|
||||
msg *larkim.Message
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
|
|
@ -436,24 +443,8 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
|||
// Append media tags to content (like Telegram does)
|
||||
content = appendMediaTags(content, messageType, mediaRefs)
|
||||
|
||||
if content == "" {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
metadata := map[string]string{}
|
||||
if messageID != "" {
|
||||
metadata["message_id"] = messageID
|
||||
}
|
||||
if messageType != "" {
|
||||
metadata["message_type"] = messageType
|
||||
}
|
||||
chatType := stringValue(message.ChatType)
|
||||
if chatType != "" {
|
||||
metadata["chat_type"] = chatType
|
||||
}
|
||||
if sender != nil && sender.TenantKey != nil {
|
||||
metadata["tenant_key"] = *sender.TenantKey
|
||||
}
|
||||
metadata := buildInboundMetadata(message, sender)
|
||||
|
||||
var peer bus.Peer
|
||||
if chatType == "p2p" {
|
||||
|
|
@ -477,12 +468,25 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
|||
content = cleaned
|
||||
}
|
||||
|
||||
if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" {
|
||||
content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs)
|
||||
}
|
||||
if content == "" {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"preview": utils.Truncate(content, 80),
|
||||
})
|
||||
logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{
|
||||
"message_id": messageID,
|
||||
"parent_id": stringValue(message.ParentId),
|
||||
"root_id": stringValue(message.RootId),
|
||||
"thread_id": stringValue(message.ThreadId),
|
||||
})
|
||||
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
||||
return nil
|
||||
|
|
|
|||
298
pkg/channels/feishu/feishu_reply.go
Normal file
298
pkg/channels/feishu/feishu_reply.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const messageCacheTTL = 30 * time.Second
|
||||
|
||||
const (
|
||||
maxReplyContextLen = 600
|
||||
)
|
||||
|
||||
func (c *FeishuChannel) prependReplyContext(
|
||||
ctx context.Context,
|
||||
message *larkim.EventMessage,
|
||||
chatID string,
|
||||
content string,
|
||||
mediaRefs []string,
|
||||
) (string, []string) {
|
||||
if message == nil {
|
||||
return content, mediaRefs
|
||||
}
|
||||
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
|
||||
if targetMessageID == "" {
|
||||
logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
|
||||
"message_id": stringValue(message.MessageId),
|
||||
"parent_id": stringValue(message.ParentId),
|
||||
"root_id": stringValue(message.RootId),
|
||||
"thread_id": stringValue(message.ThreadId),
|
||||
})
|
||||
return content, mediaRefs
|
||||
}
|
||||
|
||||
repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
|
||||
if err != nil {
|
||||
logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
|
||||
"target_message_id": targetMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return content, mediaRefs
|
||||
}
|
||||
|
||||
messageType := stringValue(repliedMessage.MsgType)
|
||||
rawContent := ""
|
||||
if repliedMessage.Body != nil {
|
||||
rawContent = stringValue(repliedMessage.Body.Content)
|
||||
}
|
||||
|
||||
var repliedMediaRefs []string
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
|
||||
if messageType == larkim.MsgTypeInteractive {
|
||||
_, externalURLs := extractCardImageKeys(rawContent)
|
||||
if len(externalURLs) > 0 {
|
||||
repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
|
||||
if len(repliedMediaRefs) > 0 {
|
||||
mediaRefs = append(repliedMediaRefs, mediaRefs...)
|
||||
}
|
||||
|
||||
return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
|
||||
if targetID := replyTargetID(message); targetID != "" {
|
||||
logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
|
||||
"message_id": stringValue(message.MessageId),
|
||||
"parent_id": stringValue(message.ParentId),
|
||||
"root_id": stringValue(message.RootId),
|
||||
"target_id": targetID,
|
||||
})
|
||||
return targetID
|
||||
}
|
||||
|
||||
currentMessageID := stringValue(message.MessageId)
|
||||
if currentMessageID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if stringValue(message.ThreadId) == "" {
|
||||
logger.DebugCF("feishu", "No reply target found; message is not in a thread", map[string]any{
|
||||
"message_id": stringValue(message.MessageId),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
msg, err := c.fetchMessageByID(ctx, currentMessageID)
|
||||
if err != nil {
|
||||
logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
|
||||
"message_id": currentMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
targetID := replyTargetIDFromMessage(msg)
|
||||
if targetID != "" {
|
||||
logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
|
||||
"message_id": currentMessageID,
|
||||
"parent_id": stringValue(msg.ParentId),
|
||||
"root_id": stringValue(msg.RootId),
|
||||
"target_id": targetID,
|
||||
})
|
||||
}
|
||||
return targetID
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
|
||||
if cached, ok := c.messageCache.Load(messageID); ok {
|
||||
cm := cached.(*cachedMessage)
|
||||
if time.Now().Before(cm.expiry) {
|
||||
return cm.msg, nil
|
||||
}
|
||||
c.messageCache.Delete(messageID)
|
||||
}
|
||||
|
||||
req := larkim.NewGetMessageReqBuilder().
|
||||
MessageId(messageID).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Get(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feishu get message: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
c.invalidateTokenOnAuthError(resp.Code)
|
||||
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
|
||||
return nil, fmt.Errorf("feishu get message: empty response")
|
||||
}
|
||||
// Items[0] contains the target message - the Feishu API returns a list
|
||||
// but we request a single message by ID, so the list always has at most one item.
|
||||
msg := resp.Data.Items[0]
|
||||
c.messageCache.Store(messageID, &cachedMessage{msg: msg, expiry: time.Now().Add(messageCacheTTL)})
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func replyTargetID(message *larkim.EventMessage) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
if parentID := stringValue(message.ParentId); parentID != "" {
|
||||
return parentID
|
||||
}
|
||||
return stringValue(message.RootId)
|
||||
}
|
||||
|
||||
func replyTargetIDFromMessage(message *larkim.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
if parentID := stringValue(message.ParentId); parentID != "" {
|
||||
return parentID
|
||||
}
|
||||
return stringValue(message.RootId)
|
||||
}
|
||||
|
||||
func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if message == nil {
|
||||
return metadata
|
||||
}
|
||||
|
||||
messageID := stringValue(message.MessageId)
|
||||
if messageID != "" {
|
||||
metadata["message_id"] = messageID
|
||||
}
|
||||
|
||||
messageType := stringValue(message.MessageType)
|
||||
if messageType != "" {
|
||||
metadata["message_type"] = messageType
|
||||
}
|
||||
|
||||
chatType := stringValue(message.ChatType)
|
||||
if chatType != "" {
|
||||
metadata["chat_type"] = chatType
|
||||
}
|
||||
|
||||
parentID := stringValue(message.ParentId)
|
||||
if parentID != "" {
|
||||
metadata["parent_id"] = parentID
|
||||
}
|
||||
|
||||
rootID := stringValue(message.RootId)
|
||||
if rootID != "" {
|
||||
metadata["root_id"] = rootID
|
||||
}
|
||||
|
||||
if replyTo := replyTargetID(message); replyTo != "" {
|
||||
metadata["reply_to_message_id"] = replyTo
|
||||
}
|
||||
|
||||
threadID := stringValue(message.ThreadId)
|
||||
if threadID != "" {
|
||||
metadata["thread_id"] = threadID
|
||||
}
|
||||
|
||||
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
|
||||
metadata["tenant_key"] = *sender.TenantKey
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
|
||||
content := extractContent(messageType, rawContent)
|
||||
|
||||
if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
|
||||
content = ""
|
||||
}
|
||||
|
||||
content = appendMediaTags(content, messageType, mediaRefs)
|
||||
if strings.TrimSpace(content) != "" {
|
||||
return content
|
||||
}
|
||||
|
||||
switch messageType {
|
||||
case larkim.MsgTypeImage:
|
||||
return "[replied image]"
|
||||
case larkim.MsgTypeFile:
|
||||
return "[replied file]"
|
||||
case larkim.MsgTypeAudio:
|
||||
return "[replied audio]"
|
||||
case larkim.MsgTypeMedia:
|
||||
return "[replied video]"
|
||||
case larkim.MsgTypeInteractive:
|
||||
return "[replied interactive card]"
|
||||
default:
|
||||
return "[replied message content unavailable]"
|
||||
}
|
||||
}
|
||||
|
||||
func containsFeishuUpgradePlaceholder(s string) bool {
|
||||
upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
|
||||
upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
|
||||
return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped)
|
||||
}
|
||||
|
||||
func formatReplyContext(parentID, repliedContent, content string) string {
|
||||
parentID = strings.TrimSpace(parentID)
|
||||
repliedContent = strings.TrimSpace(repliedContent)
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
if parentID == "" || repliedContent == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
repliedContent = utils.Truncate(repliedContent, maxReplyContextLen)
|
||||
repliedContent = sanitizeReplyContextContent(repliedContent)
|
||||
content = sanitizeReplyContextContent(content)
|
||||
header := fmt.Sprintf("[replied_message id=%q]", parentID)
|
||||
footer := "[/replied_message]"
|
||||
if content == "" {
|
||||
return header + "\n" + repliedContent + "\n" + footer
|
||||
}
|
||||
if hasLeadingCommandPrefix(content) {
|
||||
return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
|
||||
}
|
||||
return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
|
||||
}
|
||||
|
||||
func hasLeadingCommandPrefix(s string) bool {
|
||||
tokens := strings.Fields(strings.TrimSpace(s))
|
||||
if len(tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
first := tokens[0]
|
||||
return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
|
||||
}
|
||||
|
||||
func sanitizeReplyContextContent(s string) string {
|
||||
tagEscaper := strings.NewReplacer(
|
||||
"[replied_message", `\[replied_message`,
|
||||
"[/replied_message]", `\[/replied_message]`,
|
||||
"[current_message]", `\[current_message]`,
|
||||
"[/current_message]", `\[/current_message]`,
|
||||
)
|
||||
return tagEscaper.Replace(s)
|
||||
}
|
||||
229
pkg/channels/feishu/feishu_reply_test.go
Normal file
229
pkg/channels/feishu/feishu_reply_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
func TestBuildInboundMetadata(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
t.Run("includes basic and reply fields", func(t *testing.T) {
|
||||
message := &larkim.EventMessage{
|
||||
MessageId: strPtr("om_msg_1"),
|
||||
MessageType: strPtr("text"),
|
||||
ChatType: strPtr("group"),
|
||||
ParentId: strPtr("om_parent_1"),
|
||||
RootId: strPtr("om_root_1"),
|
||||
ThreadId: strPtr("omt_thread_1"),
|
||||
}
|
||||
sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
|
||||
|
||||
got := buildInboundMetadata(message, sender)
|
||||
|
||||
if got["message_id"] != "om_msg_1" {
|
||||
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
|
||||
}
|
||||
if got["message_type"] != "text" {
|
||||
t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
|
||||
}
|
||||
if got["chat_type"] != "group" {
|
||||
t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
|
||||
}
|
||||
if got["parent_id"] != "om_parent_1" {
|
||||
t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
|
||||
}
|
||||
if got["reply_to_message_id"] != "om_parent_1" {
|
||||
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
|
||||
}
|
||||
if got["root_id"] != "om_root_1" {
|
||||
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
|
||||
}
|
||||
if got["thread_id"] != "omt_thread_1" {
|
||||
t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
|
||||
}
|
||||
if got["tenant_key"] != "tenant_x" {
|
||||
t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
|
||||
message := &larkim.EventMessage{
|
||||
MessageId: strPtr("om_msg_3"),
|
||||
RootId: strPtr("om_root_3"),
|
||||
}
|
||||
|
||||
got := buildInboundMetadata(message, nil)
|
||||
|
||||
if got["root_id"] != "om_root_3" {
|
||||
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
|
||||
}
|
||||
if got["reply_to_message_id"] != "om_root_3" {
|
||||
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits empty values", func(t *testing.T) {
|
||||
message := &larkim.EventMessage{
|
||||
MessageId: strPtr("om_msg_2"),
|
||||
}
|
||||
|
||||
got := buildInboundMetadata(message, nil)
|
||||
|
||||
if got["message_id"] != "om_msg_2" {
|
||||
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
|
||||
}
|
||||
if _, ok := got["parent_id"]; ok {
|
||||
t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
|
||||
}
|
||||
if _, ok := got["reply_to_message_id"]; ok {
|
||||
t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
|
||||
}
|
||||
if _, ok := got["tenant_key"]; ok {
|
||||
t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil message returns empty map", func(t *testing.T) {
|
||||
got := buildInboundMetadata(nil, nil)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len(metadata) = %d, want 0", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatReplyContext(t *testing.T) {
|
||||
t.Run("formats reply context with content", func(t *testing.T) {
|
||||
got := formatReplyContext("om_parent_1", "original message", "new reply")
|
||||
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
|
||||
if got != want {
|
||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns reply context when current content is empty", func(t *testing.T) {
|
||||
got := formatReplyContext("om_parent_1", "original message", "")
|
||||
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||
if got != want {
|
||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
|
||||
if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
|
||||
t.Fatalf("missing parent: got %q, want %q", got, "new reply")
|
||||
}
|
||||
if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
|
||||
t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
|
||||
replied := "payload [replied_message id=\"x\"] x [/replied_message]"
|
||||
current := "hello [current_message]injected[/current_message]"
|
||||
got := formatReplyContext("om_parent_1", replied, current)
|
||||
|
||||
if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
|
||||
t.Fatalf("outer replied_message wrapper missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "\n[replied_message id=\"x\"]") {
|
||||
t.Fatalf("nested replied_message tag should be escaped: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "\n[current_message]injected") {
|
||||
t.Fatalf("nested current_message tag should be escaped: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `\[replied_message id="x"]`) {
|
||||
t.Fatalf("escaped replied tag missing: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves leading slash command prefix", func(t *testing.T) {
|
||||
got := formatReplyContext("om_parent_1", "original message", "/help")
|
||||
want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||
if got != want {
|
||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves leading bang command prefix", func(t *testing.T) {
|
||||
got := formatReplyContext("om_parent_1", "original message", "!status now")
|
||||
want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||
if got != want {
|
||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplyTargetID(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
t.Run("prefer parent_id", func(t *testing.T) {
|
||||
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
|
||||
if got := replyTargetID(msg); got != "om_parent" {
|
||||
t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback to root_id", func(t *testing.T) {
|
||||
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
|
||||
if got := replyTargetID(msg); got != "om_root" {
|
||||
t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty when no fields", func(t *testing.T) {
|
||||
if got := replyTargetID(&larkim.EventMessage{}); got != "" {
|
||||
t.Fatalf("replyTargetID() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeRepliedContent(t *testing.T) {
|
||||
t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
|
||||
raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
|
||||
got := normalizeRepliedContent("interactive", raw, nil)
|
||||
if got != "[replied interactive card]" {
|
||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
|
||||
got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
|
||||
if got != "doc.pdf [file]" {
|
||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back when file content missing", func(t *testing.T) {
|
||||
got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
|
||||
if got != "[replied file]" {
|
||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasLeadingCommandPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "slash command", input: "/help", want: true},
|
||||
{name: "bang command", input: "!status", want: true},
|
||||
{name: "leading spaces slash", input: " /ping arg", want: true},
|
||||
{name: "normal text", input: "hello /help", want: false},
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
|
||||
t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -429,6 +430,23 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
|||
m.initChannel("chatmail", "Chatmail")
|
||||
}
|
||||
|
||||
if channels.VK.Enabled && channels.VK.Token.String() != "" && channels.VK.GroupID != 0 {
|
||||
m.initChannel("vk", "VK")
|
||||
}
|
||||
|
||||
if channels.TeamsWebhook.Enabled && len(channels.TeamsWebhook.Webhooks) > 0 {
|
||||
hasValidTarget := false
|
||||
for _, target := range channels.TeamsWebhook.Webhooks {
|
||||
if target.WebhookURL.String() != "" {
|
||||
hasValidTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasValidTarget {
|
||||
m.initChannel("teams_webhook", "Teams Webhook")
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
|
@ -517,6 +535,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
|
||||
dispatchCtx, cancel := context.WithCancel(ctx)
|
||||
m.dispatchTask = &asyncTask{cancel: cancel}
|
||||
failedStarts := make([]error, 0, len(m.channels))
|
||||
failedNames := make([]string, 0, len(m.channels))
|
||||
|
||||
for name, channel := range m.channels {
|
||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||
|
|
@ -527,6 +547,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
|
||||
failedNames = append(failedNames, name)
|
||||
continue
|
||||
}
|
||||
// Lazily create worker only after channel starts successfully
|
||||
|
|
@ -536,6 +558,36 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
go m.runMediaWorker(dispatchCtx, name, w)
|
||||
}
|
||||
|
||||
if len(m.channels) > 0 && len(m.workers) == 0 {
|
||||
if m.dispatchTask != nil {
|
||||
m.dispatchTask.cancel()
|
||||
m.dispatchTask = nil
|
||||
}
|
||||
|
||||
sort.Strings(failedNames)
|
||||
if len(failedStarts) == 0 {
|
||||
return fmt.Errorf("failed to start any enabled channels")
|
||||
}
|
||||
|
||||
logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{
|
||||
"failed": len(failedNames),
|
||||
"total": len(m.channels),
|
||||
"failed_channels": failedNames,
|
||||
})
|
||||
|
||||
return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...))
|
||||
}
|
||||
|
||||
if len(failedNames) > 0 {
|
||||
sort.Strings(failedNames)
|
||||
logger.WarnCF("channels", "Some channels failed to start", map[string]any{
|
||||
"failed": len(failedNames),
|
||||
"started": len(m.workers),
|
||||
"total": len(m.channels),
|
||||
"failed_channels": failedNames,
|
||||
})
|
||||
}
|
||||
|
||||
// Start the dispatcher that reads from the bus and routes to workers
|
||||
go m.dispatchOutbound(dispatchCtx)
|
||||
go m.dispatchOutboundMedia(dispatchCtx)
|
||||
|
|
@ -557,7 +609,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
}()
|
||||
}
|
||||
|
||||
logger.InfoC("channels", "All channels started")
|
||||
logger.InfoCF("channels", "Channel startup completed", map[string]any{
|
||||
"started": len(m.workers),
|
||||
"failed": len(failedNames),
|
||||
"total": len(m.channels),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
|||
value["app_secret"] = ch.Feishu.AppSecret.String()
|
||||
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
|
||||
value["verification_token"] = ch.Feishu.VerificationToken.String()
|
||||
case "teams_webhook":
|
||||
// Expose webhook URLs for hash computation (they contain secrets)
|
||||
webhooks := make(map[string]string)
|
||||
for name, target := range ch.TeamsWebhook.Webhooks {
|
||||
webhooks[name] = target.WebhookURL.String()
|
||||
}
|
||||
value["webhooks"] = webhooks
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,4 +173,13 @@ func updateKeys(newcfg, old *config.ChannelsConfig) {
|
|||
newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey
|
||||
newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken
|
||||
}
|
||||
if newcfg.TeamsWebhook.Enabled {
|
||||
// Copy SecureString webhook URLs from old config
|
||||
for name, oldTarget := range old.TeamsWebhook.Webhooks {
|
||||
if newTarget, ok := newcfg.TeamsWebhook.Webhooks[name]; ok {
|
||||
newTarget.WebhookURL = oldTarget.WebhookURL
|
||||
newcfg.TeamsWebhook.Webhooks[name] = newTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import (
|
|||
type mockChannel struct {
|
||||
BaseChannel
|
||||
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
||||
startFn func(ctx context.Context) error
|
||||
stopFn func(ctx context.Context) error
|
||||
sentMessages []bus.OutboundMessage
|
||||
placeholdersSent int
|
||||
editedMessages int
|
||||
|
|
@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
|||
return nil, m.sendFn(ctx, msg)
|
||||
}
|
||||
|
||||
func (m *mockChannel) Start(ctx context.Context) error { return nil }
|
||||
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
|
||||
func (m *mockChannel) Start(ctx context.Context) error {
|
||||
if m.startFn != nil {
|
||||
return m.startFn(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockChannel) Stop(ctx context.Context) error {
|
||||
if m.stopFn != nil {
|
||||
return m.stopFn(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
m.placeholdersSent++
|
||||
|
|
@ -86,6 +99,101 @@ func newTestManager() *Manager {
|
|||
return &Manager{
|
||||
channels: make(map[string]Channel),
|
||||
workers: make(map[string]*channelWorker),
|
||||
bus: bus.NewMessageBus(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
|
||||
m := newTestManager()
|
||||
errA := errors.New("channel-a start failed")
|
||||
errB := errors.New("channel-b start failed")
|
||||
|
||||
m.channels["a"] = &mockChannel{
|
||||
startFn: func(_ context.Context) error { return errA },
|
||||
}
|
||||
m.channels["b"] = &mockChannel{
|
||||
startFn: func(_ context.Context) error { return errB },
|
||||
}
|
||||
|
||||
err := m.StartAll(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("expected StartAll to fail when all channels fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to start any enabled channels") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !errors.Is(err, errA) {
|
||||
t.Fatalf("expected error to wrap errA, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, errB) {
|
||||
t.Fatalf("expected error to wrap errB, got: %v", err)
|
||||
}
|
||||
if len(m.workers) != 0 {
|
||||
t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers))
|
||||
}
|
||||
if m.dispatchTask != nil {
|
||||
t.Fatal("expected dispatch task to be cleared on full startup failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) {
|
||||
m := newTestManager()
|
||||
errBad := errors.New("bad channel start failed")
|
||||
processed := make(chan struct{}, 1)
|
||||
|
||||
m.channels["good"] = &mockChannel{
|
||||
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||
if msg.Channel == "good" {
|
||||
select {
|
||||
case processed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
m.channels["bad"] = &mockChannel{
|
||||
startFn: func(_ context.Context) error { return errBad },
|
||||
}
|
||||
|
||||
err := m.StartAll(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err)
|
||||
}
|
||||
if len(m.workers) != 1 {
|
||||
t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers))
|
||||
}
|
||||
if _, ok := m.workers["good"]; !ok {
|
||||
t.Fatal("expected worker for successful channel 'good'")
|
||||
}
|
||||
if _, ok := m.workers["bad"]; ok {
|
||||
t.Fatal("did not expect worker for failed channel 'bad'")
|
||||
}
|
||||
if m.dispatchTask == nil {
|
||||
t.Fatal("expected dispatch task to run when at least one channel starts")
|
||||
}
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer pubCancel()
|
||||
if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: "good",
|
||||
ChatID: "chat-1",
|
||||
Content: "hello",
|
||||
}); err != nil {
|
||||
t.Fatalf("PublishOutbound() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-processed:
|
||||
// worker processed outbound message as expected
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected successful channel worker to process outbound message")
|
||||
}
|
||||
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer stopCancel()
|
||||
if err := m.StopAll(stopCtx); err != nil {
|
||||
t.Fatalf("StopAll() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,3 +262,57 @@ func TestSend_ClosedConnection(t *testing.T) {
|
|||
|
||||
ch.Stop(ctx)
|
||||
}
|
||||
|
||||
func TestParseInlineImageMedia_Valid(t *testing.T) {
|
||||
media, err := parseInlineImageMedia(map[string]any{
|
||||
"media": []any{
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseInlineImageMedia() error = %v", err)
|
||||
}
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("len(media) = %d, want 1", len(media))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
|
||||
mb := bus.NewMessageBus()
|
||||
ch, err := NewPicoChannel(config.PicoConfig{
|
||||
Token: *config.NewSecureString("test-token"),
|
||||
}, mb)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPicoChannel() error = %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ch.Start(ctx); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
defer ch.Stop(ctx)
|
||||
|
||||
pc := &picoConn{id: "conn-1", sessionID: "sess-1"}
|
||||
ch.handleMessageSend(pc, PicoMessage{
|
||||
ID: "msg-1",
|
||||
Payload: map[string]any{
|
||||
"media": []any{
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-mb.InboundChan():
|
||||
if msg.Content != "" {
|
||||
t.Fatalf("msg.Content = %q, want empty", msg.Content)
|
||||
}
|
||||
if len(msg.Media) != 1 || !strings.HasPrefix(msg.Media[0], "data:image/png;base64,") {
|
||||
t.Fatalf("msg.Media = %#v, want inline image payload", msg.Media)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for inbound media message")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package pico
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
|
@ -30,6 +31,14 @@ type picoConn struct {
|
|||
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
|
||||
}
|
||||
|
||||
var allowedInlineImageMIMETypes = map[string]struct{}{
|
||||
"image/jpeg": {},
|
||||
"image/png": {},
|
||||
"image/gif": {},
|
||||
"image/webp": {},
|
||||
"image/bmp": {},
|
||||
}
|
||||
|
||||
// writeJSON sends a JSON message to the connection with write locking.
|
||||
func (pc *picoConn) writeJSON(v any) error {
|
||||
if pc.closed.Load() {
|
||||
|
|
@ -516,6 +525,9 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
|||
case TypeMessageSend:
|
||||
c.handleMessageSend(pc, msg)
|
||||
|
||||
case TypeMediaSend:
|
||||
c.handleMessageSend(pc, msg)
|
||||
|
||||
default:
|
||||
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||
pc.writeJSON(errMsg)
|
||||
|
|
@ -525,8 +537,19 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
|||
// handleMessageSend processes an inbound message.send from a client.
|
||||
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||
content, _ := msg.Payload["content"].(string)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
errMsg := newError("empty_content", "message content is empty")
|
||||
media, err := parseInlineImageMedia(msg.Payload)
|
||||
if err != nil {
|
||||
errMsg := newErrorWithPayload("invalid_media", err.Error(), map[string]any{
|
||||
"request_id": msg.ID,
|
||||
})
|
||||
pc.writeJSON(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" && len(media) == 0 {
|
||||
errMsg := newErrorWithPayload("empty_content", "message content is empty", map[string]any{
|
||||
"request_id": msg.ID,
|
||||
})
|
||||
pc.writeJSON(errMsg)
|
||||
return
|
||||
}
|
||||
|
|
@ -550,6 +573,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
|||
logger.DebugCF("pico", "Received message", map[string]any{
|
||||
"session_id": sessionID,
|
||||
"preview": truncate(content, 50),
|
||||
"media": len(media),
|
||||
})
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
|
|
@ -562,7 +586,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
|||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, media, metadata, sender)
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen runes.
|
||||
|
|
@ -573,3 +597,99 @@ func truncate(s string, maxLen int) string {
|
|||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func parseInlineImageMedia(payload map[string]any) ([]string, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, ok := payload["media"]
|
||||
if !ok || raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
media := make([]string, 0, len(values))
|
||||
for i, item := range values {
|
||||
value, err := inlineImageValue(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||
}
|
||||
if err := validateInlineImageDataURL(value); err != nil {
|
||||
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||
}
|
||||
media = append(media, value)
|
||||
}
|
||||
return media, nil
|
||||
case []string:
|
||||
media := make([]string, 0, len(values))
|
||||
for i, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if err := validateInlineImageDataURL(value); err != nil {
|
||||
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||
}
|
||||
media = append(media, value)
|
||||
}
|
||||
return media, nil
|
||||
case string:
|
||||
value := strings.TrimSpace(values)
|
||||
if err := validateInlineImageDataURL(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("media must be a string or array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
func inlineImageValue(item any) (string, error) {
|
||||
switch value := item.(type) {
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("image payload is empty")
|
||||
}
|
||||
return value, nil
|
||||
case map[string]any:
|
||||
for _, key := range []string{"url", "data_url"} {
|
||||
if raw, ok := value[key].(string); ok && strings.TrimSpace(raw) != "" {
|
||||
return strings.TrimSpace(raw), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("image payload must include url or data_url")
|
||||
default:
|
||||
return "", fmt.Errorf("image payload must be a string or object")
|
||||
}
|
||||
}
|
||||
|
||||
func validateInlineImageDataURL(mediaURL string) error {
|
||||
if mediaURL == "" {
|
||||
return fmt.Errorf("image payload is empty")
|
||||
}
|
||||
if !strings.HasPrefix(mediaURL, "data:image/") {
|
||||
return fmt.Errorf("only inline image data URLs are supported")
|
||||
}
|
||||
|
||||
header, data, found := strings.Cut(mediaURL, ",")
|
||||
if !found || strings.TrimSpace(data) == "" {
|
||||
return fmt.Errorf("image data URL is malformed")
|
||||
}
|
||||
if !strings.Contains(header, ";base64") {
|
||||
return fmt.Errorf("image data URL must be base64 encoded")
|
||||
}
|
||||
mimeType, _, _ := strings.Cut(strings.TrimPrefix(header, "data:"), ";")
|
||||
if _, ok := allowedInlineImageMIMETypes[mimeType]; !ok {
|
||||
return fmt.Errorf("unsupported image format: %s", mimeType)
|
||||
}
|
||||
|
||||
data = strings.TrimSpace(data)
|
||||
if base64.StdEncoding.DecodedLen(len(data)) > config.DefaultMaxMediaSize {
|
||||
return fmt.Errorf("image exceeds %d byte limit", config.DefaultMaxMediaSize)
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
|
||||
return fmt.Errorf("invalid base64 image data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,18 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
|
|||
}
|
||||
}
|
||||
|
||||
// newError creates an error PicoMessage.
|
||||
func newError(code, message string) PicoMessage {
|
||||
return newMessage(TypeError, map[string]any{
|
||||
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
|
||||
payload := map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
for key, value := range extra {
|
||||
payload[key] = value
|
||||
}
|
||||
return newMessage(TypeError, payload)
|
||||
}
|
||||
|
||||
// newError creates an error PicoMessage.
|
||||
func newError(code, message string) PicoMessage {
|
||||
return newErrorWithPayload(code, message, nil)
|
||||
}
|
||||
|
|
|
|||
13
pkg/channels/teams_webhook/init.go
Normal file
13
pkg/channels/teams_webhook/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package teamswebhook
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("teams_webhook", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewTeamsWebhookChannel(cfg.Channels.TeamsWebhook, b)
|
||||
})
|
||||
}
|
||||
422
pkg/channels/teams_webhook/teams_webhook.go
Normal file
422
pkg/channels/teams_webhook/teams_webhook.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package teamswebhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
goteamsnotify "github.com/atc0005/go-teams-notify/v2"
|
||||
"github.com/atc0005/go-teams-notify/v2/adaptivecard"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// statusCodeRe extracts HTTP status codes from error messages like "401 Unauthorized".
|
||||
var statusCodeRe = regexp.MustCompile(`\b([45]\d{2})\b`)
|
||||
|
||||
// markdownTableRe matches a markdown table block (header + separator + rows).
|
||||
// It captures the entire table including all rows.
|
||||
var markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`)
|
||||
|
||||
// teamsMessageSender abstracts the Teams client for testability.
|
||||
type teamsMessageSender interface {
|
||||
SendWithContext(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error
|
||||
}
|
||||
|
||||
// classifyTeamsError extracts HTTP status code from error message and classifies it.
|
||||
// The go-teams-notify library returns errors like "error on notification: 401 Unauthorized, ...".
|
||||
// This allows proper retry behavior: 4xx errors are permanent, 5xx are temporary.
|
||||
func classifyTeamsError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if matches := statusCodeRe.FindStringSubmatch(errMsg); len(matches) > 1 {
|
||||
if statusCode, parseErr := strconv.Atoi(matches[1]); parseErr == nil {
|
||||
return channels.ClassifySendError(statusCode, err)
|
||||
}
|
||||
}
|
||||
// Fallback: treat as temporary network error (retryable)
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
|
||||
// TeamsWebhookChannel is an output-only channel that sends messages
|
||||
// to Microsoft Teams via Power Automate workflow webhooks.
|
||||
// Multiple webhook targets can be configured and selected via ChatID.
|
||||
type TeamsWebhookChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.TeamsWebhookConfig
|
||||
client teamsMessageSender
|
||||
}
|
||||
|
||||
// NewTeamsWebhookChannel creates a new Teams webhook channel.
|
||||
func NewTeamsWebhookChannel(
|
||||
cfg config.TeamsWebhookConfig,
|
||||
bus *bus.MessageBus,
|
||||
) (*TeamsWebhookChannel, error) {
|
||||
if len(cfg.Webhooks) == 0 {
|
||||
return nil, fmt.Errorf("teams_webhook: at least one webhook target is required")
|
||||
}
|
||||
|
||||
// Require "default" webhook target
|
||||
if _, hasDefault := cfg.Webhooks["default"]; !hasDefault {
|
||||
return nil, fmt.Errorf("teams_webhook: a 'default' webhook target is required")
|
||||
}
|
||||
|
||||
// Validate all webhook targets have valid HTTPS URLs
|
||||
for name, target := range cfg.Webhooks {
|
||||
webhookURL := target.WebhookURL.String()
|
||||
if webhookURL == "" {
|
||||
return nil, fmt.Errorf("teams_webhook: webhook %q has empty webhook_url", name)
|
||||
}
|
||||
parsed, err := url.Parse(webhookURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("teams_webhook: webhook %q has invalid URL: %w", name, err)
|
||||
}
|
||||
if !strings.EqualFold(parsed.Scheme, "https") {
|
||||
return nil, fmt.Errorf("teams_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel(
|
||||
"teams_webhook",
|
||||
cfg,
|
||||
bus,
|
||||
[]string{
|
||||
"*",
|
||||
}, // Output-only channel; "*" suppresses misleading "allows EVERYONE" audit warning
|
||||
channels.WithMaxMessageLength(24000), // Power Automate webhook payload limit is 28KB
|
||||
)
|
||||
|
||||
client := goteamsnotify.NewTeamsClient()
|
||||
|
||||
return &TeamsWebhookChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start initializes the channel. For output-only channels, this is a no-op.
|
||||
func (c *TeamsWebhookChannel) Start(ctx context.Context) error {
|
||||
targets := make([]string, 0, len(c.config.Webhooks))
|
||||
for name := range c.config.Webhooks {
|
||||
targets = append(targets, name)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
logger.InfoCF("teams_webhook", "Starting Teams webhook channel (output-only)", map[string]any{
|
||||
"targets": targets,
|
||||
})
|
||||
c.SetRunning(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts down the channel.
|
||||
func (c *TeamsWebhookChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("teams_webhook", "Stopping Teams webhook channel")
|
||||
c.SetRunning(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send delivers a message to the specified Teams webhook target.
|
||||
// The target is selected by msg.ChatID which must match a key in the webhooks map.
|
||||
func (c *TeamsWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Look up webhook target by ChatID, fall back to "default" if empty or unknown
|
||||
targetName := msg.ChatID
|
||||
if targetName == "" {
|
||||
targetName = "default"
|
||||
}
|
||||
|
||||
target, ok := c.config.Webhooks[targetName]
|
||||
if !ok {
|
||||
// Log warning and fall back to default target
|
||||
logger.WarnCF("teams_webhook", "Unknown target, falling back to default", map[string]any{
|
||||
"requested": msg.ChatID,
|
||||
"using": "default",
|
||||
})
|
||||
target = c.config.Webhooks["default"]
|
||||
}
|
||||
|
||||
// Build an Adaptive Card for rich formatting
|
||||
card, err := c.buildAdaptiveCard(msg, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("teams_webhook: failed to build card: %w", err)
|
||||
}
|
||||
|
||||
// Create the message with the card
|
||||
teamsMsg, err := adaptivecard.NewMessageFromCard(card)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("teams_webhook: failed to create message: %w", err)
|
||||
}
|
||||
|
||||
// Send to Teams
|
||||
if err := c.client.SendWithContext(ctx, target.WebhookURL.String(), teamsMsg); err != nil {
|
||||
// Log without raw error to avoid leaking webhook URL (embedded in net/http errors)
|
||||
logger.ErrorCF("teams_webhook", "Failed to send message to Teams webhook", map[string]any{
|
||||
"target": msg.ChatID,
|
||||
})
|
||||
// Classify error based on status code extracted from error message.
|
||||
// The go-teams-notify library includes status in errors like "401 Unauthorized".
|
||||
// Use ClassifySendError for proper retry behavior (4xx = permanent, 5xx = temporary).
|
||||
classifiedErr := classifyTeamsError(err)
|
||||
return nil, fmt.Errorf("teams_webhook: send failed: %w", classifiedErr)
|
||||
}
|
||||
|
||||
logger.DebugCF("teams_webhook", "Message sent successfully", map[string]any{
|
||||
"target": msg.ChatID,
|
||||
})
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// buildAdaptiveCard creates a formatted Adaptive Card from the outbound message.
|
||||
// It detects markdown tables and converts them to native Adaptive Card Table elements,
|
||||
// since TextBlocks only support a limited markdown subset (no tables).
|
||||
func (c *TeamsWebhookChannel) buildAdaptiveCard(
|
||||
msg bus.OutboundMessage,
|
||||
target config.TeamsWebhookTarget,
|
||||
) (adaptivecard.Card, error) {
|
||||
card := adaptivecard.NewCard()
|
||||
card.Type = adaptivecard.TypeAdaptiveCard
|
||||
|
||||
// Set full width for Teams rendering
|
||||
card.MSTeams.Width = "Full"
|
||||
|
||||
// Add title if configured on the target
|
||||
title := target.Title
|
||||
if title == "" {
|
||||
title = "PicoClaw Notification"
|
||||
}
|
||||
|
||||
titleBlock := adaptivecard.NewTextBlock(title, true)
|
||||
titleBlock.Size = adaptivecard.SizeLarge
|
||||
titleBlock.Weight = adaptivecard.WeightBolder
|
||||
titleBlock.Style = adaptivecard.TextBlockStyleHeading
|
||||
|
||||
if err := card.AddElement(false, titleBlock); err != nil {
|
||||
return card, err
|
||||
}
|
||||
|
||||
content := msg.Content
|
||||
if content == "" {
|
||||
content = "(empty message)"
|
||||
}
|
||||
|
||||
// Split content into text segments and tables
|
||||
// TextBlocks support: bold, italic, bullet/numbered lists, links
|
||||
// TextBlocks do NOT support: headers, tables, images
|
||||
segments := splitContentWithTables(content)
|
||||
|
||||
for _, seg := range segments {
|
||||
if seg.isTable {
|
||||
// Convert markdown table to Adaptive Card Table element
|
||||
tableElement, err := parseMarkdownTable(seg.content)
|
||||
if err != nil {
|
||||
// Fallback: render as preformatted text if parsing fails
|
||||
logger.WarnCF("teams_webhook", "Failed to parse markdown table, using fallback", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
block := adaptivecard.NewTextBlock("```\n"+seg.content+"\n```", true)
|
||||
block.Wrap = true
|
||||
if err := card.AddElement(false, block); err != nil {
|
||||
return card, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := card.AddElement(false, tableElement); err != nil {
|
||||
return card, err
|
||||
}
|
||||
} else {
|
||||
// Regular text content
|
||||
text := strings.TrimSpace(seg.content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
block := adaptivecard.NewTextBlock(text, true)
|
||||
block.Wrap = true
|
||||
if err := card.AddElement(false, block); err != nil {
|
||||
return card, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// contentSegment represents either a text block or a table in the message content.
|
||||
type contentSegment struct {
|
||||
content string
|
||||
isTable bool
|
||||
}
|
||||
|
||||
// splitContentWithTables splits content into alternating text and table segments.
|
||||
func splitContentWithTables(content string) []contentSegment {
|
||||
var segments []contentSegment
|
||||
|
||||
matches := markdownTableRe.FindAllStringSubmatchIndex(content, -1)
|
||||
if len(matches) == 0 {
|
||||
// No tables found, return entire content as text
|
||||
return []contentSegment{{content: content, isTable: false}}
|
||||
}
|
||||
|
||||
lastEnd := 0
|
||||
for _, match := range matches {
|
||||
// Text before this table
|
||||
if match[0] > lastEnd {
|
||||
segments = append(segments, contentSegment{
|
||||
content: content[lastEnd:match[0]],
|
||||
isTable: false,
|
||||
})
|
||||
}
|
||||
// The table itself
|
||||
segments = append(segments, contentSegment{
|
||||
content: content[match[0]:match[1]],
|
||||
isTable: true,
|
||||
})
|
||||
lastEnd = match[1]
|
||||
}
|
||||
|
||||
// Text after the last table
|
||||
if lastEnd < len(content) {
|
||||
segments = append(segments, contentSegment{
|
||||
content: content[lastEnd:],
|
||||
isTable: false,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// parseMarkdownTable converts a markdown table string to an Adaptive Card Table element.
|
||||
func parseMarkdownTable(tableStr string) (adaptivecard.Element, error) {
|
||||
lines := strings.Split(strings.TrimSpace(tableStr), "\n")
|
||||
if len(lines) < 2 {
|
||||
return adaptivecard.Element{}, fmt.Errorf("table must have at least header and separator rows")
|
||||
}
|
||||
|
||||
// Track header content length per column for width calculation
|
||||
var headerLengths []int
|
||||
|
||||
// Parse all rows (header + data rows, skip separator)
|
||||
var allRows [][]adaptivecard.TableCell
|
||||
for i, line := range lines {
|
||||
// Skip separator row (contains only |, -, :, and spaces)
|
||||
if i == 1 && isSeparatorRow(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
cells := parseTableRow(line)
|
||||
if len(cells) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var tableCells []adaptivecard.TableCell
|
||||
for _, cellText := range cells {
|
||||
trimmedText := strings.TrimSpace(cellText)
|
||||
|
||||
// Use header row (first row) to determine column widths
|
||||
if i == 0 {
|
||||
headerLengths = append(headerLengths, len(trimmedText))
|
||||
}
|
||||
|
||||
textBlock := adaptivecard.Element{
|
||||
Type: adaptivecard.TypeElementTextBlock,
|
||||
Text: trimmedText,
|
||||
Wrap: true,
|
||||
}
|
||||
cell := adaptivecard.TableCell{
|
||||
Type: adaptivecard.TypeTableCell,
|
||||
Items: []*adaptivecard.Element{&textBlock},
|
||||
}
|
||||
tableCells = append(tableCells, cell)
|
||||
}
|
||||
allRows = append(allRows, tableCells)
|
||||
}
|
||||
|
||||
if len(allRows) == 0 {
|
||||
return adaptivecard.Element{}, fmt.Errorf("no valid rows found in table")
|
||||
}
|
||||
|
||||
// Create table with first row as headers
|
||||
firstRowAsHeaders := true
|
||||
showGridLines := true
|
||||
|
||||
table, err := adaptivecard.NewTableFromTableCells(allRows, 0, firstRowAsHeaders, showGridLines)
|
||||
if err != nil {
|
||||
return adaptivecard.Element{}, fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
// Set column widths based on header content length
|
||||
table.Columns = calculateColumnWidths(headerLengths)
|
||||
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// calculateColumnWidths creates TableColumnDefinition entries with widths
|
||||
// proportional to the max content length of each column.
|
||||
func calculateColumnWidths(maxLengths []int) []adaptivecard.Column {
|
||||
if len(maxLengths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use content length as relative weight, with a minimum of 1
|
||||
columns := make([]adaptivecard.Column, len(maxLengths))
|
||||
for i, length := range maxLengths {
|
||||
weight := length
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
columns[i] = adaptivecard.Column{
|
||||
Type: "TableColumnDefinition",
|
||||
Width: weight,
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// isSeparatorRow checks if a line is a markdown table separator (e.g., |---|---|).
|
||||
func isSeparatorRow(line string) bool {
|
||||
// Remove pipes and spaces, check if only dashes and colons remain
|
||||
cleaned := strings.ReplaceAll(line, "|", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "-", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, ":", "")
|
||||
return cleaned == ""
|
||||
}
|
||||
|
||||
// parseTableRow extracts cell values from a markdown table row.
|
||||
func parseTableRow(line string) []string {
|
||||
// Trim leading/trailing pipes and split by |
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, "|")
|
||||
line = strings.TrimSuffix(line, "|")
|
||||
|
||||
if line == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(line, "|")
|
||||
var cells []string
|
||||
for _, p := range parts {
|
||||
cells = append(cells, strings.TrimSpace(p))
|
||||
}
|
||||
return cells
|
||||
}
|
||||
583
pkg/channels/teams_webhook/teams_webhook_test.go
Normal file
583
pkg/channels/teams_webhook/teams_webhook_test.go
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
package teamswebhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
goteamsnotify "github.com/atc0005/go-teams-notify/v2"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// mockTeamsClient implements teamsMessageSender for testing.
|
||||
type mockTeamsClient struct {
|
||||
sendFunc func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error
|
||||
}
|
||||
|
||||
func (m *mockTeamsClient) SendWithContext(
|
||||
ctx context.Context,
|
||||
webhookURL string,
|
||||
message goteamsnotify.TeamsMessage,
|
||||
) error {
|
||||
if m.sendFunc != nil {
|
||||
return m.sendFunc(ctx, webhookURL, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewTeamsWebhookChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
// Test missing webhooks
|
||||
_, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: nil,
|
||||
}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing webhooks")
|
||||
}
|
||||
|
||||
// Test missing "default" webhook
|
||||
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
|
||||
Title: "Alerts",
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing 'default' webhook")
|
||||
}
|
||||
|
||||
// Test empty webhook URL
|
||||
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {Title: "Default"},
|
||||
},
|
||||
}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty webhook_url")
|
||||
}
|
||||
|
||||
// Test HTTP URL (should fail, must be HTTPS)
|
||||
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("http://example.com/webhook"),
|
||||
Title: "Default",
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for HTTP webhook URL (must be HTTPS)")
|
||||
}
|
||||
|
||||
// Test valid config with HTTPS (must include "default")
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
|
||||
Title: "Default",
|
||||
},
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook1"),
|
||||
Title: "Alerts",
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if ch.Name() != "teams_webhook" {
|
||||
t.Errorf("expected name 'teams_webhook', got %q", ch.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_StartStop(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if ch.IsRunning() {
|
||||
t.Error("channel should not be running before Start")
|
||||
}
|
||||
|
||||
if err := ch.Start(ctx); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
if !ch.IsRunning() {
|
||||
t.Error("channel should be running after Start")
|
||||
}
|
||||
|
||||
if err := ch.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop failed: %v", err)
|
||||
}
|
||||
|
||||
if ch.IsRunning() {
|
||||
t.Error("channel should not be running after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
|
||||
Title: "Default",
|
||||
},
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
|
||||
Title: "Custom Title",
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
target := ch.config.Webhooks["alerts"]
|
||||
msg := bus.OutboundMessage{
|
||||
Content: "Test message content",
|
||||
ChatID: "alerts",
|
||||
}
|
||||
|
||||
card, err := ch.buildAdaptiveCard(msg, target)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAdaptiveCard failed: %v", err)
|
||||
}
|
||||
|
||||
if card.Type != "AdaptiveCard" {
|
||||
t.Errorf("expected card type 'AdaptiveCard', got %q", card.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_SendNotRunning(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
msg := bus.OutboundMessage{Content: "test", ChatID: "default"}
|
||||
|
||||
_, err = ch.Send(ctx, msg)
|
||||
if err == nil {
|
||||
t.Error("expected error when sending while not running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string
|
||||
}{
|
||||
{"unknown target falls back to default", "unknown"},
|
||||
{"empty ChatID uses default", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
|
||||
},
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"),
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var sentURL string
|
||||
ch.client = &mockTeamsClient{
|
||||
sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error {
|
||||
sentURL = webhookURL
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = ch.Start(ctx)
|
||||
defer ch.Stop(ctx)
|
||||
|
||||
msg := bus.OutboundMessage{Content: "test", ChatID: tt.chatID}
|
||||
_, err = ch.Send(ctx, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
if sentURL != "https://example.com/webhook-default" {
|
||||
t.Errorf("expected default webhook URL, got %q", sentURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_SendSuccess(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
|
||||
Title: "Default",
|
||||
},
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"),
|
||||
Title: "Test Alerts",
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Inject mock client
|
||||
var sentURL string
|
||||
ch.client = &mockTeamsClient{
|
||||
sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error {
|
||||
sentURL = webhookURL
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = ch.Start(ctx)
|
||||
defer ch.Stop(ctx)
|
||||
|
||||
msg := bus.OutboundMessage{Content: "Hello Teams!", ChatID: "alerts"}
|
||||
|
||||
_, err = ch.Send(ctx, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sentURL != "https://example.com/webhook-alerts" {
|
||||
t.Errorf("expected webhook URL 'https://example.com/webhook-alerts', got %q", sentURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamsWebhookChannel_SendError(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
|
||||
Enabled: true,
|
||||
Webhooks: map[string]config.TeamsWebhookTarget{
|
||||
"default": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
|
||||
},
|
||||
"alerts": {
|
||||
WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"),
|
||||
},
|
||||
},
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Inject mock client that returns an error
|
||||
ch.client = &mockTeamsClient{
|
||||
sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error {
|
||||
return errors.New("error on notification: 401 Unauthorized, forbidden")
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = ch.Start(ctx)
|
||||
defer ch.Stop(ctx)
|
||||
|
||||
msg := bus.OutboundMessage{Content: "test", ChatID: "alerts"}
|
||||
|
||||
_, err = ch.Send(ctx, msg)
|
||||
if err == nil {
|
||||
t.Error("expected error from failed send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitContentWithTables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantSegs int
|
||||
wantTbl int // number of table segments
|
||||
}{
|
||||
{
|
||||
name: "no tables",
|
||||
content: "Just some text\nwith multiple lines",
|
||||
wantSegs: 1,
|
||||
wantTbl: 0,
|
||||
},
|
||||
{
|
||||
name: "single table",
|
||||
content: `| Col1 | Col2 |
|
||||
|------|------|
|
||||
| A | B |
|
||||
| C | D |`,
|
||||
wantSegs: 1,
|
||||
wantTbl: 1,
|
||||
},
|
||||
{
|
||||
name: "text before table",
|
||||
content: `Here is some text.
|
||||
|
||||
| Col1 | Col2 |
|
||||
|------|------|
|
||||
| A | B |`,
|
||||
wantSegs: 2,
|
||||
wantTbl: 1,
|
||||
},
|
||||
{
|
||||
name: "text before and after table",
|
||||
content: `Before table.
|
||||
|
||||
| Col1 | Col2 |
|
||||
|------|------|
|
||||
| A | B |
|
||||
|
||||
After table.`,
|
||||
wantSegs: 3,
|
||||
wantTbl: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple tables",
|
||||
content: `First table:
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
Second table:
|
||||
|
||||
| X | Y |
|
||||
|---|---|
|
||||
| 3 | 4 |`,
|
||||
wantSegs: 4,
|
||||
wantTbl: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
segs := splitContentWithTables(tt.content)
|
||||
if len(segs) != tt.wantSegs {
|
||||
t.Errorf("got %d segments, want %d", len(segs), tt.wantSegs)
|
||||
}
|
||||
tableCount := 0
|
||||
for _, s := range segs {
|
||||
if s.isTable {
|
||||
tableCount++
|
||||
}
|
||||
}
|
||||
if tableCount != tt.wantTbl {
|
||||
t.Errorf("got %d tables, want %d", tableCount, tt.wantTbl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownTable(t *testing.T) {
|
||||
tableStr := `| Name | Value |
|
||||
|------|-------|
|
||||
| foo | 123 |
|
||||
| bar | 456 |`
|
||||
|
||||
elem, err := parseMarkdownTable(tableStr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if elem.Type != "Table" {
|
||||
t.Errorf("expected type 'Table', got %q", elem.Type)
|
||||
}
|
||||
|
||||
// Should have 3 rows (header + 2 data rows)
|
||||
if len(elem.Rows) != 3 {
|
||||
t.Errorf("expected 3 rows, got %d", len(elem.Rows))
|
||||
}
|
||||
|
||||
// Should have 2 columns with widths based on content length
|
||||
if len(elem.Columns) != 2 {
|
||||
t.Errorf("expected 2 columns, got %d", len(elem.Columns))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownTableColumnWidths(t *testing.T) {
|
||||
// Column widths are based on HEADER row only:
|
||||
// Col1: "Description" (11 chars)
|
||||
// Col2: "X" (1 char)
|
||||
// Col3: "Amount" (6 chars)
|
||||
tableStr := `| Description | X | Amount |
|
||||
|-------------|---|--------|
|
||||
| Short | Y | 100 |
|
||||
| Longer text | Z | 50 |`
|
||||
|
||||
elem, err := parseMarkdownTable(tableStr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(elem.Columns) != 3 {
|
||||
t.Fatalf("expected 3 columns, got %d", len(elem.Columns))
|
||||
}
|
||||
|
||||
// Verify column widths are based on header content length
|
||||
w1, ok1 := elem.Columns[0].Width.(int)
|
||||
w2, ok2 := elem.Columns[1].Width.(int)
|
||||
w3, ok3 := elem.Columns[2].Width.(int)
|
||||
|
||||
if !ok1 || !ok2 || !ok3 {
|
||||
t.Fatalf("expected int widths, got types: %T, %T, %T",
|
||||
elem.Columns[0].Width, elem.Columns[1].Width, elem.Columns[2].Width)
|
||||
}
|
||||
|
||||
// Header lengths: "Description" = 11, "X" = 1, "Amount" = 6
|
||||
if w1 != 11 {
|
||||
t.Errorf("expected col1 width 11 (from 'Description'), got %d", w1)
|
||||
}
|
||||
if w2 != 1 {
|
||||
t.Errorf("expected col2 width 1 (from 'X'), got %d", w2)
|
||||
}
|
||||
if w3 != 6 {
|
||||
t.Errorf("expected col3 width 6 (from 'Amount'), got %d", w3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateColumnWidths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxLengths []int
|
||||
wantWidths []int
|
||||
}{
|
||||
{
|
||||
name: "equal lengths",
|
||||
maxLengths: []int{10, 10, 10},
|
||||
wantWidths: []int{10, 10, 10},
|
||||
},
|
||||
{
|
||||
name: "varying lengths",
|
||||
maxLengths: []int{5, 20, 10},
|
||||
wantWidths: []int{5, 20, 10},
|
||||
},
|
||||
{
|
||||
name: "zero length gets minimum of 1",
|
||||
maxLengths: []int{0, 5, 0},
|
||||
wantWidths: []int{1, 5, 1},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
maxLengths: []int{},
|
||||
wantWidths: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cols := calculateColumnWidths(tt.maxLengths)
|
||||
|
||||
if tt.wantWidths == nil {
|
||||
if cols != nil {
|
||||
t.Errorf("expected nil, got %v", cols)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(cols) != len(tt.wantWidths) {
|
||||
t.Fatalf("expected %d columns, got %d", len(tt.wantWidths), len(cols))
|
||||
}
|
||||
|
||||
for i, col := range cols {
|
||||
width, ok := col.Width.(int)
|
||||
if !ok {
|
||||
t.Errorf("column %d: expected int width, got %T", i, col.Width)
|
||||
continue
|
||||
}
|
||||
if width != tt.wantWidths[i] {
|
||||
t.Errorf("column %d: expected width %d, got %d", i, tt.wantWidths[i], width)
|
||||
}
|
||||
if col.Type != "TableColumnDefinition" {
|
||||
t.Errorf("column %d: expected type 'TableColumnDefinition', got %q", i, col.Type)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"| A | B | C |", []string{"A", "B", "C"}},
|
||||
{"|A|B|C|", []string{"A", "B", "C"}},
|
||||
{"| foo | bar |", []string{"foo", "bar"}},
|
||||
{"", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := parseTableRow(tt.line)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseTableRow(%q): got %v, want %v", tt.line, got, tt.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("parseTableRow(%q)[%d]: got %q, want %q", tt.line, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeparatorRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want bool
|
||||
}{
|
||||
{"|---|---|", true},
|
||||
{"| --- | --- |", true},
|
||||
{"|:---|---:|", true},
|
||||
{"| :---: | :---: |", true},
|
||||
{"| A | B |", false},
|
||||
{"| foo | bar |", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := isSeparatorRow(tt.line)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSeparatorRow(%q): got %v, want %v", tt.line, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
pkg/channels/vk/init.go
Normal file
13
pkg/channels/vk/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package vk
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("vk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewVKChannel(cfg, b)
|
||||
})
|
||||
}
|
||||
286
pkg/channels/vk/vk.go
Normal file
286
pkg/channels/vk/vk.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SevereCloud/vksdk/v3/api"
|
||||
"github.com/SevereCloud/vksdk/v3/api/params"
|
||||
"github.com/SevereCloud/vksdk/v3/events"
|
||||
"github.com/SevereCloud/vksdk/v3/longpoll-bot"
|
||||
"github.com/SevereCloud/vksdk/v3/object"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
type VKChannel struct {
|
||||
*channels.BaseChannel
|
||||
vk *api.VK
|
||||
lp *longpoll.LongPoll
|
||||
config *config.Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewVKChannel(cfg *config.Config, bus *bus.MessageBus) (*VKChannel, error) {
|
||||
vkCfg := cfg.Channels.VK
|
||||
|
||||
vk := api.NewVK(vkCfg.Token.String())
|
||||
|
||||
base := channels.NewBaseChannel(
|
||||
"vk",
|
||||
vkCfg,
|
||||
bus,
|
||||
vkCfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(4000),
|
||||
channels.WithGroupTrigger(vkCfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(vkCfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
return &VKChannel{
|
||||
BaseChannel: base,
|
||||
vk: vk,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("vk", "Starting VK bot (Long Poll mode)...")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
groupID := c.config.Channels.VK.GroupID
|
||||
if groupID == 0 {
|
||||
c.cancel()
|
||||
return fmt.Errorf("group_id is required for VK bot")
|
||||
}
|
||||
|
||||
lp, err := longpoll.NewLongPoll(c.vk, groupID)
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
return fmt.Errorf("failed to create long poll: %w", err)
|
||||
}
|
||||
c.lp = lp
|
||||
|
||||
lp.MessageNew(func(_ context.Context, obj events.MessageNewObject) {
|
||||
c.handleMessage(obj.Message)
|
||||
})
|
||||
|
||||
c.SetRunning(true)
|
||||
|
||||
logger.InfoCF("vk", "VK bot connected", map[string]any{
|
||||
"group_id": groupID,
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := lp.Run(); err != nil {
|
||||
logger.ErrorCF("vk", "Long poll failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("vk", "Stopping VK bot...")
|
||||
c.SetRunning(false)
|
||||
|
||||
if c.lp != nil {
|
||||
c.lp.Shutdown()
|
||||
}
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
|
||||
if msg.Action.Type != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if bool(msg.Out) {
|
||||
return
|
||||
}
|
||||
|
||||
peerID := msg.PeerID
|
||||
chatID := strconv.Itoa(peerID)
|
||||
|
||||
fromID := msg.FromID
|
||||
userID := strconv.Itoa(fromID)
|
||||
|
||||
platformID := userID
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "vk",
|
||||
PlatformID: platformID,
|
||||
CanonicalID: identity.BuildCanonicalID("vk", platformID),
|
||||
DisplayName: c.getUserName(fromID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("vk", "Message from unauthorized user", map[string]any{
|
||||
"peer_id": peerID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
text := msg.Text
|
||||
if text == "" && len(msg.Attachments) > 0 {
|
||||
text = c.processAttachments(msg.Attachments)
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
groupTrigger := c.config.Channels.VK.GroupTrigger
|
||||
isGroupChat := peerID != fromID
|
||||
|
||||
if isGroupChat {
|
||||
isMentioned := c.isMentioned(msg)
|
||||
if isMentioned {
|
||||
text = c.stripBotMention(text)
|
||||
}
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, text)
|
||||
if !respond {
|
||||
return
|
||||
}
|
||||
text = cleaned
|
||||
_ = groupTrigger
|
||||
}
|
||||
|
||||
peerKind := "direct"
|
||||
peerIDStr := userID
|
||||
if isGroupChat {
|
||||
peerKind = "group"
|
||||
peerIDStr = chatID
|
||||
}
|
||||
|
||||
peer := bus.Peer{Kind: peerKind, ID: peerIDStr}
|
||||
messageID := strconv.Itoa(msg.ConversationMessageID)
|
||||
|
||||
metadata := map[string]string{
|
||||
"user_id": userID,
|
||||
"is_group": fmt.Sprintf("%t", isGroupChat),
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx,
|
||||
peer,
|
||||
messageID,
|
||||
userID,
|
||||
chatID,
|
||||
text,
|
||||
nil,
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *VKChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
|
||||
peerID, err := strconv.Atoi(msg.ChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
if msg.Content == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var messageIDs []string
|
||||
chunks := channels.SplitMessage(msg.Content, 4000)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if chunk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
b := params.NewMessagesSendBuilder()
|
||||
b.Message(chunk)
|
||||
b.RandomID(0)
|
||||
b.PeerID(peerID)
|
||||
|
||||
if msg.ReplyToMessageID != "" {
|
||||
if replyID, err := strconv.Atoi(msg.ReplyToMessageID); err == nil {
|
||||
b.ReplyTo(replyID)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.vk.MessagesSend(b.Params)
|
||||
if err != nil {
|
||||
logger.ErrorCF("vk", "Failed to send message", map[string]any{
|
||||
"error": err.Error(),
|
||||
"peer_id": peerID,
|
||||
})
|
||||
return messageIDs, fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
messageIDs = append(messageIDs, strconv.Itoa(resp))
|
||||
}
|
||||
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) isMentioned(msg object.MessagesMessage) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *VKChannel) stripBotMention(text string) string {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func (c *VKChannel) getUserName(userID int) string {
|
||||
users, err := c.vk.UsersGet(api.Params{
|
||||
"user_ids": userID,
|
||||
})
|
||||
if err != nil || len(users) == 0 {
|
||||
return strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
user := users[0]
|
||||
return fmt.Sprintf("%s %s", user.FirstName, user.LastName)
|
||||
}
|
||||
|
||||
func (c *VKChannel) processAttachments(attachments []object.MessagesMessageAttachment) string {
|
||||
var parts []string
|
||||
|
||||
for _, att := range attachments {
|
||||
switch att.Type {
|
||||
case "photo":
|
||||
parts = append(parts, "[photo]")
|
||||
case "video":
|
||||
parts = append(parts, "[video]")
|
||||
case "audio":
|
||||
parts = append(parts, "[audio]")
|
||||
case "doc":
|
||||
if att.Doc.Title != "" {
|
||||
parts = append(parts, fmt.Sprintf("[document: %s]", att.Doc.Title))
|
||||
} else {
|
||||
parts = append(parts, "[document]")
|
||||
}
|
||||
case "audio_message":
|
||||
parts = append(parts, "[voice]")
|
||||
case "sticker":
|
||||
parts = append(parts, "[sticker]")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (c *VKChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
||||
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
||||
}
|
||||
260
pkg/channels/vk/vk_test.go
Normal file
260
pkg/channels/vk/vk_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package vk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewVKChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing group_id", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error during creation: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config with group_id", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with allow_from", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
AllowFrom: []string{"123456789"},
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ch.IsAllowedSender(bus.SenderInfo{PlatformID: "123456789"}) {
|
||||
t.Error("user 123456789 should be allowed")
|
||||
}
|
||||
if ch.IsAllowedSender(bus.SenderInfo{PlatformID: "999999999"}) {
|
||||
t.Error("user 999999999 should not be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with group_trigger", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
GroupTrigger: config.GroupTriggerConfig{
|
||||
MentionOnly: false,
|
||||
Prefixes: []string{"/bot", "!bot"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVKChannel_MaxMessageLength(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
maxLen := ch.MaxMessageLength()
|
||||
if maxLen != 4000 {
|
||||
t.Errorf("MaxMessageLength() = %d, want 4000", maxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_SplitMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
maxLen int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "short message",
|
||||
content: "hello",
|
||||
maxLen: 4000,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "exact length",
|
||||
content: string(make([]byte, 4000)),
|
||||
maxLen: 4000,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "needs split",
|
||||
content: string(make([]byte, 5000)),
|
||||
maxLen: 4000,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "empty message",
|
||||
content: "",
|
||||
maxLen: 4000,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := channels.SplitMessage(tt.content, tt.maxLen)
|
||||
if len(got) != tt.want {
|
||||
t.Errorf("SplitMessage() got %d parts, want %d parts", len(got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_ProcessAttachments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attachments []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty attachments",
|
||||
attachments: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "photo attachment",
|
||||
attachments: []string{"photo"},
|
||||
want: "[photo]",
|
||||
},
|
||||
{
|
||||
name: "video attachment",
|
||||
attachments: []string{"video"},
|
||||
want: "[video]",
|
||||
},
|
||||
{
|
||||
name: "audio attachment",
|
||||
attachments: []string{"audio"},
|
||||
want: "[audio]",
|
||||
},
|
||||
{
|
||||
name: "document attachment",
|
||||
attachments: []string{"doc"},
|
||||
want: "[doc]",
|
||||
},
|
||||
{
|
||||
name: "sticker attachment",
|
||||
attachments: []string{"sticker"},
|
||||
want: "[sticker]",
|
||||
},
|
||||
{
|
||||
name: "audio_message attachment",
|
||||
attachments: []string{"audio_message"},
|
||||
want: "[voice]",
|
||||
},
|
||||
{
|
||||
name: "multiple attachments",
|
||||
attachments: []string{"photo", "video", "audio"},
|
||||
want: "[photo] [video] [audio]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var result string
|
||||
for i, att := range tt.attachments {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
if att == "audio_message" {
|
||||
result += "[voice]"
|
||||
} else {
|
||||
result += "[" + att + "]"
|
||||
}
|
||||
}
|
||||
if result != tt.want {
|
||||
t.Errorf("processAttachments() = %q, want %q", result, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_VoiceCapabilities(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
caps := ch.VoiceCapabilities()
|
||||
if !caps.ASR {
|
||||
t.Error("VoiceCapabilities().ASR should be true")
|
||||
}
|
||||
if !caps.TTS {
|
||||
t.Error("VoiceCapabilities().TTS should be true")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -23,20 +24,21 @@ var rrCounter atomic.Uint64
|
|||
// CurrentVersion is the latest config schema version
|
||||
const CurrentVersion = 2
|
||||
|
||||
// Config is the current config structure with version support
|
||||
// Config is the current config structure with version support.
|
||||
type Config struct {
|
||||
Version int `json:"version" yaml:"-"` // Config schema version for migration
|
||||
Agents AgentsConfig `json:"agents" yaml:"-"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
|
||||
Session SessionConfig `json:"session,omitempty" yaml:"-"`
|
||||
Channels ChannelsConfig `json:"channels" yaml:"channels"`
|
||||
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway" yaml:"-"`
|
||||
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
|
||||
Tools ToolsConfig `json:"tools" yaml:",inline"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
|
||||
Devices DevicesConfig `json:"devices" yaml:"-"`
|
||||
Voice VoiceConfig `json:"voice" yaml:"-"`
|
||||
Version int `json:"version" yaml:"-"` // Config schema version for migration
|
||||
Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
|
||||
Agents AgentsConfig `json:"agents" yaml:"-"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
|
||||
Session SessionConfig `json:"session,omitempty" yaml:"-"`
|
||||
Channels ChannelsConfig `json:"channels" yaml:"channels"`
|
||||
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway" yaml:"-"`
|
||||
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
|
||||
Tools ToolsConfig `json:"tools" yaml:",inline"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
|
||||
Devices DevicesConfig `json:"devices" yaml:"-"`
|
||||
Voice VoiceConfig `json:"voice" yaml:"-"`
|
||||
// BuildInfo contains build-time version information
|
||||
BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"`
|
||||
|
||||
|
|
@ -44,6 +46,21 @@ type Config struct {
|
|||
sensitiveCache *SensitiveDataCache
|
||||
}
|
||||
|
||||
// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
|
||||
// It is applied by the isolation package rather than by sandboxing the main process.
|
||||
type IsolationConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
ExposePaths []ExposePath `json:"expose_paths,omitempty"`
|
||||
}
|
||||
|
||||
// ExposePath describes a host path that should remain visible inside the isolated
|
||||
// child-process environment. This is currently implemented on Linux only.
|
||||
type ExposePath struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// FilterSensitiveData filters sensitive values from content before sending to LLM.
|
||||
// This prevents the LLM from seeing its own credentials.
|
||||
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
|
||||
|
|
@ -279,23 +296,25 @@ func (d *AgentDefaults) GetModelName() string {
|
|||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"`
|
||||
Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"`
|
||||
Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"`
|
||||
Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"`
|
||||
MaixCam MaixCamConfig `json:"maixcam" yaml:"-"`
|
||||
QQ QQConfig `json:"qq" yaml:"qq,omitempty"`
|
||||
DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"`
|
||||
Slack SlackConfig `json:"slack" yaml:"slack,omitempty"`
|
||||
Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"`
|
||||
LINE LINEConfig `json:"line" yaml:"line,omitempty"`
|
||||
OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"`
|
||||
WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
|
||||
Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"`
|
||||
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||
Chatmail ChatmailConfig `json:"chatmail" yaml:"chatmail,omitempty"`
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"`
|
||||
Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"`
|
||||
Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"`
|
||||
Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"`
|
||||
MaixCam MaixCamConfig `json:"maixcam" yaml:"-"`
|
||||
QQ QQConfig `json:"qq" yaml:"qq,omitempty"`
|
||||
DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"`
|
||||
Slack SlackConfig `json:"slack" yaml:"slack,omitempty"`
|
||||
Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"`
|
||||
LINE LINEConfig `json:"line" yaml:"line,omitempty"`
|
||||
OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"`
|
||||
WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
|
||||
Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"`
|
||||
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||
Chatmail ChatmailConfig `json:"chatmail" yaml:"chatmail,omitempty"`
|
||||
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
|
||||
TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"`
|
||||
}
|
||||
|
||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||
|
|
@ -559,6 +578,34 @@ type ChatmailConfig struct {
|
|||
InviteQR string `json:"invite_qr" yaml:"-" env:"PICOCLAW_CHANNELS_CHATMAIL_INVITE_QR"`
|
||||
}
|
||||
|
||||
type VKConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"`
|
||||
GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (c *VKConfig) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
// TeamsWebhookConfig configures the output-only Microsoft Teams webhook channel.
|
||||
// Multiple webhook targets can be configured and selected via ChatID at send time.
|
||||
type TeamsWebhookConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TEAMS_WEBHOOK_ENABLED"`
|
||||
Webhooks map[string]TeamsWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"`
|
||||
}
|
||||
|
||||
// TeamsWebhookTarget represents a single Teams webhook destination.
|
||||
type TeamsWebhookTarget struct {
|
||||
WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"`
|
||||
Title string `json:"title,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
|
|
@ -598,11 +645,12 @@ type ModelConfig struct {
|
|||
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
|
||||
|
||||
// Optional optimizations
|
||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request
|
||||
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
|
||||
|
||||
|
|
@ -610,6 +658,8 @@ type ModelConfig struct {
|
|||
// existing configs, the field is inferred during load: models with API keys
|
||||
// or the reserved "local-model" name are auto-enabled.
|
||||
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
||||
// UserAgent is the user agent string to use for HTTP requests.
|
||||
UserAgent string `json:"user_agent,omitempty" yaml:"-"`
|
||||
|
||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||
// Virtual models should not be persisted to config files.
|
||||
|
|
@ -772,13 +822,13 @@ type WebToolsConfig struct {
|
|||
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
||||
// and the provider's built-in search is used instead. Falls back to client-side
|
||||
// search when the provider does not support native search.
|
||||
PreferNative bool `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||
Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||
Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||
PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||
}
|
||||
|
||||
type CronToolsConfig struct {
|
||||
|
|
@ -811,8 +861,25 @@ type MediaCleanupConfig struct {
|
|||
}
|
||||
|
||||
type ReadFileToolConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxReadFileSize int `json:"max_read_file_size"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
MaxReadFileSize int `json:"max_read_file_size"`
|
||||
}
|
||||
|
||||
const (
|
||||
ReadFileModeBytes = "bytes"
|
||||
ReadFileModeLines = "lines"
|
||||
)
|
||||
|
||||
func (c ReadFileToolConfig) EffectiveMode() string {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
|
||||
case ReadFileModeLines:
|
||||
return ReadFileModeLines
|
||||
case "", ReadFileModeBytes:
|
||||
return ReadFileModeBytes
|
||||
default:
|
||||
return ReadFileModeBytes
|
||||
}
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
|
|
@ -917,10 +984,21 @@ type MCPServerConfig struct {
|
|||
type MCPConfig struct {
|
||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||
Discovery ToolDiscoveryConfig ` json:"discovery"`
|
||||
// MaxInlineTextChars controls how much MCP text stays inline before it is saved as an artifact.
|
||||
MaxInlineTextChars int `json:"max_inline_text_chars,omitempty" env:"PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS"`
|
||||
// Servers is a map of server name to server configuration
|
||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
const DefaultMCPMaxInlineTextChars = 16 * 1024
|
||||
|
||||
func (c *MCPConfig) GetMaxInlineTextChars() int {
|
||||
if c.MaxInlineTextChars > 0 {
|
||||
return c.MaxInlineTextChars
|
||||
}
|
||||
return DefaultMCPMaxInlineTextChars
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
logger.Debugf("loading config from %s", path)
|
||||
|
||||
|
|
@ -929,7 +1007,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logger.WarnF("config file not found, using default config", map[string]any{"path": path})
|
||||
logger.WarnF(
|
||||
"config file not found, using default config",
|
||||
map[string]any{"path": path},
|
||||
)
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
logger.Errorf("failed to read config file: %v", err)
|
||||
|
|
@ -952,7 +1033,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
var cfg *Config
|
||||
switch versionInfo.Version {
|
||||
case 0:
|
||||
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
// Legacy config (no version field)
|
||||
v, e := loadConfigV0(data)
|
||||
if e != nil {
|
||||
|
|
@ -960,10 +1044,16 @@ func LoadConfig(path string) (*Config, error) {
|
|||
}
|
||||
cfg, e = v.Migrate()
|
||||
if e != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, e
|
||||
}
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -971,7 +1061,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
// Load existing security config and merge with migrated one to prevent data loss
|
||||
secErr := loadSecurityConfig(cfg, securityPath(path))
|
||||
if secErr != nil && !os.IsNotExist(secErr) {
|
||||
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
|
||||
logger.WarnF(
|
||||
"failed to load existing security config during migration",
|
||||
map[string]any{"error": secErr},
|
||||
)
|
||||
return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
|
||||
}
|
||||
defer func(cfg *Config) {
|
||||
|
|
@ -979,7 +1072,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
}(cfg)
|
||||
case 1:
|
||||
// V1→V2 migration: infer Enabled and migrate channel config fields
|
||||
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -993,7 +1089,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
oldCfg := &configV1{Config: *cfg}
|
||||
cfg, err = oldCfg.Migrate()
|
||||
if err != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -1005,7 +1104,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
case CurrentVersion:
|
||||
// Current version
|
||||
cfg, err = loadConfig(data)
|
||||
|
|
@ -1218,6 +1320,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
|||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
ExtraBody: m.ExtraBody,
|
||||
CustomHeaders: m.CustomHeaders,
|
||||
isVirtual: true,
|
||||
}
|
||||
expanded = append(expanded, additionalEntry)
|
||||
|
|
@ -1238,6 +1341,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
|||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
ExtraBody: m.ExtraBody,
|
||||
CustomHeaders: m.CustomHeaders,
|
||||
APIKeys: SimpleSecureStrings(keys[0]),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,41 @@ func TestAgentConfig_FullParse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars {
|
||||
t.Fatalf(
|
||||
"DefaultConfig().Tools.MCP.GetMaxInlineTextChars() = %d, want %d",
|
||||
cfg.Tools.MCP.GetMaxInlineTextChars(),
|
||||
DefaultMCPMaxInlineTextChars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
raw := `{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"max_inline_text_chars": 2048
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(configPath): %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if got := cfg.Tools.MCP.GetMaxInlineTextChars(); got != 2048 {
|
||||
t.Fatalf("cfg.Tools.MCP.GetMaxInlineTextChars() = %d, want 2048", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
|
|
@ -317,6 +352,13 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_ReadFileMode(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Tools.ReadFile.EffectiveMode() != ReadFileModeBytes {
|
||||
t.Fatalf("expected default read_file mode %q, got %q", ReadFileModeBytes, cfg.Tools.ReadFile.EffectiveMode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permission bits are not enforced on Windows")
|
||||
|
|
@ -810,6 +852,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_IsolationEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Isolation.Enabled {
|
||||
t.Fatal("DefaultConfig().Isolation.Enabled should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_UnmarshalIsolation(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
raw := []byte(`{
|
||||
"isolation": {
|
||||
"enabled": false,
|
||||
"expose_paths": [
|
||||
{"source":"/src","target":"/dst","mode":"ro"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if err := json.Unmarshal(raw, cfg); err != nil {
|
||||
t.Fatalf("json.Unmarshal isolation config: %v", err)
|
||||
}
|
||||
if cfg.Isolation.Enabled {
|
||||
t.Fatal("Isolation.Enabled should be false after unmarshal")
|
||||
}
|
||||
if len(cfg.Isolation.ExposePaths) != 1 {
|
||||
t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths))
|
||||
}
|
||||
if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" {
|
||||
t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
|
||||
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -1486,6 +1559,42 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_CustomHeadersRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
cfg := &Config{
|
||||
Version: CurrentVersion,
|
||||
ModelList: []*ModelConfig{
|
||||
{
|
||||
ModelName: "test-model",
|
||||
Model: "openai/test",
|
||||
APIKeys: SimpleSecureStrings("sk-test"),
|
||||
CustomHeaders: map[string]string{"X-Source": "coding-plan", "X-Agent": "openclaw"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig error: %v", err)
|
||||
}
|
||||
|
||||
if loaded.ModelList[0].CustomHeaders == nil {
|
||||
t.Fatal("CustomHeaders should not be nil after round-trip")
|
||||
}
|
||||
if got := loaded.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" {
|
||||
t.Errorf("CustomHeaders[X-Source] = %q, want coding-plan", got)
|
||||
}
|
||||
if got := loaded.ModelList[0].CustomHeaders["X-Agent"]; got != "openclaw" {
|
||||
t.Errorf("CustomHeaders[X-Agent] = %q, want openclaw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ func DefaultConfig() *Config {
|
|||
|
||||
return &Config{
|
||||
Version: CurrentVersion,
|
||||
// Isolation is opt-in so existing installations keep their current behavior
|
||||
// until the user explicitly enables subprocess sandboxing.
|
||||
Isolation: IsolationConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: workspacePath,
|
||||
|
|
@ -462,7 +467,8 @@ func DefaultConfig() *Config {
|
|||
UseBM25: true,
|
||||
UseRegex: false,
|
||||
},
|
||||
Servers: map[string]MCPServerConfig{},
|
||||
MaxInlineTextChars: DefaultMCPMaxInlineTextChars,
|
||||
Servers: map[string]MCPServerConfig{},
|
||||
},
|
||||
AppendFile: ToolConfig{
|
||||
Enabled: true,
|
||||
|
|
@ -487,6 +493,7 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
ReadFile: ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
Mode: ReadFileModeBytes,
|
||||
MaxReadFileSize: 64 * 1024, // 64KB
|
||||
},
|
||||
Spawn: ToolConfig{
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/vk"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -31,6 +32,7 @@ type Check struct {
|
|||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Uptime string `json:"uptime"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Checks map[string]Check `json:"checks,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +172,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||
resp := StatusResponse{
|
||||
Status: "ok",
|
||||
Uptime: uptime.String(),
|
||||
PID: os.Getpid(),
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
|
|
|||
238
pkg/isolation/README.md
Normal file
238
pkg/isolation/README.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# `pkg/isolation`
|
||||
|
||||
`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`.
|
||||
|
||||
It does not sandbox the main `picoclaw` process itself.
|
||||
|
||||
## Scope
|
||||
|
||||
The current scope is the child-process startup path:
|
||||
|
||||
- `exec` tool
|
||||
- CLI providers such as `claude-cli` and `codex-cli`
|
||||
- process hooks
|
||||
- MCP `stdio` servers
|
||||
|
||||
## One-Sentence Model
|
||||
|
||||
- The `picoclaw` main process still runs in the host environment.
|
||||
- Every child process should enter the shared `pkg/isolation` startup path first.
|
||||
- The startup path applies platform-specific isolation according to config.
|
||||
|
||||
## Architecture
|
||||
|
||||
The implementation has four layers:
|
||||
|
||||
1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`.
|
||||
2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment.
|
||||
3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented.
|
||||
4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`.
|
||||
|
||||
All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly.
|
||||
|
||||
## Configuration
|
||||
|
||||
Isolation lives under:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": false,
|
||||
"expose_paths": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field meanings:
|
||||
|
||||
- `enabled`: enables or disables subprocess isolation. Default: `false`.
|
||||
- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": true,
|
||||
"expose_paths": [
|
||||
{
|
||||
"source": "/opt/toolchains/go",
|
||||
"target": "/opt/toolchains/go",
|
||||
"mode": "ro"
|
||||
},
|
||||
{
|
||||
"source": "/data/shared-assets",
|
||||
"target": "/opt/picoclaw-instance-a/workspace/assets",
|
||||
"mode": "rw"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules for `expose_paths`:
|
||||
|
||||
- `source` is a host path.
|
||||
- `target` is the path inside the isolated environment.
|
||||
- `mode` must be `ro` or `rw`.
|
||||
- When `target` is empty, it defaults to `source`.
|
||||
- Only one final rule may exist for the same `target`.
|
||||
- Later-loaded config overrides earlier rules for the same `target`.
|
||||
|
||||
Platform note:
|
||||
|
||||
- Linux uses a real `source -> target` mount view.
|
||||
- Windows does not currently support `expose_paths`.
|
||||
|
||||
## Instance Root And Directories
|
||||
|
||||
The instance root follows `config.GetHome()`:
|
||||
|
||||
- If `PICOCLAW_HOME` is set, use it.
|
||||
- Otherwise use the default `.picoclaw` directory under the user home.
|
||||
|
||||
If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail.
|
||||
|
||||
Default instance directories include:
|
||||
|
||||
- instance root
|
||||
- `skills`
|
||||
- `logs`
|
||||
- `cache`
|
||||
- `state`
|
||||
- `runtime-user-env`
|
||||
|
||||
`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule.
|
||||
|
||||
Windows also prepares:
|
||||
|
||||
- `runtime-user-env/AppData/Roaming`
|
||||
- `runtime-user-env/AppData/Local`
|
||||
|
||||
## User Environment Redirect
|
||||
|
||||
When isolation is enabled, child processes receive a redirected per-instance user environment.
|
||||
|
||||
Linux variables:
|
||||
|
||||
- `HOME`
|
||||
- `TMPDIR`
|
||||
- `XDG_CONFIG_HOME`
|
||||
- `XDG_CACHE_HOME`
|
||||
- `XDG_STATE_HOME`
|
||||
|
||||
Windows variables:
|
||||
|
||||
- `USERPROFILE`
|
||||
- `HOME`
|
||||
- `TEMP`
|
||||
- `TMP`
|
||||
- `APPDATA`
|
||||
- `LOCALAPPDATA`
|
||||
|
||||
These paths point into `runtime-user-env` under the instance root.
|
||||
|
||||
## Platform Behavior
|
||||
|
||||
### Linux
|
||||
|
||||
The Linux backend currently depends on `bwrap` (`bubblewrap`).
|
||||
|
||||
Capabilities:
|
||||
|
||||
- minimal filesystem view
|
||||
- `ipc` namespace isolation
|
||||
- redirected child-process user environment
|
||||
- `source -> target` read-only or read-write mounts
|
||||
|
||||
Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`.
|
||||
|
||||
At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed.
|
||||
|
||||
There is no automatic fallback when `bwrap` is missing.
|
||||
|
||||
Install examples:
|
||||
|
||||
- `apt install bubblewrap`
|
||||
- `dnf install bubblewrap`
|
||||
- `yum install bubblewrap`
|
||||
- `pacman -S bubblewrap`
|
||||
- `apk add bubblewrap`
|
||||
|
||||
If isolation must be disabled temporarily:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disabling isolation increases the risk that child processes can access or modify more host files.
|
||||
|
||||
### Windows
|
||||
|
||||
Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories.
|
||||
|
||||
`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed.
|
||||
|
||||
The Windows backend currently uses:
|
||||
|
||||
- a restricted primary token
|
||||
- low integrity level
|
||||
- a `Job Object`
|
||||
- redirected child-process user environment
|
||||
|
||||
It does not currently implement true `source -> target` filesystem remapping.
|
||||
|
||||
### macOS And Other Platforms
|
||||
|
||||
They are not implemented yet.
|
||||
|
||||
When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded.
|
||||
|
||||
## Logging And Debugging
|
||||
|
||||
When isolation is enabled, PicoClaw logs the generated isolation plan.
|
||||
|
||||
Linux log name:
|
||||
|
||||
- `linux isolation mount plan`
|
||||
|
||||
Windows log name:
|
||||
|
||||
- `windows isolation access rules`
|
||||
|
||||
If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs.
|
||||
|
||||
## Relationship To `restrict_to_workspace`
|
||||
|
||||
- `restrict_to_workspace` limits the paths an agent is normally allowed to access.
|
||||
- `pkg/isolation` limits what a child process can see and where its user environment points.
|
||||
|
||||
They complement each other and do not replace each other.
|
||||
|
||||
## Current Limits
|
||||
|
||||
- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime.
|
||||
- Linux does not currently enable a dedicated `pid` namespace by default.
|
||||
- Windows does not yet implement full host ACL enforcement for every allowed or denied path.
|
||||
- macOS is not implemented.
|
||||
- The current design isolates child processes, not the main `picoclaw` process.
|
||||
|
||||
## Suggested Reading Order
|
||||
|
||||
If you are new to this code, read it in this order:
|
||||
|
||||
1. `pkg/config/config.go`
|
||||
2. `pkg/isolation/runtime.go`
|
||||
3. `pkg/isolation/platform_linux.go`
|
||||
4. `pkg/isolation/platform_windows.go`
|
||||
5. Call sites:
|
||||
6. `pkg/tools/shell.go`
|
||||
7. `pkg/providers/*.go`
|
||||
8. `pkg/agent/hook_process.go`
|
||||
9. `pkg/mcp/manager.go`
|
||||
|
||||
That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits.
|
||||
238
pkg/isolation/README_CN.md
Normal file
238
pkg/isolation/README_CN.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# `pkg/isolation`
|
||||
|
||||
`pkg/isolation` 为 `picoclaw` 启动的子进程提供进程级隔离能力。
|
||||
|
||||
它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。
|
||||
|
||||
## 生效范围
|
||||
|
||||
当前生效范围是子进程启动链路:
|
||||
|
||||
- `exec` 工具
|
||||
- `claude-cli`、`codex-cli` 等 CLI provider
|
||||
- 进程型 hooks
|
||||
- MCP `stdio` server
|
||||
|
||||
## 一句话理解
|
||||
|
||||
- `picoclaw` 主进程仍运行在宿主环境中。
|
||||
- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。
|
||||
- 入口会根据配置和平台,为子进程施加对应隔离。
|
||||
|
||||
## 架构
|
||||
|
||||
当前实现可以分为四层:
|
||||
|
||||
1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。
|
||||
2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。
|
||||
3. 平台后端层:Linux 使用 `bwrap`;Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。
|
||||
4. 统一启动层:`PrepareCommand(cmd)`、`Start(cmd)`、`Run(cmd)`。
|
||||
|
||||
所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start` 或 `cmd.Run`。
|
||||
|
||||
## 配置
|
||||
|
||||
隔离配置位于:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": false,
|
||||
"expose_paths": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `enabled`:是否启用子进程隔离。默认值:`false`。
|
||||
- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": true,
|
||||
"expose_paths": [
|
||||
{
|
||||
"source": "/opt/toolchains/go",
|
||||
"target": "/opt/toolchains/go",
|
||||
"mode": "ro"
|
||||
},
|
||||
{
|
||||
"source": "/data/shared-assets",
|
||||
"target": "/opt/picoclaw-instance-a/workspace/assets",
|
||||
"mode": "rw"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`expose_paths` 规则:
|
||||
|
||||
- `source`:宿主机路径。
|
||||
- `target`:隔离环境内的目标路径。
|
||||
- `mode`:只能是 `ro` 或 `rw`。
|
||||
- `target` 为空时,默认等于 `source`。
|
||||
- 同一个 `target` 最终只能保留一条规则。
|
||||
- 后加载的配置会覆盖先加载的同目标规则。
|
||||
|
||||
平台说明:
|
||||
|
||||
- Linux 会真实使用 `source -> target` 挂载视图。
|
||||
- Windows 当前不支持 `expose_paths`。
|
||||
|
||||
## 实例根与目录
|
||||
|
||||
实例根遵循 `config.GetHome()`:
|
||||
|
||||
- 如果设置了 `PICOCLAW_HOME`,使用该值。
|
||||
- 否则默认使用用户目录下的 `.picoclaw`。
|
||||
|
||||
如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。
|
||||
|
||||
默认实例目录包括:
|
||||
|
||||
- 实例根本身
|
||||
- `skills`
|
||||
- `logs`
|
||||
- `cache`
|
||||
- `state`
|
||||
- `runtime-user-env`
|
||||
|
||||
`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。
|
||||
|
||||
Windows 还会额外准备:
|
||||
|
||||
- `runtime-user-env/AppData/Roaming`
|
||||
- `runtime-user-env/AppData/Local`
|
||||
|
||||
## 用户环境重定向
|
||||
|
||||
隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。
|
||||
|
||||
Linux 注入变量:
|
||||
|
||||
- `HOME`
|
||||
- `TMPDIR`
|
||||
- `XDG_CONFIG_HOME`
|
||||
- `XDG_CACHE_HOME`
|
||||
- `XDG_STATE_HOME`
|
||||
|
||||
Windows 注入变量:
|
||||
|
||||
- `USERPROFILE`
|
||||
- `HOME`
|
||||
- `TEMP`
|
||||
- `TMP`
|
||||
- `APPDATA`
|
||||
- `LOCALAPPDATA`
|
||||
|
||||
这些路径都会指向实例根下的 `runtime-user-env`。
|
||||
|
||||
## 平台行为
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 后端当前依赖 `bwrap`(`bubblewrap`)。
|
||||
|
||||
能力:
|
||||
|
||||
- 最小文件系统视图
|
||||
- `ipc namespace`
|
||||
- 子进程用户环境重定向
|
||||
- `source -> target` 只读或读写挂载
|
||||
|
||||
默认映射包括实例根,以及 `/usr`、`/bin`、`/lib`、`/lib64`、`/etc/resolv.conf` 等最小运行时系统路径。
|
||||
|
||||
运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。
|
||||
|
||||
缺少 `bwrap` 时不会自动回退。
|
||||
|
||||
安装示例:
|
||||
|
||||
- `apt install bubblewrap`
|
||||
- `dnf install bubblewrap`
|
||||
- `yum install bubblewrap`
|
||||
- `pacman -S bubblewrap`
|
||||
- `apk add bubblewrap`
|
||||
|
||||
如果需要临时关闭隔离:
|
||||
|
||||
```json
|
||||
{
|
||||
"isolation": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。
|
||||
|
||||
### Windows
|
||||
|
||||
Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object,以及用户环境目录重定向。
|
||||
|
||||
`expose_paths` 目前不支持 Windows。如果配置了该字段,启动应直接失败,而不是假装这些路径已经被暴露进隔离环境。
|
||||
|
||||
Windows 后端当前使用:
|
||||
|
||||
- 受限 primary token
|
||||
- 低完整性级别
|
||||
- `Job Object`
|
||||
- 子进程用户环境重定向
|
||||
|
||||
它当前不会实现真正的 `source -> target` 文件系统重映射。
|
||||
|
||||
### macOS 与其他平台
|
||||
|
||||
当前尚未实现。
|
||||
|
||||
当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。
|
||||
|
||||
## 日志与排障
|
||||
|
||||
隔离开启后,PicoClaw 会打印生成后的隔离计划,便于排障。
|
||||
|
||||
Linux 日志名:
|
||||
|
||||
- `linux isolation mount plan`
|
||||
|
||||
Windows 日志名:
|
||||
|
||||
- `windows isolation access rules`
|
||||
|
||||
如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。
|
||||
|
||||
## 与 `restrict_to_workspace` 的关系
|
||||
|
||||
- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。
|
||||
- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。
|
||||
|
||||
两者互补,不互相替代。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。
|
||||
- Linux 当前没有默认启用独立的 `pid namespace`。
|
||||
- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。
|
||||
- macOS 尚未实现。
|
||||
- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
如果你是第一次看这部分代码,建议按这个顺序阅读:
|
||||
|
||||
1. `pkg/config/config.go`
|
||||
2. `pkg/isolation/runtime.go`
|
||||
3. `pkg/isolation/platform_linux.go`
|
||||
4. `pkg/isolation/platform_windows.go`
|
||||
5. 调用点:
|
||||
6. `pkg/tools/shell.go`
|
||||
7. `pkg/providers/*.go`
|
||||
8. `pkg/agent/hook_process.go`
|
||||
9. `pkg/mcp/manager.go`
|
||||
|
||||
这样能最快建立对配置模型、运行流程和平台边界的整体理解。
|
||||
264
pkg/isolation/platform_linux.go
Normal file
264
pkg/isolation/platform_linux.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
//go:build linux
|
||||
|
||||
package isolation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
|
||||
if !isolation.Enabled {
|
||||
return nil
|
||||
}
|
||||
// Bubblewrap is the only supported Linux backend right now. Fail closed when
|
||||
// it is unavailable instead of silently running the child process unisolated.
|
||||
bwrapPath, err := exec.LookPath("bwrap")
|
||||
if err != nil {
|
||||
hint := bwrapInstallHint()
|
||||
disableHint := `set "isolation.enabled": false in config.json`
|
||||
logger.WarnCF("isolation", "bubblewrap is required for Linux isolation",
|
||||
map[string]any{
|
||||
"binary": "bwrap",
|
||||
"install": hint,
|
||||
"disable_isolation": disableHint,
|
||||
"risk": "disabling isolation lets child processes run without Linux filesystem isolation",
|
||||
})
|
||||
return fmt.Errorf(
|
||||
"linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files",
|
||||
err,
|
||||
hint,
|
||||
disableHint,
|
||||
)
|
||||
}
|
||||
if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
originalPath := cmd.Path
|
||||
originalArgs := append([]string{}, cmd.Args...)
|
||||
_, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start from the configured mount plan, then add only the executable, its
|
||||
// resolved path, the effective working directory, and any absolute path
|
||||
// arguments needed to preserve the original command semantics.
|
||||
plan := BuildLinuxMountPlan(root, isolation.ExposePaths)
|
||||
plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro")
|
||||
plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro")
|
||||
if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath {
|
||||
plan = ensureLinuxMountRule(plan, resolved, resolved, "ro")
|
||||
plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro")
|
||||
}
|
||||
if execDir != "" {
|
||||
plan = ensureLinuxMountRule(plan, execDir, execDir, "rw")
|
||||
if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir {
|
||||
plan = ensureLinuxMountRule(plan, resolved, resolved, "rw")
|
||||
}
|
||||
}
|
||||
plan = appendLinuxArgumentMounts(plan, originalArgs[1:])
|
||||
logger.DebugCF("isolation", "linux isolation mount plan",
|
||||
map[string]any{
|
||||
"root": root,
|
||||
"command": resolvedPath,
|
||||
"working_dir": execDir,
|
||||
"mounts": formatLinuxMountPlan(plan),
|
||||
})
|
||||
bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Path = bwrapPath
|
||||
cmd.Args = bwrapArgs
|
||||
cmd.Dir = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func bwrapInstallHint() string {
|
||||
return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap"
|
||||
}
|
||||
|
||||
// formatLinuxMountPlan reshapes the internal plan for structured logging.
|
||||
func formatLinuxMountPlan(plan []MountRule) []map[string]string {
|
||||
formatted := make([]map[string]string, 0, len(plan))
|
||||
for _, rule := range plan {
|
||||
formatted = append(formatted, map[string]string{
|
||||
"source": rule.Source,
|
||||
"target": rule.Target,
|
||||
"mode": rule.Mode,
|
||||
})
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
|
||||
}
|
||||
|
||||
// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command
|
||||
// line that re-executes the original process inside the isolated mount view.
|
||||
func buildLinuxBwrapArgs(
|
||||
originalPath string,
|
||||
resolvedPath string,
|
||||
originalArgs []string,
|
||||
execDir string,
|
||||
plan []MountRule,
|
||||
) ([]string, error) {
|
||||
bwrapArgs := []string{
|
||||
"bwrap",
|
||||
"--die-with-parent",
|
||||
"--unshare-ipc",
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
}
|
||||
for _, rule := range plan {
|
||||
flag, err := linuxBindFlag(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target)
|
||||
}
|
||||
if execDir != "" {
|
||||
bwrapArgs = append(bwrapArgs, "--chdir", execDir)
|
||||
}
|
||||
execPath := originalPath
|
||||
if isRelativeCommandPath(originalPath) {
|
||||
execPath = resolvedPath
|
||||
}
|
||||
bwrapArgs = append(bwrapArgs, "--", execPath)
|
||||
if len(originalArgs) > 1 {
|
||||
bwrapArgs = append(bwrapArgs, originalArgs[1:]...)
|
||||
}
|
||||
return bwrapArgs, nil
|
||||
}
|
||||
|
||||
func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) {
|
||||
if originalDir != "" {
|
||||
resolved, err := filepath.Abs(originalDir)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err)
|
||||
}
|
||||
return resolved, resolved, nil
|
||||
}
|
||||
if !isRelativeCommandPath(originalPath) {
|
||||
return "", "", nil
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve current working dir: %w", err)
|
||||
}
|
||||
return "", wd, nil
|
||||
}
|
||||
|
||||
func resolveLinuxCommandPath(originalPath, execDir string) (string, error) {
|
||||
if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) {
|
||||
return filepath.Clean(originalPath), nil
|
||||
}
|
||||
base := execDir
|
||||
if base == "" {
|
||||
var err error
|
||||
base, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve current working dir: %w", err)
|
||||
}
|
||||
}
|
||||
return filepath.Clean(filepath.Join(base, originalPath)), nil
|
||||
}
|
||||
|
||||
func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule {
|
||||
for _, arg := range args {
|
||||
path, ok := linuxArgumentPath(arg)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if info, err := os.Stat(clean); err == nil {
|
||||
mode := "ro"
|
||||
if info.IsDir() {
|
||||
mode = "rw"
|
||||
}
|
||||
plan = ensureLinuxMountRule(plan, clean, clean, mode)
|
||||
if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean {
|
||||
plan = ensureLinuxMountRule(plan, resolved, resolved, mode)
|
||||
}
|
||||
continue
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
parent := filepath.Dir(clean)
|
||||
if parent == clean {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(parent); err == nil {
|
||||
plan = ensureLinuxMountRule(plan, parent, parent, "rw")
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func linuxArgumentPath(arg string) (string, bool) {
|
||||
if filepath.IsAbs(arg) {
|
||||
return arg, true
|
||||
}
|
||||
idx := strings.IndexRune(arg, '=')
|
||||
if idx <= 0 || idx == len(arg)-1 {
|
||||
return "", false
|
||||
}
|
||||
value := arg[idx+1:]
|
||||
if !filepath.IsAbs(value) {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func isRelativeCommandPath(path string) bool {
|
||||
return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator)
|
||||
}
|
||||
|
||||
// ensureLinuxMountRule appends a mount rule unless another rule already owns
|
||||
// the same target path.
|
||||
func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule {
|
||||
cleanSource := filepath.Clean(source)
|
||||
cleanTarget := filepath.Clean(target)
|
||||
for _, rule := range plan {
|
||||
if filepath.Clean(rule.Target) == cleanTarget {
|
||||
return plan
|
||||
}
|
||||
}
|
||||
return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode})
|
||||
}
|
||||
|
||||
// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode.
|
||||
func linuxBindFlag(rule MountRule) (string, error) {
|
||||
info, err := os.Stat(rule.Source)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if rule.Mode == "rw" {
|
||||
return "--bind", nil
|
||||
}
|
||||
return "--ro-bind", nil
|
||||
}
|
||||
if rule.Mode == "rw" {
|
||||
return "--bind", nil
|
||||
}
|
||||
return "--ro-bind", nil
|
||||
}
|
||||
148
pkg/isolation/platform_linux_test.go
Normal file
148
pkg/isolation/platform_linux_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
//go:build linux
|
||||
|
||||
package isolation
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binaryDir := filepath.Join(root, "bin")
|
||||
if err := os.MkdirAll(binaryDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binaryPath := filepath.Join(binaryDir, "tool")
|
||||
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}})
|
||||
args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan)
|
||||
if err != nil {
|
||||
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
|
||||
}
|
||||
hasNet := false
|
||||
hasIPC := false
|
||||
hasExec := false
|
||||
for i := range args {
|
||||
switch args[i] {
|
||||
case "--unshare-net":
|
||||
hasNet = true
|
||||
case "--unshare-ipc":
|
||||
hasIPC = true
|
||||
case "--":
|
||||
if i+1 < len(args) && args[i+1] == binaryPath {
|
||||
hasExec = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasNet {
|
||||
t.Fatalf("bwrap args should not unshare net by default: %v", args)
|
||||
}
|
||||
if !hasIPC || !hasExec {
|
||||
t.Fatalf("bwrap args missing required items: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
previous, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if chdirErr := os.Chdir(previous); chdirErr != nil {
|
||||
t.Fatalf("restore cwd: %v", chdirErr)
|
||||
}
|
||||
}()
|
||||
if chdirErr := os.Chdir(cwd); chdirErr != nil {
|
||||
t.Fatal(chdirErr)
|
||||
}
|
||||
|
||||
resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLinuxWorkingDir() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(cwd, "hooks")
|
||||
if resolvedDir != want || execDir != want {
|
||||
t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) {
|
||||
execDir := filepath.Join(t.TempDir(), "hooks")
|
||||
got, err := resolveLinuxCommandPath("./hook.sh", execDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLinuxCommandPath() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(execDir, "hook.sh")
|
||||
if got != want {
|
||||
t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
execDir := filepath.Join(root, "hooks")
|
||||
if err := os.MkdirAll(execDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolvedPath := filepath.Join(execDir, "hook.sh")
|
||||
if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan := []MountRule{
|
||||
{Source: execDir, Target: execDir, Mode: "rw"},
|
||||
{Source: resolvedPath, Target: resolvedPath, Mode: "ro"},
|
||||
}
|
||||
args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan)
|
||||
if err != nil {
|
||||
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
|
||||
}
|
||||
hasExecDir := false
|
||||
for _, arg := range args {
|
||||
if arg == execDir {
|
||||
hasExecDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasExecDir {
|
||||
t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] == "--" {
|
||||
if i+1 >= len(args) || args[i+1] != resolvedPath {
|
||||
t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args)
|
||||
}
|
||||
|
||||
func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
input := filepath.Join(root, "input.txt")
|
||||
if err := os.WriteFile(input, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := filepath.Join(root, "out", "result.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output})
|
||||
if len(plan) != 2 {
|
||||
t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan))
|
||||
}
|
||||
if plan[0].Source != input || plan[0].Mode != "ro" {
|
||||
t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input)
|
||||
}
|
||||
if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" {
|
||||
t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output))
|
||||
}
|
||||
}
|
||||
22
pkg/isolation/platform_other.go
Normal file
22
pkg/isolation/platform_other.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//go:build !linux && !windows
|
||||
|
||||
package isolation
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
|
||||
// Unsupported platforms currently keep the command unchanged. Callers rely on
|
||||
// Preflight and higher-level checks to surface unsupported isolation modes.
|
||||
return nil
|
||||
}
|
||||
|
||||
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue