Merge branch 'main' into feature/tldr-on-empty-response
This commit is contained in:
commit
022ca7233a
123 changed files with 8786 additions and 989 deletions
|
|
@ -5,16 +5,13 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
# CEREBRAS_API_KEY=xxx
|
# CLAUDE_CODE_OAUTH=xxx
|
||||||
|
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
# DISCORD_BOT_TOKEN=xxx
|
# DISCORD_BOT_TOKEN=xxx
|
||||||
# LINE_CHANNEL_SECRET=xxx
|
|
||||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search (optional) ────────────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
|
||||||
# ── Timezone ──────────────────────────────
|
# ── Timezone ──────────────────────────────
|
||||||
TZ=Asia/Tokyo
|
TZ=Asia/Shanghai
|
||||||
|
|
|
||||||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -24,6 +24,25 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
|
vuln_check:
|
||||||
|
name: Security Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run Govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,11 @@ on:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
upload_tos:
|
||||||
|
description: "Upload to Volcengine TOS"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
create-tag:
|
||||||
|
|
@ -100,3 +105,12 @@ jobs:
|
||||||
gh release edit "${{ inputs.tag }}" \
|
gh release edit "${{ inputs.tag }}" \
|
||||||
--draft=${{ inputs.draft }} \
|
--draft=${{ inputs.draft }} \
|
||||||
--prerelease=${{ inputs.prerelease }}
|
--prerelease=${{ inputs.prerelease }}
|
||||||
|
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to TOS
|
||||||
|
needs: release
|
||||||
|
if: ${{ inputs.upload_tos }}
|
||||||
|
uses: ./.github/workflows/upload-tos.yml
|
||||||
|
with:
|
||||||
|
tag: ${{ inputs.tag }}
|
||||||
|
secrets: inherit
|
||||||
|
|
|
||||||
49
.github/workflows/upload-tos.yml
vendored
Normal file
49
.github/workflows/upload-tos.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload (e.g. v0.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download release assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
gh release download "${{ inputs.tag }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--dir artifacts \
|
||||||
|
--pattern "*.tar.gz" \
|
||||||
|
--pattern "*.zip" \
|
||||||
|
--pattern "*.rpm" \
|
||||||
|
--pattern "*.deb"
|
||||||
|
|
||||||
|
- name: Upload to Volcengine TOS
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: cn-beijing
|
||||||
|
run: |
|
||||||
|
aws configure set default.s3.addressing_style virtual
|
||||||
|
TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
|
||||||
|
# Upload to versioned directory
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT"
|
||||||
|
# Upload to latest (overwrite)
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT" \
|
||||||
|
--delete
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -38,6 +38,9 @@ ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
docs/plans/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
4
LICENSE
4
LICENSE
|
|
@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
|
||||||
|
|
|
||||||
36
Makefile
36
Makefile
|
|
@ -18,6 +18,28 @@ LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(G
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
|
#
|
||||||
|
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
|
||||||
|
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
|
||||||
|
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
|
||||||
|
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
|
||||||
|
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
|
||||||
|
#
|
||||||
|
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
|
||||||
|
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
|
||||||
|
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
|
||||||
|
#
|
||||||
|
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
|
||||||
|
define PATCH_MIPS_FLAGS
|
||||||
|
@if [ -f "$(1)" ]; then \
|
||||||
|
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
|
||||||
|
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
|
||||||
|
else \
|
||||||
|
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -50,6 +72,8 @@ ifeq ($(UNAME_S),Linux)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
ARCH=riscv64
|
ARCH=riscv64
|
||||||
|
else ifeq ($(UNAME_M),mipsel)
|
||||||
|
ARCH=mipsle
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
|
|
@ -97,6 +121,8 @@ build-whatsapp-native: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
|
@ -117,6 +143,14 @@ build-linux-arm64: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
|
build-linux-mipsle: generate
|
||||||
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
build-pi-zero: build-linux-arm build-linux-arm64
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
@ -130,6 +164,8 @@ build-all: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
||||||
|
|
||||||
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
|
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
|
||||||
|
|
||||||
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
||||||
|
|
||||||
|
|
@ -827,7 +827,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
|
||||||
### Fournisseurs
|
### Fournisseurs
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
|
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||||
|
|
||||||
| Fournisseur | Utilisation | Obtenir une Clé API |
|
| Fournisseur | Utilisation | Obtenir une Clé API |
|
||||||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||||
|
|
||||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||||
|
|
||||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||||
|
|
||||||
|
|
@ -785,7 +785,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
### プロバイダー
|
### プロバイダー
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
|
||||||
|
|
||||||
| プロバイダー | 用途 | API キー取得先 |
|
| プロバイダー | 用途 | API キー取得先 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
144
README.md
144
README.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
|
|
||||||
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
||||||
|
|
||||||
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go!
|
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
|
||||||
|
|
||||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||||
|
|
||||||
|
|
@ -216,7 +216,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -265,6 +265,16 @@ picoclaw onboard
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +287,12 @@ picoclaw onboard
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional):
|
||||||
|
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||||
|
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||||
|
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||||
|
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||||
|
* DuckDuckGo - Built-in fallback (no API key required)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -338,6 +353,13 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||||
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -735,6 +757,12 @@ For advanced/test setups, you can override the builtin skills root with:
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||||
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
@ -911,7 +939,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
@ -1190,6 +1218,10 @@ picoclaw agent -m "Hello"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
@ -1241,6 +1273,16 @@ picoclaw agent -m "Hello"
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
|
@ -1298,10 +1340,69 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
To enable web search:
|
#### Search Provider Priority
|
||||||
|
|
||||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
PicoClaw automatically selects the best available search provider in this order:
|
||||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
|
||||||
|
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
|
||||||
|
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
|
||||||
|
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
|
||||||
|
|
||||||
|
#### Web Search Configuration Options
|
||||||
|
|
||||||
|
**Option 1 (Best Results)**: Perplexity AI Search
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"searxng": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "http://your-server:8888",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits of SearXNG:
|
||||||
|
- **Zero cost**: No API fees or rate limits
|
||||||
|
- **Privacy-focused**: Self-hosted, no tracking
|
||||||
|
- **Aggregate results**: Queries 70+ search engines simultaneously
|
||||||
|
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
|
||||||
|
- **No API key needed**: Just deploy and configure the base URL
|
||||||
|
|
||||||
|
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
|
||||||
|
|
||||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
|
|
@ -1317,6 +1418,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1335,10 +1446,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
|
|
||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------- | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
|
|
||||||
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||||
|
|
||||||
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
|
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
|
||||||
|
|
||||||
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||||
|
|
||||||
|
|
@ -823,7 +823,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
|
||||||
### Provedores
|
### Provedores
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
|
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
|
||||||
|
|
||||||
| Provedor | Finalidade | Obter API Key |
|
| Provedor | Finalidade | Obter API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||||
|
|
||||||
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
|
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
|
||||||
|
|
||||||
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||||
|
|
||||||
|
|
@ -795,7 +795,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
|
||||||
### Nhà cung cấp (Providers)
|
### Nhà cung cấp (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
|
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
|
||||||
|
|
||||||
| Nhà cung cấp | Mục đích | Lấy API Key |
|
| Nhà cung cấp | Mục đích | Lấy API Key |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
|
||||||
23
README.zh.md
23
README.zh.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
|
|
@ -307,6 +307,13 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
|
|
||||||
|
### Telegram 命令注册(启动时自动同步)
|
||||||
|
|
||||||
|
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
@ -376,6 +383,12 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 统一命令执行策略
|
||||||
|
|
||||||
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||||
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 心跳 / 周期性任务 (Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||||
|
|
@ -459,7 +472,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
|
|
@ -715,6 +728,10 @@ picoclaw agent -m "你好"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 96 KiB |
|
|
@ -335,7 +335,11 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
s.showMessage("Test OK", resp.Status)
|
s.showMessage("Test OK", resp.Status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Test failed",
|
"Test failed",
|
||||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1392,9 +1392,7 @@ function saveModelFromModal() {
|
||||||
saveConfig().then(renderModels);
|
saveConfig().then(renderModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('modelModal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) closeModelModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Channel Forms ───────────────────────────────────
|
// ── Channel Forms ───────────────────────────────────
|
||||||
function renderChannelForm(chKey) {
|
function renderChannelForm(chKey) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -15,14 +16,17 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
const (
|
||||||
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginPasteToken(provider)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropic(useOauth bool) error {
|
||||||
|
if useOauth {
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Anthropic login method:")
|
||||||
|
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
|
||||||
|
fmt.Println(" 2) API key (from console.anthropic.com)")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("Choose [1]: ")
|
||||||
|
choice := "1"
|
||||||
|
if scanner.Scan() {
|
||||||
|
text := strings.TrimSpace(scanner.Text())
|
||||||
|
if text != "" {
|
||||||
|
choice = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case "1":
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
case "2":
|
||||||
|
return authLoginPasteToken("anthropic")
|
||||||
|
default:
|
||||||
|
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropicSetupToken() error {
|
||||||
|
cred, err := auth.LoginSetupToken(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
|
if err == nil {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: defaultAnthropicModel,
|
||||||
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
// Only set default model if user has no default configured yet
|
||||||
|
if appCfg.Agents.Defaults.GetModelName() == "" {
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Setup token saved for Anthropic!")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -217,13 +299,12 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
// Update default model
|
|
||||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
|
|
@ -360,6 +441,16 @@ func authStatusCmd() error {
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider == "anthropic" && cred.AuthMethod == "oauth" {
|
||||||
|
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Usage: unavailable (%v)\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
|
||||||
|
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
useOauth bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode)
|
return authLoginCmd(provider, useDeviceCode, useOauth)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&useOauth, "setup-token", false,
|
||||||
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
)
|
||||||
_ = cmd.MarkFlagRequired("provider")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd(debug bool) error {
|
func gatewayCmd(debug bool) error {
|
||||||
|
|
@ -134,6 +135,12 @@ func gatewayCmd(debug bool) error {
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
agentLoop.SetMediaStore(mediaStore)
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
|
|
||||||
|
// Wire up voice transcription if a supported provider is configured.
|
||||||
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||||
|
agentLoop.SetTranscriber(transcriber)
|
||||||
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
|
|
@ -223,19 +230,25 @@ func setupCronTool(
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool if enabled
|
||||||
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
var cronTool *tools.CronTool
|
||||||
if err != nil {
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
var err error
|
||||||
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentLoop.RegisterTool(cronTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
// Set onJob handler
|
||||||
|
if cronTool != nil {
|
||||||
// Set the onJob handler
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
return result, nil
|
||||||
return result, nil
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,21 @@ var (
|
||||||
goVersion string
|
goVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
|
func GetPicoclawHome() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
oldVersion, oldGit := version, gitCommit
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
if len(args) != 2 {
|
if len(args) != 1 {
|
||||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
registry string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no registry, one arg",
|
||||||
|
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||||
|
registry: "",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, one arg",
|
||||||
|
args: []string{"weather-skill"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
|
if tt.registry != "" {
|
||||||
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Args(cmd, tt.args)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, tt.errorMsg, err.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20,
|
||||||
|
"summarize_message_threshold": 20,
|
||||||
|
"summarize_token_percent": 75
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
@ -20,7 +22,8 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com/v1"
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"thinking_level": "high"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
|
|
@ -59,6 +62,7 @@
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"proxy": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": false
|
"mention_only": false
|
||||||
|
|
@ -221,27 +225,53 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
|
},
|
||||||
|
"avian": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.avian.io/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"allow_read_paths": null,
|
||||||
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"max_results": 0
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"proxy": ""
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"glm_search": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
|
"search_engine": "search_std",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"fetch_limit_bytes": 10485760
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
|
|
@ -310,19 +340,75 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": false,
|
"enabled": true,
|
||||||
"custom_deny_patterns": []
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": null,
|
||||||
|
"custom_allow_patterns": null
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"enabled": true,
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"search_path": "/api/v1/search",
|
"auth_token": "",
|
||||||
"skills_path": "/api/v1/skills",
|
"search_path": "",
|
||||||
"download_path": "/api/v1/download"
|
"skills_path": "",
|
||||||
|
"download_path": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"max_zip_size": 0,
|
||||||
|
"max_response_size": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"max_concurrent_searches": 2,
|
||||||
|
"search_cache": {
|
||||||
|
"max_size": 50,
|
||||||
|
"ttl_seconds": 300
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"media_cleanup": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_age_minutes": 30,
|
||||||
|
"interval_minutes": 5
|
||||||
|
},
|
||||||
|
"append_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"edit_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"find_skills": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"i2c": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"install_skill": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"list_dir": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"read_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spawn": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spi": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"subagent": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"web_fetch": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"write_file": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|
@ -337,4 +423,4 @@
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
@ -194,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
|
"auth_token": "",
|
||||||
"search_path": "/api/v1/search",
|
"search_path": "/api/v1/search",
|
||||||
"skills_path": "/api/v1/skills",
|
"skills_path": "/api/v1/skills",
|
||||||
"download_path": "/api/v1/download"
|
"download_path": "/api/v1/download"
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0
|
github.com/modelcontextprotocol/go-sdk v1.3.0
|
||||||
|
|
@ -37,8 +38,6 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
|
||||||
github.com/h2non/filetype v1.1.3 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ type ContextBuilder struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -602,7 +605,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
// Second pass: ensure every assistant message with tool_calls has matching
|
||||||
|
// tool result messages following it. This is required by strict providers
|
||||||
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
|
for i := 0; i < len(sanitized); i++ {
|
||||||
|
msg := sanitized[i]
|
||||||
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
|
// Collect expected tool_call IDs
|
||||||
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
expected[tc.ID] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check following messages for matching tool results
|
||||||
|
toolMsgCount := 0
|
||||||
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
|
if sanitized[j].Role != "tool" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toolMsgCount++
|
||||||
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
|
expected[sanitized[j].ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
||||||
|
allFound := true
|
||||||
|
for toolCallID, found := range expected {
|
||||||
|
if !found {
|
||||||
|
allFound = false
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Dropping assistant message with incomplete tool results",
|
||||||
|
map[string]any{
|
||||||
|
"missing_tool_call_id": toolCallID,
|
||||||
|
"expected_count": len(expected),
|
||||||
|
"found_count": toolMsgCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allFound {
|
||||||
|
// Skip this assistant message and its tool messages
|
||||||
|
i += toolMsgCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final = append(final, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation
|
||||||
|
// that ensures assistant messages with tool_calls have ALL matching tool results.
|
||||||
|
// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be
|
||||||
|
// followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
|
||||||
|
// Assistant expects tool results for both A and B, but only A is present
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
// toolResult("B") is missing - this would cause DeepSeek to fail
|
||||||
|
msg("user", "next question"),
|
||||||
|
msg("assistant", "answer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with incomplete tool results should be dropped,
|
||||||
|
// along with its partial tool result. The remaining messages are:
|
||||||
|
// user ("do two things"), user ("next question"), assistant ("answer")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
||||||
|
// an assistant message has tool_calls but no tool results follow at all.
|
||||||
|
func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do something"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
// No tool results at all
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with no tool results should be dropped.
|
||||||
|
// Remaining: user ("do something"), user ("hello"), assistant ("hi")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
||||||
|
// incomplete tool results in the middle of a conversation are properly handled.
|
||||||
|
func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "first"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
msg("user", "second"),
|
||||||
|
assistantWithTools("B", "C"),
|
||||||
|
toolResult("B"),
|
||||||
|
// toolResult("C") is missing
|
||||||
|
msg("user", "third"),
|
||||||
|
assistantWithTools("D"),
|
||||||
|
toolResult("D"),
|
||||||
|
msg("assistant", "all done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// First round is complete (user, assistant+tools, tool, assistant),
|
||||||
|
// second round is incomplete and dropped (assistant+tools, partial tool),
|
||||||
|
// third round is complete (user, assistant+tools, tool, assistant).
|
||||||
|
// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
|
||||||
|
if len(result) != 9 {
|
||||||
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,33 @@ import (
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Model string
|
Model string
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ContextWindow int
|
ThinkingLevel ThinkingLevel
|
||||||
Provider providers.LLMProvider
|
ContextWindow int
|
||||||
Sessions *session.SessionManager
|
SummarizeMessageThreshold int
|
||||||
ContextBuilder *ContextBuilder
|
SummarizeTokenPercent int
|
||||||
Tools *tools.ToolRegistry
|
Provider providers.LLMProvider
|
||||||
Subagents *config.SubagentsConfig
|
Sessions *session.SessionManager
|
||||||
SkillsFilter []string
|
ContextBuilder *ContextBuilder
|
||||||
Candidates []providers.FallbackCandidate
|
Tools *tools.ToolRegistry
|
||||||
|
Subagents *config.SubagentsConfig
|
||||||
|
SkillsFilter []string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
|
LightCandidates []providers.FallbackCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -57,17 +68,30 @@ func NewAgentInstance(
|
||||||
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
|
||||||
}
|
|
||||||
toolsRegistry.Register(execTool)
|
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
@ -101,6 +125,22 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
|
if summarizeMessageThreshold == 0 {
|
||||||
|
summarizeMessageThreshold = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeTokenPercent := defaults.SummarizeTokenPercent
|
||||||
|
if summarizeTokenPercent == 0 {
|
||||||
|
summarizeTokenPercent = 75
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
|
|
@ -148,23 +188,47 @@ func NewAgentInstance(
|
||||||
|
|
||||||
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
Model: model,
|
Model: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
ContextWindow: maxTokens,
|
ThinkingLevel: thinkingLevel,
|
||||||
Provider: provider,
|
ContextWindow: maxTokens,
|
||||||
Sessions: sessionsManager,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
ContextBuilder: contextBuilder,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Tools: toolsRegistry,
|
Provider: provider,
|
||||||
Subagents: subagents,
|
Sessions: sessionsManager,
|
||||||
SkillsFilter: skillsFilter,
|
ContextBuilder: contextBuilder,
|
||||||
Candidates: candidates,
|
Tools: toolsRegistry,
|
||||||
|
Subagents: subagents,
|
||||||
|
SkillsFilter: skillsFilter,
|
||||||
|
Candidates: candidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,12 +237,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
|
// Use the configured default workspace (respects PICOCLAW_HOME)
|
||||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
return expandHome(defaults.Workspace)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -20,6 +21,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -31,6 +33,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
|
|
@ -43,6 +46,8 @@ type AgentLoop struct {
|
||||||
fallback *providers.FallbackChain
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
|
transcriber voice.Transcriber
|
||||||
|
cmdRegistry *commands.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -58,7 +63,15 @@ type processOptions struct {
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
const (
|
||||||
|
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
sessionKeyAgentPrefix = "agent:"
|
||||||
|
metadataKeyAccountID = "account_id"
|
||||||
|
metadataKeyGuildID = "guild_id"
|
||||||
|
metadataKeyTeamID = "team_id"
|
||||||
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
|
)
|
||||||
|
|
||||||
func NewAgentLoop(
|
func NewAgentLoop(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
@ -81,14 +94,17 @@ func NewAgentLoop(
|
||||||
stateManager = state.NewManager(defaultAgent.Workspace)
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentLoop{
|
al := &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
summarizing: sync.Map{},
|
summarizing: sync.Map{},
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return al
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
||||||
|
|
@ -105,71 +121,117 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
// Web tools
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
})
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
if err != nil {
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
} else if searchTool != nil {
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||||
agent.Tools.Register(searchTool)
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
||||||
|
} else if searchTool != nil {
|
||||||
|
agent.Tools.Register(searchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
if err != nil {
|
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
if err != nil {
|
||||||
} else {
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
agent.Tools.Register(fetchTool)
|
} else {
|
||||||
|
agent.Tools.Register(fetchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
agent.Tools.Register(tools.NewSPITool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("spi") {
|
||||||
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool := tools.NewMessageTool()
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
defer pubCancel()
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
defer pubCancel()
|
||||||
Channel: channel,
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
ChatID: chatID,
|
Channel: channel,
|
||||||
Content: content,
|
ChatID: chatID,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
agent.Tools.Register(messageTool)
|
||||||
agent.Tools.Register(messageTool)
|
}
|
||||||
|
|
||||||
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
sendFileTool := tools.NewSendFileTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(sendFileTool)
|
||||||
|
}
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
})
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
searchCache := skills.NewSearchCache(
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
)
|
})
|
||||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
if find_skills_enable {
|
||||||
|
searchCache := skills.NewSearchCache(
|
||||||
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
|
||||||
|
if install_skills_enable {
|
||||||
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||||
currentAgentID := agentID
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
currentAgentID := agentID
|
||||||
})
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
agent.Tools.Register(spawnTool)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
|
})
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +239,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
al.running.Store(true)
|
||||||
|
|
||||||
// Initialize MCP servers for all agents
|
// Initialize MCP servers for all agents
|
||||||
if al.cfg.Tools.MCP.Enabled {
|
if al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
mcpManager := mcp.NewManager()
|
mcpManager := mcp.NewManager()
|
||||||
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
||||||
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
||||||
|
|
@ -219,6 +281,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
agent.Tools.Register(mcpTool)
|
agent.Tools.Register(mcpTool)
|
||||||
totalRegistrations++
|
totalRegistrations++
|
||||||
|
|
@ -332,6 +395,71 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
// SetMediaStore injects a MediaStore for media lifecycle management.
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
|
|
||||||
|
// Propagate store to send_file tools in all agents.
|
||||||
|
al.registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||||
|
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||||
|
sf.SetMediaStore(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||||
|
func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
|
||||||
|
al.transcriber = t
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
|
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
||||||
|
// replaces audio annotations in msg.Content with the transcribed text.
|
||||||
|
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage {
|
||||||
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcribe each audio media ref in order.
|
||||||
|
var transcriptions []string
|
||||||
|
for _, ref := range msg.Media {
|
||||||
|
path, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := al.transcriber.Transcribe(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
|
||||||
|
transcriptions = append(transcriptions, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transcriptions = append(transcriptions, result.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(transcriptions) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace audio annotations sequentially with transcriptions.
|
||||||
|
idx := 0
|
||||||
|
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
||||||
|
if idx >= len(transcriptions) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
text := transcriptions[idx]
|
||||||
|
idx++
|
||||||
|
return "[voice: " + text + "]"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append any remaining transcriptions not matched by an annotation.
|
||||||
|
for ; idx < len(transcriptions); idx++ {
|
||||||
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Content = newContent
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
|
|
@ -445,52 +573,46 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
msg = al.transcribeAudioInMessage(ctx, msg)
|
||||||
|
|
||||||
// Route system messages to processSystemMessage
|
// Route system messages to processSystemMessage
|
||||||
if msg.Channel == "system" {
|
if msg.Channel == "system" {
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for commands
|
route, agent, routeErr := al.resolveMessageRoute(msg)
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
|
||||||
|
// Commands are checked before requiring a successful route.
|
||||||
|
// Global commands (/help, /show, /switch) work even when routing fails;
|
||||||
|
// context-dependent commands check their own Runtime fields and report
|
||||||
|
// "unavailable" when the required capability is nil.
|
||||||
|
if response, handled := al.handleCommand(ctx, msg, agent); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to determine agent and session key
|
if routeErr != nil {
|
||||||
route := al.registry.ResolveRoute(routing.RouteInput{
|
return "", routeErr
|
||||||
Channel: msg.Channel,
|
|
||||||
AccountID: msg.Metadata["account_id"],
|
|
||||||
Peer: extractPeer(msg),
|
|
||||||
ParentPeer: extractParentPeer(msg),
|
|
||||||
GuildID: msg.Metadata["guild_id"],
|
|
||||||
TeamID: msg.Metadata["team_id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
agent, ok := al.registry.GetAgent(route.AgentID)
|
|
||||||
if !ok {
|
|
||||||
agent = al.registry.GetDefaultAgent()
|
|
||||||
}
|
|
||||||
if agent == nil {
|
|
||||||
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||||
mt.SetContext(msg.Channel, msg.ChatID)
|
resetter.ResetSentInRound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
||||||
sessionKey := route.SessionKey
|
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
||||||
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
|
sessionKey := scopeKey
|
||||||
sessionKey = msg.SessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"session_key": sessionKey,
|
"scope_key": scopeKey,
|
||||||
"matched_by": route.MatchedBy,
|
"session_key": sessionKey,
|
||||||
|
"matched_by": route.MatchedBy,
|
||||||
|
"route_agent": route.AgentID,
|
||||||
|
"route_channel": route.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
|
@ -505,6 +627,34 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
AccountID: inboundMetadata(msg, metadataKeyAccountID),
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
ParentPeer: extractParentPeer(msg),
|
||||||
|
GuildID: inboundMetadata(msg, metadataKeyGuildID),
|
||||||
|
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
||||||
|
})
|
||||||
|
|
||||||
|
agent, ok := al.registry.GetAgent(route.AgentID)
|
||||||
|
if !ok {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return route, agent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
|
||||||
|
if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) {
|
||||||
|
return msgSessionKey
|
||||||
|
}
|
||||||
|
return route.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processSystemMessage(
|
func (al *AgentLoop) processSystemMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
|
|
@ -591,10 +741,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Update tool contexts
|
// 1. Build messages (skip history for heartbeat)
|
||||||
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
|
||||||
|
|
||||||
// 2. Build messages (skip history for heartbeat)
|
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !opts.NoHistory {
|
if !opts.NoHistory {
|
||||||
|
|
@ -614,7 +761,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 3. Save user message to session
|
// 2. Save user message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||||
|
|
||||||
// 4. Run LLM iteration loop
|
// 4. Run LLM iteration loop
|
||||||
|
|
@ -626,7 +773,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
||||||
// This is controlled by the tool's Silent flag and ForUser content
|
// This is controlled by the tool's Silent flag and ForUser content
|
||||||
|
|
||||||
// 5. Handle empty response
|
// 4. Handle empty response
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
// Deduplicate tools for TLDR
|
// Deduplicate tools for TLDR
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
@ -640,16 +787,16 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
finalContent = generateTLDR(opts.UserMessage, uniqueTools, iteration, al.cfg.Agents.Defaults.TLDRIncludeMessage)
|
finalContent = generateTLDR(opts.UserMessage, uniqueTools, iteration, al.cfg.Agents.Defaults.TLDRIncludeMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save final assistant message to session
|
// 5. Save final assistant message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||||
agent.Sessions.Save(opts.SessionKey)
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
|
||||||
// 7. Optional: summarization
|
// 6. Optional: summarization
|
||||||
if opts.EnableSummary {
|
if opts.EnableSummary {
|
||||||
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 7. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse {
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
|
|
@ -658,7 +805,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Log response
|
// 8. Log response
|
||||||
responsePreview := utils.Truncate(finalContent, 120)
|
responsePreview := utils.Truncate(finalContent, 120)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -738,6 +885,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
var finalContent string
|
var finalContent string
|
||||||
executedTools := []string{}
|
executedTools := []string{}
|
||||||
|
|
||||||
|
// Determine effective model tier for this conversation turn.
|
||||||
|
// selectCandidates evaluates routing once and the decision is sticky for
|
||||||
|
// all tool-follow-up iterations within the same turn so that a multi-step
|
||||||
|
// tool chain doesn't switch models mid-way through.
|
||||||
|
activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages)
|
||||||
|
|
||||||
for iteration < agent.MaxIterations {
|
for iteration < agent.MaxIterations {
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
|
|
@ -756,7 +909,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": agent.Model,
|
"model": activeModel,
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
"tools_count": len(providerToolDefs),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": agent.MaxTokens,
|
"max_tokens": agent.MaxTokens,
|
||||||
|
|
@ -772,27 +925,33 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"tools_json": formatToolsForLog(providerToolDefs),
|
"tools_json": formatToolsForLog(providerToolDefs),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Call LLM with fallback chain if candidates are configured.
|
// Call LLM with fallback chain if multiple candidates are configured.
|
||||||
var response *providers.LLMResponse
|
var response *providers.LLMResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
llmOpts := map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": agent.Temperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
}
|
||||||
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
||||||
|
// so checking != ThinkingOff is sufficient.
|
||||||
|
if agent.ThinkingLevel != ThinkingOff {
|
||||||
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||||
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||||
|
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callLLM := func() (*providers.LLMResponse, error) {
|
callLLM := func() (*providers.LLMResponse, error) {
|
||||||
if len(agent.Candidates) > 1 && al.fallback != nil {
|
if len(activeCandidates) > 1 && al.fallback != nil {
|
||||||
fbResult, fbErr := al.fallback.Execute(
|
fbResult, fbErr := al.fallback.Execute(
|
||||||
ctx,
|
ctx,
|
||||||
agent.Candidates,
|
activeCandidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
return agent.Provider.Chat(
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
ctx,
|
|
||||||
messages,
|
|
||||||
providerToolDefs,
|
|
||||||
model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if fbErr != nil {
|
if fbErr != nil {
|
||||||
|
|
@ -808,11 +967,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts)
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
|
|
@ -891,7 +1046,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
return "", iteration, nil, fmt.Errorf("LLM call failed after retries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
|
|
@ -977,62 +1132,76 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
// Save assistant message with tool calls to session
|
// Save assistant message with tool calls to session
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||||
|
|
||||||
// Execute tool calls
|
// Execute tool calls in parallel
|
||||||
for _, tc := range normalizedToolCalls {
|
type indexedAgentResult struct {
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
result *tools.ToolResult
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
tc providers.ToolCall
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
}
|
||||||
map[string]any{
|
|
||||||
"agent_id": agent.ID,
|
|
||||||
"tool": tc.Name,
|
|
||||||
"iteration": iteration,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncTool
|
agentResults := make([]indexedAgentResult, len(normalizedToolCalls))
|
||||||
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
|
var wg sync.WaitGroup
|
||||||
// Instead, they notify the agent via PublishInbound, and the agent decides
|
|
||||||
// whether to forward the result to the user (in processSystemMessage).
|
for i, tc := range normalizedToolCalls {
|
||||||
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
agentResults[i].tc = tc
|
||||||
// Log the async completion but don't send directly to user
|
|
||||||
// The agent will handle user notification via processSystemMessage
|
wg.Add(1)
|
||||||
if !result.Silent && result.ForUser != "" {
|
go func(idx int, tc providers.ToolCall) {
|
||||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
defer wg.Done()
|
||||||
map[string]any{
|
|
||||||
"tool": tc.Name,
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
"content_len": len(result.ForUser),
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
})
|
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"tool": tc.Name,
|
||||||
|
"iteration": iteration,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create async callback for tools that implement AsyncExecutor
|
||||||
|
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
||||||
|
if !result.Silent && result.ForUser != "" {
|
||||||
|
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
||||||
|
map[string]any{
|
||||||
|
"tool": tc.Name,
|
||||||
|
"content_len": len(result.ForUser),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
toolResult := agent.Tools.ExecuteWithContext(
|
toolResult := agent.Tools.ExecuteWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
tc.Name,
|
tc.Name,
|
||||||
tc.Arguments,
|
tc.Arguments,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
asyncCallback,
|
asyncCallback,
|
||||||
)
|
)
|
||||||
|
agentResults[idx].result = toolResult
|
||||||
|
}(i, tc)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Process results in original order (send to user, save to session)
|
||||||
|
for _, r := range agentResults {
|
||||||
// Send ForUser content to user immediately if not Silent
|
// Send ForUser content to user immediately if not Silent
|
||||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse {
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: toolResult.ForUser,
|
Content: r.result.ForUser,
|
||||||
})
|
})
|
||||||
logger.DebugCF("agent", "Sent tool result to user",
|
logger.DebugCF("agent", "Sent tool result to user",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": tc.Name,
|
"tool": r.tc.Name,
|
||||||
"content_len": len(toolResult.ForUser),
|
"content_len": len(r.result.ForUser),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tool returned media refs, publish them as outbound media
|
// If tool returned media refs, publish them as outbound media
|
||||||
if len(toolResult.Media) > 0 && opts.SendResponse {
|
if len(r.result.Media) > 0 {
|
||||||
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
parts := make([]bus.MediaPart, 0, len(r.result.Media))
|
||||||
for _, ref := range toolResult.Media {
|
for _, ref := range r.result.Media {
|
||||||
part := bus.MediaPart{Ref: ref}
|
part := bus.MediaPart{Ref: ref}
|
||||||
// Populate metadata from MediaStore when available
|
|
||||||
if al.mediaStore != nil {
|
if al.mediaStore != nil {
|
||||||
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||||
part.Filename = meta.Filename
|
part.Filename = meta.Filename
|
||||||
|
|
@ -1050,15 +1219,15 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content for LLM based on tool result
|
// Determine content for LLM based on tool result
|
||||||
contentForLLM := toolResult.ForLLM
|
contentForLLM := r.result.ForLLM
|
||||||
if contentForLLM == "" && toolResult.Err != nil {
|
if contentForLLM == "" && r.result.Err != nil {
|
||||||
contentForLLM = toolResult.Err.Error()
|
contentForLLM = r.result.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: r.tc.ID,
|
||||||
}
|
}
|
||||||
messages = append(messages, toolResultMsg)
|
messages = append(messages, toolResultMsg)
|
||||||
|
|
||||||
|
|
@ -1067,36 +1236,54 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalContent, iteration, nil
|
return finalContent, iteration, executedTools, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
// selectCandidates returns the model candidates and resolved model name to use
|
||||||
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
// for a conversation turn. When model routing is configured and the incoming
|
||||||
// Use ContextualTool interface instead of type assertions
|
// message scores below the complexity threshold, it returns the light model
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
// candidates instead of the primary ones.
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
//
|
||||||
mt.SetContext(channel, chatID)
|
// The returned (candidates, model) pair is used for all LLM calls within one
|
||||||
}
|
// turn — tool follow-up iterations use the same tier as the initial call so
|
||||||
|
// that a multi-step tool chain doesn't switch models mid-way.
|
||||||
|
func (al *AgentLoop) selectCandidates(
|
||||||
|
agent *AgentInstance,
|
||||||
|
userMsg string,
|
||||||
|
history []providers.Message,
|
||||||
|
) (candidates []providers.FallbackCandidate, model string) {
|
||||||
|
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
}
|
}
|
||||||
if tool, ok := agent.Tools.Get("spawn"); ok {
|
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
st.SetContext(channel, chatID)
|
if !usedLight {
|
||||||
}
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
}
|
map[string]any{
|
||||||
if tool, ok := agent.Tools.Get("subagent"); ok {
|
"agent_id": agent.ID,
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
"score": score,
|
||||||
st.SetContext(channel, chatID)
|
"threshold": agent.Router.Threshold(),
|
||||||
}
|
})
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Model routing: light model selected",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"light_model": agent.Router.LightModel(),
|
||||||
|
"score": score,
|
||||||
|
"threshold": agent.Router.Threshold(),
|
||||||
|
})
|
||||||
|
return agent.LightCandidates, agent.Router.LightModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
newHistory := agent.Sessions.GetHistory(sessionKey)
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
tokenEstimate := al.estimateTokens(newHistory)
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
threshold := agent.ContextWindow * 75 / 100
|
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
||||||
|
|
||||||
if len(newHistory) > 20 || tokenEstimate > threshold {
|
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
|
||||||
summarizeKey := agent.ID + ":" + sessionKey
|
summarizeKey := agent.ID + ":" + sessionKey
|
||||||
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -1381,94 +1568,87 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
return totalChars * 2 / 5
|
return totalChars * 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
func (al *AgentLoop) handleCommand(
|
||||||
content := strings.TrimSpace(msg.Content)
|
ctx context.Context,
|
||||||
if !strings.HasPrefix(content, "/") {
|
msg bus.InboundMessage,
|
||||||
|
agent *AgentInstance,
|
||||||
|
) (string, bool) {
|
||||||
|
if !commands.HasCommandPrefix(msg.Content) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Fields(content)
|
if al.cmdRegistry == nil {
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := parts[0]
|
rt := al.buildCommandsRuntime(agent)
|
||||||
args := parts[1:]
|
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||||
|
|
||||||
switch cmd {
|
var commandReply string
|
||||||
case "/show":
|
result := executor.Execute(ctx, commands.Request{
|
||||||
if len(args) < 1 {
|
Channel: msg.Channel,
|
||||||
return "Usage: /show [model|channel|agents]", true
|
ChatID: msg.ChatID,
|
||||||
}
|
SenderID: msg.SenderID,
|
||||||
switch args[0] {
|
Text: msg.Content,
|
||||||
case "model":
|
Reply: func(text string) error {
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
commandReply = text
|
||||||
if defaultAgent == nil {
|
return nil
|
||||||
return "No default agent configured", true
|
},
|
||||||
}
|
})
|
||||||
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
|
||||||
case "channel":
|
|
||||||
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/list":
|
switch result.Outcome {
|
||||||
if len(args) < 1 {
|
case commands.OutcomeHandled:
|
||||||
return "Usage: /list [models|channels|agents]", true
|
if result.Err != nil {
|
||||||
|
return mapCommandError(result), true
|
||||||
}
|
}
|
||||||
switch args[0] {
|
if commandReply != "" {
|
||||||
case "models":
|
return commandReply, true
|
||||||
return "Available models: configured in config.json per agent", true
|
}
|
||||||
case "channels":
|
return "", true
|
||||||
|
default: // OutcomePassthrough — let the message fall through to LLM
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime {
|
||||||
|
rt := &commands.Runtime{
|
||||||
|
Config: al.cfg,
|
||||||
|
ListAgentIDs: al.registry.ListAgentIDs,
|
||||||
|
ListDefinitions: al.cmdRegistry.Definitions,
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return nil
|
||||||
}
|
}
|
||||||
channels := al.channelManager.GetEnabledChannels()
|
return al.channelManager.GetEnabledChannels()
|
||||||
if len(channels) == 0 {
|
},
|
||||||
return "No channels enabled", true
|
SwitchChannel: func(value string) error {
|
||||||
}
|
|
||||||
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/switch":
|
|
||||||
if len(args) < 3 || args[1] != "to" {
|
|
||||||
return "Usage: /switch [model|channel] to <name>", true
|
|
||||||
}
|
|
||||||
target := args[0]
|
|
||||||
value := args[2]
|
|
||||||
|
|
||||||
switch target {
|
|
||||||
case "model":
|
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
|
||||||
if defaultAgent == nil {
|
|
||||||
return "No default agent configured", true
|
|
||||||
}
|
|
||||||
oldModel := defaultAgent.Model
|
|
||||||
defaultAgent.Model = value
|
|
||||||
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
|
||||||
case "channel":
|
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return fmt.Errorf("channel manager not initialized")
|
||||||
}
|
}
|
||||||
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
||||||
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
return fmt.Errorf("channel '%s' not found or not enabled", value)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Switched target channel to %s", value), true
|
return nil
|
||||||
default:
|
},
|
||||||
return fmt.Sprintf("Unknown switch target: %s", target), true
|
}
|
||||||
|
if agent != nil {
|
||||||
|
rt.GetModelInfo = func() (string, string) {
|
||||||
|
return agent.Model, al.cfg.Agents.Defaults.Provider
|
||||||
|
}
|
||||||
|
rt.SwitchModel = func(value string) (string, error) {
|
||||||
|
oldModel := agent.Model
|
||||||
|
agent.Model = value
|
||||||
|
return oldModel, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
return "", false
|
func mapCommandError(result commands.ExecuteResult) string {
|
||||||
|
if result.Command == "" {
|
||||||
|
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
|
|
@ -1487,10 +1667,17 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboundMetadata(msg bus.InboundMessage, key string) string {
|
||||||
|
if msg.Metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg.Metadata[key]
|
||||||
|
}
|
||||||
|
|
||||||
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
parentKind := msg.Metadata["parent_peer_kind"]
|
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
||||||
parentID := msg.Metadata["parent_peer_id"]
|
parentID := inboundMetadata(msg, metadataKeyParentPeerID)
|
||||||
if parentKind == "" || parentID == "" {
|
if parentKind == "" || parentID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -164,35 +165,21 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
// TestToolContext_Updates verifies tool context helpers work correctly
|
||||||
func TestToolContext_Updates(t *testing.T) {
|
func TestToolContext_Updates(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
if got := tools.ToolChannel(ctx); got != "telegram" {
|
||||||
Agents: config.AgentsConfig{
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
Defaults: config.AgentDefaults{
|
}
|
||||||
Workspace: tmpDir,
|
if got := tools.ToolChatID(ctx); got != "chat-42" {
|
||||||
Model: "test-model",
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
// Empty context returns empty strings
|
||||||
provider := &simpleMockProvider{response: "OK"}
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
|
}
|
||||||
// Verify that ContextualTool interface is defined and can be implemented
|
|
||||||
// This test validates the interface contract exists
|
|
||||||
ctxTool := &mockContextualTool{}
|
|
||||||
|
|
||||||
// Verify the tool implements the interface correctly
|
|
||||||
var _ tools.ContextualTool = ctxTool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
@ -241,16 +228,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := config.DefaultConfig()
|
||||||
Agents: config.AgentsConfig{
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
Defaults: config.AgentDefaults{
|
cfg.Agents.Defaults.Model = "test-model"
|
||||||
Workspace: tmpDir,
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
Model: "test-model",
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
@ -337,6 +319,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingMockProvider struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
|
return "counting-mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
// mockCustomTool is a simple mock tool for registration testing
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
type mockCustomTool struct{}
|
||||||
|
|
||||||
|
|
@ -359,36 +364,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
||||||
return tools.SilentResult("Custom tool executed")
|
return tools.SilentResult("Custom tool executed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockContextualTool tracks context updates
|
|
||||||
type mockContextualTool struct {
|
|
||||||
lastChannel string
|
|
||||||
lastChatID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Name() string {
|
|
||||||
return "mock_contextual"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Description() string {
|
|
||||||
return "Mock contextual tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
|
||||||
return tools.SilentResult("Contextual tool executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
|
||||||
m.lastChannel = channel
|
|
||||||
m.lastChatID = chatID
|
|
||||||
}
|
|
||||||
|
|
||||||
// testHelper executes a message and returns the response
|
// testHelper executes a message and returns the response
|
||||||
type testHelper struct {
|
type testHelper struct {
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
@ -408,6 +383,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
|
||||||
|
|
||||||
const responseTimeout = 3 * time.Second
|
const responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
})
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
_ = helper.executeAndGetResponse(t, context.Background(), msg)
|
||||||
|
|
||||||
|
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected session history len=2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Fatalf("unexpected first message in session: %+v", history[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
baseMsg := bus.InboundMessage{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/show channel",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if showResp != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("unexpected /show reply: %q", showResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/foo",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if fooResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 1 {
|
||||||
|
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/new",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if newResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /new reply: %q", newResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "before-switch",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/switch model to after-switch",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
|
||||||
|
t.Fatalf("unexpected /switch reply: %q", switchResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/show model",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
|
||||||
|
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
|
@ -100,6 +101,19 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForEachTool calls fn for every tool registered under the given name
|
||||||
|
// across all agents. This is useful for propagating dependencies (e.g.
|
||||||
|
// MediaStore) to tools after registry construction.
|
||||||
|
func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if t, ok := agent.Tools.Get(name); ok {
|
||||||
|
fn(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
39
pkg/agent/thinking.go
Normal file
39
pkg/agent/thinking.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ThinkingLevel controls how the provider sends thinking parameters.
|
||||||
|
//
|
||||||
|
// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
|
||||||
|
// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
|
||||||
|
// - "off": disables thinking
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThinkingOff ThinkingLevel = "off"
|
||||||
|
ThinkingLow ThinkingLevel = "low"
|
||||||
|
ThinkingMedium ThinkingLevel = "medium"
|
||||||
|
ThinkingHigh ThinkingLevel = "high"
|
||||||
|
ThinkingXHigh ThinkingLevel = "xhigh"
|
||||||
|
ThinkingAdaptive ThinkingLevel = "adaptive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
||||||
|
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
||||||
|
// Returns ThinkingOff for unknown or empty values.
|
||||||
|
func parseThinkingLevel(level string) ThinkingLevel {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "adaptive":
|
||||||
|
return ThinkingAdaptive
|
||||||
|
case "low":
|
||||||
|
return ThinkingLow
|
||||||
|
case "medium":
|
||||||
|
return ThinkingMedium
|
||||||
|
case "high":
|
||||||
|
return ThinkingHigh
|
||||||
|
case "xhigh":
|
||||||
|
return ThinkingXHigh
|
||||||
|
default:
|
||||||
|
return ThinkingOff
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pkg/agent/thinking_test.go
Normal file
35
pkg/agent/thinking_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseThinkingLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want ThinkingLevel
|
||||||
|
}{
|
||||||
|
{"off", "off", ThinkingOff},
|
||||||
|
{"empty", "", ThinkingOff},
|
||||||
|
{"low", "low", ThinkingLow},
|
||||||
|
{"medium", "medium", ThinkingMedium},
|
||||||
|
{"high", "high", ThinkingHigh},
|
||||||
|
{"xhigh", "xhigh", ThinkingXHigh},
|
||||||
|
{"adaptive", "adaptive", ThinkingAdaptive},
|
||||||
|
{"unknown", "unknown", ThinkingOff},
|
||||||
|
// Case-insensitive and whitespace-tolerant
|
||||||
|
{"upper_Medium", "Medium", ThinkingMedium},
|
||||||
|
{"upper_HIGH", "HIGH", ThinkingHigh},
|
||||||
|
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
||||||
|
{"leading_space", " high", ThinkingHigh},
|
||||||
|
{"trailing_space", "low ", ThinkingLow},
|
||||||
|
{"both_spaces", " medium ", ThinkingMedium},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseThinkingLevel(tt.input); got != tt.want {
|
||||||
|
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pkg/auth/anthropic_usage.go
Normal file
71
pkg/auth/anthropic_usage.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicBetaHeader = "oauth-2025-04-20"
|
||||||
|
anthropicAPIVersion = "2023-06-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
|
||||||
|
// It is a var (not const) to allow overriding in tests.
|
||||||
|
var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
|
||||||
|
func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
|
||||||
|
|
||||||
|
type AnthropicUsage struct {
|
||||||
|
FiveHourUtilization float64
|
||||||
|
SevenDayUtilization float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
|
||||||
|
req, err := http.NewRequest("GET", anthropicUsageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Anthropic-Version", anthropicAPIVersion)
|
||||||
|
req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
FiveHour struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"five_hour"`
|
||||||
|
SevenDay struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"seven_day"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnthropicUsage{
|
||||||
|
FiveHourUtilization: result.FiveHour.Utilization,
|
||||||
|
SevenDayUtilization: result.SevenDay.Utilization,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
98
pkg/auth/anthropic_usage_test.go
Normal file
98
pkg/auth/anthropic_usage_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
||||||
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Temporarily override the URL by using the test server
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
usage, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.FiveHourUtilization != 0.42 {
|
||||||
|
t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
|
||||||
|
}
|
||||||
|
if usage.SevenDayUtilization != 0.85 {
|
||||||
|
t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":"forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "insufficient scope") {
|
||||||
|
t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`internal error`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error containing '500', got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parsing usage response") {
|
||||||
|
t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -212,7 +212,10 @@ func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
return nil, fmt.Errorf("pending")
|
return nil, fmt.Errorf("pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
AuthorizationCode string `json:"authorization_code"`
|
||||||
|
|
@ -401,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +506,10 @@ func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFilePath() string {
|
func authFilePath() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return filepath.Join(home, "auth.json")
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
|
||||||
|
fmt.Println("Paste your setup token from `claude setup-token`:")
|
||||||
|
fmt.Print("> ")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no input received")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, "sk-ant-oat01-") {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 80 {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
61
pkg/auth/token_test.go
Normal file
61
pkg/auth/token_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginSetupToken(t *testing.T) {
|
||||||
|
// A valid token: correct prefix + at least 80 chars
|
||||||
|
validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"valid token", validToken, ""},
|
||||||
|
{"empty input", "", "expected prefix sk-ant-oat01-"},
|
||||||
|
{"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
|
||||||
|
{"too short", "sk-ant-oat01-short", "too short"},
|
||||||
|
{"whitespace only", " ", "expected prefix sk-ant-oat01-"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := strings.NewReader(tt.input + "\n")
|
||||||
|
cred, err := LoginSetupToken(r)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cred.AccessToken != validToken {
|
||||||
|
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
|
||||||
|
}
|
||||||
|
if cred.Provider != "anthropic" {
|
||||||
|
t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
|
||||||
|
}
|
||||||
|
if cred.AuthMethod != "oauth" {
|
||||||
|
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSetupToken_EmptyReader(t *testing.T) {
|
||||||
|
r := strings.NewReader("")
|
||||||
|
_, err := LoginSetupToken(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty reader, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,16 @@ package discord
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -23,6 +27,12 @@ const (
|
||||||
sendTimeout = 10 * time.Second
|
sendTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
|
||||||
|
channelRefRe = regexp.MustCompile(`<#(\d+)>`)
|
||||||
|
msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
|
||||||
|
)
|
||||||
|
|
||||||
type DiscordChannel struct {
|
type DiscordChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
|
|
@ -40,6 +50,9 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := applyDiscordProxy(session, cfg.Proxy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
|
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
|
||||||
channels.WithMaxMessageLength(2000),
|
channels.WithMaxMessageLength(2000),
|
||||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
|
@ -332,6 +345,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
content = c.stripBotMention(content)
|
content = c.stripBotMention(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve Discord refs in main content before concatenation to avoid
|
||||||
|
// double-expanding links that appear in the referenced message.
|
||||||
|
content = c.resolveDiscordRefs(s, content, m.GuildID)
|
||||||
|
|
||||||
|
// Prepend referenced (quoted) message content if this is a reply
|
||||||
|
if m.MessageReference != nil && m.ReferencedMessage != nil {
|
||||||
|
refContent := m.ReferencedMessage.Content
|
||||||
|
if refContent != "" {
|
||||||
|
refAuthor := "unknown"
|
||||||
|
if m.ReferencedMessage.Author != nil {
|
||||||
|
refAuthor = m.ReferencedMessage.Author.Username
|
||||||
|
}
|
||||||
|
refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
|
||||||
|
content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
|
||||||
|
refAuthor, refContent, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
senderID := m.Author.ID
|
senderID := m.Author.ID
|
||||||
|
|
||||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||||
|
|
@ -465,9 +496,88 @@ func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func()
|
||||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
LoggerPrefix: "discord",
|
LoggerPrefix: "discord",
|
||||||
|
ProxyURL: c.config.Proxy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
||||||
|
var proxyFunc func(*http.Request) (*url.URL, error)
|
||||||
|
if proxyAddr != "" {
|
||||||
|
proxyURL, err := url.Parse(proxyAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err)
|
||||||
|
}
|
||||||
|
proxyFunc = http.ProxyURL(proxyURL)
|
||||||
|
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
|
||||||
|
proxyFunc = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxyFunc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{Proxy: proxyFunc}
|
||||||
|
session.Client = &http.Client{
|
||||||
|
Timeout: sendTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Dialer != nil {
|
||||||
|
dialerCopy := *session.Dialer
|
||||||
|
dialerCopy.Proxy = proxyFunc
|
||||||
|
session.Dialer = &dialerCopy
|
||||||
|
} else {
|
||||||
|
session.Dialer = &websocket.Dialer{Proxy: proxyFunc}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
|
||||||
|
// expands Discord message links to show the linked message content.
|
||||||
|
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
|
||||||
|
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
|
||||||
|
// 1. Resolve channel references: <#id> → #channel-name
|
||||||
|
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
parts := channelRefRe.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// Prefer session state cache to avoid API calls
|
||||||
|
if ch, err := s.State.Channel(parts[1]); err == nil {
|
||||||
|
return "#" + ch.Name
|
||||||
|
}
|
||||||
|
if ch, err := s.Channel(parts[1]); err == nil {
|
||||||
|
return "#" + ch.Name
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Expand Discord message links (max 3, same guild only)
|
||||||
|
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
linkGuildID, channelID, messageID := m[1], m[2], m[3]
|
||||||
|
// Security: only expand links from the same guild
|
||||||
|
if linkGuildID != guildID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, err := s.ChannelMessage(channelID, messageID)
|
||||||
|
if err != nil || msg == nil || msg.Content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
author := "unknown"
|
||||||
|
if msg.Author != nil {
|
||||||
|
author = msg.Author.Username
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
// stripBotMention removes the bot mention from the message content.
|
// stripBotMention removes the bot mention from the message content.
|
||||||
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
||||||
func (c *DiscordChannel) stripBotMention(text string) string {
|
func (c *DiscordChannel) stripBotMention(text string) string {
|
||||||
|
|
|
||||||
98
pkg/channels/discord/discord_resolve_test.go
Normal file
98
pkg/channels/discord/discord_resolve_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChannelRefRegex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantID string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"basic channel ref", "<#123456789>", "123456789", true},
|
||||||
|
{"long id", "<#9876543210123456>", "9876543210123456", true},
|
||||||
|
{"no match plain text", "hello world", "", false},
|
||||||
|
{"no match partial", "<#>", "", false},
|
||||||
|
{"no match letters", "<#abc>", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := channelRefRe.FindStringSubmatch(tt.input)
|
||||||
|
if tt.wantOK {
|
||||||
|
if len(matches) < 2 || matches[1] != tt.wantID {
|
||||||
|
t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgLinkRegex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantGuild string
|
||||||
|
wantChan string
|
||||||
|
wantMsg string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"discord.com link",
|
||||||
|
"https://discord.com/channels/111/222/333",
|
||||||
|
"111", "222", "333", true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"discordapp.com link",
|
||||||
|
"https://discordapp.com/channels/111/222/333",
|
||||||
|
"111", "222", "333", true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"real world ids",
|
||||||
|
"check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please",
|
||||||
|
"9000000000000001", "9000000000000002", "9000000000000003", true,
|
||||||
|
},
|
||||||
|
{"no match http", "http://discord.com/channels/1/2/3", "", "", "", false},
|
||||||
|
{"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false},
|
||||||
|
{"no match plain text", "hello world", "", "", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := msgLinkRe.FindStringSubmatch(tt.input)
|
||||||
|
if tt.wantOK {
|
||||||
|
if len(matches) < 4 {
|
||||||
|
t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s",
|
||||||
|
tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg)
|
||||||
|
}
|
||||||
|
if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg {
|
||||||
|
t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s",
|
||||||
|
tt.input, matches[1], matches[2], matches[3],
|
||||||
|
tt.wantGuild, tt.wantChan, tt.wantMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgLinkRegex_MultipleMatches(t *testing.T) {
|
||||||
|
input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12"
|
||||||
|
matches := msgLinkRe.FindAllStringSubmatch(input, 3)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
t.Fatalf("expected 3 matches (capped), got %d", len(matches))
|
||||||
|
}
|
||||||
|
// Verify the 3rd match is 7/8/9 (not 10/11/12)
|
||||||
|
if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" {
|
||||||
|
t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
91
pkg/channels/discord/discord_test.go
Normal file
91
pkg/channels/discord/discord_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyDiscordProxy_CustomProxy(t *testing.T) {
|
||||||
|
session, err := discordgo.New("Bot test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discordgo.New() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = applyDiscordProxy(session, "http://127.0.0.1:7890"); err != nil {
|
||||||
|
t.Fatalf("applyDiscordProxy() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("http.NewRequest() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restProxy := session.Client.Transport.(*http.Transport).Proxy
|
||||||
|
restProxyURL, err := restProxy(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rest proxy func error: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := restProxyURL.String(), "http://127.0.0.1:7890"; got != want {
|
||||||
|
t.Fatalf("REST proxy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsProxyURL, err := session.Dialer.Proxy(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ws proxy func error: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := wsProxyURL.String(), "http://127.0.0.1:7890"; got != want {
|
||||||
|
t.Fatalf("WS proxy = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDiscordProxy_FromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
|
||||||
|
t.Setenv("http_proxy", "http://127.0.0.1:8888")
|
||||||
|
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
|
||||||
|
t.Setenv("https_proxy", "http://127.0.0.1:8888")
|
||||||
|
t.Setenv("ALL_PROXY", "")
|
||||||
|
t.Setenv("all_proxy", "")
|
||||||
|
t.Setenv("NO_PROXY", "")
|
||||||
|
t.Setenv("no_proxy", "")
|
||||||
|
|
||||||
|
session, err := discordgo.New("Bot test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discordgo.New() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = applyDiscordProxy(session, ""); err != nil {
|
||||||
|
t.Fatalf("applyDiscordProxy() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("http.NewRequest() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotURL, err := session.Dialer.Proxy(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ws proxy func error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantURL, err := url.Parse("http://127.0.0.1:8888")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error: %v", err)
|
||||||
|
}
|
||||||
|
if gotURL.String() != wantURL.String() {
|
||||||
|
t.Fatalf("WS proxy = %q, want %q", gotURL.String(), wantURL.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) {
|
||||||
|
session, err := discordgo.New("Bot test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discordgo.New() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = applyDiscordProxy(session, "://bad-proxy"); err == nil {
|
||||||
|
t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
// TypingCapable — channels that can show a typing/thinking indicator.
|
// TypingCapable — channels that can show a typing/thinking indicator.
|
||||||
// StartTyping begins the indicator and returns a stop function.
|
// StartTyping begins the indicator and returns a stop function.
|
||||||
|
|
@ -39,3 +43,10 @@ type PlaceholderRecorder interface {
|
||||||
RecordTypingStop(channel, chatID string, stop func())
|
RecordTypingStop(channel, chatID string, stop func())
|
||||||
RecordReactionUndo(channel, chatID string, undo func())
|
RecordReactionUndo(channel, chatID string, undo func())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandRegistrarCapable is implemented by channels that can register
|
||||||
|
// command menus with their upstream platform (e.g. Telegram BotCommand).
|
||||||
|
// Channels that do not support platform-level command menus can ignore it.
|
||||||
|
type CommandRegistrarCapable interface {
|
||||||
|
RegisterCommands(ctx context.Context, defs []commands.Definition) error
|
||||||
|
}
|
||||||
|
|
|
||||||
16
pkg/channels/interfaces_command_test.go
Normal file
16
pkg/channels/interfaces_command_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockRegistrar struct{}
|
||||||
|
|
||||||
|
func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil }
|
||||||
|
|
||||||
|
func TestCommandRegistrarCapable_Compiles(t *testing.T) {
|
||||||
|
var _ CommandRegistrarCapable = mockRegistrar{}
|
||||||
|
}
|
||||||
|
|
@ -654,7 +654,10 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
|
||||||
|
}
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
116
pkg/channels/telegram/command_registration.go
Normal file
116
pkg/channels/telegram/command_registration.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var commandRegistrationBackoff = []time.Duration{
|
||||||
|
5 * time.Second,
|
||||||
|
15 * time.Second,
|
||||||
|
60 * time.Second,
|
||||||
|
5 * time.Minute,
|
||||||
|
10 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandRegistrationDelay(attempt int) time.Duration {
|
||||||
|
if len(commandRegistrationBackoff) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)]
|
||||||
|
// Full jitter in [0.5, 1.0) to avoid synchronized retries across instances.
|
||||||
|
return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCommands registers bot commands on Telegram platform.
|
||||||
|
func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {
|
||||||
|
botCommands := make([]telego.BotCommand, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == "" || def.Description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
botCommands = append(botCommands, telego.BotCommand{
|
||||||
|
Command: def.Name,
|
||||||
|
Description: def.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})
|
||||||
|
if err != nil {
|
||||||
|
// If we can't read current commands, fall through to set them.
|
||||||
|
logger.WarnCF("telegram", "Failed to get current commands, will set unconditionally",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
} else if slices.Equal(current, botCommands) {
|
||||||
|
logger.DebugCF("telegram", "Bot commands are up to date", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
||||||
|
Commands: botCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
register := c.registerFunc
|
||||||
|
if register == nil {
|
||||||
|
register = c.RegisterCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
regCtx, cancel := context.WithCancel(ctx)
|
||||||
|
c.commandRegCancel = cancel
|
||||||
|
|
||||||
|
// Registration runs asynchronously so Telegram message intake is never blocked
|
||||||
|
// by temporary upstream API failures. Retry stops on success or channel shutdown.
|
||||||
|
go func() {
|
||||||
|
attempt := 0
|
||||||
|
timer := time.NewTimer(0)
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
err := register(regCtx, defs)
|
||||||
|
if err == nil {
|
||||||
|
logger.InfoCF("telegram", "Telegram commands registered", map[string]any{
|
||||||
|
"count": len(defs),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := commandRegistrationDelay(attempt)
|
||||||
|
logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"retry_after": delay.String(),
|
||||||
|
})
|
||||||
|
attempt++
|
||||||
|
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(delay)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-regCtx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
96
pkg/channels/telegram/command_registration_test.go
Normal file
96
pkg/channels/telegram/command_registration_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_DoesNotBlock(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
started := make(chan struct{}, 1)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
started <- struct{}{}
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("registration did not start asynchronously")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
n := attempts.Add(1)
|
||||||
|
if n < 3 {
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if attempts.Load() >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if attempts.Load() < 3 {
|
||||||
|
t.Fatalf("expected at least 3 attempts, got %d", attempts.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
attempts.Add(1)
|
||||||
|
return errors.New("always fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -18,6 +17,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -40,13 +40,15 @@ var (
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
bh *th.BotHandler
|
bh *th.BotHandler
|
||||||
commands TelegramCommander
|
config *config.Config
|
||||||
config *config.Config
|
chatIDs map[string]int64
|
||||||
chatIDs map[string]int64
|
ctx context.Context
|
||||||
ctx context.Context
|
cancel context.CancelFunc
|
||||||
cancel context.CancelFunc
|
|
||||||
|
registerFunc func(context.Context, []commands.Definition) error
|
||||||
|
commandRegCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
|
|
@ -93,7 +95,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
commands: NewTelegramCommands(bot, cfg),
|
|
||||||
bot: bot,
|
bot: bot,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
|
|
@ -105,12 +106,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
if err := c.initBotCommands(c.ctx); err != nil {
|
|
||||||
logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
})
|
})
|
||||||
|
|
@ -126,21 +121,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.bh = bh
|
c.bh = bh
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Start(ctx, message)
|
|
||||||
}, th.CommandEqual("start"))
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Help(ctx, message)
|
|
||||||
}, th.CommandEqual("help"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Show(ctx, message)
|
|
||||||
}, th.CommandEqual("show"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.List(ctx, message)
|
|
||||||
}, th.CommandEqual("list"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
return c.handleMessage(ctx, &message)
|
return c.handleMessage(ctx, &message)
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
@ -150,6 +130,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
"username": c.bot.Username(),
|
"username": c.bot.Username(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err = bh.Start(); err != nil {
|
if err = bh.Start(); err != nil {
|
||||||
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
||||||
|
|
@ -174,50 +156,8 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
if c.commandRegCancel != nil {
|
||||||
return nil
|
c.commandRegCancel()
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) initBotCommands(ctx context.Context) error {
|
|
||||||
currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{
|
|
||||||
Scope: tu.ScopeDefault(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get commands: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
commands := []telego.BotCommand{
|
|
||||||
{
|
|
||||||
Command: "start",
|
|
||||||
Description: "Start the bot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "help",
|
|
||||||
Description: "Show a help message",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "show",
|
|
||||||
Description: "Show current configuration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "list",
|
|
||||||
Description: "List available options",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed
|
|
||||||
if !slices.Equal(currentCommands, commands) {
|
|
||||||
logger.InfoC("telegram", "Updating bot commands")
|
|
||||||
|
|
||||||
err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
|
||||||
Commands: commands,
|
|
||||||
Scope: tu.ScopeDefault(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set commands: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.DebugC("telegram", "Bot commands are up to date")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -721,34 +661,34 @@ func escapeHTML(text string) string {
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
botUsername := c.bot.Username()
|
text, entities := telegramEntityTextAndList(message)
|
||||||
if botUsername == "" {
|
if text == "" || len(entities) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
entities := message.Entities
|
botUsername := ""
|
||||||
if entities == nil {
|
if c.bot != nil {
|
||||||
entities = message.CaptionEntities
|
botUsername = c.bot.Username()
|
||||||
}
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
|
||||||
for _, entity := range entities {
|
for _, entity := range entities {
|
||||||
if entity.Type == "mention" {
|
entityText, ok := telegramEntityText(runes, entity)
|
||||||
// Extract the mention text from the message
|
if !ok {
|
||||||
text := message.Text
|
continue
|
||||||
if text == "" {
|
|
||||||
text = message.Caption
|
|
||||||
}
|
|
||||||
runes := []rune(text)
|
|
||||||
end := entity.Offset + entity.Length
|
|
||||||
if end <= len(runes) {
|
|
||||||
mention := string(runes[entity.Offset:end])
|
|
||||||
if strings.EqualFold(mention, "@"+botUsername) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if entity.Type == "text_mention" && entity.User != nil {
|
|
||||||
if entity.User.Username == botUsername {
|
switch entity.Type {
|
||||||
|
case telego.EntityTypeMention:
|
||||||
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeTextMention:
|
||||||
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeBotCommand:
|
||||||
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +696,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
||||||
|
if message.Text != "" {
|
||||||
|
return message.Text, message.Entities
|
||||||
|
}
|
||||||
|
return message.Caption, message.CaptionEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
||||||
|
if entity.Offset < 0 || entity.Length <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
end := entity.Offset + entity.Length
|
||||||
|
if entity.Offset >= len(runes) || end > len(runes) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(runes[entity.Offset:end]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
||||||
|
if !strings.HasPrefix(entityText, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
command := strings.TrimPrefix(entityText, "/")
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
at := strings.IndexRune(command, '@')
|
||||||
|
if at == -1 {
|
||||||
|
// A bare /command delivered to this bot is intended for this bot.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionUsername := command[at+1:]
|
||||||
|
if mentionUsername == "" || botUsername == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(mentionUsername, botUsername)
|
||||||
|
}
|
||||||
|
|
||||||
// stripBotMention removes the @bot mention from the content.
|
// stripBotMention removes the @bot mention from the content.
|
||||||
func (c *TelegramChannel) stripBotMention(content string) string {
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
||||||
botUsername := c.bot.Username()
|
botUsername := c.bot.Username()
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
package telegram
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TelegramCommander interface {
|
|
||||||
Help(ctx context.Context, message telego.Message) error
|
|
||||||
Start(ctx context.Context, message telego.Message) error
|
|
||||||
Show(ctx context.Context, message telego.Message) error
|
|
||||||
List(ctx context.Context, message telego.Message) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmd struct {
|
|
||||||
bot *telego.Bot
|
|
||||||
config *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
|
|
||||||
return &cmd{
|
|
||||||
bot: bot,
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandArgs(text string) string {
|
|
||||||
parts := strings.SplitN(text, " ", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
|
||||||
msg := `/start - Start the bot
|
|
||||||
/help - Show this help message
|
|
||||||
/show [model|channel] - Show current configuration
|
|
||||||
/list [models|channels] - List available options
|
|
||||||
`
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: msg,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Start(ctx context.Context, message telego.Message) error {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Hello! I am PicoClaw 🦞",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /show [model|channel]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "model":
|
|
||||||
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
|
|
||||||
c.config.Agents.Defaults.GetModelName(),
|
|
||||||
c.config.Agents.Defaults.Provider)
|
|
||||||
case "channel":
|
|
||||||
response = "Current Channel: telegram"
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /list [models|channels]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "models":
|
|
||||||
provider := c.config.Agents.Defaults.Provider
|
|
||||||
if provider == "" {
|
|
||||||
provider = "configured default"
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
|
||||||
|
|
||||||
case "channels":
|
|
||||||
var enabled []string
|
|
||||||
if c.config.Channels.Telegram.Enabled {
|
|
||||||
enabled = append(enabled, "telegram")
|
|
||||||
}
|
|
||||||
if c.config.Channels.WhatsApp.Enabled {
|
|
||||||
enabled = append(enabled, "whatsapp")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Feishu.Enabled {
|
|
||||||
enabled = append(enabled, "feishu")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Discord.Enabled {
|
|
||||||
enabled = append(enabled, "discord")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Slack.Enabled {
|
|
||||||
enabled = append(enabled, "slack")
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
|
|
||||||
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "/new",
|
||||||
|
MessageID: 9,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "private",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 42,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "telegram" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getMeCaller struct {
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) {
|
||||||
|
if strings.HasSuffix(url, "/getMe") {
|
||||||
|
result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username)
|
||||||
|
return &ta.Response{Ok: true, Result: []byte(result)}, nil
|
||||||
|
}
|
||||||
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestTelegramBot(t *testing.T, username string) *telego.Bot {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := "123456:" + strings.Repeat("a", 35)
|
||||||
|
bot, err := telego.NewBot(token,
|
||||||
|
telego.WithAPICaller(getMeCaller{username: username}),
|
||||||
|
telego.WithDiscardLogger(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBot error: %v", err)
|
||||||
|
}
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil,
|
||||||
|
channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}),
|
||||||
|
),
|
||||||
|
bot: newTestTelegramBot(t, botUsername),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
return ch, messageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
wantForwarded bool
|
||||||
|
wantContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "command with bot username",
|
||||||
|
text: "/new@testbot",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare command",
|
||||||
|
text: "/new",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command for another bot",
|
||||||
|
text: "/new@otherbot",
|
||||||
|
wantForwarded: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ch, messageBus := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: tc.text,
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeBotCommand,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len([]rune(tc.text)),
|
||||||
|
}},
|
||||||
|
MessageID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if tc.wantForwarded {
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Content != tc.wantContent {
|
||||||
|
t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {
|
||||||
|
ch, _ := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "@testbot hello",
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeMention,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len("@testbot"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.isBotMentioned(msg) {
|
||||||
|
t.Fatal("expected mention entity to be treated as bot mention")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -793,7 +793,10 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode == http.StatusTooManyRequests:
|
case resp.StatusCode == http.StatusTooManyRequests:
|
||||||
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
||||||
|
|
|
||||||
|
|
@ -321,8 +321,17 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom upload error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom upload error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -371,8 +380,17 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom_app error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom_app API error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
|
|
@ -453,8 +453,17 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
|
if readErr != nil {
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading webhook error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("webhook API error: %s", string(body)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncomingMessage(map[string]any{
|
||||||
|
"type": "message",
|
||||||
|
"id": "mid1",
|
||||||
|
"from": "user1",
|
||||||
|
"chat": "chat1",
|
||||||
|
"content": "/help",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/help" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//go:build whatsapp_native
|
||||||
|
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
"go.mau.fi/whatsmeow/types/events"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppNativeChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
runCtx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := &events.Message{
|
||||||
|
Info: types.MessageInfo{
|
||||||
|
MessageSource: types.MessageSource{
|
||||||
|
Sender: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
Chat: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
},
|
||||||
|
ID: "mid1",
|
||||||
|
PushName: "Alice",
|
||||||
|
},
|
||||||
|
Message: &waE2E.Message{
|
||||||
|
Conversation: proto.String("/new"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncoming(evt)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp_native" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/commands/builtin.go
Normal file
16
pkg/commands/builtin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
// BuiltinDefinitions returns all built-in command definitions.
|
||||||
|
// Each command group is defined in its own cmd_*.go file.
|
||||||
|
// Definitions are stateless — runtime dependencies are provided
|
||||||
|
// via the Runtime parameter passed to handlers at execution time.
|
||||||
|
func BuiltinDefinitions() []Definition {
|
||||||
|
return []Definition{
|
||||||
|
startCommand(),
|
||||||
|
helpCommand(),
|
||||||
|
showCommand(),
|
||||||
|
listCommand(),
|
||||||
|
switchCommand(),
|
||||||
|
checkCommand(),
|
||||||
|
}
|
||||||
|
}
|
||||||
145
pkg/commands/builtin_test.go
Normal file
145
pkg/commands/builtin_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition {
|
||||||
|
t.Helper()
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == name {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing /%s definition", name)
|
||||||
|
return Definition{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
helpDef := findDefinitionByName(t, defs, "help")
|
||||||
|
if helpDef.Handler == nil {
|
||||||
|
t.Fatalf("/help handler should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
err := helpDef.Handler(context.Background(), Request{
|
||||||
|
Text: "/help",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("/help handler error: %v", err)
|
||||||
|
}
|
||||||
|
// Now uses auto-generated EffectiveUsage which includes agents
|
||||||
|
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||||
|
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "/list [models|channels|agents]") {
|
||||||
|
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
cases := []string{"telegram", "whatsapp"}
|
||||||
|
for _, channel := range cases {
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: channel,
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/show channel on %s: outcome=%v, want=%v", channel, res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Current Channel: " + channel
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("/show channel reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
|
return []string{"telegram", "slack"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/list channels: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") {
|
||||||
|
t.Fatalf("/list channels reply=%q, want telegram and slack", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListAgentIDs: func() []string {
|
||||||
|
return []string{"default", "coder"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show agents",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/show agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
|
||||||
|
t.Fatalf("/show agents reply=%q, want agent IDs", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListAgentIDs: func() []string {
|
||||||
|
return []string{"default", "coder"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/list agents",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/list agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
|
||||||
|
t.Fatalf("/list agents reply=%q, want agent IDs", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pkg/commands/cmd_check.go
Normal file
33
pkg/commands/cmd_check.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "check",
|
||||||
|
Description: "Check channel availability",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Check if a channel is available",
|
||||||
|
ArgsUsage: "<name>",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SwitchChannel == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
value := nthToken(req.Text, 2)
|
||||||
|
if value == "" {
|
||||||
|
return req.Reply("Usage: /check channel <name>")
|
||||||
|
}
|
||||||
|
if err := rt.SwitchChannel(value); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
44
pkg/commands/cmd_help.go
Normal file
44
pkg/commands/cmd_help.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func helpCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "help",
|
||||||
|
Description: "Show this help message",
|
||||||
|
Usage: "/help",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
var defs []Definition
|
||||||
|
if rt != nil && rt.ListDefinitions != nil {
|
||||||
|
defs = rt.ListDefinitions()
|
||||||
|
} else {
|
||||||
|
defs = BuiltinDefinitions()
|
||||||
|
}
|
||||||
|
return req.Reply(formatHelpMessage(defs))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHelpMessage(defs []Definition) string {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return "No commands available."
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
usage := def.EffectiveUsage()
|
||||||
|
if usage == "" {
|
||||||
|
usage = "/" + def.Name
|
||||||
|
}
|
||||||
|
desc := def.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "No description"
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s - %s", usage, desc))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
52
pkg/commands/cmd_list.go
Normal file
52
pkg/commands/cmd_list.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List available options",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "models",
|
||||||
|
Description: "Configured models",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
name, provider := rt.GetModelInfo()
|
||||||
|
if provider == "" {
|
||||||
|
provider = "configured default"
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf(
|
||||||
|
"Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
|
name, provider,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channels",
|
||||||
|
Description: "Enabled channels",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetEnabledChannels == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
enabled := rt.GetEnabledChannels()
|
||||||
|
if len(enabled) == 0 {
|
||||||
|
return req.Reply("No channels enabled")
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "agents",
|
||||||
|
Description: "Registered agents",
|
||||||
|
Handler: agentsHandler(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
38
pkg/commands/cmd_show.go
Normal file
38
pkg/commands/cmd_show.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func showCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "show",
|
||||||
|
Description: "Show current configuration",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "model",
|
||||||
|
Description: "Current model and provider",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
name, provider := rt.GetModelInfo()
|
||||||
|
return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Current channel",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "agents",
|
||||||
|
Description: "Registered agents",
|
||||||
|
Handler: agentsHandler(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
14
pkg/commands/cmd_start.go
Normal file
14
pkg/commands/cmd_start.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func startCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "start",
|
||||||
|
Description: "Start the bot",
|
||||||
|
Usage: "/start",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply("Hello! I am PicoClaw 🦞")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pkg/commands/cmd_switch.go
Normal file
42
pkg/commands/cmd_switch.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func switchCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "switch",
|
||||||
|
Description: "Switch model",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "model",
|
||||||
|
Description: "Switch to a different model",
|
||||||
|
ArgsUsage: "to <name>",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SwitchModel == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
// Parse: /switch model to <value>
|
||||||
|
value := nthToken(req.Text, 3) // tokens: [/switch, model, to, <value>]
|
||||||
|
if nthToken(req.Text, 2) != "to" || value == "" {
|
||||||
|
return req.Reply("Usage: /switch model to <name>")
|
||||||
|
}
|
||||||
|
oldModel, err := rt.SwitchModel(value)
|
||||||
|
if err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Moved to /check channel",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply("This command has moved. Please use: /check channel <name>")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
279
pkg/commands/cmd_switch_test.go
Normal file
279
pkg/commands/cmd_switch_test.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSwitchModel_Success(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old-model", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Switched model from old-model to gpt-4"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_MissingToKeyword(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /switch model to <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_MissingValue(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /switch model to <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_Error(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "", fmt.Errorf("model not found")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to bad-model",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "model not found" {
|
||||||
|
t.Fatalf("reply=%q, want error message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_NilDep(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Command unavailable in current context." {
|
||||||
|
t.Fatalf("reply=%q, want unavailable message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchChannel_Redirect(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch channel to telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "This command has moved. Please use: /check channel <name>"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_Success(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Channel 'telegram' is available and enabled"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_Error(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return fmt.Errorf("channel '%s' not found", value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel unknown",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "channel 'unknown' not found" {
|
||||||
|
t.Fatalf("reply=%q, want error message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_NilDep(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Command unavailable in current context." {
|
||||||
|
t.Fatalf("reply=%q, want unavailable message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_MissingValue(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /check channel <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitch_BangPrefix(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "!switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Switched model from old to gpt-4" {
|
||||||
|
t.Fatalf("! prefix: reply=%q, want success message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitch_NoSubCommand(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
// Should get usage message from executor's sub-command routing
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("expected usage reply for bare /switch")
|
||||||
|
}
|
||||||
|
}
|
||||||
48
pkg/commands/definition.go
Normal file
48
pkg/commands/definition.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubCommand defines a single sub-command within a parent command.
|
||||||
|
type SubCommand struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
ArgsUsage string // optional, e.g. "<session-id>"
|
||||||
|
Handler Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition is the single-source metadata and behavior contract for a slash command.
|
||||||
|
//
|
||||||
|
// Design notes (phase 1):
|
||||||
|
// - Every channel reads command shape from this type instead of keeping local copies.
|
||||||
|
// - Visibility is global: all definitions are considered available to all channels.
|
||||||
|
// - Platform menu registration (for example Telegram BotCommand) also derives from this
|
||||||
|
// same definition so UI labels and runtime behavior stay aligned.
|
||||||
|
type Definition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Usage string // for simple commands; ignored when SubCommands is set
|
||||||
|
Aliases []string
|
||||||
|
SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers
|
||||||
|
Handler Handler // for simple commands without sub-commands
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveUsage returns the usage string. When SubCommands are present,
|
||||||
|
// it is auto-generated from sub-command names so metadata and behavior
|
||||||
|
// cannot drift.
|
||||||
|
func (d Definition) EffectiveUsage() string {
|
||||||
|
if len(d.SubCommands) == 0 {
|
||||||
|
return d.Usage
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(d.SubCommands))
|
||||||
|
for _, sc := range d.SubCommands {
|
||||||
|
name := sc.Name
|
||||||
|
if sc.ArgsUsage != "" {
|
||||||
|
name += " " + sc.ArgsUsage
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/%s [%s]", d.Name, strings.Join(names, "|"))
|
||||||
|
}
|
||||||
41
pkg/commands/definition_test.go
Normal file
41
pkg/commands/definition_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) {
|
||||||
|
d := Definition{Name: "start", Usage: "/start"}
|
||||||
|
if got := d.EffectiveUsage(); got != "/start" {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, "/start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) {
|
||||||
|
d := Definition{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
{Name: "channel"},
|
||||||
|
{Name: "agents"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := "/show [model|channel|agents]"
|
||||||
|
if got := d.EffectiveUsage(); got != want {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) {
|
||||||
|
d := Definition{
|
||||||
|
Name: "session",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "list"},
|
||||||
|
{Name: "resume", ArgsUsage: "<id>"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := "/session [list|resume <id>]"
|
||||||
|
if got := d.EffectiveUsage(); got != want {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
pkg/commands/executor.go
Normal file
89
pkg/commands/executor.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Outcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OutcomePassthrough means this input should continue through normal agent flow.
|
||||||
|
OutcomePassthrough Outcome = iota
|
||||||
|
// OutcomeHandled means a command handler executed (with or without handler error).
|
||||||
|
OutcomeHandled
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecuteResult struct {
|
||||||
|
Outcome Outcome
|
||||||
|
Command string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
reg *Registry
|
||||||
|
rt *Runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor(reg *Registry, rt *Runtime) *Executor {
|
||||||
|
return &Executor{reg: reg, rt: rt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute implements a two-state command decision:
|
||||||
|
// 1) handled: execute command immediately;
|
||||||
|
// 2) passthrough: not a command or intentionally deferred to agent logic.
|
||||||
|
func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
|
||||||
|
cmdName, ok := parseCommandName(req.Text)
|
||||||
|
if !ok {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e == nil || e.reg == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
def, found := e.reg.Lookup(cmdName)
|
||||||
|
if !found {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.executeDefinition(ctx, req, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult {
|
||||||
|
// Ensure Reply is always non-nil so handlers don't need to check.
|
||||||
|
if req.Reply == nil {
|
||||||
|
req.Reply = func(string) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple command — no sub-commands
|
||||||
|
if len(def.SubCommands) == 0 {
|
||||||
|
if def.Handler == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
err := def.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-command routing
|
||||||
|
subName := nthToken(req.Text, 1)
|
||||||
|
if subName == "" {
|
||||||
|
err := req.Reply("Usage: " + def.EffectiveUsage())
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeCommandName(subName)
|
||||||
|
for _, sc := range def.SubCommands {
|
||||||
|
if normalizeCommandName(sc.Name) == normalized {
|
||||||
|
if sc.Handler == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
err := sc.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown sub-command
|
||||||
|
err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage()))
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
260
pkg/commands/executor_test.go
Normal file
260
pkg/commands/executor_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
Aliases: []string{"display"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "show" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "show")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "clear",
|
||||||
|
Aliases: []string{"reset"},
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "clear" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "clear")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
|
||||||
|
// With Lookup-based dispatch, the first registered definition for a name wins.
|
||||||
|
// A definition with nil Handler and no SubCommands returns Passthrough.
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
|
||||||
|
wantErr := errors.New("handler failed")
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
return wantErr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !errors.Is(res.Err, wantErr) {
|
||||||
|
t.Fatalf("err=%v, want=%v", res.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
|
||||||
|
modelCalled := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error {
|
||||||
|
modelCalled = true
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !modelCalled {
|
||||||
|
t.Fatal("model sub-command handler was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /show [model|channel]" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show foobar",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "foobar") {
|
||||||
|
t.Fatalf("reply=%q, should mention unknown sub-command", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"}, // nil Handler
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
pkg/commands/handler_agents.go
Normal file
21
pkg/commands/handler_agents.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentsHandler returns a shared handler for both /show agents and /list agents.
|
||||||
|
func agentsHandler() Handler {
|
||||||
|
return func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ListAgentIDs == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
ids := rt.ListAgentIDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return req.Reply("No agents registered")
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pkg/commands/registry.go
Normal file
55
pkg/commands/registry.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
defs []Definition
|
||||||
|
index map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry stores the canonical command set used by both dispatch and
|
||||||
|
// optional platform registration adapters.
|
||||||
|
func NewRegistry(defs []Definition) *Registry {
|
||||||
|
stored := make([]Definition, len(defs))
|
||||||
|
copy(stored, defs)
|
||||||
|
|
||||||
|
index := make(map[string]int, len(stored)*2)
|
||||||
|
for i, def := range stored {
|
||||||
|
registerCommandName(index, def.Name, i)
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
registerCommandName(index, alias, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Registry{defs: stored, index: index}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions returns all registered command definitions.
|
||||||
|
// Command availability is global and no longer channel-scoped.
|
||||||
|
func (r *Registry) Definitions() []Definition {
|
||||||
|
out := make([]Definition, len(r.defs))
|
||||||
|
copy(out, r.defs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns a command definition by normalized command name or alias.
|
||||||
|
func (r *Registry) Lookup(name string) (Definition, bool) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
idx, ok := r.index[key]
|
||||||
|
if !ok {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
return r.defs[idx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCommandName(index map[string]int, name string, defIndex int) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := index[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index[key] = defIndex
|
||||||
|
}
|
||||||
49
pkg/commands/registry_test.go
Normal file
49
pkg/commands/registry_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRegistry_Definitions_ReturnsCopy(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "help", Description: "Show help"},
|
||||||
|
{Name: "admin", Description: "Admin command"},
|
||||||
|
}
|
||||||
|
r := NewRegistry(defs)
|
||||||
|
|
||||||
|
got := r.Definitions()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("definitions len = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
got[0].Name = "mutated"
|
||||||
|
again := r.Definitions()
|
||||||
|
if again[0].Name != "help" {
|
||||||
|
t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) {
|
||||||
|
r := NewRegistry([]Definition{
|
||||||
|
{Name: "Help", Aliases: []string{"Assist"}},
|
||||||
|
{Name: "List"},
|
||||||
|
})
|
||||||
|
|
||||||
|
def, ok := r.Lookup("help")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("HELP")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("assist")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("ASSIST")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pkg/commands/request.go
Normal file
75
pkg/commands/request.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler func(ctx context.Context, req Request, rt *Runtime) error
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Channel string
|
||||||
|
ChatID string
|
||||||
|
SenderID string
|
||||||
|
Text string
|
||||||
|
Reply func(text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableMsg = "Command unavailable in current context."
|
||||||
|
|
||||||
|
var commandPrefixes = []string{"/", "!"}
|
||||||
|
|
||||||
|
// parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then
|
||||||
|
// normalizes to lowercase command names.
|
||||||
|
func parseCommandName(input string) (string, bool) {
|
||||||
|
token := nthToken(input, 0)
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
name, ok := trimCommandPrefix(token)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if i := strings.Index(name, "@"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
name = normalizeCommandName(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimCommandPrefix(token string) (string, bool) {
|
||||||
|
for _, prefix := range commandPrefixes {
|
||||||
|
if strings.HasPrefix(token, prefix) {
|
||||||
|
return strings.TrimPrefix(token, prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCommandPrefix returns true if the input starts with a recognized
|
||||||
|
// command prefix (e.g. "/" or "!").
|
||||||
|
func HasCommandPrefix(input string) bool {
|
||||||
|
token := nthToken(input, 0)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := trimCommandPrefix(token)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// nthToken returns the 0-indexed token from whitespace-split input.
|
||||||
|
func nthToken(input string, n int) string {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(input))
|
||||||
|
if n >= len(parts) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommandName(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
28
pkg/commands/request_test.go
Normal file
28
pkg/commands/request_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHasCommandPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/help", true},
|
||||||
|
{"!help", true},
|
||||||
|
{"/switch model to gpt-4", true},
|
||||||
|
{"!switch model to gpt-4", true},
|
||||||
|
{"hello", false},
|
||||||
|
{"", false},
|
||||||
|
{" ", false},
|
||||||
|
{"hello /world", false},
|
||||||
|
{"/", true},
|
||||||
|
{"!", true},
|
||||||
|
{" /help", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := HasCommandPrefix(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("HasCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/commands/runtime.go
Normal file
16
pkg/commands/runtime.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
||||||
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
|
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||||
|
type Runtime struct {
|
||||||
|
Config *config.Config
|
||||||
|
GetModelInfo func() (name, provider string)
|
||||||
|
ListAgentIDs func() []string
|
||||||
|
ListDefinitions func() []Definition
|
||||||
|
GetEnabledChannels func() []string
|
||||||
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
|
SwitchChannel func(value string) error
|
||||||
|
}
|
||||||
85
pkg/commands/show_list_handlers_test.go
Normal file
85
pkg/commands/show_list_handlers_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShowListHandlers_ChannelPolicy(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil)
|
||||||
|
|
||||||
|
var telegramReply string
|
||||||
|
handled := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "telegram",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
telegramReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handled.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if telegramReply != "Current Channel: telegram" {
|
||||||
|
t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram")
|
||||||
|
}
|
||||||
|
|
||||||
|
var whatsappReply string
|
||||||
|
handledWhatsApp := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
whatsappReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handledWhatsApp.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if handledWhatsApp.Command != "show" {
|
||||||
|
t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show")
|
||||||
|
}
|
||||||
|
if whatsappReply != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp")
|
||||||
|
}
|
||||||
|
|
||||||
|
passthrough := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/foo",
|
||||||
|
})
|
||||||
|
if passthrough.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if passthrough.Command != "foo" {
|
||||||
|
t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
|
return []string{"telegram"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "list" {
|
||||||
|
t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") {
|
||||||
|
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -167,21 +167,36 @@ type SessionConfig struct {
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoutingConfig controls the intelligent model routing feature.
|
||||||
|
// When enabled, each incoming message is scored against structural features
|
||||||
|
// (message length, code blocks, tool call history, conversation depth, attachments).
|
||||||
|
// Messages scoring below Threshold are sent to LightModel; all others use the
|
||||||
|
// agent's primary model. This reduces cost and latency for simple tasks without
|
||||||
|
// requiring any keyword matching — all scoring is language-agnostic.
|
||||||
|
type RoutingConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
|
||||||
|
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
|
||||||
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
TLDRIncludeMessage bool `json:"tldr_include_message" env:"PICOCLAW_AGENTS_DEFAULTS_TLDR_INCLUDE_MESSAGE"`
|
TLDRIncludeMessage bool `json:"tldr_include_message" env:"PICOCLAW_AGENTS_DEFAULTS_TLDR_INCLUDE_MESSAGE"`
|
||||||
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
||||||
|
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
||||||
|
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
||||||
|
Routing *RoutingConfig `json:"routing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
@ -272,6 +287,7 @@ type FeishuConfig struct {
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
|
@ -429,6 +445,7 @@ type ProvidersConfig struct {
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
|
Avian ProviderConfig `json:"avian"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -452,7 +469,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
|
p.Avian.APIKey == "" && p.Avian.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -503,6 +521,7 @@ type ModelConfig struct {
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
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")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
@ -521,6 +540,10 @@ type GatewayConfig struct {
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
|
@ -545,11 +568,30 @@ type PerplexityConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearXNGConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GLMSearchConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
||||||
|
// SearchEngine specifies the search backend: "search_std" (default),
|
||||||
|
// "search_pro", "search_pro_sogou", or "search_pro_quark".
|
||||||
|
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Tavily TavilyConfig `json:"tavily"`
|
Brave BraveConfig ` json:"brave"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
Tavily TavilyConfig ` json:"tavily"`
|
||||||
Perplexity PerplexityConfig `json:"perplexity"`
|
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
||||||
|
Perplexity PerplexityConfig ` json:"perplexity"`
|
||||||
|
SearXNG SearXNGConfig ` json:"searxng"`
|
||||||
|
GLMSearch GLMSearchConfig ` json:"glm_search"`
|
||||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
// 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.
|
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
|
|
@ -557,19 +599,29 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||||
|
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
||||||
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
||||||
|
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
||||||
|
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsToolsConfig struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
|
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||||
|
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||||
|
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaCleanupConfig struct {
|
type MediaCleanupConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
|
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
|
||||||
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
|
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
|
||||||
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
|
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -581,12 +633,20 @@ type ToolsConfig struct {
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
}
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
type SkillsToolsConfig struct {
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig `json:"registries"`
|
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
SearchCache SearchCacheConfig `json:"search_cache"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
|
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
|
|
@ -632,8 +692,7 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
// Enabled globally enables/disables MCP integration
|
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
|
|
||||||
// Servers is a map of server name to server configuration
|
// Servers is a map of server name to server configuration
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -819,3 +878,50 @@ func (c *Config) ValidateModelList() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "web":
|
||||||
|
return t.Web.Enabled
|
||||||
|
case "cron":
|
||||||
|
return t.Cron.Enabled
|
||||||
|
case "exec":
|
||||||
|
return t.Exec.Enabled
|
||||||
|
case "skills":
|
||||||
|
return t.Skills.Enabled
|
||||||
|
case "media_cleanup":
|
||||||
|
return t.MediaCleanup.Enabled
|
||||||
|
case "append_file":
|
||||||
|
return t.AppendFile.Enabled
|
||||||
|
case "edit_file":
|
||||||
|
return t.EditFile.Enabled
|
||||||
|
case "find_skills":
|
||||||
|
return t.FindSkills.Enabled
|
||||||
|
case "i2c":
|
||||||
|
return t.I2C.Enabled
|
||||||
|
case "install_skill":
|
||||||
|
return t.InstallSkill.Enabled
|
||||||
|
case "list_dir":
|
||||||
|
return t.ListDir.Enabled
|
||||||
|
case "message":
|
||||||
|
return t.Message.Enabled
|
||||||
|
case "read_file":
|
||||||
|
return t.ReadFile.Enabled
|
||||||
|
case "spawn":
|
||||||
|
return t.Spawn.Enabled
|
||||||
|
case "spi":
|
||||||
|
return t.SPI.Enabled
|
||||||
|
case "subagent":
|
||||||
|
return t.Subagent.Enabled
|
||||||
|
case "web_fetch":
|
||||||
|
return t.WebFetch.Enabled
|
||||||
|
case "send_file":
|
||||||
|
return t.SendFile.Enabled
|
||||||
|
case "write_file":
|
||||||
|
return t.WriteFile.Enabled
|
||||||
|
case "mcp":
|
||||||
|
return t.MCP.Enabled
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,18 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_DMScope verifies the default dm_scope value
|
// TestDefaultConfig_DMScope verifies the default dm_scope value
|
||||||
|
// TestDefaultConfig_SummarizationThresholds verifies summarization defaults
|
||||||
|
func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 {
|
||||||
|
t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold)
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.SummarizeTokenPercent != 75 {
|
||||||
|
t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_DMScope(t *testing.T) {
|
func TestDefaultConfig_DMScope(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,15 @@ func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Workspace: workspacePath,
|
Workspace: workspacePath,
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "",
|
Model: "",
|
||||||
MaxTokens: 32768,
|
MaxTokens: 32768,
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 50,
|
||||||
|
SummarizeMessageThreshold: 20,
|
||||||
|
SummarizeTokenPercent: 75,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
@ -306,6 +308,20 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Avian - https://avian.io
|
||||||
|
{
|
||||||
|
ModelName: "deepseek-v3.2",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "kimi-k2.5",
|
||||||
|
Model: "avian/moonshotai/kimi-k2.5",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -320,11 +336,16 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
MediaCleanup: MediaCleanupConfig{
|
MediaCleanup: MediaCleanupConfig{
|
||||||
Enabled: true,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
MaxAge: 30,
|
MaxAge: 30,
|
||||||
Interval: 5,
|
Interval: 5,
|
||||||
},
|
},
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Proxy: "",
|
Proxy: "",
|
||||||
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
|
|
@ -341,14 +362,36 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
SearXNG: SearXNGConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BaseURL: "",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
GLMSearch: GLMSearchConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
|
SearchEngine: "search_std",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
},
|
},
|
||||||
Exec: ExecConfig{
|
Exec: ExecConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
|
TimeoutSeconds: 60,
|
||||||
},
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Registries: SkillsRegistriesConfig{
|
Registries: SkillsRegistriesConfig{
|
||||||
ClawHub: ClawHubRegistryConfig{
|
ClawHub: ClawHubRegistryConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -361,10 +404,54 @@ func DefaultConfig() *Config {
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
SendFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
MCP: MCPConfig{
|
MCP: MCPConfig{
|
||||||
Enabled: false,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
|
AppendFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
EditFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
FindSkills: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
I2C: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
InstallSkill: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ListDir: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Message: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ReadFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Spawn: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
SPI: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
Subagent: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WebFetch: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WriteFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Heartbeat: HeartbeatConfig{
|
Heartbeat: HeartbeatConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"avian"},
|
||||||
|
protocol: "avian",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "avian",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIKey: p.Avian.APIKey,
|
||||||
|
APIBase: p.Avian.APIBase,
|
||||||
|
Proxy: p.Avian.Proxy,
|
||||||
|
RequestTimeout: p.Avian.RequestTimeout,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each provider migration
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
|
Avian: ProviderConfig{APIKey: "key19"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 19 providers should be converted
|
// All 20 providers should be converted
|
||||||
if len(result) != 19 {
|
if len(result) != 20 {
|
||||||
t.Errorf("len(result) = %d, want 19", len(result))
|
t.Errorf("len(result) = %d, want 20", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,14 +190,21 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
cs.mu.RUnlock()
|
cs.mu.RUnlock()
|
||||||
|
|
||||||
if callbackJob == nil {
|
if callbackJob == nil {
|
||||||
|
log.Printf("[cron] job %s not found, skipping", jobID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log job execution start
|
||||||
|
log.Printf("[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)",
|
||||||
|
callbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if cs.onJob != nil {
|
if cs.onJob != nil {
|
||||||
_, err = cs.onJob(callbackJob)
|
_, err = cs.onJob(callbackJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execDuration := time.Now().UnixMilli() - startTime
|
||||||
|
|
||||||
// Now acquire lock to update state
|
// Now acquire lock to update state
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -220,22 +227,35 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
job.State.LastStatus = "error"
|
job.State.LastStatus = "error"
|
||||||
job.State.LastError = err.Error()
|
job.State.LastError = err.Error()
|
||||||
|
log.Printf("[cron] ✗ job '%s' failed after %dms: %v", job.Name, execDuration, err)
|
||||||
} else {
|
} else {
|
||||||
job.State.LastStatus = "ok"
|
job.State.LastStatus = "ok"
|
||||||
job.State.LastError = ""
|
job.State.LastError = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute next run time
|
// Compute next run time
|
||||||
|
var nextRunStr string
|
||||||
if job.Schedule.Kind == "at" {
|
if job.Schedule.Kind == "at" {
|
||||||
if job.DeleteAfterRun {
|
if job.DeleteAfterRun {
|
||||||
cs.removeJobUnsafe(job.ID)
|
cs.removeJobUnsafe(job.ID)
|
||||||
|
nextRunStr = "(deleted)"
|
||||||
} else {
|
} else {
|
||||||
job.Enabled = false
|
job.Enabled = false
|
||||||
job.State.NextRunAtMS = nil
|
job.State.NextRunAtMS = nil
|
||||||
|
nextRunStr = "(disabled)"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
||||||
job.State.NextRunAtMS = nextRun
|
job.State.NextRunAtMS = nextRun
|
||||||
|
if nextRun != nil {
|
||||||
|
nextRunStr = time.UnixMilli(*nextRun).Format("2006-01-02 15:04:05")
|
||||||
|
} else {
|
||||||
|
nextRunStr = "(none)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[cron] ✓ job '%s' completed in %dms, next run: %s", job.Name, execDuration, nextRunStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cs.saveStoreUnsafe(); err != nil {
|
if err := cs.saveStoreUnsafe(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,9 @@ func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {
|
||||||
mgr := NewManager()
|
mgr := NewManager()
|
||||||
|
|
||||||
mcpCfg := config.MCPConfig{
|
mcpCfg := config.MCPConfig{
|
||||||
Enabled: true,
|
ToolConfig: config.ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Servers: map[string]config.MCPServerConfig{
|
Servers: map[string]config.MCPServerConfig{
|
||||||
"test-server": {
|
"test-server": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -228,12 +230,20 @@ func TestNewManager_InitialState(t *testing.T) {
|
||||||
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
|
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
|
||||||
mgr := NewManager()
|
mgr := NewManager()
|
||||||
|
|
||||||
err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp")
|
err := mgr.LoadFromMCPConfig(
|
||||||
|
context.Background(),
|
||||||
|
config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: false}},
|
||||||
|
"/tmp",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
|
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: true}, "/tmp")
|
err = mgr.LoadFromMCPConfig(
|
||||||
|
context.Background(),
|
||||||
|
config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}},
|
||||||
|
"/tmp",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected nil error when no servers configured, got: %v", err)
|
t.Fatalf("expected nil error when no servers configured, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
460
pkg/memory/jsonl.go
Normal file
460
pkg/memory/jsonl.go
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// numLockShards is the fixed number of mutexes used to serialize
|
||||||
|
// per-session access. Using a sharded array instead of a map keeps
|
||||||
|
// memory bounded regardless of how many sessions are created over
|
||||||
|
// the lifetime of the process — important for a long-running daemon.
|
||||||
|
numLockShards = 64
|
||||||
|
|
||||||
|
// maxLineSize is the maximum size of a single JSON line in a .jsonl
|
||||||
|
// file. Tool results (read_file, web search, etc.) can be large, so
|
||||||
|
// we set a generous limit. The scanner starts at 64 KB and grows
|
||||||
|
// only as needed up to this cap.
|
||||||
|
maxLineSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionMeta holds per-session metadata stored in a .meta.json file.
|
||||||
|
type sessionMeta struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Skip int `json:"skip"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONLStore implements Store using append-only JSONL files.
|
||||||
|
//
|
||||||
|
// Each session is stored as two files:
|
||||||
|
//
|
||||||
|
// {sanitized_key}.jsonl — one JSON-encoded message per line, append-only
|
||||||
|
// {sanitized_key}.meta.json — session metadata (summary, logical truncation offset)
|
||||||
|
//
|
||||||
|
// Messages are never physically deleted from the JSONL file. Instead,
|
||||||
|
// TruncateHistory records a "skip" offset in the metadata file and
|
||||||
|
// GetHistory ignores lines before that offset. This keeps all writes
|
||||||
|
// append-only, which is both fast and crash-safe.
|
||||||
|
type JSONLStore struct {
|
||||||
|
dir string
|
||||||
|
locks [numLockShards]sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONLStore creates a new JSONL-backed store rooted at dir.
|
||||||
|
func NewJSONLStore(dir string) (*JSONLStore, error) {
|
||||||
|
err := os.MkdirAll(dir, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("memory: create directory: %w", err)
|
||||||
|
}
|
||||||
|
return &JSONLStore{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionLock returns a mutex for the given session key.
|
||||||
|
// Keys are mapped to a fixed pool of shards via FNV hash, so
|
||||||
|
// memory usage is O(1) regardless of total session count.
|
||||||
|
func (s *JSONLStore) sessionLock(key string) *sync.Mutex {
|
||||||
|
h := fnv.New32a()
|
||||||
|
h.Write([]byte(key))
|
||||||
|
return &s.locks[h.Sum32()%numLockShards]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) jsonlPath(key string) string {
|
||||||
|
return filepath.Join(s.dir, sanitizeKey(key)+".jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) metaPath(key string) string {
|
||||||
|
return filepath.Join(s.dir, sanitizeKey(key)+".meta.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeKey converts a session key to a safe filename component.
|
||||||
|
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
|
||||||
|
//
|
||||||
|
// Note: this is a lossy mapping — "telegram:123" and "telegram_123"
|
||||||
|
// both produce the same filename. This is an intentional tradeoff:
|
||||||
|
// keys with colons (e.g. from channels) are by far the common case,
|
||||||
|
// and a bidirectional encoding (like URL-encoding) would complicate
|
||||||
|
// file listings and debugging.
|
||||||
|
func sanitizeKey(key string) string {
|
||||||
|
return strings.ReplaceAll(key, ":", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMeta loads the metadata file for a session.
|
||||||
|
// Returns a zero-value sessionMeta if the file does not exist.
|
||||||
|
func (s *JSONLStore) readMeta(key string) (sessionMeta, error) {
|
||||||
|
data, err := os.ReadFile(s.metaPath(key))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return sessionMeta{Key: key}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err)
|
||||||
|
}
|
||||||
|
var meta sessionMeta
|
||||||
|
err = json.Unmarshal(data, &meta)
|
||||||
|
if err != nil {
|
||||||
|
return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err)
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMeta atomically writes the metadata file using the project's
|
||||||
|
// standard WriteFileAtomic (temp + fsync + rename).
|
||||||
|
func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error {
|
||||||
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("memory: encode meta: %w", err)
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMessages reads valid JSON lines from a .jsonl file, skipping
|
||||||
|
// the first `skip` lines without unmarshaling them. This avoids the
|
||||||
|
// cost of json.Unmarshal on logically truncated messages.
|
||||||
|
// Malformed trailing lines (e.g. from a crash) are silently skipped.
|
||||||
|
func readMessages(path string, skip int) ([]providers.Message, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []providers.Message{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("memory: open jsonl: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var msgs []providers.Message
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
// Allow large lines for tool results (read_file, web search, etc.).
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
|
||||||
|
|
||||||
|
lineNum := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineNum++
|
||||||
|
if lineNum <= skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var msg providers.Message
|
||||||
|
if err := json.Unmarshal(line, &msg); err != nil {
|
||||||
|
// Corrupt line — likely a partial write from a crash.
|
||||||
|
// Log so operators know data was skipped, but don't
|
||||||
|
// fail the entire read; this is the standard JSONL
|
||||||
|
// recovery pattern.
|
||||||
|
log.Printf("memory: skipping corrupt line %d in %s: %v",
|
||||||
|
lineNum, filepath.Base(path), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
}
|
||||||
|
if scanner.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("memory: scan jsonl: %w", scanner.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []providers.Message{}
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countLines counts the total number of non-empty lines in a .jsonl file.
|
||||||
|
// Used by TruncateHistory to reconcile a stale meta.Count without
|
||||||
|
// the overhead of unmarshaling every message.
|
||||||
|
func countLines(path string) (int, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("memory: open jsonl: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if len(scanner.Bytes()) > 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) AddMessage(
|
||||||
|
_ context.Context, sessionKey, role, content string,
|
||||||
|
) error {
|
||||||
|
return s.addMsg(sessionKey, providers.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) AddFullMessage(
|
||||||
|
_ context.Context, sessionKey string, msg providers.Message,
|
||||||
|
) error {
|
||||||
|
return s.addMsg(sessionKey, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMsg is the shared implementation for AddMessage and AddFullMessage.
|
||||||
|
func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
// Append the message as a single JSON line.
|
||||||
|
line, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("memory: marshal message: %w", err)
|
||||||
|
}
|
||||||
|
line = append(line, '\n')
|
||||||
|
|
||||||
|
f, err := os.OpenFile(
|
||||||
|
s.jsonlPath(sessionKey),
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("memory: open jsonl for append: %w", err)
|
||||||
|
}
|
||||||
|
_, writeErr := f.Write(line)
|
||||||
|
if writeErr != nil {
|
||||||
|
f.Close()
|
||||||
|
return fmt.Errorf("memory: append message: %w", writeErr)
|
||||||
|
}
|
||||||
|
// Flush to physical storage before closing. This matches the
|
||||||
|
// durability guarantee of writeMeta and rewriteJSONL (which use
|
||||||
|
// WriteFileAtomic with fsync). Without Sync, a power loss could
|
||||||
|
// leave the append in the kernel page cache only — lost on reboot.
|
||||||
|
if syncErr := f.Sync(); syncErr != nil {
|
||||||
|
f.Close()
|
||||||
|
return fmt.Errorf("memory: sync jsonl: %w", syncErr)
|
||||||
|
}
|
||||||
|
if closeErr := f.Close(); closeErr != nil {
|
||||||
|
return fmt.Errorf("memory: close jsonl: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata.
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if meta.Count == 0 && meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = now
|
||||||
|
}
|
||||||
|
meta.Count++
|
||||||
|
meta.UpdatedAt = now
|
||||||
|
|
||||||
|
return s.writeMeta(sessionKey, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) GetHistory(
|
||||||
|
_ context.Context, sessionKey string,
|
||||||
|
) ([]providers.Message, error) {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass meta.Skip so readMessages skips those lines without
|
||||||
|
// unmarshaling them — avoids wasted CPU on truncated messages.
|
||||||
|
msgs, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) GetSummary(
|
||||||
|
_ context.Context, sessionKey string,
|
||||||
|
) (string, error) {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return meta.Summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) SetSummary(
|
||||||
|
_ context.Context, sessionKey, summary string,
|
||||||
|
) error {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = now
|
||||||
|
}
|
||||||
|
meta.Summary = summary
|
||||||
|
meta.UpdatedAt = now
|
||||||
|
|
||||||
|
return s.writeMeta(sessionKey, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) TruncateHistory(
|
||||||
|
_ context.Context, sessionKey string, keepLast int,
|
||||||
|
) error {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always reconcile meta.Count with the actual line count on disk.
|
||||||
|
// A crash between the JSONL append and the meta update in addMsg
|
||||||
|
// leaves meta.Count stale (e.g. file has 101 lines but meta says
|
||||||
|
// 100). Counting lines is cheap — no unmarshal, just a scan — and
|
||||||
|
// TruncateHistory is not a hot path, so always re-count.
|
||||||
|
n, countErr := countLines(s.jsonlPath(sessionKey))
|
||||||
|
if countErr != nil {
|
||||||
|
return countErr
|
||||||
|
}
|
||||||
|
meta.Count = n
|
||||||
|
|
||||||
|
if keepLast <= 0 {
|
||||||
|
meta.Skip = meta.Count
|
||||||
|
} else {
|
||||||
|
effective := meta.Count - meta.Skip
|
||||||
|
if keepLast < effective {
|
||||||
|
meta.Skip = meta.Count - keepLast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
return s.writeMeta(sessionKey, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) SetHistory(
|
||||||
|
_ context.Context,
|
||||||
|
sessionKey string,
|
||||||
|
history []providers.Message,
|
||||||
|
) error {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = now
|
||||||
|
}
|
||||||
|
meta.Skip = 0
|
||||||
|
meta.Count = len(history)
|
||||||
|
meta.UpdatedAt = now
|
||||||
|
|
||||||
|
// Write meta BEFORE rewriting the JSONL file. If we crash between
|
||||||
|
// the two writes, meta has Skip=0 and the old file is still intact,
|
||||||
|
// so GetHistory reads from line 1 — returning "too many" messages
|
||||||
|
// rather than losing data. The next SetHistory call corrects this.
|
||||||
|
err = s.writeMeta(sessionKey, meta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.rewriteJSONL(sessionKey, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact physically rewrites the JSONL file, dropping all logically
|
||||||
|
// skipped lines. This reclaims disk space that accumulates after
|
||||||
|
// repeated TruncateHistory calls.
|
||||||
|
//
|
||||||
|
// It is safe to call at any time; if there is nothing to compact
|
||||||
|
// (skip == 0) the method returns immediately.
|
||||||
|
func (s *JSONLStore) Compact(
|
||||||
|
_ context.Context, sessionKey string,
|
||||||
|
) error {
|
||||||
|
l := s.sessionLock(sessionKey)
|
||||||
|
l.Lock()
|
||||||
|
defer l.Unlock()
|
||||||
|
|
||||||
|
meta, err := s.readMeta(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if meta.Skip == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read only the active messages, skipping truncated lines
|
||||||
|
// without unmarshaling them.
|
||||||
|
active, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write meta BEFORE rewriting the JSONL file. If the process
|
||||||
|
// crashes between the two writes, meta has Skip=0 and the old
|
||||||
|
// (uncompacted) file is still intact, so GetHistory reads from
|
||||||
|
// line 1 — returning previously-truncated messages rather than
|
||||||
|
// losing data. The next Compact or TruncateHistory corrects this.
|
||||||
|
meta.Skip = 0
|
||||||
|
meta.Count = len(active)
|
||||||
|
meta.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
err = s.writeMeta(sessionKey, meta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.rewriteJSONL(sessionKey, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteJSONL atomically replaces the JSONL file with the given messages
|
||||||
|
// using the project's standard WriteFileAtomic (temp + fsync + rename).
|
||||||
|
func (s *JSONLStore) rewriteJSONL(
|
||||||
|
sessionKey string, msgs []providers.Message,
|
||||||
|
) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for i, msg := range msgs {
|
||||||
|
line, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("memory: marshal message %d: %w", i, err)
|
||||||
|
}
|
||||||
|
buf.Write(line)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JSONLStore) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
835
pkg/memory/jsonl_test.go
Normal file
835
pkg/memory/jsonl_test.go
Normal file
|
|
@ -0,0 +1,835 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *JSONLStore {
|
||||||
|
t.Helper()
|
||||||
|
store, err := NewJSONLStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewJSONLStore_CreatesDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "nested", "sessions")
|
||||||
|
store, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Errorf("expected directory, got file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddMessage_BasicRoundtrip(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := store.AddMessage(ctx, "s1", "user", "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
err = store.AddMessage(ctx, "s1", "assistant", "hi there")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Errorf("msg[0] = %+v", history[0])
|
||||||
|
}
|
||||||
|
if history[1].Role != "assistant" || history[1].Content != "hi there" {
|
||||||
|
t.Errorf("msg[1] = %+v", history[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddMessage_AutoCreatesSession(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Adding a message to a non-existent session should work.
|
||||||
|
err := store.AddMessage(ctx, "new-session", "user", "first message")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "new-session")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFullMessage_WithToolCalls(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Let me search that.",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_abc",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "web_search",
|
||||||
|
Arguments: `{"q":"golang jsonl"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.AddFullMessage(ctx, "tc", msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddFullMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "tc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", len(history))
|
||||||
|
}
|
||||||
|
if len(history[0].ToolCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls))
|
||||||
|
}
|
||||||
|
tc := history[0].ToolCalls[0]
|
||||||
|
if tc.ID != "call_abc" {
|
||||||
|
t.Errorf("tool call ID = %q", tc.ID)
|
||||||
|
}
|
||||||
|
if tc.Function == nil || tc.Function.Name != "web_search" {
|
||||||
|
t.Errorf("tool call function = %+v", tc.Function)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFullMessage_ToolCallID(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "search results here",
|
||||||
|
ToolCallID: "call_abc",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.AddFullMessage(ctx, "tr", msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddFullMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "tr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].ToolCallID != "call_abc" {
|
||||||
|
t.Errorf("ToolCallID = %q", history[0].ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHistory_EmptySession(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if history == nil {
|
||||||
|
t.Fatal("expected non-nil empty slice")
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Errorf("expected 0 messages, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHistory_Ordering(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := store.AddMessage(
|
||||||
|
ctx, "order",
|
||||||
|
"user",
|
||||||
|
string(rune('a'+i)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage(%d): %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "order")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 5 {
|
||||||
|
t.Fatalf("expected 5, got %d", len(history))
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
expected := string(rune('a' + i))
|
||||||
|
if history[i].Content != expected {
|
||||||
|
t.Errorf("msg[%d].Content = %q, want %q", i, history[i].Content, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSummary_GetSummary(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// No summary yet.
|
||||||
|
summary, err := store.GetSummary(ctx, "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary != "" {
|
||||||
|
t.Errorf("expected empty, got %q", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a summary.
|
||||||
|
err = store.SetSummary(ctx, "s1", "talked about Go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetSummary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err = store.GetSummary(ctx, "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary != "talked about Go" {
|
||||||
|
t.Errorf("summary = %q", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary.
|
||||||
|
err = store.SetSummary(ctx, "s1", "updated summary")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetSummary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err = store.GetSummary(ctx, "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary != "updated summary" {
|
||||||
|
t.Errorf("summary = %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateHistory_KeepLast(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
err := store.AddMessage(
|
||||||
|
ctx, "trunc",
|
||||||
|
"user",
|
||||||
|
string(rune('a'+i)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.TruncateHistory(ctx, "trunc", 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "trunc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 4 {
|
||||||
|
t.Fatalf("expected 4, got %d", len(history))
|
||||||
|
}
|
||||||
|
// Should be the last 4: g, h, i, j
|
||||||
|
if history[0].Content != "g" {
|
||||||
|
t.Errorf("first kept = %q, want 'g'", history[0].Content)
|
||||||
|
}
|
||||||
|
if history[3].Content != "j" {
|
||||||
|
t.Errorf("last kept = %q, want 'j'", history[3].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateHistory_KeepZero(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := store.AddMessage(ctx, "empty", "user", "msg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.TruncateHistory(ctx, "empty", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "empty")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Errorf("expected 0, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateHistory_KeepMoreThanExists(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
err := store.AddMessage(ctx, "few", "user", "msg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep 100, but only 3 exist — should keep all.
|
||||||
|
err := store.TruncateHistory(ctx, "few", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "few")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Errorf("expected 3, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHistory_ReplacesAll(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Add some initial messages.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := store.AddMessage(ctx, "replace", "user", "old")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace with new history.
|
||||||
|
newHistory := []providers.Message{
|
||||||
|
{Role: "user", Content: "new1"},
|
||||||
|
{Role: "assistant", Content: "new2"},
|
||||||
|
}
|
||||||
|
err := store.SetHistory(ctx, "replace", newHistory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "replace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected 2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "new1" || history[1].Content != "new2" {
|
||||||
|
t.Errorf("history = %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHistory_ResetsSkip(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Add messages and truncate.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
err := store.AddMessage(ctx, "skip-reset", "user", "old")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := store.TruncateHistory(ctx, "skip-reset", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHistory should reset skip to 0.
|
||||||
|
newHistory := []providers.Message{
|
||||||
|
{Role: "user", Content: "fresh"},
|
||||||
|
}
|
||||||
|
err = store.SetHistory(ctx, "skip-reset", newHistory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "skip-reset")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "fresh" {
|
||||||
|
t.Errorf("content = %q", history[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColonInKey(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := store.AddMessage(ctx, "telegram:123", "user", "hi")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "telegram:123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", len(history))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file is named with underscore.
|
||||||
|
jsonlFile := filepath.Join(store.dir, "telegram_123.jsonl")
|
||||||
|
if _, statErr := os.Stat(jsonlFile); statErr != nil {
|
||||||
|
t.Errorf("expected file %s to exist: %v", jsonlFile, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact_RemovesSkippedMessages(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Write 10 messages, then truncate to keep last 3.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
err := store.AddMessage(ctx, "compact", "user", string(rune('a'+i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := store.TruncateHistory(ctx, "compact", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before compact: file still has 10 lines.
|
||||||
|
allOnDisk, err := readMessages(store.jsonlPath("compact"), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readMessages: %v", err)
|
||||||
|
}
|
||||||
|
if len(allOnDisk) != 10 {
|
||||||
|
t.Fatalf("before compact: expected 10 on disk, got %d", len(allOnDisk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact.
|
||||||
|
err = store.Compact(ctx, "compact")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After compact: file should have only 3 lines.
|
||||||
|
allOnDisk, err = readMessages(store.jsonlPath("compact"), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readMessages: %v", err)
|
||||||
|
}
|
||||||
|
if len(allOnDisk) != 3 {
|
||||||
|
t.Fatalf("after compact: expected 3 on disk, got %d", len(allOnDisk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory should still return the same 3 messages.
|
||||||
|
history, err := store.GetHistory(ctx, "compact")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("expected 3, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "h" || history[2].Content != "j" {
|
||||||
|
t.Errorf("wrong content: %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact_NoOpWhenNoSkip(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := store.AddMessage(ctx, "noop", "user", "msg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact without prior truncation — should be a no-op.
|
||||||
|
err := store.Compact(ctx, "noop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "noop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 5 {
|
||||||
|
t.Errorf("expected 5, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact_ThenAppend(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
err := store.AddMessage(ctx, "cap", "user", string(rune('a'+i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.TruncateHistory(ctx, "cap", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
err = store.Compact(ctx, "cap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append after compaction should work correctly.
|
||||||
|
err = store.AddMessage(ctx, "cap", "user", "new")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage after compact: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "cap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("expected 3, got %d", len(history))
|
||||||
|
}
|
||||||
|
// g, h (kept from truncation), new (appended after compaction).
|
||||||
|
if history[0].Content != "g" {
|
||||||
|
t.Errorf("first = %q, want 'g'", history[0].Content)
|
||||||
|
}
|
||||||
|
if history[2].Content != "new" {
|
||||||
|
t.Errorf("last = %q, want 'new'", history[2].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateHistory_StaleMetaCount(t *testing.T) {
|
||||||
|
// Simulates a crash between JSONL append and meta update in addMsg:
|
||||||
|
// file has N+1 lines but meta.Count is still N. TruncateHistory must
|
||||||
|
// reconcile with the real line count so that keepLast is accurate.
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Write 10 messages normally (meta.Count = 10).
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
err := store.AddMessage(ctx, "stale", "user", string(rune('a'+i)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate crash: append a line to JSONL but do NOT update meta.
|
||||||
|
// This leaves meta.Count = 10 while the file has 11 lines.
|
||||||
|
jsonlPath := store.jsonlPath("stale")
|
||||||
|
f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open for append: %v", err)
|
||||||
|
}
|
||||||
|
_, err = f.WriteString(`{"role":"user","content":"orphan"}` + "\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write orphan: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// TruncateHistory(keepLast=4) should keep the last 4 of 11 lines,
|
||||||
|
// not the last 4 of 10.
|
||||||
|
err = store.TruncateHistory(ctx, "stale", 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TruncateHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "stale")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 4 {
|
||||||
|
t.Fatalf("expected 4, got %d", len(history))
|
||||||
|
}
|
||||||
|
// Last 4 of [a,b,c,d,e,f,g,h,i,j,orphan] = [h,i,j,orphan]
|
||||||
|
if history[0].Content != "h" {
|
||||||
|
t.Errorf("first kept = %q, want 'h'", history[0].Content)
|
||||||
|
}
|
||||||
|
if history[3].Content != "orphan" {
|
||||||
|
t.Errorf("last kept = %q, want 'orphan'", history[3].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrashRecovery_PartialLine(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Write a valid message first.
|
||||||
|
err := store.AddMessage(ctx, "crash", "user", "valid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a crash by appending a partial JSON line directly.
|
||||||
|
jsonlPath := store.jsonlPath("crash")
|
||||||
|
f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open for append: %v", err)
|
||||||
|
}
|
||||||
|
_, err = f.WriteString(`{"role":"user","content":"incomple`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write partial: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// GetHistory should return only the valid message.
|
||||||
|
history, err := store.GetHistory(ctx, "crash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1 valid message, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "valid" {
|
||||||
|
t.Errorf("content = %q", history[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistence_AcrossInstances(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Write with first instance.
|
||||||
|
store1, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
err = store1.AddMessage(ctx, "persist", "user", "remember me")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
err = store1.SetSummary(ctx, "persist", "a test session")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetSummary: %v", err)
|
||||||
|
}
|
||||||
|
store1.Close()
|
||||||
|
|
||||||
|
// Read with second instance.
|
||||||
|
store2, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store2.Close()
|
||||||
|
|
||||||
|
history, err := store2.GetHistory(ctx, "persist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 || history[0].Content != "remember me" {
|
||||||
|
t.Errorf("history = %+v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := store2.GetSummary(ctx, "persist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary != "a test session" {
|
||||||
|
t.Errorf("summary = %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrent_AddAndRead(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
const goroutines = 10
|
||||||
|
const msgsPerGoroutine = 20
|
||||||
|
|
||||||
|
// Concurrent writes.
|
||||||
|
for g := 0; g < goroutines; g++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < msgsPerGoroutine; i++ {
|
||||||
|
_ = store.AddMessage(ctx, "concurrent", "user", "msg")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "concurrent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
expected := goroutines * msgsPerGoroutine
|
||||||
|
if len(history) != expected {
|
||||||
|
t.Errorf("expected %d messages, got %d", expected, len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrent_SummarizeRace(t *testing.T) {
|
||||||
|
// Simulates the #704 race: one goroutine adds messages while
|
||||||
|
// another truncates + sets summary — like summarizeSession().
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seed with some messages.
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
err := store.AddMessage(ctx, "race", "user", "seed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Writer goroutine (main agent loop).
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
_ = store.AddMessage(ctx, "race", "user", "new")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Summarizer goroutine (background task).
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_ = store.SetSummary(ctx, "race", "summary")
|
||||||
|
_ = store.TruncateHistory(ctx, "race", 5)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify the store is still in a consistent state.
|
||||||
|
_, err := store.GetHistory(ctx, "race")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory after race: %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.GetSummary(ctx, "race")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary after race: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleSessions_Isolation(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := store.AddMessage(ctx, "s1", "user", "msg for s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
err = store.AddMessage(ctx, "s2", "user", "msg for s2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, err := store.GetHistory(ctx, "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory s1: %v", err)
|
||||||
|
}
|
||||||
|
h2, err := store.GetHistory(ctx, "s2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory s2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(h1) != 1 || h1[0].Content != "msg for s1" {
|
||||||
|
t.Errorf("s1 history = %+v", h1)
|
||||||
|
}
|
||||||
|
if len(h2) != 1 || h2[0].Content != "msg for s2" {
|
||||||
|
t.Errorf("s2 history = %+v", h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkAddMessage(b *testing.B) {
|
||||||
|
dir := b.TempDir()
|
||||||
|
store, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = store.AddMessage(ctx, "bench", "user", "benchmark message content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGetHistory_100(b *testing.B) {
|
||||||
|
dir := b.TempDir()
|
||||||
|
store, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
_ = store.AddMessage(ctx, "bench", "user", "message content")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = store.GetHistory(ctx, "bench")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGetHistory_1000(b *testing.B) {
|
||||||
|
dir := b.TempDir()
|
||||||
|
store, err := NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("NewJSONLStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
_ = store.AddMessage(ctx, "bench", "user", "message content")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = store.GetHistory(ctx, "bench")
|
||||||
|
}
|
||||||
|
}
|
||||||
108
pkg/memory/migration.go
Normal file
108
pkg/memory/migration.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonSession mirrors pkg/session.Session for migration purposes.
|
||||||
|
type jsonSession struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Messages []providers.Message `json:"messages"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateFromJSON reads legacy sessions/*.json files from sessionsDir,
|
||||||
|
// writes them into the Store, and renames each migrated file to
|
||||||
|
// .json.migrated as a backup. Returns the number of sessions migrated.
|
||||||
|
//
|
||||||
|
// Files that fail to parse are logged and skipped. Already-migrated
|
||||||
|
// files (.json.migrated) are ignored, making the function idempotent.
|
||||||
|
func MigrateFromJSON(
|
||||||
|
ctx context.Context, sessionsDir string, store Store,
|
||||||
|
) (int, error) {
|
||||||
|
entries, err := os.ReadDir(sessionsDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("memory: read sessions dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip already-migrated files.
|
||||||
|
if strings.HasSuffix(name, ".migrated") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := filepath.Join(sessionsDir, name)
|
||||||
|
|
||||||
|
data, readErr := os.ReadFile(srcPath)
|
||||||
|
if readErr != nil {
|
||||||
|
log.Printf("memory: migrate: skip %s: %v", name, readErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess jsonSession
|
||||||
|
if parseErr := json.Unmarshal(data, &sess); parseErr != nil {
|
||||||
|
log.Printf("memory: migrate: skip %s: %v", name, parseErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the key from the JSON content, not the filename.
|
||||||
|
// Filenames are sanitized (":" → "_") but keys are not.
|
||||||
|
key := sess.Key
|
||||||
|
if key == "" {
|
||||||
|
key = strings.TrimSuffix(name, ".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use SetHistory (atomic replace) instead of per-message
|
||||||
|
// AddFullMessage. This makes migration idempotent: if the
|
||||||
|
// process crashes after writing messages but before the
|
||||||
|
// rename below, a retry replaces the partial data cleanly
|
||||||
|
// instead of duplicating messages.
|
||||||
|
if setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil {
|
||||||
|
return migrated, fmt.Errorf(
|
||||||
|
"memory: migrate %s: set history: %w",
|
||||||
|
name, setErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.Summary != "" {
|
||||||
|
if sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil {
|
||||||
|
return migrated, fmt.Errorf(
|
||||||
|
"memory: migrate %s: set summary: %w",
|
||||||
|
name, sumErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename to .migrated as backup (not delete).
|
||||||
|
renameErr := os.Rename(srcPath, srcPath+".migrated")
|
||||||
|
if renameErr != nil {
|
||||||
|
log.Printf("memory: migrate: rename %s: %v", name, renameErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated++
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated, nil
|
||||||
|
}
|
||||||
384
pkg/memory/migration_test.go
Normal file
384
pkg/memory/migration_test.go
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSONSession(
|
||||||
|
t *testing.T, dir string, filename string, sess jsonSession,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.MarshalIndent(sess, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal session: %v", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(filepath.Join(dir, filename), data, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write session file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_Basic(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeJSONSession(t, sessionsDir, "test.json", jsonSession{
|
||||||
|
Key: "test",
|
||||||
|
Messages: []providers.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "hi"},
|
||||||
|
},
|
||||||
|
Summary: "A greeting.",
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected 1 migrated, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "hello" || history[1].Content != "hi" {
|
||||||
|
t.Errorf("unexpected messages: %+v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := store.GetSummary(ctx, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary != "A greeting." {
|
||||||
|
t.Errorf("summary = %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_WithToolCalls(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeJSONSession(t, sessionsDir, "tools.json", jsonSession{
|
||||||
|
Key: "tools",
|
||||||
|
Messages: []providers.Message{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Searching...",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "web_search",
|
||||||
|
Arguments: `{"q":"test"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "result",
|
||||||
|
ToolCallID: "call_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected 1, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "tools")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(history))
|
||||||
|
}
|
||||||
|
if len(history[0].ToolCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls))
|
||||||
|
}
|
||||||
|
if history[0].ToolCalls[0].Function.Name != "web_search" {
|
||||||
|
t.Errorf("function = %q", history[0].ToolCalls[0].Function.Name)
|
||||||
|
}
|
||||||
|
if history[1].ToolCallID != "call_1" {
|
||||||
|
t.Errorf("ToolCallID = %q", history[1].ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_MultipleFiles(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := string(rune('a' + i))
|
||||||
|
writeJSONSession(t, sessionsDir, key+".json", jsonSession{
|
||||||
|
Key: key,
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: "msg " + key}},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("expected 3, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := string(rune('a' + i))
|
||||||
|
history, histErr := store.GetHistory(ctx, key)
|
||||||
|
if histErr != nil {
|
||||||
|
t.Fatalf("GetHistory(%q): %v", key, histErr)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Errorf("session %q: expected 1 msg, got %d", key, len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_InvalidJSON(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// One valid, one invalid.
|
||||||
|
writeJSONSession(t, sessionsDir, "good.json", jsonSession{
|
||||||
|
Key: "good",
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: "ok"}},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
err := os.WriteFile(
|
||||||
|
filepath.Join(sessionsDir, "bad.json"),
|
||||||
|
[]byte("{invalid json"),
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write bad file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected 1 (bad file skipped), got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "good")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Errorf("expected 1 message, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_RenamesFiles(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeJSONSession(t, sessionsDir, "rename.json", jsonSession{
|
||||||
|
Key: "rename",
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: "hi"}},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original .json should not exist.
|
||||||
|
_, statErr := os.Stat(filepath.Join(sessionsDir, "rename.json"))
|
||||||
|
if !os.IsNotExist(statErr) {
|
||||||
|
t.Error("rename.json should have been renamed")
|
||||||
|
}
|
||||||
|
// .json.migrated should exist.
|
||||||
|
_, statErr = os.Stat(
|
||||||
|
filepath.Join(sessionsDir, "rename.json.migrated"),
|
||||||
|
)
|
||||||
|
if statErr != nil {
|
||||||
|
t.Errorf("rename.json.migrated should exist: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_Idempotent(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeJSONSession(t, sessionsDir, "idem.json", jsonSession{
|
||||||
|
Key: "idem",
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: "once"}},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
count1, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first migration: %v", err)
|
||||||
|
}
|
||||||
|
if count1 != 1 {
|
||||||
|
t.Errorf("first run: expected 1, got %d", count1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second run should find only .migrated files, skip them.
|
||||||
|
count2, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second migration: %v", err)
|
||||||
|
}
|
||||||
|
if count2 != 0 {
|
||||||
|
t.Errorf("second run: expected 0, got %d", count2)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "idem")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Errorf("expected 1 message, got %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_ColonInKey(t *testing.T) {
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// File is named telegram_123 (sanitized), but the key inside is telegram:123.
|
||||||
|
writeJSONSession(t, sessionsDir, "telegram_123.json", jsonSession{
|
||||||
|
Key: "telegram:123",
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: "from telegram"}},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected 1, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accessible via the original key "telegram:123".
|
||||||
|
history, err := store.GetHistory(ctx, "telegram:123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "from telegram" {
|
||||||
|
t.Errorf("content = %q", history[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the file-based store, "telegram:123" and "telegram_123" both
|
||||||
|
// sanitize to the same filename, so they share storage. This is
|
||||||
|
// expected — the colon-to-underscore mapping is a one-way function.
|
||||||
|
history2, err := store.GetHistory(ctx, "telegram_123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history2) != 1 {
|
||||||
|
t.Errorf("expected 1 (same file), got %d", len(history2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_RetryAfterCrash(t *testing.T) {
|
||||||
|
// Simulates a crash during migration: first run writes messages
|
||||||
|
// but doesn't rename the .json file. Second run must replace
|
||||||
|
// (not duplicate) the messages thanks to SetHistory semantics.
|
||||||
|
sessionsDir := t.TempDir()
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeJSONSession(t, sessionsDir, "retry.json", jsonSession{
|
||||||
|
Key: "retry",
|
||||||
|
Messages: []providers.Message{
|
||||||
|
{Role: "user", Content: "one"},
|
||||||
|
{Role: "assistant", Content: "two"},
|
||||||
|
},
|
||||||
|
Created: time.Now(),
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// First migration succeeds — writes messages and renames file.
|
||||||
|
count, err := MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first migration: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate "crash before rename": restore the .json file.
|
||||||
|
src := filepath.Join(sessionsDir, "retry.json.migrated")
|
||||||
|
dst := filepath.Join(sessionsDir, "retry.json")
|
||||||
|
if renameErr := os.Rename(src, dst); renameErr != nil {
|
||||||
|
t.Fatalf("restore .json: %v", renameErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second migration should re-import without duplicating messages.
|
||||||
|
count, err = MigrateFromJSON(ctx, sessionsDir, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second migration: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.GetHistory(ctx, "retry")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory: %v", err)
|
||||||
|
}
|
||||||
|
// Must be exactly 2 messages (not 4 from duplication).
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages (no duplicates), got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "one" || history[1].Content != "two" {
|
||||||
|
t.Errorf("unexpected messages: %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
count, err := MigrateFromJSON(ctx, "/nonexistent/path", store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MigrateFromJSON: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected 0, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pkg/memory/store.go
Normal file
42
pkg/memory/store.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store defines an interface for persistent session storage.
|
||||||
|
// Each method is an atomic operation — there is no separate Save() call.
|
||||||
|
type Store interface {
|
||||||
|
// AddMessage appends a simple text message to a session.
|
||||||
|
AddMessage(ctx context.Context, sessionKey, role, content string) error
|
||||||
|
|
||||||
|
// AddFullMessage appends a complete message (with tool calls, etc.) to a session.
|
||||||
|
AddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error
|
||||||
|
|
||||||
|
// GetHistory returns all messages for a session in insertion order.
|
||||||
|
// Returns an empty slice (not nil) if the session does not exist.
|
||||||
|
GetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error)
|
||||||
|
|
||||||
|
// GetSummary returns the conversation summary for a session.
|
||||||
|
// Returns an empty string if no summary exists.
|
||||||
|
GetSummary(ctx context.Context, sessionKey string) (string, error)
|
||||||
|
|
||||||
|
// SetSummary updates the conversation summary for a session.
|
||||||
|
SetSummary(ctx context.Context, sessionKey, summary string) error
|
||||||
|
|
||||||
|
// TruncateHistory removes all but the last keepLast messages from a session.
|
||||||
|
// If keepLast <= 0, all messages are removed.
|
||||||
|
TruncateHistory(ctx context.Context, sessionKey string, keepLast int) error
|
||||||
|
|
||||||
|
// SetHistory replaces all messages in a session with the provided history.
|
||||||
|
SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error
|
||||||
|
|
||||||
|
// Compact reclaims storage by physically removing logically truncated
|
||||||
|
// data. Backends that do not accumulate dead data may return nil.
|
||||||
|
Compact(ctx context.Context, sessionKey string) error
|
||||||
|
|
||||||
|
// Close releases any resources held by the store.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,10 @@ type (
|
||||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultBaseURL = "https://api.anthropic.com"
|
const (
|
||||||
|
defaultBaseURL = "https://api.anthropic.com"
|
||||||
|
anthropicBetaHeader = "oauth-2025-04-20"
|
||||||
|
)
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
client *anthropic.Client
|
client *anthropic.Client
|
||||||
|
|
@ -31,6 +34,9 @@ type Provider struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupportsThinking implements providers.ThinkingCapable.
|
||||||
|
func (p *Provider) SupportsThinking() bool { return true }
|
||||||
|
|
||||||
func NewProvider(token string) *Provider {
|
func NewProvider(token string) *Provider {
|
||||||
return NewProviderWithBaseURL(token, "")
|
return NewProviderWithBaseURL(token, "")
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +83,10 @@ func (p *Provider) Chat(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, option.WithAuthToken(tok))
|
opts = append(opts,
|
||||||
|
option.WithAuthToken(tok),
|
||||||
|
option.WithHeader("anthropic-beta", anthropicBetaHeader),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
params, err := buildParams(messages, tools, model, options)
|
params, err := buildParams(messages, tools, model, options)
|
||||||
|
|
@ -85,6 +94,11 @@ func (p *Provider) Chat(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth/setup-tokens require streaming; API keys use non-streaming.
|
||||||
|
if p.tokenSource != nil {
|
||||||
|
return p.chatStreaming(ctx, params, opts)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
|
@ -93,6 +107,28 @@ func (p *Provider) Chat(
|
||||||
return parseResponse(resp), nil
|
return parseResponse(resp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Provider) chatStreaming(
|
||||||
|
ctx context.Context,
|
||||||
|
params anthropic.MessageNewParams,
|
||||||
|
opts []option.RequestOption,
|
||||||
|
) (*LLMResponse, error) {
|
||||||
|
stream := p.client.Messages.NewStreaming(ctx, params, opts...)
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
var msg anthropic.Message
|
||||||
|
for stream.Next() {
|
||||||
|
event := stream.Current()
|
||||||
|
if err := msg.Accumulate(event); err != nil {
|
||||||
|
return nil, fmt.Errorf("claude streaming accumulate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(&msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) GetDefaultModel() string {
|
func (p *Provider) GetDefaultModel() string {
|
||||||
return "claude-sonnet-4.6"
|
return "claude-sonnet-4.6"
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +180,16 @@ func buildParams(
|
||||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||||
}
|
}
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
args := tc.Arguments
|
||||||
|
if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))
|
||||||
}
|
}
|
||||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -164,8 +209,12 @@ func buildParams(
|
||||||
maxTokens = int64(mt)
|
maxTokens = int64(mt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6),
|
||||||
|
// but config may use dots (claude-sonnet-4.6).
|
||||||
|
apiModel := strings.ReplaceAll(model, ".", "-")
|
||||||
|
|
||||||
params := anthropic.MessageNewParams{
|
params := anthropic.MessageNewParams{
|
||||||
Model: anthropic.Model(model),
|
Model: anthropic.Model(apiModel),
|
||||||
Messages: anthropicMessages,
|
Messages: anthropicMessages,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +231,80 @@ func buildParams(
|
||||||
params.Tools = translateTools(tools)
|
params.Tools = translateTools(tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended Thinking / Adaptive Thinking
|
||||||
|
// The thinking_level value directly determines the API parameter format:
|
||||||
|
// "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort
|
||||||
|
// "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}}
|
||||||
|
if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" {
|
||||||
|
applyThinkingConfig(¶ms, level)
|
||||||
|
}
|
||||||
|
|
||||||
return params, nil
|
return params, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyThinkingConfig sets thinking parameters based on the level value.
|
||||||
|
// "adaptive" uses the adaptive thinking API (Claude 4.6+).
|
||||||
|
// All other levels use budget_tokens which is universally supported.
|
||||||
|
//
|
||||||
|
// Anthropic API constraint: temperature must not be set when thinking is enabled.
|
||||||
|
// budget_tokens must be strictly less than max_tokens.
|
||||||
|
func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
|
||||||
|
// Anthropic API rejects requests with temperature set alongside thinking.
|
||||||
|
// Reset to zero value (omitted from JSON serialization).
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level)
|
||||||
|
}
|
||||||
|
params.Temperature = anthropic.MessageNewParams{}.Temperature
|
||||||
|
|
||||||
|
if level == "adaptive" {
|
||||||
|
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
|
||||||
|
params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
|
||||||
|
params.OutputConfig = anthropic.OutputConfigParam{
|
||||||
|
Effort: anthropic.OutputConfigEffortHigh,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
budget := int64(levelToBudget(level))
|
||||||
|
if budget <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting.
|
||||||
|
if budget >= params.MaxTokens {
|
||||||
|
log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1)
|
||||||
|
budget = params.MaxTokens - 1
|
||||||
|
} else if budget > params.MaxTokens*80/100 {
|
||||||
|
log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated",
|
||||||
|
budget, params.MaxTokens)
|
||||||
|
}
|
||||||
|
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelToBudget maps a thinking level to budget_tokens.
|
||||||
|
// Values are based on Anthropic's recommendations and community best practices:
|
||||||
|
//
|
||||||
|
// low = 4,096 — simple reasoning, quick debugging (Claude Code "think")
|
||||||
|
// medium = 16,384 — Anthropic recommended sweet spot for most tasks
|
||||||
|
// high = 32,000 — complex architecture, deep analysis (diminishing returns above this)
|
||||||
|
// xhigh = 64,000 — extreme reasoning, research problems, benchmarks
|
||||||
|
//
|
||||||
|
// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens.
|
||||||
|
func levelToBudget(level string) int {
|
||||||
|
switch level {
|
||||||
|
case "low":
|
||||||
|
return 4096
|
||||||
|
case "medium":
|
||||||
|
return 16384
|
||||||
|
case "high":
|
||||||
|
return 32000
|
||||||
|
case "xhigh":
|
||||||
|
return 64000
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
|
|
@ -213,10 +333,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
|
|
||||||
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
var reasoning strings.Builder
|
||||||
var toolCalls []ToolCall
|
var toolCalls []ToolCall
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
for _, block := range resp.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
|
case "thinking":
|
||||||
|
tb := block.AsThinking()
|
||||||
|
reasoning.WriteString(tb.Thinking)
|
||||||
case "text":
|
case "text":
|
||||||
tb := block.AsText()
|
tb := block.AsText()
|
||||||
content.WriteString(tb.Text)
|
content.WriteString(tb.Text)
|
||||||
|
|
@ -247,6 +371,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: content.String(),
|
Content: content.String(),
|
||||||
|
Reasoning: reasoning.String(),
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
Usage: &UsageInfo{
|
Usage: &UsageInfo{
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
if string(params.Model) != "claude-sonnet-4.6" {
|
if string(params.Model) != "claude-sonnet-4-6" {
|
||||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6")
|
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
|
||||||
}
|
}
|
||||||
if params.MaxTokens != 1024 {
|
if params.MaxTokens != 1024 {
|
||||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||||
|
|
@ -262,6 +262,65 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProvider_ChatStreamingRoundTrip(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
||||||
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
|
||||||
|
events := []string{
|
||||||
|
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
|
||||||
|
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
|
||||||
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n",
|
||||||
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n",
|
||||||
|
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
|
||||||
|
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
|
||||||
|
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
w.Write([]byte(e))
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
|
||||||
|
return "refreshed-token", nil
|
||||||
|
}, server.URL)
|
||||||
|
|
||||||
|
resp, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "Hello"}},
|
||||||
|
nil,
|
||||||
|
"claude-sonnet-4.6",
|
||||||
|
map[string]any{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Content != "Hello world" {
|
||||||
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||||
|
}
|
||||||
|
if resp.Usage.CompletionTokens != 5 {
|
||||||
|
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
||||||
c := anthropic.NewClient(
|
c := anthropic.NewClient(
|
||||||
anthropicoption.WithAuthToken(token),
|
anthropicoption.WithAuthToken(token),
|
||||||
|
|
|
||||||
212
pkg/providers/anthropic/thinking_test.go
Normal file
212
pkg/providers/anthropic/thinking_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package anthropicprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_Adaptive(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{
|
||||||
|
MaxTokens: 16000,
|
||||||
|
Temperature: anthropic.Float(0.7),
|
||||||
|
}
|
||||||
|
applyThinkingConfig(¶ms, "adaptive")
|
||||||
|
|
||||||
|
if params.Thinking.OfAdaptive == nil {
|
||||||
|
t.Fatal("expected adaptive thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled != nil {
|
||||||
|
t.Error("should not set enabled thinking in adaptive mode")
|
||||||
|
}
|
||||||
|
if params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh {
|
||||||
|
t.Errorf("effort = %q, want %q", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh)
|
||||||
|
}
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_BudgetLevels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
level string
|
||||||
|
wantBudget int64
|
||||||
|
}{
|
||||||
|
{"low", 4096},
|
||||||
|
{"medium", 16384},
|
||||||
|
{"high", 32000},
|
||||||
|
{"xhigh", 64000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.level, func(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{
|
||||||
|
MaxTokens: 200000,
|
||||||
|
Temperature: anthropic.Float(0.5),
|
||||||
|
}
|
||||||
|
applyThinkingConfig(¶ms, tt.level)
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfAdaptive != nil {
|
||||||
|
t.Error("should not set adaptive thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget {
|
||||||
|
t.Errorf("budget_tokens = %d, want %d", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget)
|
||||||
|
}
|
||||||
|
if params.OutputConfig.Effort != "" {
|
||||||
|
t.Errorf("effort = %q, want empty", params.OutputConfig.Effort)
|
||||||
|
}
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking is enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_BudgetClamp(t *testing.T) {
|
||||||
|
// budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens.
|
||||||
|
params := anthropic.MessageNewParams{MaxTokens: 4096}
|
||||||
|
applyThinkingConfig(¶ms, "high") // budget=32000 > maxTokens=4096
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != 4095 {
|
||||||
|
t.Errorf("budget_tokens = %d, want 4095 (maxTokens-1)", params.Thinking.OfEnabled.BudgetTokens)
|
||||||
|
}
|
||||||
|
if params.MaxTokens != 4096 {
|
||||||
|
t.Errorf("max_tokens should not be modified, got %d", params.MaxTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThinkingConfig_UnknownLevel(t *testing.T) {
|
||||||
|
params := anthropic.MessageNewParams{MaxTokens: 16000}
|
||||||
|
applyThinkingConfig(¶ms, "unknown")
|
||||||
|
|
||||||
|
if params.Thinking.OfEnabled != nil {
|
||||||
|
t.Error("should not set enabled thinking for unknown level")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfAdaptive != nil {
|
||||||
|
t.Error("should not set adaptive thinking for unknown level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelToBudget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
level string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"low", "low", 4096},
|
||||||
|
{"medium", "medium", 16384},
|
||||||
|
{"high", "high", 32000},
|
||||||
|
{"xhigh", "xhigh", 64000},
|
||||||
|
{"off", "off", 0},
|
||||||
|
{"empty", "", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := levelToBudget(tt.level); got != tt.want {
|
||||||
|
t.Errorf("levelToBudget(%q) = %d, want %d", tt.level, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_ThinkingClearsTemperature(t *testing.T) {
|
||||||
|
msgs := []Message{{Role: "user", Content: "hello"}}
|
||||||
|
opts := map[string]any{
|
||||||
|
"max_tokens": 200000,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"thinking_level": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be cleared when thinking_level is set")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled == nil {
|
||||||
|
t.Fatal("expected enabled thinking")
|
||||||
|
}
|
||||||
|
if params.Thinking.OfEnabled.BudgetTokens != 16384 {
|
||||||
|
t.Errorf("budget_tokens = %d, want 16384", params.Thinking.OfEnabled.BudgetTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that
|
||||||
|
// the internal JSON.raw field is populated (required by AsText/AsThinking).
|
||||||
|
func unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion {
|
||||||
|
t.Helper()
|
||||||
|
var blocks []anthropic.ContentBlockUnion
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil {
|
||||||
|
t.Fatalf("unmarshalBlocks: %v", err)
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_ThinkingBlock(t *testing.T) {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
Content: unmarshalBlocks(t, `[
|
||||||
|
{"type":"thinking","thinking":"Let me reason step by step...","signature":"sig"},
|
||||||
|
{"type":"text","text":"The answer is 42."}
|
||||||
|
]`),
|
||||||
|
StopReason: anthropic.StopReasonEndTurn,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseResponse(resp)
|
||||||
|
|
||||||
|
if result.Reasoning != "Let me reason step by step..." {
|
||||||
|
t.Errorf("Reasoning = %q, want thinking content", result.Reasoning)
|
||||||
|
}
|
||||||
|
if result.Content != "The answer is 42." {
|
||||||
|
t.Errorf("Content = %q, want text content", result.Content)
|
||||||
|
}
|
||||||
|
if result.FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want stop", result.FinishReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_NoThinkingBlock(t *testing.T) {
|
||||||
|
resp := &anthropic.Message{
|
||||||
|
Content: unmarshalBlocks(t, `[
|
||||||
|
{"type":"text","text":"Just a normal response."}
|
||||||
|
]`),
|
||||||
|
StopReason: anthropic.StopReasonEndTurn,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseResponse(resp)
|
||||||
|
|
||||||
|
if result.Reasoning != "" {
|
||||||
|
t.Errorf("Reasoning = %q, want empty", result.Reasoning)
|
||||||
|
}
|
||||||
|
if result.Content != "Just a normal response." {
|
||||||
|
t.Errorf("Content = %q, want text content", result.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) {
|
||||||
|
msgs := []Message{{Role: "user", Content: "hello"}}
|
||||||
|
opts := map[string]any{
|
||||||
|
"temperature": 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.Temperature.Valid() {
|
||||||
|
t.Error("temperature should be preserved when thinking is not set")
|
||||||
|
}
|
||||||
|
if params.Temperature.Value != 0.8 {
|
||||||
|
t.Errorf("temperature = %f, want 0.8", params.Temperature.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -640,7 +640,10 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading loadCodeAssist response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -681,7 +684,10 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading fetchAvailableModels response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"fetchAvailableModels failed (HTTP %d): %s",
|
"fetchAvailableModels failed (HTTP %d): %s",
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.model = "deepseek-chat"
|
sel.model = "deepseek-chat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "avian":
|
||||||
|
if cfg.Providers.Avian.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Avian.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.avian.io/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
case "mistral":
|
case "mistral":
|
||||||
if cfg.Providers.Mistral.APIKey != "" {
|
if cfg.Providers.Mistral.APIKey != "" {
|
||||||
sel.apiKey = cfg.Providers.Mistral.APIKey
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
|
@ -300,6 +309,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Avian.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.avian.io/v1"
|
||||||
|
}
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen", "mistral":
|
"volcengine", "vllm", "qwen", "mistral", "avian":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -208,6 +208,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
case "mistral":
|
case "mistral":
|
||||||
return "https://api.mistral.ai/v1"
|
return "https://api.mistral.ai/v1"
|
||||||
|
case "avian":
|
||||||
|
return "https://api.avian.io/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,12 +323,14 @@ func serializeMessages(messages []Message) []any {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, mediaURL := range m.Media {
|
for _, mediaURL := range m.Media {
|
||||||
parts = append(parts, map[string]any{
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
"type": "image_url",
|
parts = append(parts, map[string]any{
|
||||||
"image_url": map[string]any{
|
"type": "image_url",
|
||||||
"url": mediaURL,
|
"image_url": map[string]any{
|
||||||
},
|
"url": mediaURL,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := map[string]any{
|
msg := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@ type StatefulProvider interface {
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThinkingCapable is an optional interface for providers that support
|
||||||
|
// extended thinking (e.g. Anthropic). Used by the agent loop to warn
|
||||||
|
// when thinking_level is configured but the active provider cannot use it.
|
||||||
|
type ThinkingCapable interface {
|
||||||
|
SupportsThinking() bool
|
||||||
|
}
|
||||||
|
|
||||||
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
||||||
type FailoverReason string
|
type FailoverReason string
|
||||||
|
|
||||||
|
|
|
||||||
80
pkg/routing/classifier.go
Normal file
80
pkg/routing/classifier.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
// Classifier evaluates a feature set and returns a complexity score in [0, 1].
|
||||||
|
// A higher score indicates a more complex task that benefits from a heavy model.
|
||||||
|
// The score is compared against the configured threshold: score >= threshold selects
|
||||||
|
// the primary (heavy) model; score < threshold selects the light model.
|
||||||
|
//
|
||||||
|
// Classifier is an interface so that future implementations (ML-based, embedding-based,
|
||||||
|
// or any other approach) can be swapped in without changing routing infrastructure.
|
||||||
|
type Classifier interface {
|
||||||
|
Score(f Features) float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleClassifier is the v1 implementation.
|
||||||
|
// It uses a weighted sum of structural signals with no external dependencies,
|
||||||
|
// no API calls, and sub-microsecond latency. The raw sum is capped at 1.0 so
|
||||||
|
// that the returned score always falls within the [0, 1] contract.
|
||||||
|
//
|
||||||
|
// Individual weights (multiple signals can fire simultaneously):
|
||||||
|
//
|
||||||
|
// token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex
|
||||||
|
// token 50-200: 0.15 — medium length; may or may not be complex
|
||||||
|
// code block present: 0.40 — coding tasks need the heavy model
|
||||||
|
// tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow
|
||||||
|
// tool calls 1-3 (recent): 0.10 — some tool activity
|
||||||
|
// conversation depth > 10: 0.10 — long sessions carry implicit complexity
|
||||||
|
// attachments present: 1.00 — hard gate; multi-modal always needs heavy model
|
||||||
|
//
|
||||||
|
// Default threshold is 0.35, so:
|
||||||
|
// - Pure greetings / trivial Q&A: 0.00 → light ✓
|
||||||
|
// - Medium prose message (50–200 tokens): 0.15 → light ✓
|
||||||
|
// - Message with code block: 0.40 → heavy ✓
|
||||||
|
// - Long message (>200 tokens): 0.35 → heavy ✓
|
||||||
|
// - Active tool session + medium message: 0.25 → light (acceptable)
|
||||||
|
// - Any message with an image/audio attachment: 1.00 → heavy ✓
|
||||||
|
type RuleClassifier struct{}
|
||||||
|
|
||||||
|
// Score computes the complexity score for the given feature set.
|
||||||
|
// The returned value is in [0, 1]. Attachments short-circuit to 1.0.
|
||||||
|
func (c *RuleClassifier) Score(f Features) float64 {
|
||||||
|
// Hard gate: multi-modal inputs always require the heavy model.
|
||||||
|
if f.HasAttachments {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var score float64
|
||||||
|
|
||||||
|
// Token estimate — primary verbosity signal
|
||||||
|
switch {
|
||||||
|
case f.TokenEstimate > 200:
|
||||||
|
score += 0.35
|
||||||
|
case f.TokenEstimate > 50:
|
||||||
|
score += 0.15
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenced code blocks — strongest indicator of a coding/technical task
|
||||||
|
if f.CodeBlockCount > 0 {
|
||||||
|
score += 0.40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent tool call density — indicates an ongoing agentic workflow
|
||||||
|
switch {
|
||||||
|
case f.RecentToolCalls > 3:
|
||||||
|
score += 0.25
|
||||||
|
case f.RecentToolCalls > 0:
|
||||||
|
score += 0.10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation depth — accumulated context implies compound task
|
||||||
|
if f.ConversationDepth > 10 {
|
||||||
|
score += 0.10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 1.0 to honor the [0, 1] contract even when multiple signals fire
|
||||||
|
// simultaneously (e.g., long message + code block + tool chain = 1.10 raw).
|
||||||
|
if score > 1.0 {
|
||||||
|
score = 1.0
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
127
pkg/routing/features.go
Normal file
127
pkg/routing/features.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookbackWindow is the number of recent history entries scanned for tool calls.
|
||||||
|
// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).
|
||||||
|
const lookbackWindow = 6
|
||||||
|
|
||||||
|
// Features holds the structural signals extracted from a message and its session context.
|
||||||
|
// Every dimension is language-agnostic by construction — no keyword or pattern matching
|
||||||
|
// against natural-language content. This ensures consistent routing for all locales.
|
||||||
|
type Features struct {
|
||||||
|
// TokenEstimate is a proxy for token count.
|
||||||
|
// CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each.
|
||||||
|
// This avoids API calls while giving accurate estimates for all scripts.
|
||||||
|
TokenEstimate int
|
||||||
|
|
||||||
|
// CodeBlockCount is the number of fenced code blocks (``` pairs) in the message.
|
||||||
|
// Coding tasks almost always require the heavy model.
|
||||||
|
CodeBlockCount int
|
||||||
|
|
||||||
|
// RecentToolCalls is the count of tool_call messages in the last lookbackWindow
|
||||||
|
// history entries. A high density indicates an active agentic workflow.
|
||||||
|
RecentToolCalls int
|
||||||
|
|
||||||
|
// ConversationDepth is the total number of messages in the session history.
|
||||||
|
// Deep sessions tend to carry implicit complexity built up over many turns.
|
||||||
|
ConversationDepth int
|
||||||
|
|
||||||
|
// HasAttachments is true when the message appears to contain media (images,
|
||||||
|
// audio, video). Multi-modal inputs require vision-capable heavy models.
|
||||||
|
HasAttachments bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFeatures computes the structural feature vector for a message.
|
||||||
|
// It is a pure function with no side effects and zero allocations beyond
|
||||||
|
// the returned struct.
|
||||||
|
func ExtractFeatures(msg string, history []providers.Message) Features {
|
||||||
|
return Features{
|
||||||
|
TokenEstimate: estimateTokens(msg),
|
||||||
|
CodeBlockCount: countCodeBlocks(msg),
|
||||||
|
RecentToolCalls: countRecentToolCalls(history),
|
||||||
|
ConversationDepth: len(history),
|
||||||
|
HasAttachments: hasAttachments(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateTokens returns a token count proxy that handles both CJK and Latin text.
|
||||||
|
// CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) map to roughly one
|
||||||
|
// token each, while non-CJK runes average ~0.25 tokens/rune (≈4 chars per token
|
||||||
|
// for English). Splitting the count this way avoids the 3x underestimation that a
|
||||||
|
// flat rune_count/3 would produce for Chinese, Japanese, and Korean text.
|
||||||
|
func estimateTokens(msg string) int {
|
||||||
|
total := utf8.RuneCountInString(msg)
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cjk := 0
|
||||||
|
for _, r := range msg {
|
||||||
|
if r >= 0x2E80 && r <= 0x9FFF || r >= 0xF900 && r <= 0xFAFF || r >= 0xAC00 && r <= 0xD7AF {
|
||||||
|
cjk++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cjk + (total-cjk)/4
|
||||||
|
}
|
||||||
|
|
||||||
|
// countCodeBlocks counts the number of complete fenced code blocks.
|
||||||
|
// Each ``` delimiter increments a counter; pairs of delimiters form one block.
|
||||||
|
// An unclosed opening fence (odd count) is treated as zero complete blocks
|
||||||
|
// since it may just be an inline code span or a typo.
|
||||||
|
func countCodeBlocks(msg string) int {
|
||||||
|
n := strings.Count(msg, "```")
|
||||||
|
return n / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRecentToolCalls counts messages with tool calls in the last lookbackWindow
|
||||||
|
// entries of history. It examines the ToolCalls field rather than parsing
|
||||||
|
// the content string, so it is robust to any message format.
|
||||||
|
func countRecentToolCalls(history []providers.Message) int {
|
||||||
|
start := len(history) - lookbackWindow
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, msg := range history[start:] {
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
count += len(msg.ToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAttachments returns true when the message content contains embedded media.
|
||||||
|
// It checks for base64 data URIs (data:image/, data:audio/, data:video/) and
|
||||||
|
// common image/audio URL extensions. This is intentionally conservative —
|
||||||
|
// false negatives (missing an attachment) just mean the routing falls back to
|
||||||
|
// the primary model anyway.
|
||||||
|
func hasAttachments(msg string) bool {
|
||||||
|
lower := strings.ToLower(msg)
|
||||||
|
|
||||||
|
// Base64 data URIs embedded directly in the message
|
||||||
|
if strings.Contains(lower, "data:image/") ||
|
||||||
|
strings.Contains(lower, "data:audio/") ||
|
||||||
|
strings.Contains(lower, "data:video/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common image/audio extensions in URLs or file references
|
||||||
|
mediaExts := []string{
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp",
|
||||||
|
".mp3", ".wav", ".ogg", ".m4a", ".flac",
|
||||||
|
".mp4", ".avi", ".mov", ".webm",
|
||||||
|
}
|
||||||
|
for _, ext := range mediaExts {
|
||||||
|
if strings.Contains(lower, ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue