Merge branch 'main' into fix/openai-compat-error-message-and-update-feishu

This commit is contained in:
ywj 2026-03-13 09:01:40 +00:00 committed by GitHub
commit 2840ceb006
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
166 changed files with 13257 additions and 1989 deletions

View file

@ -5,6 +5,7 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
# MODELSCOPE_API_KEY=xxx
# CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...

View file

@ -9,64 +9,37 @@ permissions:
contents: read
jobs:
create-tag:
name: Create Git Tag
nightly:
name: Nightly Build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.version.outputs.changelog }}
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate and push tag
- name: Compute version
id: version
run: |
DATE=$(date -u +%Y%m%d)
SHA=$(git rev-parse --short=8 HEAD)
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
TAG="v0.0.0-nightly.${DATE}.${SHA}"
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
else
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi
VERSION=$TAG
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists, reusing existing tag"
else
git tag -a "$TAG" -m "Nightly build $VERSION"
fi
git push origin "$TAG"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
fi
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.create-tag.outputs.tag }}
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
- name: Setup Go from go.mod
id: setup-go
@ -95,6 +68,16 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@ -106,6 +89,7 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@ -113,26 +97,14 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
update-rolling:
name: Update Rolling Nightly
needs: [create-tag, release]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Update nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
TITLE: ${{ needs.create-tag.outputs.version }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
CHANGELOG='${{ steps.version.outputs.changelog }}'
NOTES=$(cat <<EOF
Nightly build for **${TITLE}**
Nightly build for **${VERSION}**
This is an automated build and may be unstable. Use with caution.
@ -140,65 +112,27 @@ jobs:
EOF
)
# Download assets from the newly created release if it exists,
# otherwise fall back to using locally built dist/ artifacts.
mkdir -p build
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Downloading assets from GitHub release for $TAG..."
gh release download "$TAG" --dir build
else
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
if [ -d "dist" ]; then
cp -R dist/* build/
else
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
exit 1
fi
fi
# Delete existing nightly release and tag
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
# Delete existing nightly release and tag to avoid conflicts
echo "Deleting existing nightly release and tag..."
gh release delete nightly --cleanup-tag -y || true
git push origin :refs/tags/nightly || true
# Force-update nightly tag to current HEAD
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -fa nightly -m "Nightly build ${VERSION}"
git push origin nightly
# Collect release artifacts from goreleaser dist/
ASSETS=()
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
[ -f "$f" ] && ASSETS+=("$f")
done
# Create nightly release (prerelease, NOT latest)
gh release create nightly \
--title "Nightly Build" \
--notes "$NOTES" \
--target "${{ github.sha }}" \
--prerelease \
build/*
--latest=false \
"${ASSETS[@]}"
echo "Cleaning up old nightly releases (keeping only the most recent)..."
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
echo "Deleting old nightly release: $old_tag"
gh release delete "$old_tag" --cleanup-tag -y || true
fi
done
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
OWNER="${{ github.repository_owner }}"
PACKAGE_NAME="${{ github.event.repository.name }}"
# Check if owner is an organization or user
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
if echo "$ORG_TEST" | grep -q '"login"'; then
ACCOUNT_TYPE="orgs"
else
ACCOUNT_TYPE="users"
fi
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$PACKAGE_URL" \
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
for version_id in $OLD_NIGHTLY_VERSIONS; do
if [ -n "$version_id" ]; then
echo "Deleting Docker image version ID: $version_id"
gh api -X DELETE -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
fi
done

View file

@ -27,6 +27,7 @@ builds:
- windows
- darwin
- freebsd
- netbsd
goarch:
- amd64
- arm64
@ -44,6 +45,12 @@ builds:
ignore:
- goos: windows
goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher
binary: picoclaw-launcher
@ -116,9 +123,9 @@ dockers_v2:
- picoclaw
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags:
- "{{ .Tag }}"
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms:
- linux/amd64
@ -133,9 +140,9 @@ dockers_v2:
- picoclaw-launcher-tui
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags:
- "{{ .Tag }}-launcher"
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
platforms:
- linux/amd64
@ -215,6 +222,7 @@ changelog:
# lzma: true
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
footer: >-
---

View file

@ -181,6 +181,8 @@ build-all: generate
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=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Démarrage Rapide
> [!TIP]
> Configurez votre clé API dans `~/.picoclaw/config.json`.
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
**1. Initialiser**
@ -222,8 +220,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -231,7 +234,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"channels": {
@ -649,7 +652,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'Agent
├── SOUL.md # Âme de l'Agent
├── TOOLS.md # Description des outils
└── USER.md # Préférences utilisateur
```
@ -833,6 +835,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
@ -978,8 +981,11 @@ Cette conception permet également le **support multi-agent** avec une sélectio
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -989,8 +995,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1006,7 +1017,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1017,8 +1028,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1061,14 +1081,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1200,6 +1220,14 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
| Service | Offre Gratuite | Cas d'Utilisation |
| ---------------- | -------------------- | ------------------------------------- |
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
| **ModelScope** | 2000 requêtes/jour | Inférence gratuite (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 クイックスタート(ネイティブ)
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
**1. 初期化**
@ -184,8 +182,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -193,7 +196,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"channels": {
@ -610,7 +613,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認
├── IDENTITY.md # エージェントのアイデンティティ
├── SOUL.md # エージェントのソウル
├── TOOLS.md # ツールの説明
└── USER.md # ユーザー設定
```
@ -791,6 +793,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| --- | --- | --- |
| `gemini` | LLMGemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLMZhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter`(要テスト) | LLM推奨、全モデルにアクセス可能 | [openrouter.ai](https://openrouter.ai) |
| `anthropic`(要テスト) | LLMClaude 直接) | [console.anthropic.com](https://console.anthropic.com) |
| `openai`(要テスト) | LLMGPT 直接) | [platform.openai.com](https://platform.openai.com) |
@ -919,8 +922,11 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -930,8 +936,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -947,7 +958,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -958,8 +969,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1002,14 +1022,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1120,9 +1140,17 @@ Web 検索を有効にするには:
| サービス | 無料枠 | ユースケース |
|---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデルDoubao、DeepSeek等 |
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論Llama, Mixtral |
| **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) |
| **ModelScope** | 1 日 2000 リクエスト | 無料推論Qwen, GLM, DeepSeek など) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
@ -227,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Quick Start
> [!TIP]
> 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)
> 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.
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
**1. Initialize**
@ -244,7 +242,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@ -252,8 +250,13 @@ picoclaw onboard
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
@ -787,7 +790,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul
├── TOOLS.md # Tool descriptions
└── USER.md # User preferences
```
@ -990,13 +992,14 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> 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 |
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
@ -1033,9 +1036,12 @@ This design also enables **multi-agent support** with flexible provider selectio
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [Get Key](https://cloud.siliconflow.cn) |
@ -1045,8 +1051,13 @@ This design also enables **multi-agent support** with flexible provider selectio
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1062,7 +1073,7 @@ This design also enables **multi-agent support** with flexible provider selectio
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1074,8 +1085,18 @@ This design also enables **multi-agent support** with flexible provider selectio
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1112,6 +1133,26 @@ This design also enables **multi-agent support** with flexible provider selectio
> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Anthropic Messages API (native format)**
For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
}
```
> Use `anthropic-messages` protocol when:
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
>
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
**Ollama (local)**
```json
@ -1165,14 +1206,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1512,9 +1553,17 @@ This happens when another instance of the bot is running. Make sure only one `pi
| Service | Free Tier | Use Case |
| ---------------- | ------------------------ | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
| **ModelScope** | 2000 requests/day | Free inference (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Início Rápido
> [!TIP]
> Configure sua API key em `~/.picoclaw/config.json`.
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
**1. Inicializar**
@ -223,8 +221,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -232,7 +235,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"tools": {
@ -645,7 +648,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
├── IDENTITY.md # Identidade do Agente
├── SOUL.md # Alma do Agente
├── TOOLS.md # Descrição das ferramentas
└── USER.md # Preferencias do usuario
```
@ -829,6 +831,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
| --- | --- | --- |
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
@ -974,8 +977,11 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obter Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -985,8 +991,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1002,7 +1013,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1013,8 +1024,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1057,14 +1077,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1196,7 +1216,15 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
| Serviço | Plano Gratuito | Caso de Uso |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
| **ModelScope** | 2000 requisições/dia | Inferência gratuita (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Bắt đầu nhanh
> [!TIP]
> Thiết lập API key trong `~/.picoclaw/config.json`.
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
**1. Khởi tạo**
@ -203,8 +201,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -617,7 +620,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính Agent
├── SOUL.md # Tâm hồn/Tính cách Agent
├── TOOLS.md # Mô tả công cụ
└── USER.md # Tùy chọn người dùng
```
@ -801,6 +803,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
| --- | --- | --- |
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
@ -943,8 +946,11 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Lấy Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -954,8 +960,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -971,7 +982,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -982,8 +993,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1026,14 +1046,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1165,6 +1185,14 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
| **ModelScope** | 2000 yêu cầu/ngày | Suy luận miễn phí (Qwen, GLM, DeepSeek, v.v.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 快速开始
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
**1. 初始化 (Initialize)**
@ -226,7 +224,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@ -234,8 +232,13 @@ picoclaw onboard
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
@ -365,7 +368,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格
├── TOOLS.md # 工具描述
└── USER.md # 用户偏好
```
@ -479,10 +481,11 @@ Agent 读取 HEARTBEAT.md
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
@ -518,8 +521,11 @@ Agent 读取 HEARTBEAT.md
| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [获取密钥](https://cloud.siliconflow.cn) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **火山引擎Doubao** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -529,8 +535,13 @@ Agent 读取 HEARTBEAT.md
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -546,7 +557,7 @@ Agent 读取 HEARTBEAT.md
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -558,8 +569,18 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**火山引擎Doubao**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -596,6 +617,26 @@ Agent 读取 HEARTBEAT.md
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
**Anthropic Messages API原生格式**
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
}
```
> 使用 `anthropic-messages` 协议的场景:
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
>
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
**Ollama (本地)**
```json
@ -625,14 +666,14 @@ Agent 读取 HEARTBEAT.md
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -878,7 +919,16 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| 服务 | 免费层级 | 适用场景 |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型豆包、DeepSeek 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 345 KiB

View file

@ -49,7 +49,7 @@ func (s *appState) modelMenu() tview.Primitive {
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
@ -291,7 +291,7 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},

View file

@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it
if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
})
}
// Update default model to use OpenAI
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.2")
fmt.Println("Default model set to: gpt-5.4")
return nil
}
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "token",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)

View file

@ -1,23 +1,42 @@
package gateway
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
func NewGatewayCommand() *cobra.Command {
var debug bool
var noTruncate bool
cmd := &cobra.Command{
Use: "gateway",
Aliases: []string{"g"},
Short: "Start picoclaw gateway",
Args: cobra.NoArgs,
PreRunE: func(_ *cobra.Command, _ []string) error {
if noTruncate && !debug {
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
}
if noTruncate {
utils.SetDisableTruncation(true)
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
}
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
return gatewayCmd(debug)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
return cmd
}

View file

@ -3,10 +3,10 @@ package gateway
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"sync"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
@ -41,12 +41,31 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
// Timeout constants for service operations
const (
serviceRestartTimeout = 30 * time.Second
serviceShutdownTimeout = 30 * time.Second
providerReloadTimeout = 30 * time.Second
gracefulShutdownTimeout = 15 * time.Second
)
// gatewayServices holds references to all running services
type gatewayServices struct {
CronService *cron.CronService
HeartbeatService *heartbeat.HeartbeatService
MediaStore media.MediaStore
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
}
func gatewayCmd(debug bool) error {
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
configPath := internal.GetConfigPath()
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
@ -83,9 +102,55 @@ func gatewayCmd(debug bool) error {
"skills_available": skillsInfo["available"],
})
// Setup and start all services
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
if err != nil {
return err
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go agentLoop.Run(ctx)
// Setup config file watcher for hot reload
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug)
defer stopWatch()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
// Main event loop - wait for signals or config changes
for {
select {
case <-sigChan:
logger.Info("Shutting down...")
shutdownGateway(services, agentLoop, provider, true)
return nil
case newCfg := <-configReloadChan:
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus)
if err != nil {
logger.Errorf("Config reload failed: %v", err)
}
}
}
}
// setupAndStartServices initializes and starts all services
func setupAndStartServices(
cfg *config.Config,
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
) (*gatewayServices, error) {
services := &gatewayServices{}
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool(
services.CronService = setupCronTool(
agentLoop,
msgBus,
cfg.WorkspacePath(),
@ -93,20 +158,26 @@ func gatewayCmd(debug bool) error {
execTimeout,
cfg,
)
if err := services.CronService.Start(); err != nil {
return nil, fmt.Errorf("error starting cron service: %w", err)
}
fmt.Println("✓ Cron service started")
heartbeatService := heartbeat.NewHeartbeatService(
// Setup heartbeat service
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
heartbeatService.SetBus(msgBus)
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
services.HeartbeatService.SetBus(msgBus)
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
// Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
var response string
var err error
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
@ -118,24 +189,36 @@ func gatewayCmd(debug bool) error {
// sent to user via processSystemMessage when the async task completes
return tools.SilentResult(response)
})
if err := services.HeartbeatService.Start(); err != nil {
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
}
fmt.Println("✓ Heartbeat service started")
// Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
mediaStore.Start()
// Start the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start()
}
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
// Create channel manager
var err error
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil {
mediaStore.Stop()
return fmt.Errorf("error creating channel manager: %w", err)
// Stop the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return nil, fmt.Errorf("error creating channel manager: %w", err)
}
// Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager)
agentLoop.SetMediaStore(mediaStore)
agentLoop.SetChannelManager(services.ChannelManager)
agentLoop.SetMediaStore(services.MediaStore)
// Wire up voice transcription if a supported provider is configured.
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
@ -143,83 +226,386 @@ func gatewayCmd(debug bool) error {
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
}
enabledChannels := channelManager.GetEnabledChannels()
enabledChannels := services.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else {
fmt.Println("⚠ Warning: No channels enabled")
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
fmt.Println("✓ Cron service started")
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
deviceService.SetBus(msgBus)
if err := deviceService.Start(ctx); err != nil {
fmt.Printf("Error starting device service: %v\n", err)
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
// Setup shared HTTP server with health endpoints and webhook handlers
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.SetupHTTPServer(addr, healthServer)
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
return err
if err := services.ChannelManager.StartAll(context.Background()); err != nil {
return nil, fmt.Errorf("error starting channels: %w", err)
}
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
<-sigChan
fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
// Setup state manager and device service
stateManager := state.NewManager(cfg.WorkspacePath())
services.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
services.DeviceService.SetBus(msgBus)
if err := services.DeviceService.Start(context.Background()); err != nil {
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
cancel()
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown,
// since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
return services, nil
}
// stopAndCleanupServices stops all services and cleans up resources
func stopAndCleanupServices(
services *gatewayServices,
shutdownTimeout time.Duration,
) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
mediaStore.Stop()
if services.ChannelManager != nil {
services.ChannelManager.StopAll(shutdownCtx)
}
if services.DeviceService != nil {
services.DeviceService.Stop()
}
if services.HeartbeatService != nil {
services.HeartbeatService.Stop()
}
if services.CronService != nil {
services.CronService.Stop()
}
if services.MediaStore != nil {
// Stop the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
}
}
// shutdownGateway performs a complete gateway shutdown
func shutdownGateway(
services *gatewayServices,
agentLoop *agent.AgentLoop,
provider providers.LLMProvider,
fullShutdown bool,
) {
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
cp.Close()
}
stopAndCleanupServices(services, gracefulShutdownTimeout)
agentLoop.Stop()
agentLoop.Close()
fmt.Println("✓ Gateway stopped")
logger.Info("✓ Gateway stopped")
}
// handleConfigReload handles config file reload by stopping all services,
// reloading the provider and config, and restarting services with the new config.
func handleConfigReload(
ctx context.Context,
al *agent.AgentLoop,
newCfg *config.Config,
providerRef *providers.LLMProvider,
services *gatewayServices,
msgBus *bus.MessageBus,
) error {
logger.Info("🔄 Config file changed, reloading...")
newModel := newCfg.Agents.Defaults.ModelName
if newModel == "" {
newModel = newCfg.Agents.Defaults.Model
}
logger.Infof(" New model is '%s', recreating provider...", newModel)
// Stop all services before reloading
logger.Info(" Stopping all services...")
stopAndCleanupServices(services, serviceShutdownTimeout)
// Create new provider from updated config first to ensure validity
// This will use the correct API key and settings from newCfg.ModelList
newProvider, newModelID, err := providers.CreateProvider(newCfg)
if err != nil {
logger.Errorf(" ⚠ Error creating new provider: %v", err)
logger.Warn(" Attempting to restart services with old provider and config...")
// Try to restart services with old configuration
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
}
return fmt.Errorf("error creating new provider: %w", err)
}
if newModelID != "" {
newCfg.Agents.Defaults.ModelName = newModelID
}
// Use the atomic reload method on AgentLoop to safely swap provider and config.
// This handles locking internally to prevent races with in-flight LLM calls
// and concurrent reads of registry/config while the swap occurs.
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
defer reloadCancel()
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
// Close the newly created provider since it wasn't adopted
if cp, ok := newProvider.(providers.StatefulProvider); ok {
cp.Close()
}
logger.Warn(" Attempting to restart services with old provider and config...")
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
}
return fmt.Errorf("error reloading agent loop: %w", err)
}
// Update local provider reference only after successful atomic reload
*providerRef = newProvider
// Restart all services with new config
logger.Info(" Restarting all services with new configuration...")
if err := restartServices(al, services, msgBus); err != nil {
logger.Errorf(" ⚠ Error restarting services: %v", err)
return fmt.Errorf("error restarting services: %w", err)
}
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
return nil
}
// restartServices restarts all services after a config reload
func restartServices(
al *agent.AgentLoop,
services *gatewayServices,
msgBus *bus.MessageBus,
) error {
// Create an independent context with timeout for service restart
// This prevents cancellation from the main loop context during reload
ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout)
defer cancel()
// Get current config from agent loop (which has been updated if this is a reload)
cfg := al.GetConfig()
// Re-create and start cron service with new config
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
services.CronService = setupCronTool(
al,
msgBus,
cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout,
cfg,
)
if err := services.CronService.Start(); err != nil {
return fmt.Errorf("error restarting cron service: %w", err)
}
fmt.Println(" ✓ Cron service restarted")
// Re-create and start heartbeat service with new config
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
services.HeartbeatService.SetBus(msgBus)
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
var response string
var err error
response, err = al.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK")
}
return tools.SilentResult(response)
})
if err := services.HeartbeatService.Start(); err != nil {
return fmt.Errorf("error restarting heartbeat service: %w", err)
}
fmt.Println(" ✓ Heartbeat service restarted")
// Stop the old media store before creating a new one
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
// Re-create media store with new config
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
// Start the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start()
}
al.SetMediaStore(services.MediaStore)
// Re-create channel manager with new config
var err error
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil {
// Stop the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return fmt.Errorf("error recreating channel manager: %w", err)
}
al.SetChannelManager(services.ChannelManager)
enabledChannels := services.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
} else {
fmt.Println(" ⚠ Warning: No channels enabled")
}
// Setup HTTP server with new config
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
if err := services.ChannelManager.StartAll(ctx); err != nil {
return fmt.Errorf("error restarting channels: %w", err)
}
fmt.Printf(
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
// Re-create device service with new config
stateManager := state.NewManager(cfg.WorkspacePath())
services.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
services.DeviceService.SetBus(msgBus)
if err := services.DeviceService.Start(ctx); err != nil {
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled {
fmt.Println(" ✓ Device event service restarted")
}
// Wire up voice transcription with new config
transcriber := voice.DetectTranscriber(cfg)
al.SetTranscriber(transcriber) // This will set it to nil if disabled
if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} else {
logger.InfoCF("voice", "Transcription disabled", nil)
}
return nil
}
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
// Returns a channel for config updates and a stop function
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
configChan := make(chan *config.Config, 1)
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Get initial file info
lastModTime := getFileModTime(configPath)
lastSize := getFileSize(configPath)
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
defer ticker.Stop()
for {
select {
case <-ticker.C:
currentModTime := getFileModTime(configPath)
currentSize := getFileSize(configPath)
// Check if file changed (modification time or size changed)
if currentModTime.After(lastModTime) || currentSize != lastSize {
if debug {
logger.Debugf("🔍 Config file change detected")
}
// Debounce - wait a bit to ensure file write is complete
time.Sleep(500 * time.Millisecond)
// Validate and load new config
newCfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Errorf("⚠ Error loading new config: %v", err)
logger.Warn(" Using previous valid config")
continue
}
// Validate the new config
if err := newCfg.ValidateModelList(); err != nil {
logger.Errorf(" ⚠ New config validation failed: %v", err)
logger.Warn(" Using previous valid config")
continue
}
logger.Info("✓ Config file validated and loaded")
// Update last known state
lastModTime = currentModTime
lastSize = currentSize
// Send new config to main loop (non-blocking)
select {
case configChan <- newCfg:
default:
// Channel full, skip this update
logger.Warn("⚠ Previous config reload still in progress, skipping")
}
}
case <-stop:
return
}
}
}()
stopFunc := func() {
close(stop)
wg.Wait()
}
return configChan, stopFunc
}
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
func getFileModTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return info.ModTime()
}
// getFileSize returns the size of a file, or 0 if file doesn't exist
func getFileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}
func setupCronTool(
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
@ -239,7 +625,7 @@ func setupCronTool(
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)
logger.Fatalf("Critical error during CronTool initialization: %v", err)
}
agentLoop.RegisterTool(cronTool)

View file

@ -0,0 +1,138 @@
package model
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
const LocalModel = "local-model"
func NewModelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "model [model_name]",
Short: "Show or change the default model",
Long: `Show or change the default model configuration.
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configPath := internal.GetConfigPath()
// Load current config
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if len(args) == 0 {
// Show current default model
showCurrentModel(cfg)
return nil
}
// Set new default model
modelName := args[0]
return setDefaultModel(configPath, cfg, modelName)
},
}
return cmd
}
func showCurrentModel(cfg *config.Config) {
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
defaultModel = cfg.Agents.Defaults.Model
}
if defaultModel == "" {
fmt.Println("No default model is currently set.")
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
} else {
fmt.Printf("Current default model: %s\n", defaultModel)
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
}
func listAvailableModels(cfg *config.Config) {
if len(cfg.ModelList) == 0 {
fmt.Println(" No models configured in model_list")
return
}
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
defaultModel = cfg.Agents.Defaults.Model
}
for _, model := range cfg.ModelList {
marker := " "
if model.ModelName == defaultModel {
marker = "> "
}
if model.APIKey == "" {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
}
}
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
if model.APIKey != "" && model.ModelName == modelName {
modelFound = true
break
}
}
if !modelFound && modelName != LocalModel {
return fmt.Errorf("cannot found model '%s' in config", modelName)
}
// Update the default model
// Clear old model field and set new model_name
oldModel := cfg.Agents.Defaults.ModelName
if oldModel == "" {
oldModel = cfg.Agents.Defaults.Model
}
cfg.Agents.Defaults.ModelName = modelName
cfg.Agents.Defaults.Model = "" // Clear deprecated field
// Save config back to file
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
formatModelName(oldModel), modelName)
fmt.Println("\nThe new default model will be used for all agent interactions.")
return nil
}
func formatModelName(name string) string {
if name == "" {
return "(none)"
}
return name
}

View file

@ -0,0 +1,369 @@
package model
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
var configPath = ""
func initTest(t *testing.T) {
tmpDir := t.TempDir()
configPath = filepath.Join(tmpDir, "config.json")
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
}
// captureStdout captures stdout during the execution of fn and returns the captured output
func captureStdout(fn func()) string {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
return buf.String()
}
func TestNewModelCommand(t *testing.T) {
cmd := NewModelCommand()
require.NotNil(t, cmd)
assert.Equal(t, "model [model_name]", cmd.Use)
assert.Equal(t, "Show or change the default model", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: gpt-4")
assert.Contains(t, output, "Available models in your config:")
assert.Contains(t, output, "gpt-4")
assert.Contains(t, output, "claude-3")
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
Model: "",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "No default model is currently set.")
assert.Contains(t, output, "Available models in your config:")
}
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Model: "legacy-model",
},
},
ModelList: []config.ModelConfig{},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: legacy-model")
}
func TestListAvailableModels_Empty(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, "No models configured in model_list")
}
func TestListAvailableModels_WithModels(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.NotEmpty(t, output)
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
assert.NotContains(t, output, "no-key-model")
}
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
// Verify config was updated
updatedCfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
}
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Model: "legacy-old",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
}
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
},
}
err := setDefaultModel(invalidPath, cfg, "new-model")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to save config")
}
func TestFormatModelName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", "(none)"},
{"simple model", "gpt-4", "gpt-4"},
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
{"model with spaces", "my model", "my model"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatModelName(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{})
assert.NoError(t, err)
})
assert.Contains(t, output, "Current default model: test-model")
}
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{"new-model"})
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
}
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
cmd := NewModelCommand()
err := cmd.RunE(cmd, []string{"model1", "model2"})
assert.Error(t, err)
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, " - first-model (openai/first)")
assert.Contains(t, output, "> - middle-model (openai/middle)")
assert.Contains(t, output, " - last-model (openai/last)")
}

View file

@ -29,7 +29,15 @@ func NewSkillsCommand() *cobra.Command {
}
d.workspace = cfg.WorkspacePath()
d.installer = skills.NewSkillInstaller(d.workspace)
installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token,
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
return fmt.Errorf("error creating skills installer: %w", err)
}
d.installer = installer
// get global config directory and builtin skills directory
globalDir := filepath.Dir(internal.GetConfigPath())

View file

@ -18,6 +18,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
@ -43,6 +44,7 @@ func NewPicoclawCommand() *cobra.Command {
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
version.NewVersionCommand(),
)

View file

@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"cron",
"gateway",
"migrate",
"model",
"onboard",
"skills",
"status",

View file

@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20,
@ -13,8 +13,8 @@
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@ -25,6 +25,13 @@
"api_base": "https://api.anthropic.com/v1",
"thinking_level": "high"
},
{
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
},
{
"model_name": "gemini",
"model": "antigravity/gemini-2.0-flash",
@ -49,12 +56,25 @@
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_key": "your-modelscope-access-token",
"api_base": "https://api-inference.modelscope.cn/v1"
},
{
"model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
}
@ -285,6 +305,14 @@
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
},
"longcat": {
"api_key": "",
"api_base": "https://api.longcat.chat/openai"
},
"modelscope": {
"api_key": "",
"api_base": "https://api-inference.modelscope.cn/v1"
}
},
"tools": {
@ -429,6 +457,10 @@
"max_response_size": 0
}
},
"github": {
"proxy": "http://127.0.0.1:7891",
"token": ""
},
"max_concurrent_searches": 2,
"search_cache": {
"max_size": 50,
@ -488,6 +520,9 @@
"enabled": false,
"monitor_usb": true
},
"voice": {
"echo_transcription": false
},
"gateway": {
"host": "127.0.0.1",
"port": 18790

View file

@ -22,7 +22,8 @@ Add this to `config.json`:
"enabled": true,
"text": "Thinking..."
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"message_format": "richtext"
}
}
}
@ -42,10 +43,12 @@ Add this to `config.json`:
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output |
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
## 3. Currently Supported
- Text message send/receive
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
- Configurable message format (`richtext` / `plain`)
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send

33
docs/debug.md Normal file
View file

@ -0,0 +1,33 @@
# Debugging PicoClaw
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
## Starting PicoClaw in Debug Mode
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
```bash
picoclaw gateway --debug
# or
picoclaw gateway -d
```
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
## Disabling Log Truncation (Full Logs)
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
**Note:** This flag *only* works when combined with the `--debug` mode.
```bash
picoclaw gateway --debug --no-truncate
```
When this flag is active, the global truncation function is disabled. This is extremely useful for:
* Verifying the exact syntax of the messages sent to the provider.
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
* Debugging the session history saved in memory.

View file

@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"api_key": "sk-xxx"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-xxx"
},
{
@ -128,7 +128,7 @@ type Config struct {
type ModelConfig struct {
// Required
ModelName string `json:"model_name"` // user-facing name (alias)
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
// Common config
APIBase string `json:"api_base,omitempty"`
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
"model": "deepseek-chat"
},
"coder": {
"model": "gpt-5.2",
"model": "gpt-5.4",
"system_prompt": "You are a coding assistant..."
},
"translator": {
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-5.2
model: openai/gpt-5.4
api_key: xxx
- model_name: my-custom
litellm_params:

View file

@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Prefix | Description | Example |
|--------|-------------|---------|
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Field | Required | Description |
|-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
| `api_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL |
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key3",
"api_base": "https://api3.example.com/v1"
}

3
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/ergochat/irc-go v0.5.0
github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@ -20,6 +21,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.34.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@ -49,7 +51,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect

4
go.sum
View file

@ -81,6 +81,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -271,8 +273,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=

View file

@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
type ContextBuilder struct {
@ -538,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages(
})
// Log preview of system prompt (avoid logging huge content)
preview := fullSystemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
preview := utils.Truncate(fullSystemPrompt, 500)
logger.DebugCF("agent", "System prompt preview",
map[string]any{
"preview": preview,

View file

@ -25,7 +25,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
@ -48,6 +47,10 @@ type AgentLoop struct {
mediaStore media.MediaStore
transcriber voice.Transcriber
cmdRegistry *commands.Registry
mcp mcpRuntime
mu sync.RWMutex
// Track active requests for safe provider cleanup
activeRequests sync.WaitGroup
}
// processOptions configures how a message is processed
@ -240,118 +243,8 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true)
// Initialize MCP servers for all agents
if al.cfg.Tools.IsToolEnabled("mcp") {
mcpManager := mcp.NewManager()
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
} else {
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
return fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
)
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
}
if err := al.ensureMCPInitialized(ctx); err != nil {
return err
}
for al.running.Load() {
@ -389,7 +282,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// If so, skip publishing to avoid duplicate messages to the user.
// Use default agent's tools to check (message tool is shared).
alreadySent := false
defaultAgent := al.registry.GetDefaultAgent()
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
@ -431,12 +324,24 @@ func (al *AgentLoop) Stop() {
// Close releases resources held by agent session stores. Call after Stop.
func (al *AgentLoop) Close() {
al.registry.Close()
mcpManager := al.mcp.takeManager()
if mcpManager != nil {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}
al.GetRegistry().Close()
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
for _, agentID := range al.registry.ListAgentIDs() {
if agent, ok := al.registry.GetAgent(agentID); ok {
registry := al.GetRegistry()
for _, agentID := range registry.ListAgentIDs() {
if agent, ok := registry.GetAgent(agentID); ok {
agent.Tools.Register(tool)
}
}
@ -462,12 +367,123 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
}
}
// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization.
// It uses a context to allow timeout control from the caller.
// Returns an error if the reload fails or context is canceled.
func (al *AgentLoop) ReloadProviderAndConfig(
ctx context.Context,
provider providers.LLMProvider,
cfg *config.Config,
) error {
// Validate inputs
if provider == nil {
return fmt.Errorf("provider cannot be nil")
}
if cfg == nil {
return fmt.Errorf("config cannot be nil")
}
// Create new registry with updated config and provider
// Wrap in defer/recover to handle any panics gracefully
var registry *AgentRegistry
var panicErr error
done := make(chan struct{}, 1)
go func() {
defer func() {
if r := recover(); r != nil {
panicErr = fmt.Errorf("panic during registry creation: %v", r)
logger.ErrorCF("agent", "Panic during registry creation",
map[string]any{"panic": r})
}
close(done)
}()
registry = NewAgentRegistry(cfg, provider)
}()
// Wait for completion or context cancellation
select {
case <-done:
if registry == nil {
if panicErr != nil {
return fmt.Errorf("registry creation failed: %w", panicErr)
}
return fmt.Errorf("registry creation failed (nil result)")
}
case <-ctx.Done():
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
}
// Check context again before proceeding
if err := ctx.Err(); err != nil {
return fmt.Errorf("context canceled after registry creation: %w", err)
}
// Ensure shared tools are re-registered on the new registry
registerSharedTools(cfg, al.bus, registry, provider)
// Atomically swap the config and registry under write lock
// This ensures readers see a consistent pair
al.mu.Lock()
oldRegistry := al.registry
// Store new values
al.cfg = cfg
al.registry = registry
// Also update fallback chain with new config
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
al.mu.Unlock()
// Close old provider after releasing the lock
// This prevents blocking readers while closing
if oldProvider, ok := extractProvider(oldRegistry); ok {
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
// Give in-flight requests a moment to complete
// Use a reasonable timeout that balances cleanup vs resource usage
select {
case <-time.After(100 * time.Millisecond):
stateful.Close()
case <-ctx.Done():
// Context canceled, close immediately but log warning
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
map[string]any{"error": ctx.Err()})
stateful.Close()
}
}
}
logger.InfoCF("agent", "Provider and config reloaded successfully",
map[string]any{
"model": cfg.Agents.Defaults.GetModelName(),
})
return nil
}
// GetRegistry returns the current registry (thread-safe)
func (al *AgentLoop) GetRegistry() *AgentRegistry {
al.mu.RLock()
defer al.mu.RUnlock()
return al.registry
}
// GetConfig returns the current config (thread-safe)
func (al *AgentLoop) GetConfig() *config.Config {
al.mu.RLock()
defer al.mu.RUnlock()
return al.cfg
}
// SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s
// Propagate store to send_file tools in all agents.
al.registry.ForEachTool("send_file", func(t tools.Tool) {
registry := al.GetRegistry()
registry.ForEachTool("send_file", func(t tools.Tool) {
if sf, ok := t.(*tools.SendFileTool); ok {
sf.SetMediaStore(s)
}
@ -483,9 +499,10 @@ 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 {
// Returns the (possibly modified) message and true if audio was transcribed.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg
return msg, false
}
// Transcribe each audio media ref in order.
@ -509,9 +526,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
}
if len(transcriptions) == 0 {
return msg
return msg, false
}
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
// Replace audio annotations sequentially with transcriptions.
idx := 0
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
@ -529,7 +548,48 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
}
msg.Content = newContent
return msg
return msg, true
}
// sendTranscriptionFeedback sends feedback to the user with the result of
// audio transcription if the option is enabled. It uses Manager.SendMessage
// which executes synchronously (rate limiting, splitting, retry) so that
// ordering with the subsequent placeholder is guaranteed.
func (al *AgentLoop) sendTranscriptionFeedback(
ctx context.Context,
channel, chatID, messageID string,
validTexts []string,
) {
if !al.cfg.Voice.EchoTranscription {
return
}
if al.channelManager == nil {
return
}
var nonEmpty []string
for _, t := range validTexts {
if t != "" {
nonEmpty = append(nonEmpty, t)
}
}
var feedbackMsg string
if len(nonEmpty) > 0 {
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
} else {
feedbackMsg = "No voice detected in the audio"
}
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: feedbackMsg,
ReplyToMessageID: messageID,
})
if err != nil {
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
}
}
// inferMediaType determines the media type ("image", "audio", "video", "file")
@ -591,6 +651,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context,
content, sessionKey, channel, chatID string,
) (string, error) {
if err := al.ensureMCPInitialized(ctx); err != nil {
return "", err
}
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@ -608,7 +672,7 @@ func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context,
content, channel, chatID string,
) (string, error) {
agent := al.registry.GetDefaultAgent()
agent := al.GetRegistry().GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat")
}
@ -643,7 +707,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
},
)
msg = al.transcribeAudioInMessage(ctx, msg)
var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
// Route system messages to processSystemMessage
if msg.Channel == "system" {
@ -697,7 +768,8 @@ 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{
registry := al.GetRegistry()
route := registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel,
AccountID: inboundMetadata(msg, metadataKeyAccountID),
Peer: extractPeer(msg),
@ -706,9 +778,9 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
TeamID: inboundMetadata(msg, metadataKeyTeamID),
})
agent, ok := al.registry.GetAgent(route.AgentID)
agent, ok := registry.GetAgent(route.AgentID)
if !ok {
agent = al.registry.GetDefaultAgent()
agent = registry.GetDefaultAgent()
}
if agent == nil {
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
@ -770,7 +842,7 @@ func (al *AgentLoop) processSystemMessage(
}
// Use default agent for system messages
agent := al.registry.GetDefaultAgent()
agent := al.GetRegistry().GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for system message")
}
@ -826,7 +898,8 @@ func (al *AgentLoop) runAgentLoop(
)
// Resolve media:// refs to base64 data URLs (streaming)
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
cfg := al.GetConfig()
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
// 2. Save user message to session
@ -1004,6 +1077,9 @@ func (al *AgentLoop) runLLMIteration(
}
callLLM := func() (*providers.LLMResponse, error) {
al.activeRequests.Add(1)
defer al.activeRequests.Done()
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(
ctx,
@ -1102,6 +1178,7 @@ func (al *AgentLoop) runLLMIteration(
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"model": activeModel,
"error": err.Error(),
})
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
@ -1453,7 +1530,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
registry := al.GetRegistry()
agent := registry.GetDefaultAgent()
if agent == nil {
return info
}
@ -1470,8 +1548,8 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
// Agents info
info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
"count": len(registry.ListAgentIDs()),
"ids": registry.ListAgentIDs(),
}
return info
@ -1659,7 +1737,10 @@ func (al *AgentLoop) retryLLMCall(
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err = agent.Provider.Chat(
al.activeRequests.Add(1)
resp, err = func() (*providers.LLMResponse, error) {
defer al.activeRequests.Done()
return agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
@ -1670,6 +1751,8 @@ func (al *AgentLoop) retryLLMCall(
"prompt_cache_key": agent.ID,
},
)
}()
if err == nil && resp != nil && resp.Content != "" {
return resp, nil
}
@ -1802,9 +1885,11 @@ func (al *AgentLoop) handleCommand(
}
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
registry := al.GetRegistry()
cfg := al.GetConfig()
rt := &commands.Runtime{
Config: al.cfg,
ListAgentIDs: al.registry.ListAgentIDs,
Config: cfg,
ListAgentIDs: registry.ListAgentIDs,
ListDefinitions: al.cmdRegistry.Definitions,
GetEnabledChannels: func() []string {
if al.channelManager == nil {
@ -1824,7 +1909,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
}
if agent != nil {
rt.GetModelInfo = func() (string, string) {
return agent.Model, al.cfg.Agents.Defaults.Provider
return agent.Model, cfg.Agents.Defaults.Provider
}
rt.SwitchModel = func(value string) (string, error) {
oldModel := agent.Model
@ -1888,3 +1973,16 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
}
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}
// Helper to extract provider from registry for cleanup
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
if registry == nil {
return nil, false
}
// Get any agent to access the provider
defaultAgent := registry.GetDefaultAgent()
if defaultAgent == nil {
return nil, false
}
return defaultAgent.Provider, true
}

200
pkg/agent/loop_mcp.go Normal file
View file

@ -0,0 +1,200 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"context"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/tools"
)
type mcpRuntime struct {
initOnce sync.Once
mu sync.Mutex
manager *mcp.Manager
initErr error
}
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
r.mu.Lock()
r.manager = manager
r.initErr = nil
r.mu.Unlock()
}
func (r *mcpRuntime) setInitErr(err error) {
r.mu.Lock()
r.initErr = err
r.mu.Unlock()
}
func (r *mcpRuntime) getInitErr() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.initErr
}
func (r *mcpRuntime) takeManager() *mcp.Manager {
r.mu.Lock()
defer r.mu.Unlock()
manager := r.manager
r.manager = nil
return manager
}
func (r *mcpRuntime) hasManager() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.manager != nil
}
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
// agent mode share the same initialization path.
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
if !al.cfg.Tools.IsToolEnabled("mcp") {
return nil
}
if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 {
logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil)
return nil
}
findValidServer := false
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
if serverCfg.Enabled {
findValidServer = true
}
}
if !findValidServer {
logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil)
return nil
}
al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager()
defaultAgent := al.registry.GetDefaultAgent()
workspacePath := al.cfg.WorkspacePath()
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
al.mcp.setInitErr(fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
))
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
al.mcp.setManager(mcpManager)
})
return al.mcp.getInitErr()
}

View file

@ -788,6 +788,63 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
}
}
// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that
// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.
// Note: Manager is only initialized when at least one MCP server is configured
// and successfully connected.
func TestProcessDirectWithChannel_TriggersMCPInitialization(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)
// Test with MCP enabled but no servers - should not initialize manager
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{
Enabled: true,
},
// No servers configured - manager should not be initialized
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil before first direct processing")
}
_, err = al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-1",
"cli",
"direct",
)
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
// Manager should not be initialized when no servers are configured
if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil when no servers are configured")
}
}
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {

View file

@ -33,6 +33,7 @@ type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
}
// MediaPart describes a single media attachment to send.

View file

@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"regexp"
"strconv"
"strings"
"sync/atomic"
@ -32,6 +33,9 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:])
}
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters.
@ -284,13 +288,18 @@ func (c *BaseChannel) HandleMessage(
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
}
}
// Placeholder — independent pipeline
// Placeholder — independent pipeline.
// Skip when the message contains audio: the agent will send the
// placeholder after transcription completes, so the user sees
// "Thinking…" only once the voice has been processed.
if !audioAnnotationRe.MatchString(content) {
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
}
}
}
}
if err := c.bus.PublishInbound(ctx, msg); err != nil {
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{

View file

@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
// Set the logger for the Stream SDK
dinglog.SetLogger(logger.NewLogger("dingtalk"))
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),

View file

@ -45,6 +45,14 @@ type DiscordChannel struct {
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
discordgo.LogWarning: logger.WARN,
discordgo.LogInformational: logger.INFO,
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil
}
return c.sendChunk(ctx, channelID, msg.Content)
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
}
// SendMedia implements the channels.MediaSender interface.
@ -259,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
// Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.ChannelMessageSend(channelID, content)
var err error
// If we have an ID, we send the message as "Reply"
if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content,
Reference: &discordgo.MessageReference{
MessageID: replyToID,
ChannelID: channelID,
},
})
} else {
// Otherwise, we send a normal message
_, err = c.session.ChannelMessageSend(channelID, content)
}
done <- err
}()

View file

@ -32,6 +32,10 @@ const (
lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second
// Limit request body to prevent memory exhaustion (DoS).
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
maxWebhookBodySize = 1 << 20 // 1 MiB
)
type replyTokenEntry struct {
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if int64(len(body)) > maxWebhookBodySize {
logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return
}
signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) {

View file

@ -0,0 +1,81 @@
package line
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &LINEChannel{}
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
// Missing signature should be rejected, but the body size should not trigger 413.
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &LINEChannel{}
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestWebhookRejectsInvalidSignature(t *testing.T) {
ch := &LINEChannel{}
body := `{"events":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}

View file

@ -102,11 +102,37 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
}
// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID
// and records it for later editing. Returns true if a placeholder was sent.
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
m.mu.RLock()
ch, ok := m.channels[channel]
m.mu.RUnlock()
if !ok {
return false
}
pc, ok := ch.(PlaceholderCapable)
if !ok {
return false
}
phID, err := pc.SendPlaceholder(ctx, chatID)
if err != nil || phID == "" {
return false
}
m.RecordPlaceholder(channel, chatID, phID)
return true
}
// RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
entry := typingEntry{stop: stop, createdAt: time.Now()}
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
oldEntry.stop()
}
}
}
// RecordReactionUndo registers a reaction undo function for later invocation.
@ -841,6 +867,39 @@ func (m *Manager) UnregisterChannel(name string) {
delete(m.channels, name)
}
// SendMessage sends an outbound message synchronously through the channel
// worker's rate limiter and retry logic. It blocks until the message is
// delivered (or all retries are exhausted), which preserves ordering when
// a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", msg.Channel)
}
if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel)
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
for _, chunk := range SplitMessage(msg.Content, maxLen) {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
}
} else {
m.sendWithRetry(ctx, msg.Channel, w, msg)
}
return nil
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock()
_, exists := m.channels[channelName]

View file

@ -18,15 +18,31 @@ import (
type mockChannel struct {
BaseChannel
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
sentMessages []bus.OutboundMessage
placeholdersSent int
editedMessages int
lastPlaceholderID string
}
func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
m.sentMessages = append(m.sentMessages, msg)
return m.sendFn(ctx, msg)
}
func (m *mockChannel) Start(ctx context.Context) error { return nil }
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
m.placeholdersSent++
m.lastPlaceholderID = "mock-ph-123"
return m.lastPlaceholderID, nil
}
func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
m.editedMessages++
return nil
}
// newTestManager creates a minimal Manager suitable for unit tests.
func newTestManager() *Manager {
return &Manager{
@ -600,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
wg.Wait()
}
func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
m := newTestManager()
var oldStopCalls int
var newStopCalls int
m.RecordTypingStop("test", "123", func() {
oldStopCalls++
})
m.RecordTypingStop("test", "123", func() {
newStopCalls++
})
if oldStopCalls != 1 {
t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls)
}
if newStopCalls != 0 {
t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
m.preSend(context.Background(), "test", msg, &mockChannel{})
if newStopCalls != 1 {
t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls)
}
if oldStopCalls != 1 {
t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls)
}
}
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
m := newTestManager()
var sendCalled bool
@ -860,3 +907,286 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) {
t.Fatalf("expected %s, got %s", expected, scope)
}
}
func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
worker := newChannelWorker("mock", mockCh)
mgr.channels["mock"] = mockCh
mgr.workers["mock"] = worker
ctx := context.Background()
key := "mock:chat-1"
// Simulate a placeholder recorded by base.go HandleMessage
mgr.RecordPlaceholder("mock", "chat-1", "ph-123")
if _, ok := mgr.placeholders.Load(key); !ok {
t.Fatal("expected placeholder to be recorded")
}
// Transcription feedback arrives first — it should consume the placeholder
// and be delivered via EditMessage, not Send.
msgTranscript := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Transcript: hello",
}
mgr.sendWithRetry(ctx, "mock", worker, msgTranscript)
if mockCh.editedMessages != 1 {
t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages)
}
if len(mockCh.sentMessages) != 0 {
t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages))
}
// Placeholder should be gone now
if _, ok := mgr.placeholders.Load(key); ok {
t.Error("expected placeholder to be removed after being consumed")
}
// Final LLM response arrives — no placeholder left, so it goes through Send
msgFinal := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Final Answer",
}
mgr.sendWithRetry(ctx, "mock", worker, msgFinal)
if len(mockCh.sentMessages) != 1 {
t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages))
}
}
func TestSendMessage_Synchronous(t *testing.T) {
m := newTestManager()
var received []bus.OutboundMessage
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
ReplyToMessageID: "msg-456",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// SendMessage is synchronous — message should already be delivered
if len(received) != 1 {
t.Fatalf("expected 1 message sent, got %d", len(received))
}
if received[0].ReplyToMessageID != "msg-456" {
t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID)
}
if received[0].Content != "hello world" {
t.Fatalf("expected content 'hello world', got %s", received[0].Content)
}
}
func TestSendMessage_UnknownChannel(t *testing.T) {
m := newTestManager()
msg := bus.OutboundMessage{
Channel: "nonexistent",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error for unknown channel")
}
}
func TestSendMessage_NoWorker(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
}
m.channels["test"] = ch
// No worker registered
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error when no worker exists")
}
}
func TestSendMessage_WithRetry(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount == 1 {
return fmt.Errorf("transient: %w", ErrTemporary)
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "retry me",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount)
}
}
func TestSendMessage_WithSplitting(t *testing.T) {
m := newTestManager()
var received []string
ch := &mockChannelWithLength{
mockChannel: mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg.Content)
return nil
},
},
maxLen: 5,
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(received) < 2 {
t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received))
}
}
func TestSendMessage_PreservesOrdering(t *testing.T) {
m := newTestManager()
var order []string
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
order = append(order, msg.Content)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
// Send two messages sequentially — they must arrive in order
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "first",
})
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "second",
})
if len(order) != 2 {
t.Fatalf("expected 2 messages, got %d", len(order))
}
if order[0] != "first" || order[1] != "second" {
t.Fatalf("expected [first, second], got %v", order)
}
}
func TestManager_SendPlaceholder(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
mgr.channels["mock"] = mockCh
ctx := context.Background()
// SendPlaceholder should send a placeholder and record it
ok := mgr.SendPlaceholder(ctx, "mock", "chat-1")
if !ok {
t.Fatal("expected SendPlaceholder to succeed")
}
if mockCh.placeholdersSent != 1 {
t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent)
}
key := "mock:chat-1"
if _, loaded := mgr.placeholders.Load(key); !loaded {
t.Error("expected placeholder to be recorded in manager")
}
// SendPlaceholder on unknown channel should return false
ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1")
if ok {
t.Error("expected SendPlaceholder to fail for unknown channel")
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"html"
"io"
"mime"
"net/url"
"os"
@ -13,6 +14,9 @@ import (
"sync"
"time"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
return nil
}
func markdownToHTML(md string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
}
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgText,
Body: content,
})
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
if err != nil {
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
}
return nil
}
func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}
if c.config.MessageFormat != "plain" {
mc.Format = event.FormatHTML
mc.FormattedBody = markdownToHTML(text)
}
return mc
}
// SendMedia implements channels.MediaSender.
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI
return fmt.Errorf("matrix message ID is empty")
}
editContent := &event.MessageEventContent{
MsgType: event.MsgText,
Body: content,
}
editContent := c.messageContent(content)
editContent.SetEdit(id.EventID(messageID))
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
defer cancel()
data, err := c.client.DownloadBytes(reqCtx, parsed)
resp, err := c.client.Download(reqCtx, parsed)
if err != nil {
return "", err
}
defer resp.Body.Close()
reader := resp.Body
readerClose := func() error { return nil }
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
err = msgEvt.File.DecryptInPlace(data)
if err != nil {
if err = msgEvt.File.PrepareForDecryption(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
decryptReader := msgEvt.File.DecryptStream(resp.Body)
reader = decryptReader
readerClose = decryptReader.Close
}
label := matrixMediaLabel(msgEvt, mediaKind)
@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
if err != nil {
return "", err
}
defer tmp.Close()
tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err = tmp.Write(data); err != nil {
_ = os.Remove(tmp.Name())
_, err = io.Copy(tmp, reader)
if err != nil {
return "", err
}
if err = readerClose(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
if err = tmp.Close(); err != nil {
return "", err
}
return tmp.Name(), nil
cleanup = false
return tmpPath, nil
}
func matrixContentType(msgEvt *event.MessageEventContent) string {

View file

@ -2,14 +2,19 @@ package matrix
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
@ -194,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
}
}
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
const wantBody = "matrix-media-payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
t.Fatalf("unexpected download path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(wantBody))
}))
defer server.Close()
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ch := &MatrixChannel{client: client}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.png",
URL: id.ContentURIString("mxc://matrix.test/abc123"),
Info: &event.FileInfo{MimeType: "image/png"},
}
path, err := ch.downloadMedia(context.Background(), msg, "image")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
}
defer os.Remove(path)
if ext := filepath.Ext(path); ext != ".png" {
t.Fatalf("temp file extension=%q want=.png", ext)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != wantBody {
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
@ -289,3 +338,50 @@ func TestMatrixOutboundContent(t *testing.T) {
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
}
}
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
input string
contains string
}{
{"bold", "**hello**", "<strong>hello</strong>"},
{"italic", "_world_", "<em>world</em>"},
{"header", "### Title", "<h3"},
{"code block", "```\nfoo()\n```", "<code>"},
{"inline code", "`x`", "<code>x</code>"},
{"plain text", "just text", "just text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markdownToHTML(tt.input)
if !strings.Contains(got, tt.contains) {
t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
}
})
}
}
func TestMessageContent(t *testing.T) {
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
for _, c := range []*MatrixChannel{richtext, defaultt} {
mc := c.messageContent("**hi**")
if mc.Format != event.FormatHTML {
t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
}
if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") {
t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody)
}
if mc.Body != "**hi**" {
t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
}
}
mc := plain.messageContent("**hi**")
if mc.Format != "" || mc.FormattedBody != "" {
t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
}
}

View file

@ -78,6 +78,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.

View file

@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
slack.MsgOptionText(msg.Content, false),
}
if threadTS != "" {
if msg.ReplyToMessageID != "" && threadTS == "" {
// Answer to the message by creating a Thread under it
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
} else if threadTS != "" {
// If we are already in a thread, continue in the thread
opts = append(opts, slack.MsgOptionTS(threadTS))
}

View file

@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
}
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
@ -168,7 +169,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return channels.ErrNotRunning
}
chatID, err := parseChatID(msg.ChatID)
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@ -180,6 +181,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
return err
}
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
return nil
@ -210,9 +214,20 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// sendHTMLChunk sends a single HTML message, falling back to the original
// markdown as plain text on parse failure so users never see raw HTML tags.
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
func (c *TelegramChannel) sendHTMLChunk(
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
tgMsg.MessageThreadID = threadID
if replyToID != "" {
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
tgMsg.ReplyParameters = &telego.ReplyParameters{
MessageID: mid,
}
}
}
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
@ -232,13 +247,16 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, err := parseChatID(chatID)
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
return func() {}, err
}
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
action.MessageThreadID = threadID
// Send the first typing action immediately
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
_ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx)
go func() {
@ -249,7 +267,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
case <-typingCtx.Done():
return
case <-ticker.C:
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
a.MessageThreadID = threadID
_ = c.bot.SendChatAction(typingCtx, a)
}
}
}()
@ -259,7 +279,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
cid, err := parseChatID(chatID)
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
}
@ -288,12 +308,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
text = "Thinking... 💭"
}
cid, err := parseChatID(chatID)
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
return "", err
}
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
phMsg := tu.Message(tu.ID(cid), text)
phMsg.MessageThreadID = threadID
pMsg, err := c.bot.SendMessage(ctx, phMsg)
if err != nil {
return "", err
}
@ -307,7 +329,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
return channels.ErrNotRunning
}
chatID, err := parseChatID(msg.ChatID)
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@ -340,6 +362,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
case "image":
params := &telego.SendPhotoParams{
ChatID: tu.ID(chatID),
MessageThreadID: threadID,
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
@ -347,6 +370,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
case "audio":
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
MessageThreadID: threadID,
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
@ -354,6 +378,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
case "video":
params := &telego.SendVideoParams{
ChatID: tu.ID(chatID),
MessageThreadID: threadID,
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
@ -361,6 +386,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
MessageThreadID: threadID,
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
@ -506,19 +532,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
// must share one session per group.
compositeChatID := fmt.Sprintf("%d", chatID)
threadID := message.MessageThreadID
if message.Chat.IsForum && threadID != 0 {
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
}
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": sender.CanonicalID,
"chat_id": fmt.Sprintf("%d", chatID),
"chat_id": compositeChatID,
"thread_id": threadID,
"preview": utils.Truncate(content, 50),
})
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" {
peerKind = "group"
peerID = fmt.Sprintf("%d", chatID)
peerID = compositeChatID
}
peer := bus.Peer{Kind: peerKind, ID: peerID}
@ -531,11 +566,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
metadata["parent_peer_kind"] = "topic"
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
}
c.HandleMessage(c.ctx,
peer,
messageID,
platformID,
fmt.Sprintf("%d", chatID),
compositeChatID,
content,
mediaPaths,
metadata,
@ -583,10 +624,23 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
return c.downloadFileWithInfo(file, ext)
}
func parseChatID(chatIDStr string) (int64, error) {
var id int64
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
return id, err
// parseTelegramChatID splits "chatID/threadID" into its components.
// Returns threadID=0 when no "/" is present (non-forum messages).
func parseTelegramChatID(chatID string) (int64, int, error) {
idx := strings.Index(chatID, "/")
if idx == -1 {
cid, err := strconv.ParseInt(chatID, 10, 64)
return cid, 0, err
}
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
if err != nil {
return 0, 0, err
}
tid, err := strconv.Atoi(chatID[idx+1:])
if err != nil {
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
}
return cid, tid, nil
}
func markdownToTelegramHTML(text string) string {

View file

@ -6,6 +6,7 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi"
@ -271,3 +272,191 @@ func TestSend_InvalidChatID(t *testing.T) {
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
assert.Empty(t, caller.calls)
}
func TestParseTelegramChatID_Plain(t *testing.T) {
cid, tid, err := parseTelegramChatID("12345")
assert.NoError(t, err)
assert.Equal(t, int64(12345), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890/42")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 42, tid)
}
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
cid, tid, err := parseTelegramChatID("-100123/1")
assert.NoError(t, err)
assert.Equal(t, int64(-100123), cid)
assert.Equal(t, 1, tid)
}
func TestParseTelegramChatID_Invalid(t *testing.T) {
_, _, err := parseTelegramChatID("not-a-number")
assert.Error(t, err)
}
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
_, _, err := parseTelegramChatID("-100123/not-a-thread")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid thread ID")
}
func TestSend_WithForumThreadID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1)
}
func TestHandleMessage_ForumTopic_SetsMetadata(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: "hello from topic",
MessageID: 10,
MessageThreadID: 42,
Chat: telego.Chat{
ID: -1001234567890,
Type: "supergroup",
IsForum: true,
},
From: &telego.User{
ID: 7,
FirstName: "Alice",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok, "expected inbound message")
// Composite chatID should include thread ID
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
// Peer ID should include thread ID for session key isolation
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
// Parent peer metadata should be set for agent binding
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_NoForum_NoThreadMetadata(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: "regular group message",
MessageID: 11,
Chat: telego.Chat{
ID: -100999,
Type: "group",
},
From: &telego.User{
ID: 8,
FirstName: "Bob",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// Plain chatID without thread suffix
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (no thread suffix)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
// In regular groups, reply threads set MessageThreadID to the original
// message ID. This should NOT trigger per-thread session isolation.
msg := &telego.Message{
Text: "reply in thread",
MessageID: 20,
MessageThreadID: 15,
Chat: telego.Chat{
ID: -100999,
Type: "supergroup",
IsForum: false,
},
From: &telego.User{
ID: 9,
FirstName: "Carol",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// chatID should NOT include thread suffix for non-forum groups
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (shared session for whole group)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}

View file

@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
})
t.Run("empty token skips verification", func(t *testing.T) {
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComAppConfig{
CorpID: "test_corp_id",
CorpSecret: "test_secret",
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
}

View file

@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
}
})
t.Run("empty token skips verification", func(t *testing.T) {
// Create a channel manually with empty token to test the behavior
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
}

View file

@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string {
// This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
return false
}
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
}

View file

@ -17,6 +17,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@ -48,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@ -59,6 +85,7 @@ type Config struct {
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"`
}
@ -195,8 +222,8 @@ type AgentDefaults struct {
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"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
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
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -355,6 +382,7 @@ type MatrixConfig struct {
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
@ -472,6 +500,10 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type VoiceConfig struct {
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
@ -495,6 +527,8 @@ type ProvidersConfig struct {
Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
ModelScope ProviderConfig `json:"modelscope"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -521,7 +555,9 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == ""
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -668,6 +704,7 @@ type CronToolsConfig struct {
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
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)
@ -676,6 +713,7 @@ type ExecConfig struct {
type SkillsToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
Registries SkillsRegistriesConfig ` json:"registries"`
Github SkillsGithubConfig ` json:"github"`
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
SearchCache SearchCacheConfig ` json:"search_cache"`
}
@ -725,6 +763,11 @@ type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type SkillsGithubConfig struct {
Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`

View file

@ -342,8 +342,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
t.Fatalf("ReadFile failed: %v", err)
}
if !strings.Contains(string(data), `"model": ""`) {
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
if !strings.Contains(string(data), `"model_name": ""`) {
t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
}
}
@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
}
}
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
}
}
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
}
}
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
}
}
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@ -421,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
@ -482,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
}
}
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "English commas only",
input: "123,456,789",
expected: []string{"123", "456", "789"},
},
{
name: "Chinese commas only",
input: "123456789",
expected: []string{"123", "456", "789"},
},
{
name: "Mixed English and Chinese commas",
input: "123,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Single value",
input: "123",
expected: []string{"123"},
},
{
name: "Values with whitespace",
input: " 123 , 456 , 789 ",
expected: []string{"123", "456", "789"},
},
{
name: "Empty string",
input: "",
expected: nil,
},
{
name: "Only commas - English",
input: ",,",
expected: []string{},
},
{
name: "Only commas - Chinese",
input: "",
expected: []string{},
},
{
name: "Mixed commas with empty parts",
input: "123,,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Complex mixed values",
input: "user1@example.comuser2@test.com, admin@domain.org",
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(tt.input))
if err != nil {
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
}
if tt.expected == nil {
if f != nil {
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
}
return
}
if len(f) != len(tt.expected) {
t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
return
}
for i, v := range tt.expected {
if f[i] != v {
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
}
}
})
}
}
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
t.Run("Empty string returns nil", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(""))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f != nil {
t.Errorf("Empty string should return nil, got %v", f)
}
})
t.Run("Commas only returns empty slice", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(",,,"))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f == nil {
t.Error("Commas only should return empty slice, not nil")
}
if len(f) != 0 {
t.Errorf("Expected empty slice, got %v", f)
}
})
}

View file

@ -194,8 +194,8 @@ func DefaultConfig() *Config {
// OpenAI - https://platform.openai.com/api-keys
{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1",
APIKey: "",
},
@ -256,8 +256,8 @@ func DefaultConfig() *Config {
APIKey: "",
},
{
ModelName: "openrouter-gpt-5.2",
Model: "openrouter/openai/gpt-5.2",
ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
@ -287,6 +287,12 @@ func DefaultConfig() *Config {
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
@ -311,8 +317,8 @@ func DefaultConfig() *Config {
// GitHub Copilot - https://github.com/settings/tokens
{
ModelName: "copilot-gpt-5.2",
Model: "github-copilot/gpt-5.2",
ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.4",
APIBase: "http://localhost:4321",
AuthMethod: "oauth",
},
@ -355,6 +361,22 @@ func DefaultConfig() *Config {
APIKey: "",
},
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
APIKey: "",
},
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
{
ModelName: "modelscope-qwen",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIBase: "https://api-inference.modelscope.cn/v1",
APIKey: "",
},
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
@ -427,6 +449,7 @@ func DefaultConfig() *Config {
Enabled: true,
},
EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
@ -510,6 +533,9 @@ func DefaultConfig() *Config {
Enabled: false,
MonitorUSB: true,
},
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,

View file

@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "openai",
Model: "openai/gpt-5.2",
Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2",
Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode,
}, true
@ -407,6 +407,40 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
{
providerNames: []string{"longcat"},
protocol: "longcat",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
APIBase: p.LongCat.APIBase,
Proxy: p.LongCat.Proxy,
RequestTimeout: p.LongCat.RequestTimeout,
}, true
},
},
{
providerNames: []string{"modelscope"},
protocol: "modelscope",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIKey: p.ModelScope.APIKey,
APIBase: p.ModelScope.APIBase,
Proxy: p.ModelScope.Proxy,
RequestTimeout: p.ModelScope.RequestTimeout,
}, true
},
},
}
// Process each provider migration

View file

@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
}
if result[0].Model != "openai/gpt-5.2" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
if result[0].Model != "openai/gpt-5.4" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
}
if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
@ -162,14 +162,16 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"},
LongCat: ProviderConfig{APIKey: "key-longcat"},
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
},
}
result := ConvertProvidersToModelList(cfg)
// All 21 providers should be converted
if len(result) != 21 {
t.Errorf("len(result) = %d, want 21", len(result))
// All 23 providers should be converted
if len(result) != 23 {
t.Errorf("len(result) = %d, want 23", len(result))
}
}
@ -383,8 +385,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
for _, mc := range result {
switch mc.ModelName {
case "openai":
if mc.Model != "openai/gpt-5.2" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
if mc.Model != "openai/gpt-5.4" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
}
case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" {
@ -557,9 +559,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2")
if result != "openai/gpt-5.2" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
}
}

View file

@ -59,6 +59,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
}
}
// Keep track of explicit username format
isAtUsername := strings.HasPrefix(allowed, "@")
// Strip leading "@" for username matching
trimmed := strings.TrimPrefix(allowed, "@")
@ -75,12 +78,10 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
return true
}
// Match against Username
if sender.Username != "" {
if sender.Username == trimmed || sender.Username == allowedUser {
// Match against Username only when explicitly requested via "@username"
if isAtUsername && sender.Username != "" && sender.Username == trimmed {
return true
}
}
// Match compound sender format against allowed parts
if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {

View file

@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) {
allowed: "@alice",
want: true,
},
{
name: "plain entry does not match username",
sender: bus.SenderInfo{
Platform: "discord",
PlatformID: "999999",
Username: "123456",
},
allowed: "123456",
want: false,
},
{
name: "@username does not match",
sender: telegramSender,
@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) {
allowed: "999|alice",
want: true,
},
{
name: "compound matches by ID when username differs",
sender: bus.SenderInfo{
Platform: "discord",
PlatformID: "123456",
Username: "not123456",
},
allowed: "123456|alice",
want: true,
},
{
name: "compound does not match",
sender: telegramSender,

View file

@ -1,24 +1,24 @@
package logger
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
)
type LogLevel int
type LogLevel = zerolog.Level
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
FATAL
DEBUG = zerolog.DebugLevel
INFO = zerolog.InfoLevel
WARN = zerolog.WarnLevel
ERROR = zerolog.ErrorLevel
FATAL = zerolog.FatalLevel
)
var (
@ -31,27 +31,24 @@ var (
}
currentLevel = INFO
logger *Logger
logger zerolog.Logger
fileLogger zerolog.Logger
logFile *os.File
once sync.Once
mu sync.RWMutex
)
type Logger struct {
file *os.File
}
type LogEntry struct {
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
Caller string `json:"caller,omitempty"`
}
func init() {
once.Do(func() {
logger = &Logger{}
zerolog.SetGlobalLevel(zerolog.InfoLevel)
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05", // TODO: make it configurable???
}
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
fileLogger = zerolog.Logger{}
})
}
@ -59,6 +56,7 @@ func SetLevel(level LogLevel) {
mu.Lock()
defer mu.Unlock()
currentLevel = level
zerolog.SetGlobalLevel(level)
}
func GetLevel() LogLevel {
@ -71,17 +69,22 @@ func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return fmt.Errorf("failed to create log directory: %w", err)
}
newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
if logger.file != nil {
logger.file.Close()
// Close old file if exists
if logFile != nil {
logFile.Close()
}
logger.file = file
log.Println("File logging enabled:", filePath)
logFile = newFile
fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
return nil
}
@ -89,10 +92,58 @@ func DisableFileLogging() {
mu.Lock()
defer mu.Unlock()
if logger.file != nil {
logger.file.Close()
logger.file = nil
log.Println("File logging disabled")
if logFile != nil {
logFile.Close()
logFile = nil
}
fileLogger = zerolog.Logger{}
}
func getCallerInfo() (string, int, string) {
for i := 2; i < 15; i++ {
pc, file, line, ok := runtime.Caller(i)
if !ok {
continue
}
fn := runtime.FuncForPC(pc)
if fn == nil {
continue
}
// bypass common loggers
if strings.HasSuffix(file, "/logger.go") ||
strings.HasSuffix(file, "/logger_3rd_party.go") ||
strings.HasSuffix(file, "/log.go") {
continue
}
funcName := fn.Name()
if strings.HasPrefix(funcName, "runtime.") {
continue
}
return filepath.Base(file), line, filepath.Base(funcName)
}
return "???", 0, "???"
}
//nolint:zerologlint
func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {
switch level {
case zerolog.DebugLevel:
return logger.Debug()
case zerolog.InfoLevel:
return logger.Info()
case zerolog.WarnLevel:
return logger.Warn()
case zerolog.ErrorLevel:
return logger.Error()
case zerolog.FatalLevel:
return logger.Fatal()
default:
return logger.Info()
}
}
@ -101,65 +152,41 @@ func logMessage(level LogLevel, component string, message string, fields map[str
return
}
entry := LogEntry{
Level: logLevelNames[level],
Timestamp: time.Now().UTC().Format(time.RFC3339),
Component: component,
Message: message,
Fields: fields,
}
callerFile, callerLine, callerFunc := getCallerInfo()
if pc, file, line, ok := runtime.Caller(2); ok {
fn := runtime.FuncForPC(pc)
if fn != nil {
entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
}
}
event := getEvent(logger, level)
if logger.file != nil {
jsonData, err := json.Marshal(entry)
if err == nil {
logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
// Build combined field with component and caller
if component != "" {
event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
} else {
fieldStr = ""
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
entry.Timestamp,
logLevelNames[level],
formatComponent(component),
message,
fieldStr,
)
for k, v := range fields {
event.Interface(k, v)
}
log.Println(logLine)
event.Msg(message)
// Also log to file if enabled
if fileLogger.GetLevel() != zerolog.NoLevel {
fileEvent := getEvent(fileLogger, level)
if component != "" {
fileEvent.Str("component", component)
}
for k, v := range fields {
fileEvent.Interface(k, v)
}
fileEvent.Msg(message)
}
if level == FATAL {
os.Exit(1)
}
}
func formatComponent(component string) string {
if component == "" {
return ""
}
return fmt.Sprintf(" %s:", component)
}
func formatFields(fields map[string]any) string {
parts := make([]string, 0, len(fields))
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
return fmt.Sprintf("{%s}", strings.Join(parts, ", "))
}
func Debug(message string) {
logMessage(DEBUG, "", message, nil)
}
@ -168,6 +195,10 @@ func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil)
}
func Debugf(message string, ss ...any) {
logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil)
}
func DebugF(message string, fields map[string]any) {
logMessage(DEBUG, "", message, fields)
}
@ -188,6 +219,10 @@ func InfoF(message string, fields map[string]any) {
logMessage(INFO, "", message, fields)
}
func Infof(message string, ss ...any) {
logMessage(INFO, "", fmt.Sprintf(message, ss...), nil)
}
func InfoCF(component string, message string, fields map[string]any) {
logMessage(INFO, component, message, fields)
}
@ -216,6 +251,10 @@ func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil)
}
func Errorf(message string, ss ...any) {
logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil)
}
func ErrorF(message string, fields map[string]any) {
logMessage(ERROR, "", message, fields)
}
@ -232,6 +271,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil)
}
func Fatalf(message string, ss ...any) {
logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil)
}
func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields)
}

View file

@ -0,0 +1,95 @@
// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project
package logger
import "fmt"
// Logger implements common Logger interface
type Logger struct {
component string
levels map[int]LogLevel
}
// Debug logs debug messages
func (b *Logger) Debug(v ...any) {
logMessage(DEBUG, b.component, fmt.Sprint(v...), nil)
}
// Info logs info messages
func (b *Logger) Info(v ...any) {
logMessage(INFO, b.component, fmt.Sprint(v...), nil)
}
// Warn logs warning messages
func (b *Logger) Warn(v ...any) {
logMessage(WARN, b.component, fmt.Sprint(v...), nil)
}
// Error logs error messages
func (b *Logger) Error(v ...any) {
logMessage(ERROR, b.component, fmt.Sprint(v...), nil)
}
// Debugf logs formatted debug messages
func (b *Logger) Debugf(format string, v ...any) {
logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil)
}
// Infof logs formatted info messages
func (b *Logger) Infof(format string, v ...any) {
logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil)
}
// Warnf logs formatted warning messages
func (b *Logger) Warnf(format string, v ...any) {
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
}
// Warningf logs formatted warning messages
func (b *Logger) Warningf(format string, v ...any) {
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
}
// Errorf logs formatted error messages
func (b *Logger) Errorf(format string, v ...any) {
logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil)
}
// Fatalf logs formatted fatal messages and exits
func (b *Logger) Fatalf(format string, v ...any) {
logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil)
}
// Log logs a message at a given level with caller information
// the func name must be this because 3rd party loggers expect this
// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL)
// caller: unused parameter reserved for compatibility
// format: format string
// a: format arguments
//
//nolint:goprintffuncname
func (b *Logger) Log(msgL, caller int, format string, a ...any) {
level := LogLevel(msgL)
if b.levels != nil {
if lvl, ok := b.levels[msgL]; ok {
level = lvl
}
}
logMessage(level, b.component, fmt.Sprintf(format, a...), nil)
}
// Sync flushes log buffer (no-op for this implementation)
func (b *Logger) Sync() error {
return nil
}
// WithLevels sets log levels mapping for this logger
func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger {
b.levels = levels
return b
}
// NewLogger creates a new logger instance with optional component name
func NewLogger(component string) *Logger {
return &Logger{component: component}
}

View file

@ -123,17 +123,21 @@ func TestLoggerHelperFunctions(t *testing.T) {
SetLevel(INFO)
Debug("This should not log")
Debugf("this should not log")
Info("This should log")
Warn("This should log")
Error("This should log")
InfoC("test", "Component message")
InfoF("Fields message", map[string]any{"key": "value"})
Infof("test from %v", "Infof")
WarnC("test", "Warning with component")
ErrorF("Error with fields", map[string]any{"error": "test"})
Errorf("test from %v", "Errorf")
SetLevel(DEBUG)
DebugC("test", "Debug with component")
Debugf("test from %v", "Debugf")
WarnF("Warning with fields", map[string]any{"key": "value"})
}

View file

@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string {
// 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.
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_'
// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts")
// do not create subdirectories or break on Windows.
func sanitizeKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
s := strings.ReplaceAll(key, ":", "_")
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\\", "_")
return s
}
// readMeta loads the metadata file for a session.

View file

@ -48,6 +48,12 @@ func MigrateFromJSON(
if !strings.HasSuffix(name, ".json") {
continue
}
// Skip JSONL metadata files. They are part of the new storage format,
// not legacy session snapshots, and re-importing them would overwrite
// the paired .jsonl history with an empty message list.
if strings.HasSuffix(name, ".meta.json") {
continue
}
// Skip already-migrated files.
if strings.HasSuffix(name, ".migrated") {
continue

View file

@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
t.Errorf("expected 0, got %d", count)
}
}
func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) {
sessionsDir := t.TempDir()
store, err := NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore: %v", err)
}
ctx := context.Background()
if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil {
t.Fatalf("AddMessage: %v", addErr)
}
if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil {
t.Fatalf("SetSummary: %v", summaryErr)
}
metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json")
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file missing before migration: %v", statErr)
}
count, err := MigrateFromJSON(ctx, sessionsDir, store)
if err != nil {
t.Fatalf("MigrateFromJSON: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 migrated, got %d", count)
}
history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history) != 1 || history[0].Content != "keep me" {
t.Fatalf("history = %+v, want preserved single message", history)
}
summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetSummary: %v", err)
}
if summary != "keep summary" {
t.Fatalf("summary = %q, want %q", summary, "keep summary")
}
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file should remain in place: %v", statErr)
}
if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) {
t.Fatalf("meta file should not be renamed, stat err = %v", statErr)
}
}

View file

@ -4,7 +4,6 @@ var migrateableFiles = []string{
"AGENTS.md",
"SOUL.md",
"USER.md",
"TOOLS.md",
"HEARTBEAT.md",
}

View file

@ -1111,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
Exec: config.ExecConfig{
EnableDenyPatterns: c.Exec.EnableDenyPatterns,
CustomDenyPatterns: c.Exec.CustomDenyPatterns,
AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote,
},
}
}

View file

@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) {
}
}
func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) {
cfg := (&PicoClawConfig{
Tools: ToolsConfig{
Exec: ExecConfig{
EnableDenyPatterns: true,
},
},
}).ToStandardConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true")
}
}
func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")

View file

@ -0,0 +1,415 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package anthropicmessages
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
type (
ToolCall = protocoltypes.ToolCall
FunctionCall = protocoltypes.FunctionCall
LLMResponse = protocoltypes.LLMResponse
UsageInfo = protocoltypes.UsageInfo
Message = protocoltypes.Message
ToolDefinition = protocoltypes.ToolDefinition
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
)
const (
defaultAPIVersion = "2023-06-01"
defaultBaseURL = "https://api.anthropic.com/v1"
defaultRequestTimeout = 120 * time.Second
)
// Provider implements Anthropic Messages API via HTTP (without SDK).
// It supports custom endpoints that use Anthropic's native message format.
type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
}
// NewProvider creates a new Anthropic Messages API provider.
func NewProvider(apiKey, apiBase string) *Provider {
return NewProviderWithTimeout(apiKey, apiBase, 0)
}
// NewProviderWithTimeout creates a provider with custom request timeout.
func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
baseURL := normalizeBaseURL(apiBase)
timeout := defaultRequestTimeout
if timeoutSeconds > 0 {
timeout = time.Duration(timeoutSeconds) * time.Second
}
return &Provider{
apiKey: apiKey,
apiBase: baseURL,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// Chat sends messages to the Anthropic Messages API and returns the response.
func (p *Provider) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) {
if p.apiKey == "" {
return nil, fmt.Errorf("API key not configured")
}
// Build request body
requestBody, err := buildRequestBody(messages, tools, model, options)
if err != nil {
return nil, fmt.Errorf("building request body: %w", err)
}
// Serialize to JSON
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("serializing request body: %w", err)
}
// Build request URL
endpointURL, err := url.JoinPath(p.apiBase, "messages")
if err != nil {
return nil, fmt.Errorf("building endpoint URL: %w", err)
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, "POST", endpointURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
req.Header.Set("Anthropic-Version", defaultAPIVersion)
// Execute request
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing HTTP request: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
// Check for HTTP errors with detailed messages
switch resp.StatusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (401): check your API key")
case http.StatusTooManyRequests:
return nil, fmt.Errorf("rate limited (429): %s", string(body))
case http.StatusBadRequest:
return nil, fmt.Errorf("bad request (400): %s", string(body))
case http.StatusNotFound:
return nil, fmt.Errorf("endpoint not found (404): %s", string(body))
case http.StatusInternalServerError:
return nil, fmt.Errorf("internal server error (500): %s", string(body))
case http.StatusServiceUnavailable:
return nil, fmt.Errorf("service unavailable (503): %s", string(body))
default:
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
}
// Parse response
return parseResponseBody(body)
}
// GetDefaultModel returns the default model for this provider.
func (p *Provider) GetDefaultModel() string {
return "claude-sonnet-4.6"
}
// buildRequestBody converts internal message format to Anthropic Messages API format.
func buildRequestBody(
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (map[string]any, error) {
// max_tokens is required and guaranteed by agent loop
maxTokens, ok := asInt(options["max_tokens"])
if !ok {
return nil, fmt.Errorf("max_tokens is required in options")
}
result := map[string]any{
"model": model,
"max_tokens": int64(maxTokens),
"messages": []any{},
}
// Set temperature from options
if temp, ok := asFloat(options["temperature"]); ok {
result["temperature"] = temp
}
// Process messages
var systemPrompt string
var apiMessages []any
for _, msg := range messages {
switch msg.Role {
case "system":
// Accumulate system messages
if systemPrompt != "" {
systemPrompt += "\n\n" + msg.Content
} else {
systemPrompt = msg.Content
}
case "user":
if msg.ToolCallID != "" {
// Tool result message
content := []map[string]any{
{
"type": "tool_result",
"tool_use_id": msg.ToolCallID,
"content": msg.Content,
},
}
apiMessages = append(apiMessages, map[string]any{
"role": "user",
"content": content,
})
} else {
// Regular user message
apiMessages = append(apiMessages, map[string]any{
"role": "user",
"content": msg.Content,
})
}
case "assistant":
content := []any{}
// Add text content if present
if msg.Content != "" {
content = append(content, map[string]any{
"type": "text",
"text": msg.Content,
})
}
// Add tool_use blocks
for _, tc := range msg.ToolCalls {
toolUse := map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Name,
"input": tc.Arguments,
}
content = append(content, toolUse)
}
apiMessages = append(apiMessages, map[string]any{
"role": "assistant",
"content": content,
})
case "tool":
// Tool result (alternative format)
content := []map[string]any{
{
"type": "tool_result",
"tool_use_id": msg.ToolCallID,
"content": msg.Content,
},
}
apiMessages = append(apiMessages, map[string]any{
"role": "user",
"content": content,
})
}
}
result["messages"] = apiMessages
// Set system prompt if present
if systemPrompt != "" {
result["system"] = systemPrompt
}
// Add tools if present
if len(tools) > 0 {
result["tools"] = buildTools(tools)
}
return result, nil
}
// buildTools converts tool definitions to Anthropic format.
func buildTools(tools []ToolDefinition) []any {
result := make([]any, len(tools))
for i, tool := range tools {
toolDef := map[string]any{
"name": tool.Function.Name,
"description": tool.Function.Description,
"input_schema": tool.Function.Parameters,
}
result[i] = toolDef
}
return result
}
// parseResponseBody parses Anthropic Messages API response.
func parseResponseBody(body []byte) (*LLMResponse, error) {
var resp anthropicMessageResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing JSON response: %w", err)
}
// Extract content and tool calls
var content strings.Builder
toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization
for _, block := range resp.Content {
switch block.Type {
case "text":
content.WriteString(block.Text)
case "tool_use":
argsJSON, _ := json.Marshal(block.Input)
toolCalls = append(toolCalls, ToolCall{
ID: block.ID,
Name: block.Name,
Arguments: block.Input,
Function: &FunctionCall{
Name: block.Name,
Arguments: string(argsJSON),
},
})
}
}
// Map stop_reason
finishReason := "stop"
switch resp.StopReason {
case "tool_use":
finishReason = "tool_calls"
case "max_tokens":
finishReason = "length"
case "end_turn":
finishReason = "stop"
case "stop_sequence":
finishReason = "stop"
}
return &LLMResponse{
Content: content.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: &UsageInfo{
PromptTokens: int(resp.Usage.InputTokens),
CompletionTokens: int(resp.Usage.OutputTokens),
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
},
}, nil
}
// normalizeBaseURL ensures the base URL is properly formatted.
// It removes /v1 suffix if present (to avoid duplication) and always appends /v1.
// This handles edge cases like "https://api.example.com/v1/proxy" correctly.
func normalizeBaseURL(apiBase string) string {
base := strings.TrimSpace(apiBase)
if base == "" {
return defaultBaseURL
}
// Remove trailing slashes
base = strings.TrimRight(base, "/")
// Remove /v1 suffix if present (will be re-added)
// This prevents duplication for URLs like "https://api.example.com/v1/proxy"
if before, ok := strings.CutSuffix(base, "/v1"); ok {
base = before
}
// Ensure we don't have an empty string after cutting
if base == "" {
return defaultBaseURL
}
// Add /v1 suffix (required by Anthropic Messages API)
return base + "/v1"
}
// Helper functions for type conversion
func asInt(v any) (int, bool) {
switch val := v.(type) {
case int:
return val, true
case float64:
return int(val), true
case int64:
return int(val), true
default:
return 0, false
}
}
func asFloat(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case int:
return float64(val), true
case int64:
return float64(val), true
default:
return 0, false
}
}
// Anthropic API response structures
type anthropicMessageResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []contentBlock `json:"content"`
StopReason string `json:"stop_reason"`
Model string `json:"model"`
Usage usageInfo `json:"usage"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
}
type usageInfo struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}

View file

@ -0,0 +1,622 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package anthropicmessages
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestBuildRequestBody(t *testing.T) {
tests := []struct {
name string
messages []Message
tools []ToolDefinition
model string
options map[string]any
want map[string]any
wantErr bool
}{
{
name: "basic user message",
messages: []Message{
{Role: "user", Content: "Hello, world!"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
want: map[string]any{
"model": "test-model",
"max_tokens": int64(8192),
"messages": []any{
map[string]any{
"role": "user",
"content": "Hello, world!",
},
},
},
},
{
name: "user and assistant messages",
messages: []Message{
{Role: "user", Content: "What is 2+2?"},
{Role: "assistant", Content: "4"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
want: map[string]any{
"model": "test-model",
"max_tokens": int64(8192),
"messages": []any{
map[string]any{
"role": "user",
"content": "What is 2+2?",
},
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "text",
"text": "4",
},
},
},
},
},
},
{
name: "with system message",
messages: []Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "Hello"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
want: map[string]any{
"model": "test-model",
"max_tokens": int64(8192),
"system": "You are a helpful assistant.",
"messages": []any{
map[string]any{
"role": "user",
"content": "Hello",
},
},
},
},
{
name: "with custom max_tokens and temperature",
messages: []Message{
{Role: "user", Content: "Test"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 2048,
"temperature": 0.5,
},
want: map[string]any{
"model": "test-model",
"max_tokens": int64(2048),
"temperature": 0.5,
"messages": []any{
map[string]any{
"role": "user",
"content": "Test",
},
},
},
},
{
name: "missing max_tokens returns error",
messages: []Message{
{Role: "user", Content: "Test"},
},
model: "test-model",
options: map[string]any{},
want: nil,
wantErr: true,
},
{
name: "with tools",
messages: []Message{
{Role: "user", Content: "What's the weather?"},
},
tools: []ToolDefinition{
{
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get current weather",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"location": map[string]any{
"type": "string",
"description": "City name",
},
},
},
},
},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
want: map[string]any{
"model": "test-model",
"max_tokens": int64(8192),
"messages": []any{
map[string]any{
"role": "user",
"content": "What's the weather?",
},
},
"tools": []any{
map[string]any{
"name": "get_weather",
"description": "Get current weather",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"location": map[string]any{
"type": "string",
"description": "City name",
},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
if (err != nil) != tt.wantErr {
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(tt.want, "", " ")
t.Errorf("buildRequestBody() mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
})
}
}
func TestParseResponseBody(t *testing.T) {
tests := []struct {
name string
body []byte
want *LLMResponse
wantErr bool
}{
{
name: "basic text response",
body: []byte(`{
"id": "msg-123",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "Hello, how can I help?"}
],
"stop_reason": "end_turn",
"model": "test-model",
"usage": {
"input_tokens": 10,
"output_tokens": 5
}
}`),
want: &LLMResponse{
Content: "Hello, how can I help?",
ToolCalls: []ToolCall{},
FinishReason: "stop",
Usage: &UsageInfo{
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 15,
},
Reasoning: "",
ReasoningDetails: nil,
},
wantErr: false,
},
{
name: "response with tool use",
body: []byte(`{
"id": "msg-456",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I'll check the weather for you."},
{
"type": "tool_use",
"id": "toolu-123",
"name": "get_weather",
"input": {"location": "Tokyo"}
}
],
"stop_reason": "tool_use",
"model": "test-model",
"usage": {
"input_tokens": 20,
"output_tokens": 15
}
}`),
want: &LLMResponse{
Content: "I'll check the weather for you.",
ToolCalls: []ToolCall{
{
ID: "toolu-123",
Name: "get_weather",
Arguments: map[string]any{
"location": "Tokyo",
},
Function: &FunctionCall{
Name: "get_weather",
Arguments: `{"location":"Tokyo"}`,
},
},
},
FinishReason: "tool_calls",
Usage: &UsageInfo{
PromptTokens: 20,
CompletionTokens: 15,
TotalTokens: 35,
},
Reasoning: "",
ReasoningDetails: nil,
},
wantErr: false,
},
{
name: "invalid JSON",
body: []byte(`invalid json`),
want: nil,
wantErr: true,
},
{
name: "max_tokens stop reason",
body: []byte(`{
"id": "msg-789",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "Partial response"}
],
"stop_reason": "max_tokens",
"model": "test-model",
"usage": {
"input_tokens": 100,
"output_tokens": 4096
}
}`),
want: &LLMResponse{
Content: "Partial response",
ToolCalls: []ToolCall{},
FinishReason: "length",
Usage: &UsageInfo{
PromptTokens: 100,
CompletionTokens: 4096,
TotalTokens: 4196,
},
Reasoning: "",
ReasoningDetails: nil,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseResponseBody(tt.body)
if (err != nil) != tt.wantErr {
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
// Compare individual fields
if got.Content != tt.want.Content {
t.Errorf("Content = %q, want %q", got.Content, tt.want.Content)
}
if got.FinishReason != tt.want.FinishReason {
t.Errorf("FinishReason = %q, want %q", got.FinishReason, tt.want.FinishReason)
}
if got.Usage == nil && tt.want.Usage != nil {
t.Errorf("Usage = nil, want non-nil")
} else if got.Usage != nil && tt.want.Usage == nil {
t.Errorf("Usage = non-nil, want nil")
} else if got.Usage != nil && tt.want.Usage != nil {
if got.Usage.PromptTokens != tt.want.Usage.PromptTokens {
t.Errorf("Usage.PromptTokens = %d, want %d", got.Usage.PromptTokens, tt.want.Usage.PromptTokens)
}
if got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens {
t.Errorf("Usage.CompletionTokens = %d, want %d",
got.Usage.CompletionTokens, tt.want.Usage.CompletionTokens)
}
if got.Usage.TotalTokens != tt.want.Usage.TotalTokens {
t.Errorf("Usage.TotalTokens = %d, want %d", got.Usage.TotalTokens, tt.want.Usage.TotalTokens)
}
}
if len(got.ToolCalls) != len(tt.want.ToolCalls) {
t.Errorf("ToolCalls length = %d, want %d", len(got.ToolCalls), len(tt.want.ToolCalls))
} else {
for i := range got.ToolCalls {
if got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID {
t.Errorf("ToolCalls[%d].ID = %q, want %q",
i, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID)
}
if got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name {
t.Errorf("ToolCalls[%d].Name = %q, want %q",
i, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name)
}
}
}
})
}
}
func TestNormalizeBaseURL(t *testing.T) {
tests := []struct {
name string
apiBase string
expected string
}{
{
name: "empty string defaults to official API",
apiBase: "",
expected: "https://api.anthropic.com/v1",
},
{
name: "URL without /v1 gets it appended",
apiBase: "https://api.example.com/anthropic",
expected: "https://api.example.com/anthropic/v1",
},
{
name: "URL with /v1 remains unchanged",
apiBase: "https://api.example.com/v1",
expected: "https://api.example.com/v1",
},
{
name: "URL with trailing slash gets cleaned",
apiBase: "https://api.example.com/anthropic/",
expected: "https://api.example.com/anthropic/v1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeBaseURL(tt.apiBase)
if got != tt.expected {
t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected)
}
})
}
}
func TestNewProvider(t *testing.T) {
provider := NewProvider("test-key", "https://api.example.com")
if provider == nil {
t.Fatal("NewProvider() returned nil")
}
if provider.apiKey != "test-key" {
t.Errorf("provider.apiKey = %q, want %q", provider.apiKey, "test-key")
}
if provider.apiBase != "https://api.example.com/v1" {
t.Errorf("provider.apiBase = %q, want %q", provider.apiBase, "https://api.example.com/v1")
}
}
func TestGetDefaultModel(t *testing.T) {
provider := NewProvider("test-key", "")
got := provider.GetDefaultModel()
expected := "claude-sonnet-4.6"
if got != expected {
t.Errorf("GetDefaultModel() = %q, want %q", got, expected)
}
}
// TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody.
func TestBuildRequestBodyEdgeCases(t *testing.T) {
tests := []struct {
name string
messages []Message
tools []ToolDefinition
model string
options map[string]any
wantErr bool
}{
{
name: "empty message list",
messages: []Message{},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
wantErr: false,
},
{
name: "very long system message",
messages: []Message{
{Role: "system", Content: strings.Repeat("This is a very long system prompt. ", 1000)},
{Role: "user", Content: "Hello"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
wantErr: false,
},
{
name: "multiple consecutive system messages",
messages: []Message{
{Role: "system", Content: "First system message"},
{Role: "system", Content: "Second system message"},
{Role: "system", Content: "Third system message"},
{Role: "user", Content: "Hello"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
wantErr: false,
},
{
name: "tool result without tool call",
messages: []Message{
{Role: "user", Content: "Use a tool"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{
{ID: "tool-1", Name: "test_tool", Arguments: map[string]any{"arg": "value"}},
}},
{Role: "user", ToolCallID: "tool-1", Content: "Tool result"},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
if (err != nil) != tt.wantErr {
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
// Verify basic structure
if got == nil {
t.Error("buildRequestBody() returned nil")
return
}
if got["model"] != tt.model {
t.Errorf("model = %v, want %v", got["model"], tt.model)
}
})
}
}
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
func TestParseResponseBodyEdgeCases(t *testing.T) {
tests := []struct {
name string
body []byte
wantErr bool
check func(*testing.T, *LLMResponse)
}{
{
name: "empty content blocks",
body: []byte(`{
"id": "msg-empty",
"type": "message",
"role": "assistant",
"content": [],
"stop_reason": "end_turn",
"model": "test-model",
"usage": {"input_tokens": 5, "output_tokens": 0}
}`),
wantErr: false,
check: func(t *testing.T, resp *LLMResponse) {
if resp.Content != "" {
t.Errorf("Content = %q, want empty string", resp.Content)
}
if len(resp.ToolCalls) != 0 {
t.Errorf("ToolCalls length = %d, want 0", len(resp.ToolCalls))
}
},
},
{
name: "multiple tool use blocks",
body: []byte(`{
"id": "msg-multi",
"type": "message",
"role": "assistant",
"content": [
{"type": "tool_use", "id": "tool-1", "name": "func1", "input": {"arg": "val1"}},
{"type": "tool_use", "id": "tool-2", "name": "func2", "input": {"arg": "val2"}}
],
"stop_reason": "tool_use",
"model": "test-model",
"usage": {"input_tokens": 10, "output_tokens": 20}
}`),
wantErr: false,
check: func(t *testing.T, resp *LLMResponse) {
if len(resp.ToolCalls) != 2 {
t.Errorf("ToolCalls length = %d, want 2", len(resp.ToolCalls))
}
},
},
{
name: "malformed JSON response",
body: []byte(`{invalid json`),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseResponseBody(tt.body)
if (err != nil) != tt.wantErr {
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.check != nil && err == nil {
tt.check(t, got)
}
})
}
}
// TestProviderChatErrors tests error handling in Chat.
// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default.
func TestProviderChatErrors(t *testing.T) {
tests := []struct {
name string
apiKey string
messages []Message
wantErrMsg string
}{
{
name: "missing API key",
apiKey: "",
messages: []Message{{Role: "user", Content: "Test"}},
wantErrMsg: "API key not configured",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create provider using constructor to ensure proper initialization
provider := NewProvider(tt.apiKey, "https://api.example.com")
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
if err == nil {
t.Fatal("Chat() expected error, got nil")
}
if err.Error() != tt.wantErrMsg {
t.Errorf("Chat() error = %q, want %q", err.Error(), tt.wantErrMsg)
}
})
}
}

View file

@ -524,7 +524,7 @@ func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
}
messages := []Message{{Role: "user", Content: "test"}}
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil)
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil)
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@ -536,7 +536,7 @@ func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
}
args := string(argsData)
if !strings.Contains(args, "-m gpt-5.2-codex") {
if !strings.Contains(args, "-m gpt-5.3-codex") {
t.Errorf("args should contain model flag, got: %s", args)
}
if !strings.Contains(args, "--json") {

View file

@ -16,7 +16,7 @@ import (
)
const (
codexDefaultModel = "gpt-5.2"
codexDefaultModel = "gpt-5.3-codex"
codexDefaultInstructions = "You are Codex, a coding assistant."
)

View file

@ -568,7 +568,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
messages := []Message{{Role: "user", Content: "Hello"}}
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil)
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil)
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@ -599,7 +599,7 @@ func TestResolveCodexModel(t *testing.T) {
wantFallback: true,
},
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
{name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false},
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
}

View file

@ -40,6 +40,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)
if providerName == "" && model == "" {
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
}
sel := providerSelection{
providerType: providerTypeHTTPCompat,
model: model,
@ -217,6 +221,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://api.minimaxi.com/v1"
}
}
case "longcat":
if cfg.Providers.LongCat.APIKey != "" {
sel.apiKey = cfg.Providers.LongCat.APIKey
sel.apiBase = cfg.Providers.LongCat.APIBase
sel.proxy = cfg.Providers.LongCat.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.longcat.chat/openai"
}
}
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@ -348,6 +361,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "https://api.avian.io/v1"
}
case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "":
sel.apiKey = cfg.Providers.LongCat.APIKey
sel.apiBase = cfg.Providers.LongCat.APIBase
sel.proxy = cfg.Providers.LongCat.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.longcat.chat/openai"
}
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
)
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
@ -53,7 +54,8 @@ func ExtractProtocol(model string) (protocol, modelID string) {
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity,
// claude-cli, codex-cli, github-copilot
// Returns the provider, the model ID (without protocol prefix), and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil {
@ -96,6 +98,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax", "siliconflow":
"minimax", "longcat", "modelscope":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -137,6 +140,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout,
), modelID, nil
case "anthropic-messages":
// Anthropic Messages API with native format (HTTP-based, no SDK)
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
}
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey,
apiBase,
cfg.RequestTimeout,
), modelID, nil
case "antigravity":
return NewAntigravityProvider(), modelID, nil
@ -226,6 +244,10 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.minimaxi.com/v1"
case "siliconflow":
return "https://api.siliconflow.cn/v1"
case "longcat":
return "https://api.longcat.chat/openai"
case "modelscope":
return "https://api-inference.modelscope.cn/v1"
default:
return ""
}

View file

@ -120,6 +120,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"deepseek", "deepseek"},
{"ollama", "ollama"},
{"siliconflow", "siliconflow"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
}
for _, tt := range tests {
@ -175,6 +177,58 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
}
}
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: "test-key",
APIBase: "https://api.longcat.chat/openai",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "LongCat-Flash-Thinking" {
t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_ModelScope(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIKey: "test-key",
APIBase: "https://api-inference.modelscope.cn/v1",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "Qwen/Qwen3-235B-A22B-Instruct-2507" {
t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-235B-A22B-Instruct-2507")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestGetDefaultAPIBase_ModelScope(t *testing.T) {
if got := getDefaultAPIBase("modelscope"); got != "https://api-inference.modelscope.cn/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "modelscope", got, "https://api-inference.modelscope.cn/v1")
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-anthropic",

View file

@ -178,6 +178,26 @@ func TestResolveProviderSelection(t *testing.T) {
wantAPIBase: "https://api.moonshot.cn/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit longcat provider uses defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "longcat"
cfg.Providers.LongCat.APIKey = "longcat-key"
cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.longcat.chat/openai",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "longcat model fallback uses longcat base default",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking"
cfg.Providers.LongCat.APIKey = "longcat-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.longcat.chat/openai",
},
{
name: "missing keys returns model config error",
setup: func(cfg *config.Config) {

View file

@ -161,9 +161,10 @@ func (p *Provider) Chat(
// The key is typically the agent ID — stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints.
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
// Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
// fields with 422 errors, so only include it for OpenAI APIs.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey
}
}
@ -403,7 +404,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
ExtraContent *struct {
Google *struct {
@ -442,12 +443,7 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
if tc.Function != nil {
name = tc.Function.Name
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = tc.Function.Arguments
}
}
arguments = decodeToolCallArguments(tc.Function.Arguments, name)
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
@ -480,6 +476,39 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
}, nil
}
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
arguments := make(map[string]any)
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return arguments
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
arguments["raw"] = string(raw)
return arguments
}
switch v := decoded.(type) {
case string:
if strings.TrimSpace(v) == "" {
return arguments
}
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = v
}
return arguments
case map[string]any:
return v
default:
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
arguments["raw"] = string(raw)
return arguments
}
}
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
// It mirrors protocoltypes.Message but omits SystemParts, which is an
// internal field that would be unknown to third-party endpoints.
@ -619,3 +648,16 @@ func asFloat(v any) (float64, bool) {
return 0, false
}
}
// supportsPromptCacheKey reports whether the given API base is known to
// support the prompt_cache_key request field. Currently only OpenAI's own
// API and Azure OpenAI support this. All other OpenAI-compatible providers
// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.
func supportsPromptCacheKey(apiBase string) bool {
u, err := url.Parse(apiBase)
if err != nil {
return false
}
host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}

View file

@ -108,6 +108,55 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
}
}
func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{
"content": "",
"tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
"function": map[string]any{
"name": "get_weather",
"arguments": map[string]any{
"city": "SF",
"metric": true,
},
},
},
},
},
"finish_reason": "tool_calls",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
if out.ToolCalls[0].Name != "get_weather" {
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
}
if out.ToolCalls[0].Arguments["city"] != "SF" {
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
}
if out.ToolCalls[0].Arguments["metric"] != true {
t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"])
}
}
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
@ -804,6 +853,111 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
}
}
// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key,
// and returns the decoded request body for assertion.
func chatWithCacheKey(t *testing.T, apiBase string) map[string]any {
t.Helper()
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
p.apiBase = apiBase
p.httpClient = &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r.URL, _ = url.Parse(server.URL + r.URL.Path)
return http.DefaultTransport.RoundTrip(r)
}),
}
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"test-model",
map[string]any{"prompt_cache_key": "agent-main"},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
return requestBody
}
func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) {
body := chatWithCacheKey(t, "https://api.openai.com/v1")
if body["prompt_cache_key"] != "agent-main" {
t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main")
}
}
func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) {
tests := []struct {
name string
apiBase string
}{
{"mistral", "https://api.mistral.ai/v1"},
{"gemini", "https://generativelanguage.googleapis.com/v1beta"},
{"deepseek", "https://api.deepseek.com/v1"},
{"groq", "https://api.groq.com/openai/v1"},
{"minimax", "https://api.minimaxi.com/v1"},
{"ollama_local", "http://localhost:11434/v1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := chatWithCacheKey(t, tt.apiBase)
if _, exists := body["prompt_cache_key"]; exists {
t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name)
}
})
}
}
func TestSupportsPromptCacheKey(t *testing.T) {
tests := []struct {
apiBase string
want bool
}{
{"https://api.openai.com/v1", true},
{"https://api.openai.com/v1/", true},
{"https://myresource.openai.azure.com/openai/deployments/gpt-4", true},
{"https://eastus.openai.azure.com/v1", true},
{"https://api.mistral.ai/v1", false},
{"https://generativelanguage.googleapis.com/v1beta", false},
{"https://api.deepseek.com/v1", false},
{"https://api.groq.com/openai/v1", false},
{"http://localhost:11434/v1", false},
{"https://openrouter.ai/api/v1", false},
// Edge cases: proxy URLs with openai.com in path should NOT match
{"https://my-proxy.com/api.openai.com/v1", false},
{"https://proxy.example.com/openai.azure.com/v1", false},
// Malformed or empty
{"", false},
{"not-a-url", false},
}
for _, tt := range tests {
if got := supportsPromptCacheKey(tt.apiBase); got != tt.want {
t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want)
}
}
}
func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{
{

View file

@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager {
}
if storage != "" {
os.MkdirAll(storage, 0o755)
os.MkdirAll(storage, 0o700)
sm.loadSessions()
}
@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
}
// sanitizeFilename converts a session key into a cross-platform safe filename.
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
// volume separator on Windows, so filepath.Base would misinterpret the key.
// We replace it with '_'. The original key is preserved inside the JSON file,
// so loadSessions still maps back to the right in-memory key.
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
// subdirectories or break on Windows. The original key is preserved inside
// the JSON file, so loadSessions still maps back to the right in-memory key.
func sanitizeFilename(key string) string {
return strings.ReplaceAll(key, ":", "_")
s := strings.ReplaceAll(key, ":", "_")
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\\", "_")
return s
}
func (sm *SessionManager) Save(key string) error {
@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error {
filename := sanitizeFilename(key)
// filepath.IsLocal rejects empty names, "..", absolute paths, and
// OS-reserved device names (NUL, COM1 … on Windows).
// The extra checks reject "." and any directory separators so that
// the session file is always written directly inside sm.storage.
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
// OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename
// already replaced '/' and '\' with '_', so no subdirs are created.
if filename == "." || !filepath.IsLocal(filename) {
return os.ErrInvalid
}
@ -214,7 +216,7 @@ func (sm *SessionManager) Save(key string) error {
_ = tmpFile.Close()
return err
}
if err := tmpFile.Chmod(0o644); err != nil {
if err := tmpFile.Chmod(0o600); err != nil {
_ = tmpFile.Close()
return err
}

View file

@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) {
{"slack:C01234", "slack_C01234"},
{"no-colons-here", "no-colons-here"},
{"multiple:colons:here", "multiple_colons_here"},
{"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"},
}
for _, tt := range tests {
@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) {
tmpDir := t.TempDir()
sm := NewSessionManager(tmpDir)
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
// Invalid names that must still be rejected.
badKeys := []string{"", ".", ".."}
for _, key := range badKeys {
sm.GetOrCreate(key)
if err := sm.Save(key); err == nil {
t.Errorf("Save(%q) should have failed but didn't", key)
}
}
// Keys containing path separators are sanitized (no subdirs created).
sm.GetOrCreate("foo/bar")
if err := sm.Save("foo/bar"); err != nil {
t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) {
t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)")
}
}

View file

@ -2,80 +2,289 @@ package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
workspace string
// GitHubContent represents a file or directory in GitHub API response
type GitHubContent struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir"
DownloadURL string `json:"download_url"`
URL string `json:"url"` // API URL for subdirectories
}
// GitHubRef represents a parsed GitHub reference
type GitHubRef struct {
Owner string // Repository owner
RepoName string // Repository name
Ref string // Git reference (branch, tag, or commit)
SubPath string // Path within the repository
}
type SkillInstaller struct {
workspace string
client *http.Client
githubToken string
proxy string
}
// NewSkillInstaller creates a new skill installer.
// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills.
func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) {
client, err := utils.CreateHTTPClient(proxy, 15*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{
workspace: workspace,
client: client,
githubToken: githubToken,
proxy: proxy,
}, nil
}
// parseGitHubRef parses a GitHub reference.
// Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path"
func parseGitHubRef(repo string) (GitHubRef, error) {
repo = strings.TrimSpace(repo)
// Handle full URL
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
u, err := url.Parse(repo)
if err != nil {
return GitHubRef{}, fmt.Errorf("invalid URL: %w", err)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return GitHubRef{}, fmt.Errorf("invalid GitHub URL")
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
Ref: "main",
}
// Look for /tree/ or /blob/ in the path
for i := 2; i < len(parts); i++ {
if parts[i] == "tree" || parts[i] == "blob" {
if i+1 < len(parts) {
ref.Ref = parts[i+1]
ref.SubPath = strings.Join(parts[i+2:], "/")
}
break
}
}
return ref, nil
}
// Handle shorthand format
parts := strings.Split(strings.Trim(repo, "/"), "/")
if len(parts) < 2 {
return GitHubRef{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo)
}
ref := GitHubRef{
Owner: parts[0],
RepoName: parts[1],
Ref: "main",
}
if len(parts) > 2 {
ref.SubPath = strings.Join(parts[2:], "/")
}
return ref, nil
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
if _, err := os.Stat(skillDir); err == nil {
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
ref, err := parseGitHubRef(repo)
if err != nil {
return err
}
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
skillName := ref.RepoName
if ref.SubPath != "" {
skillName = filepath.Base(ref.SubPath)
}
skillDirectory := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDirectory); err == nil {
return fmt.Errorf("skill '%s' already exists", skillName)
}
// Build GitHub API URL
apiPath := path.Join(ref.Owner, ref.RepoName, "contents")
if ref.SubPath != "" {
apiPath = path.Join(apiPath, ref.SubPath)
}
apiURL := fmt.Sprintf("https://api.github.com/repos/%s?ref=%s", apiPath, ref.Ref)
if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil {
// Fallback to raw download
return si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory)
}
if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil {
return fmt.Errorf("SKILL.md not found in repository")
}
return nil
}
// downloadDir recursively downloads a directory from GitHub API
// isRoot: true if this is the skill root directory (only download SKILL.md at root)
func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, localDir string, isRoot bool) error {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return err
}
if si.githubToken != "" {
req.Header.Set("Authorization", "Bearer "+si.githubToken)
}
resp, err := utils.DoRequestWithRetry(si.client, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
var items []GitHubContent
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return err
}
for _, item := range items {
localPath := filepath.Join(localDir, item.Name)
switch item.Type {
case "file":
if !shouldDownload(item.Name, isRoot) {
continue
}
if err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil {
return fmt.Errorf("download %s: %w", item.Name, err)
}
case "dir":
if !isSkillDirectory(item.Name) {
continue
}
if err := si.getGithubDirAllFiles(ctx, item.URL, localPath, false); err != nil {
return err
}
}
}
return nil
}
// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com
func (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error {
urlPath := path.Join(owner, repo, ref)
if subPath != "" {
urlPath = path.Join(urlPath, subPath)
}
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/SKILL.md", urlPath)
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := utils.DoRequestWithRetry(client, req)
// Use chunked download to temporary file.
tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
defer resp.Body.Close()
defer os.Remove(tmpPath)
if resp.StatusCode != 200 {
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
if err := os.MkdirAll(localDir, 0o755); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
localPath := filepath.Join(localDir, "SKILL.md")
// Use unified atomic write utility with explicit sync for flash storage reliability.
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
// Atomic move from temp to final location.
if err := os.Rename(tmpPath, localPath); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
return nil
return os.Chmod(localPath, 0o600)
}
func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
// Use chunked download to temporary file, then move atomically to target.
tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)
if err != nil {
return err
}
defer os.Remove(tmpPath)
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return err
}
// Atomic move from temp to final location.
if err := os.Rename(tmpPath, localPath); err != nil {
return fmt.Errorf("failed to move downloaded file: %w", err)
}
return os.Chmod(localPath, 0o600)
}
// shouldDownload determines if a file should be downloaded
// root: true if we're at the skill root directory
func shouldDownload(name string, root bool) bool {
if root {
return name == "SKILL.md"
}
return true
}
// isSkillDir checks if a directory is a standard skill resource directory
func isSkillDirectory(name string) bool {
switch name {
case "scripts", "references", "assets", "templates", "docs":
return true
}
return false
}
func (si *SkillInstaller) Uninstall(skillName string) error {
skillDir := filepath.Join(si.workspace, "skills", skillName)
parts := strings.Split(skillName, "/")
var finalSkillName string
for i := len(parts) - 1; i >= 0; i-- {
if parts[i] != "" {
finalSkillName = parts[i]
break
}
}
if finalSkillName == "" {
finalSkillName = skillName
}
skillDir := filepath.Join(si.workspace, "skills", finalSkillName)
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
return fmt.Errorf("skill '%s' not found", skillName)
return fmt.Errorf("skill '%s' not found (processed as '%s')", skillName, finalSkillName)
}
if err := os.RemoveAll(skillDir); err != nil {
return fmt.Errorf("failed to remove skill: %w", err)
return fmt.Errorf("failed to remove skill '%s': %w", finalSkillName, err)
}
return nil

View file

@ -0,0 +1,665 @@
package skills
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseGitHubRef(t *testing.T) {
tests := []struct {
name string
repo string
wantOwner string
wantRepoName string
wantRef string
wantSubPath string
wantErr bool
wantErrContain string
}{
{
name: "simple owner/repo",
repo: "sipeed/picoclaw",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "main",
wantSubPath: "",
},
{
name: "owner/repo with subpath",
repo: "sipeed/picoclaw/skills/test",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "main",
wantSubPath: "skills/test",
},
{
name: "full URL with tree",
repo: "https://github.com/sipeed/picoclaw/tree/dev/skills/test",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "dev",
wantSubPath: "skills/test",
},
{
name: "full URL with blob",
repo: "https://github.com/sipeed/picoclaw/blob/main/README.md",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "main",
wantSubPath: "README.md",
},
{
name: "full URL without ref",
repo: "https://github.com/sipeed/picoclaw",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "main",
wantSubPath: "",
},
{
name: "invalid format - single part",
repo: "sipeed",
wantErr: true,
wantErrContain: "expected 'owner/repo'",
},
{
name: "invalid URL",
repo: "http://[invalid",
wantErr: true,
wantErrContain: "invalid URL",
},
{
name: "invalid GitHub URL - only one path part",
repo: "https://github.com/sipeed",
wantErr: true,
wantErrContain: "invalid GitHub URL",
},
{
name: "with whitespace",
repo: " sipeed/picoclaw ",
wantOwner: "sipeed",
wantRepoName: "picoclaw",
wantRef: "main",
wantSubPath: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref, err := parseGitHubRef(tt.repo)
if tt.wantErr {
if err == nil {
t.Errorf("parseGitHubRef() error = nil, wantErr = true")
return
}
if tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) {
t.Errorf("parseGitHubRef() error = %v, want error containing %v", err, tt.wantErrContain)
}
return
}
if err != nil {
t.Errorf("parseGitHubRef() unexpected error = %v", err)
return
}
if ref.Owner != tt.wantOwner {
t.Errorf("parseGitHubRef() owner = %v, want %v", ref.Owner, tt.wantOwner)
}
if ref.RepoName != tt.wantRepoName {
t.Errorf("parseGitHubRef() repoName = %v, want %v", ref.RepoName, tt.wantRepoName)
}
if ref.Ref != tt.wantRef {
t.Errorf("parseGitHubRef() ref = %v, want %v", ref.Ref, tt.wantRef)
}
if ref.SubPath != tt.wantSubPath {
t.Errorf("parseGitHubRef() subPath = %v, want %v", ref.SubPath, tt.wantSubPath)
}
})
}
}
func TestShouldDownload(t *testing.T) {
tests := []struct {
name string
file string
root bool
want bool
}{
{"SKILL.md at root", "SKILL.md", true, true},
{"other file at root", "README.md", true, false},
{"script at root", "script.py", true, false},
{"SKILL.md not at root", "SKILL.md", false, true},
{"any file not at root", "any.txt", false, true},
{"script not at root", "script.py", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDownload(tt.file, tt.root)
if got != tt.want {
t.Errorf("shouldDownload(%q, %v) = %v, want %v", tt.file, tt.root, got, tt.want)
}
})
}
}
func TestIsSkillDirectory(t *testing.T) {
tests := []struct {
name string
dir string
want bool
}{
{"scripts dir", "scripts", true},
{"references dir", "references", true},
{"assets dir", "assets", true},
{"templates dir", "templates", true},
{"docs dir", "docs", true},
{"other dir", "other", false},
{"src dir", "src", false},
{"empty string", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSkillDirectory(tt.dir)
if got != tt.want {
t.Errorf("isSkillDirectory(%q) = %v, want %v", tt.dir, got, tt.want)
}
})
}
}
func TestNewSkillInstaller(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "test-token", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
if installer == nil {
t.Fatal("NewSkillInstaller() returned nil")
}
if installer.workspace != tmpDir {
t.Errorf("workspace = %v, want %v", installer.workspace, tmpDir)
}
if installer.githubToken != "test-token" {
t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken)
}
if installer.proxy != "" {
t.Errorf("proxy = %v, want empty", installer.proxy)
}
if installer.client == nil {
t.Error("client is nil")
} else if installer.client.Timeout != 15*time.Second {
t.Errorf("client.Timeout = %v, want 15s", installer.client.Timeout)
}
}
func TestNewSkillInstaller_WithProxy(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "test-token", "http://127.0.0.1:7890")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
if installer.proxy != "http://127.0.0.1:7890" {
t.Errorf("proxy = %v, want 'http://127.0.0.1:7890'", installer.proxy)
}
if installer.client == nil {
t.Fatal("client is nil")
}
// Verify the transport has proxy configured
transport, ok := installer.client.Transport.(*http.Transport)
if !ok {
t.Fatal("client.Transport is not *http.Transport")
}
if transport.Proxy == nil {
t.Error("transport.Proxy is nil, expected non-nil")
}
}
func TestNewSkillInstaller_InvalidProxy(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy")
if err == nil {
t.Error("NewSkillInstaller() expected error for invalid proxy, got nil")
}
if installer != nil {
t.Error("expected nil installer on error")
}
}
func TestSkillInstaller_DownloadFile(t *testing.T) {
// Create a test server that serves files
content := "test file content for skill download"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(content))
}))
defer server.Close()
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
t.Run("successful download", func(t *testing.T) {
localPath := filepath.Join(tmpDir, "test-skill", "SKILL.md")
err := installer.downloadFile(context.Background(), server.URL, localPath)
if err != nil {
t.Errorf("downloadFile() error = %v", err)
return
}
// Verify file was downloaded
data, err := os.ReadFile(localPath)
if err != nil {
t.Errorf("failed to read downloaded file: %v", err)
return
}
if string(data) != content {
t.Errorf("downloaded content = %q, want %q", string(data), content)
}
// Check file permissions
info, err := os.Stat(localPath)
if err != nil {
t.Errorf("failed to stat file: %v", err)
return
}
if info.Mode().Perm() != 0o600 {
t.Errorf("file permissions = %o, want %o", info.Mode().Perm(), 0o600)
}
})
t.Run("http error", func(t *testing.T) {
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer errorServer.Close()
localPath := filepath.Join(tmpDir, "error-test", "SKILL.md")
err := installer.downloadFile(context.Background(), errorServer.URL, localPath)
if err == nil {
t.Error("downloadFile() expected error for 404, got nil")
}
})
}
func TestSkillInstaller_DownloadRaw(t *testing.T) {
content := "raw skill content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(content))
}))
defer server.Close()
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
// Replace the client with one that points to our test server
// We need to modify the URL in the function, so we'll test indirectly
localDir := filepath.Join(tmpDir, "raw-test")
ctx := context.Background()
// Create a simple test by calling downloadFile directly since downloadRaw
// constructs its own URL
testFile := filepath.Join(localDir, "SKILL.md")
err = installer.downloadFile(ctx, server.URL, testFile)
if err != nil {
t.Errorf("downloadFile() error = %v", err)
}
// Verify file content
data, err := os.ReadFile(testFile)
if err != nil {
t.Errorf("failed to read file: %v", err)
return
}
if string(data) != content {
t.Errorf("content = %q, want %q", string(data), content)
}
}
func TestSkillInstaller_Uninstall(t *testing.T) {
tmpDir := t.TempDir()
skillsDir := filepath.Join(tmpDir, "skills")
os.MkdirAll(skillsDir, 0o755)
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
t.Run("uninstall existing skill", func(t *testing.T) {
skillName := "test-skill"
skillDir := filepath.Join(skillsDir, skillName)
// Create skill directory with a file
os.MkdirAll(skillDir, 0o755)
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
if err := installer.Uninstall(skillName); err != nil {
t.Errorf("Uninstall() error = %v", err)
}
// Verify directory was removed
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Error("skill directory still exists after uninstall")
}
})
t.Run("uninstall non-existent skill", func(t *testing.T) {
if err := installer.Uninstall("non-existent-skill"); err == nil {
t.Error("Uninstall() expected error for non-existent skill, got nil")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("error message = %q, want 'not found'", err.Error())
}
})
t.Run("uninstall with path separator", func(t *testing.T) {
skillName := "owner/repo/skill-name"
skillDir := filepath.Join(skillsDir, "skill-name")
// Create skill directory
os.MkdirAll(skillDir, 0o755)
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
if err := installer.Uninstall(skillName); err != nil {
t.Errorf("Uninstall() error = %v", err)
}
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Error("skill directory still exists after uninstall")
}
})
t.Run("uninstall with trailing slash", func(t *testing.T) {
skillName := "skill-name/"
skillDir := filepath.Join(skillsDir, "skill-name")
// Create skill directory
os.MkdirAll(skillDir, 0o755)
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644)
if err := installer.Uninstall(skillName); err != nil {
t.Errorf("Uninstall() error = %v", err)
}
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
t.Error("skill directory still exists after uninstall")
}
})
}
func TestSkillInstaller_InstallFromGitHub_SkillAlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
skillsDir := filepath.Join(tmpDir, "skills")
os.MkdirAll(skillsDir, 0o755)
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
// Create an existing skill directory
existingSkill := filepath.Join(skillsDir, "picoclaw")
os.MkdirAll(existingSkill, 0o755)
os.WriteFile(filepath.Join(existingSkill, "SKILL.md"), []byte("existing"), 0o644)
// Try to install the same skill - should fail
err = installer.InstallFromGitHub(context.Background(), "sipeed/picoclaw")
if err == nil {
t.Error("InstallFromGitHub() expected error for existing skill, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("error message = %q, want 'already exists'", err.Error())
}
}
func TestGitHubContent_Struct(t *testing.T) {
// Test that GitHubContent struct can be properly unmarshaled
jsonData := `{
"name": "test.md",
"path": "skills/test.md",
"type": "file",
"download_url": "https://example.com/download",
"url": "https://api.github.com/contents/skills/test.md"
}`
var content GitHubContent
err := json.Unmarshal([]byte(jsonData), &content)
if err != nil {
t.Errorf("failed to unmarshal GitHubContent: %v", err)
}
if content.Name != "test.md" {
t.Errorf("Name = %q, want 'test.md'", content.Name)
}
if content.Type != "file" {
t.Errorf("Type = %q, want 'file'", content.Type)
}
if content.DownloadURL != "https://example.com/download" {
t.Errorf("DownloadURL = %q, want 'https://example.com/download'", content.DownloadURL)
}
}
func TestSkillInstaller_GetGithubDirAllFiles(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
// Create a test server that mimics GitHub API
fileContent := "skill file content"
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check for authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" && !strings.HasPrefix(authHeader, "Bearer ") {
t.Errorf("expected Bearer token, got: %s", authHeader)
}
// Return different responses based on path
if strings.Contains(r.URL.Path, "/contents") {
// API response for directory listing
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
items := []map[string]any{
{
"name": "SKILL.md",
"path": "SKILL.md",
"type": "file",
"download_url": serverURL + "/download/SKILL.md",
},
{
"name": "scripts",
"path": "scripts",
"type": "dir",
"url": serverURL + "/api/scripts",
},
}
json.NewEncoder(w).Encode(items)
} else if strings.Contains(r.URL.Path, "/api/scripts") {
// API response for scripts subdirectory
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
items := []map[string]any{
{
"name": "test.py",
"path": "scripts/test.py",
"type": "file",
"download_url": serverURL + "/download/test.py",
},
}
json.NewEncoder(w).Encode(items)
} else if strings.Contains(r.URL.Path, "/download/") {
// Raw file download
w.WriteHeader(http.StatusOK)
w.Write([]byte(fileContent))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
serverURL = server.URL
defer server.Close()
localDir := filepath.Join(tmpDir, "test-skill")
t.Run("download from GitHub API", func(t *testing.T) {
err := installer.getGithubDirAllFiles(context.Background(), server.URL+"/contents", localDir, true)
if err != nil {
t.Errorf("getGithubDirAllFiles() error = %v", err)
return
}
// Verify SKILL.md was downloaded
skillMd := filepath.Join(localDir, "SKILL.md")
data, err := os.ReadFile(skillMd)
if err != nil {
t.Errorf("failed to read SKILL.md: %v", err)
return
}
if string(data) != fileContent {
t.Errorf("SKILL.md content = %q, want %q", string(data), fileContent)
}
// Verify scripts directory and file
scriptFile := filepath.Join(localDir, "scripts", "test.py")
data, err = os.ReadFile(scriptFile)
if err != nil {
t.Errorf("failed to read test.py: %v", err)
return
}
if string(data) != fileContent {
t.Errorf("test.py content = %q, want %q", string(data), fileContent)
}
})
t.Run("http error response", func(t *testing.T) {
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer errorServer.Close()
err := installer.getGithubDirAllFiles(
context.Background(),
errorServer.URL,
filepath.Join(tmpDir, "error-test"),
true,
)
if err == nil {
t.Error("getGithubDirAllFiles() expected error for 403, got nil")
}
})
}
func TestSkillInstaller_InstallFromGitHub_WithToken(t *testing.T) {
tmpDir := t.TempDir()
skillsDir := filepath.Join(tmpDir, "skills")
os.MkdirAll(skillsDir, 0o755)
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
tokenReceived := strings.TrimPrefix(authHeader, "Bearer ")
t.Fatalf("github token is %s", tokenReceived)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
items := []map[string]any{
{
"name": "SKILL.md",
"path": "SKILL.md",
"type": "file",
"download_url": serverURL + "/download/SKILL.md",
},
}
json.NewEncoder(w).Encode(items)
}))
serverURL = server.URL
defer server.Close()
installer, err := NewSkillInstaller(tmpDir, "test-github-token", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
// We need to test the token is passed - the actual install will fail
// because we're not fully mocking the download, but we can verify
// the token is sent in the request
// Use a simple context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// The install will fail because download URL isn't properly set up,
// but the token should be sent in the API request
_ = installer.InstallFromGitHub(ctx, "owner/repo")
// Note: We can't easily intercept the download request since it's a different URL,
// but the fact that the API request was made verifies the token flow
// In a real scenario, the token would be sent to both API and raw downloads
}
func TestSkillInstaller_ContextCancellation(t *testing.T) {
tmpDir := t.TempDir()
installer, err := NewSkillInstaller(tmpDir, "", "")
if err != nil {
t.Fatalf("NewSkillInstaller() error = %v", err)
}
// Create a slow server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte("response"))
}))
defer server.Close()
// Create a canceled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
localPath := filepath.Join(tmpDir, "cancel-test", "file.txt")
err = installer.downloadFile(ctx, server.URL, localPath)
if err == nil {
t.Error("downloadFile() expected error for canceled context, got nil")
}
}

View file

@ -10,14 +10,15 @@ import (
"regexp"
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/logger"
)
var (
namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
)
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
const (
MaxNameLength = 64
@ -226,11 +227,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
return nil
}
frontmatter := sl.extractFrontmatter(string(content))
if frontmatter == "" {
return &SkillMetadata{
Name: filepath.Base(filepath.Dir(skillPath)),
frontmatter, bodyContent := splitFrontmatter(string(content))
dirName := filepath.Base(filepath.Dir(skillPath))
title, bodyDescription := extractMarkdownMetadata(bodyContent)
metadata := &SkillMetadata{
Name: dirName,
Description: bodyDescription,
}
if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength {
metadata.Name = title
}
if frontmatter == "" {
return metadata
}
// Try JSON first (for backward compatibility)
@ -239,60 +249,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
Description string `json:"description"`
}
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
return &SkillMetadata{
Name: jsonMeta.Name,
Description: jsonMeta.Description,
if jsonMeta.Name != "" {
metadata.Name = jsonMeta.Name
}
if jsonMeta.Description != "" {
metadata.Description = jsonMeta.Description
}
return metadata
}
// Fall back to simple YAML parsing
yamlMeta := sl.parseSimpleYAML(frontmatter)
return &SkillMetadata{
Name: yamlMeta["name"],
Description: yamlMeta["description"],
if name := yamlMeta["name"]; name != "" {
metadata.Name = name
}
if description := yamlMeta["description"]; description != "" {
metadata.Description = description
}
return metadata
}
// parseSimpleYAML parses simple key: value YAML format
// Example: name: github\n description: "..."
// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
func extractMarkdownMetadata(content string) (title, description string) {
p := parser.NewWithExtensions(parser.CommonExtensions)
doc := markdown.Parse([]byte(content), p)
if doc == nil {
return "", ""
}
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if !entering {
return ast.GoToNext
}
switch n := node.(type) {
case *ast.Heading:
if title == "" && n.Level == 1 {
title = nodeText(n)
if title != "" && description != "" {
return ast.Terminate
}
}
case *ast.Paragraph:
if description == "" {
description = nodeText(n)
if title != "" && description != "" {
return ast.Terminate
}
}
}
return ast.GoToNext
})
return title, description
}
func nodeText(n ast.Node) string {
var b strings.Builder
ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus {
if !entering {
return ast.GoToNext
}
switch t := node.(type) {
case *ast.Text:
b.Write(t.Literal)
case *ast.Code:
b.Write(t.Literal)
case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace:
b.WriteByte(' ')
}
return ast.GoToNext
})
return strings.Join(strings.Fields(b.String()), " ")
}
// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields.
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
result := make(map[string]string)
// Normalize line endings: convert \r\n and \r to \n
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
for line := range strings.SplitSeq(normalized, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
var meta struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
value = strings.Trim(value, "\"'")
result[key] = value
if err := yaml.Unmarshal([]byte(content), &meta); err != nil {
return result
}
if meta.Name != "" {
result["name"] = meta.Name
}
if meta.Description != "" {
result["description"] = meta.Description
}
return result
}
func (sl *SkillsLoader) extractFrontmatter(content string) string {
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
match := reFrontmatter.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
return ""
frontmatter, _ := splitFrontmatter(content)
return frontmatter
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
return reStripFrontmatter.ReplaceAllString(content, "")
_, body := splitFrontmatter(content)
return body
}
func splitFrontmatter(content string) (frontmatter, body string) {
normalized := string(parser.NormalizeNewlines([]byte(content)))
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || lines[0] != "---" {
return "", content
}
end := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
end = i
break
}
}
if end == -1 {
return "", content
}
frontmatter = strings.Join(lines[1:end], "\n")
body = strings.Join(lines[end+1:], "\n")
body = strings.TrimLeft(body, "\n")
return frontmatter, body
}
func escapeXML(s string) string {

View file

@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {
builtin,
}, roots)
}
func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "plain-skill", meta.Name)
assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description)
}
func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "frontmatter description", meta.Description)
}
func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "line 1: with colon\nline 2", meta.Description)
}
func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Invalid Heading Name\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "valid-name", meta.Name)
assert.Equal(t, "Body description.", meta.Description)
}
func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "<!--\n# COPYRIGHT NOTICE\n# This file is part of the \"Universal Biomedical Skills\" project.\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\n# All Rights Reserved.\n#\n# This code is proprietary and confidential.\n# Unauthorized copying of this file, via any medium is strictly prohibited.\n#\n# Provenance: Authenticated by MD BABU MIA\n\n-->\n\n# Biomed Skill\n\nSummarize biomedical papers.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "biomed-skill", meta.Name)
assert.Equal(t, "Summarize biomedical papers.", meta.Description)
}

View file

@ -40,8 +40,8 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
if err := os.MkdirAll(stateDir, 0o755); err != nil {
log.Fatalf("[FATAL] state: failed to create state directory: %v", err)
if err := os.MkdirAll(stateDir, 0o700); err != nil {
log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err)
}
sm := &Manager{

View file

@ -2,7 +2,6 @@ package state
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@ -217,10 +216,7 @@ func TestNewManager_EmptyWorkspace(t *testing.T) {
}
}
func TestNewManager_MkdirFailureCrashes(t *testing.T) {
// Since log.Fatalf calls os.Exit(1), we cannot test it normally
// Otherwise, the test suite would stop altogether.
// We use the standard pattern of Go: rerun this test in a subprocess.
func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
if os.Getenv("BE_CRASHER") == "1" {
tmpDir := os.Getenv("CRASH_DIR")
@ -240,15 +236,11 @@ func TestNewManager_MkdirFailureCrashes(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureCrashes")
cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash")
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir)
err = cmd.Run()
var e *exec.ExitError
if errors.As(err, &e) && !e.Success() {
return
if err != nil {
t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err)
}
t.Fatalf("The process ended without error, a crash was expected via os.Exit(1). Err: %v", err)
}

View file

@ -8,6 +8,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -73,6 +74,10 @@ func (t *CronTool) Parameters() map[string]any {
"type": "string",
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
},
"command_confirm": map[string]any{
"type": "boolean",
"description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.",
},
"at_seconds": map[string]any{
"type": "integer",
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
@ -175,12 +180,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
deliver = d
}
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm.
// Non-command reminders (plain messages) remain open to all channels.
command, _ := args["command"].(string)
commandConfirm, _ := args["command_confirm"].(bool)
if command != "" {
// Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically)
// Actually, let's keep deliver=false to let the system know it's not a simple chat message
// But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set.
// However, logically, it's not "delivered" to chat directly as is.
if !constants.IsInternalChannel(channel) {
return ErrorResult("scheduling command execution is restricted to internal channels")
}
if !commandConfirm {
return ErrorResult("command_confirm=true is required to schedule command execution")
}
deliver = false
}
@ -282,6 +292,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
if job.Payload.Command != "" {
args := map[string]any{
"command": job.Payload.Command,
"__channel": channel,
"__chat_id": chatID,
}
result := t.execTool.Execute(ctx, args)

116
pkg/tools/cron_test.go Normal file
View file

@ -0,0 +1,116 @@
package tools
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
)
func newTestCronTool(t *testing.T) *CronTool {
t.Helper()
storePath := filepath.Join(t.TempDir(), "cron.json")
cronService := cron.NewCronService(storePath, nil)
msgBus := bus.NewMessageBus()
cfg := config.DefaultConfig()
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
if err != nil {
t.Fatalf("NewCronTool() error: %v", err)
}
return tool
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected command scheduling to be blocked from remote channel")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
}
}
// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required
func TestCronTool_CommandRequiresConfirm(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected error when command_confirm is missing")
}
if !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM)
}
}
// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels
func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "check disk",
"command": "df -h",
"command_confirm": true,
"at_seconds": float64(60),
})
if result.IsError {
t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Cron job added") {
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
}
}
// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing
func TestCronTool_AddJobRequiresSessionContext(t *testing.T) {
tool := newTestCronTool(t)
result := tool.Execute(context.Background(), map[string]any{
"action": "add",
"message": "reminder",
"at_seconds": float64(60),
})
if !result.IsError {
t.Fatal("expected error when session context is missing")
}
if !strings.Contains(result.ForLLM, "no session context") {
t.Errorf("expected 'no session context' message, got: %s", result.ForLLM)
}
}
// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel
func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "time to stretch",
"at_seconds": float64(600),
})
if result.IsError {
t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM)
}
}

View file

@ -14,6 +14,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
)
type ExecTool struct {
@ -23,6 +24,7 @@ type ExecTool struct {
allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp
restrictToWorkspace bool
allowRemote bool
}
var (
@ -100,10 +102,12 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
customAllowPatterns := make([]*regexp.Regexp, 0)
allowRemote := true
if config != nil {
execConfig := config.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 {
@ -143,6 +147,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
allowPatterns: nil,
customAllowPatterns: customAllowPatterns,
restrictToWorkspace: restrict,
allowRemote: allowRemote,
}, nil
}
@ -177,6 +182,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult("command is required")
}
// GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks)
// unless explicitly opted-in via config. Fail-closed: empty channel = blocked.
if !t.allowRemote {
channel := ToolChannel(ctx)
if channel == "" {
channel, _ = args["__channel"].(string)
}
channel = strings.TrimSpace(channel)
if channel == "" || !constants.IsInternalChannel(channel) {
return ErrorResult("exec is restricted to internal channels")
}
}
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
@ -201,6 +219,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult(guardError)
}
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
// between validation and cmd.Dir assignment.
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
resolved, err := filepath.EvalSymlinks(cwd)
if err != nil {
return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err))
}
absWorkspace, _ := filepath.Abs(t.workingDir)
wsResolved, _ := filepath.EvalSymlinks(absWorkspace)
if wsResolved == "" {
wsResolved = absWorkspace
}
rel, err := filepath.Rel(wsResolved, resolved)
if err != nil || !filepath.IsLocal(rel) {
return ErrorResult("Command blocked by safety guard (working directory escaped workspace)")
}
cwd = resolved
}
// timeout == 0 means no timeout
var cmdCtx context.Context
var cancel context.CancelFunc

View file

@ -301,6 +301,85 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
}
}
// TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels
func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if !result.IsError {
t.Fatal("expected remote-channel exec to be blocked")
}
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
t.Errorf("expected 'restricted to internal channels' message, got: %s", result.ForLLM)
}
}
// TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels
func TestShellTool_InternalChannelAllowed(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "hi") {
t.Errorf("expected output to contain 'hi', got: %s", result.ForLLM)
}
}
// TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context
func TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = false
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"command": "echo hi",
})
if !result.IsError {
t.Fatal("expected exec with empty channel to be blocked when allowRemote=false")
}
}
// TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel
func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) {
cfg := &config.Config{}
cfg.Tools.Exec.EnableDenyPatterns = true
cfg.Tools.Exec.AllowRemote = true
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM)
}
}
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()

View file

@ -7,12 +7,15 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
@ -40,43 +43,6 @@ var (
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
)
// createHTTPClient creates an HTTP client with optional proxy support
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
TLSHandshakeTimeout: 15 * time.Second,
},
}
if proxyURL != "" {
proxy, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
scheme := strings.ToLower(proxy.Scheme)
switch scheme {
case "http", "https", "socks5", "socks5h":
default:
return nil, fmt.Errorf(
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
proxy.Scheme,
)
}
if proxy.Host == "" {
return nil, fmt.Errorf("invalid proxy URL: missing host")
}
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
} else {
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
}
return client, nil
}
type APIKeyPool struct {
keys []string
current uint32
@ -677,7 +643,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
maxResults := 5
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
}
@ -690,7 +656,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
maxResults = opts.PerplexityMaxResults
}
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
}
@ -704,7 +670,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
maxResults = opts.SearXNGMaxResults
}
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
}
@ -718,7 +684,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
maxResults = opts.TavilyMaxResults
}
} else if opts.DuckDuckGoEnabled {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
}
@ -727,7 +693,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
maxResults = opts.DuckDuckGoMaxResults
}
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
}
@ -818,18 +784,32 @@ func NewWebFetchTool(maxChars int, fetchLimitBytes int64) (*WebFetchTool, error)
return NewWebFetchToolWithProxy(maxChars, "", fetchLimitBytes)
}
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
var allowPrivateWebFetchHosts atomic.Bool
func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) {
if maxChars <= 0 {
maxChars = defaultMaxChars
}
client, err := createHTTPClient(proxy, fetchTimeout)
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
}
if transport, ok := client.Transport.(*http.Transport); ok {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 30 * time.Second,
}
transport.DialContext = newSafeDialContext(dialer)
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if isObviousPrivateHost(req.URL.Hostname()) {
return fmt.Errorf("redirect target is private or local network host")
}
return nil
}
if fetchLimitBytes <= 0 {
@ -888,6 +868,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("missing domain in URL")
}
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
// The real SSRF guard is newSafeDialContext at connect time.
hostname := parsedURL.Hostname()
if isObviousPrivateHost(hostname) {
return ErrorResult("fetching private or local network hosts is not allowed")
}
maxChars := t.maxChars
if mc, ok := args["maxChars"].(float64); ok {
if int(mc) > 100 {
@ -901,7 +888,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
}
req.Header.Set("User-Agent", userAgent)
resp, err := t.client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
@ -992,3 +978,127 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
return strings.Join(cleanLines, "\n")
}
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if allowPrivateWebFetchHosts.Load() {
return dialer.DialContext(ctx, network, address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid target address %q: %w", address, err)
}
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if ip := net.ParseIP(host); ip != nil {
if isPrivateOrRestrictedIP(ip) {
return nil, fmt.Errorf("blocked private or local target: %s", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
}
attempted := 0
var lastErr error
for _, ipAddr := range ipAddrs {
if isPrivateOrRestrictedIP(ipAddr.IP) {
continue
}
attempted++
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if attempted == 0 {
return nil, fmt.Errorf("all resolved addresses for %s are private or restricted", host)
}
if lastErr != nil {
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}
}
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
func isObviousPrivateHost(host string) bool {
if allowPrivateWebFetchHosts.Load() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimSuffix(h, ".")
if h == "" {
return true
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return isPrivateOrRestrictedIP(ip)
}
return false
}
// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:
// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,
// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32).
func isPrivateOrRestrictedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.
if ip4[0] == 10 ||
ip4[0] == 127 ||
ip4[0] == 0 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) {
return true
}
return false
}
if len(ip) == net.IPv6len {
// IPv6 unique local addresses (fc00::/7)
if (ip[0] & 0xfe) == 0xfc {
return true
}
// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].
if ip[0] == 0x20 && ip[1] == 0x02 {
embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])
return isPrivateOrRestrictedIP(embedded)
}
// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)
return isPrivateOrRestrictedIP(client)
}
}
return false
}

View file

@ -5,11 +5,11 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
@ -18,6 +18,8 @@ const testFetchLimit = int64(10 * 1024 * 1024)
// TestWebTool_WebFetch_Success verifies successful URL fetching
func TestWebTool_WebFetch_Success(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
@ -55,6 +57,8 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
// TestWebTool_WebFetch_JSON verifies JSON content handling
func TestWebTool_WebFetch_JSON(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
testData := map[string]string{"key": "value", "number": "123"}
expectedJSON, _ := json.MarshalIndent(testData, "", " ")
@ -163,6 +167,8 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) {
// TestWebTool_WebFetch_Truncation verifies content truncation
func TestWebTool_WebFetch_Truncation(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
longContent := strings.Repeat("x", 20000)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -205,6 +211,8 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
// Create a mock HTTP server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@ -290,6 +298,8 @@ func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
@ -404,6 +414,205 @@ func TestWebFetchTool_extractText(t *testing.T) {
}
}
func withPrivateWebFetchHostsAllowed(t *testing.T) {
t.Helper()
previous := allowPrivateWebFetchHosts.Load()
allowPrivateWebFetchHosts.Store(true)
t.Cleanup(func() {
allowPrivateWebFetchHosts.Store(previous)
})
}
func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://127.0.0.1:0",
})
if !result.IsError {
t.Errorf("expected error for private host URL, got success")
}
if !strings.Contains(result.ForLLM, "private or local network") &&
!strings.Contains(result.ForUser, "private or local network") {
t.Errorf("expected private host block message, got %q", result.ForLLM)
}
}
func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer server.Close()
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": server.URL,
})
if result.IsError {
t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM)
}
}
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[::ffff:127.0.0.1]:0",
})
if !result.IsError {
t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success")
}
}
// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked
func TestWebFetch_BlocksMetadataIP(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://169.254.169.254/latest/meta-data",
})
if !result.IsError {
t.Error("expected error for cloud metadata IP, got success")
}
}
// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked
func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[fd00::1]:0",
})
if !result.IsError {
t.Error("expected error for IPv6 unique local address, got success")
}
}
// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked
func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2002:7f00:0001::1 embeds 127.0.0.1
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2002:7f00:0001::1]:0",
})
if !result.IsError {
t.Error("expected error for 6to4 with private embedded IPv4, got success")
}
}
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
// 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass,
// connection will fail (no listener) but that's after the SSRF check.
result := tool.Execute(context.Background(), map[string]any{
"url": "http://[2002:0801:0101::1]:0",
})
// Should NOT be blocked by SSRF check — error should be connection failure, not "private"
if result.IsError && strings.Contains(result.ForLLM, "private") {
t.Error("6to4 with public embedded IPv4 should not be blocked as private")
}
}
// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked
func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Redirect to a private IP
http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound)
}))
defer server.Close()
// Temporarily disable private host allowance for the redirect check
allowPrivateWebFetchHosts.Store(false)
defer allowPrivateWebFetchHosts.Store(true)
tool, err := NewWebFetchTool(50000, testFetchLimit)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": server.URL,
})
if !result.IsError {
t.Error("expected error when redirecting to private IP, got success")
}
}
// TestIsPrivateOrRestrictedIP_Table tests IP classification logic
func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
tests := []struct {
ip string
blocked bool
desc string
}{
{"127.0.0.1", true, "IPv4 loopback"},
{"10.0.0.1", true, "IPv4 private class A"},
{"172.16.0.1", true, "IPv4 private class B"},
{"192.168.1.1", true, "IPv4 private class C"},
{"169.254.169.254", true, "link-local / cloud metadata"},
{"100.64.0.1", true, "carrier-grade NAT"},
{"0.0.0.0", true, "unspecified"},
{"8.8.8.8", false, "public DNS"},
{"1.1.1.1", false, "public DNS"},
{"::1", true, "IPv6 loopback"},
{"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"},
{"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"},
{"fc00::1", true, "IPv6 unique local"},
{"fd00::1", true, "IPv6 unique local"},
{"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"},
{"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"},
{"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"},
{"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"},
{"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"},
{"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP: %s", tt.ip)
}
got := isPrivateOrRestrictedIP(ip)
if got != tt.blocked {
t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked)
}
})
}
}
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool, err := NewWebFetchTool(50000, testFetchLimit)
@ -429,108 +638,6 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
}
}
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
if client.Timeout != 12*time.Second {
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("transport.Proxy is nil, want non-nil")
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
proxyURL, err := tr.Proxy(req)
if err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
}
}
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
_, err := createHTTPClient("://bad-proxy", 10*time.Second)
if err == nil {
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
}
}
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
proxyURL, err := tr.Proxy(req)
if err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
}
}
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
_, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
if err == nil {
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
}
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
}
}
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(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", "")
client, err := createHTTPClient("", 10*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("transport.Proxy is nil, want proxy function from environment")
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
if _, err := tr.Proxy(req); err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
}
func TestNewWebFetchToolWithProxy(t *testing.T) {
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
if err != nil {

48
pkg/utils/http_client.go Normal file
View file

@ -0,0 +1,48 @@
package utils
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// CreateHTTPClient creates an HTTP client with optional proxy support.
// If proxyURL is empty, it uses the system environment proxy settings.
// Supported proxy schemes: http, https, socks5, socks5h.
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
TLSHandshakeTimeout: 15 * time.Second,
},
}
if proxyURL != "" {
proxy, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
scheme := strings.ToLower(proxy.Scheme)
switch scheme {
case "http", "https", "socks5", "socks5h":
default:
return nil, fmt.Errorf(
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
proxy.Scheme,
)
}
if proxy.Host == "" {
return nil, fmt.Errorf("invalid proxy URL: missing host")
}
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
} else {
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
}
return client, nil
}

View file

@ -0,0 +1,110 @@
package utils
import (
"net/http"
"strings"
"testing"
"time"
)
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
client, err := CreateHTTPClient("http://127.0.0.1:7890", 12*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
if client.Timeout != 12*time.Second {
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("transport.Proxy is nil, want non-nil")
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
proxyURL, err := tr.Proxy(req)
if err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
}
}
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
_, err := CreateHTTPClient("://bad-proxy", 10*time.Second)
if err == nil {
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
}
}
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
client, err := CreateHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
proxyURL, err := tr.Proxy(req)
if err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
}
}
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
_, err := CreateHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
if err == nil {
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
}
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
}
}
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(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", "")
client, err := CreateHTTPClient("", 10*time.Second)
if err != nil {
t.Fatalf("createHTTPClient() error: %v", err)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("transport.Proxy is nil, want proxy function from environment")
}
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
if _, err := tr.Proxy(req); err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
}

View file

@ -2,9 +2,18 @@ package utils
import (
"strings"
"sync/atomic"
"unicode"
)
// Global variable to disable truncation
var disableTruncation atomic.Bool
// SetDisableTruncation globally enables or disables string truncation
func SetDisableTruncation(enabled bool) {
disableTruncation.Store(enabled)
}
// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
// zero-width characters), and other non-graphic characters that could confuse an LLM
// or cause display issues in the agent UI.
@ -30,6 +39,10 @@ func SanitizeMessageContent(input string) string {
// Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation.
func Truncate(s string, maxLen int) string {
// If the no-truncate flag is active, it returns the full string
if disableTruncation.Load() {
return s
}
if maxLen <= 0 {
return ""
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -17,36 +16,11 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
}
// loadFilteredConfig loads the configuration and filters out default placeholder credentials
// (like API limits/keys) if the configuration file has not been created yet by the user.
func (h *Handler) loadFilteredConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil, err
}
configExists := false
if h.configPath != "" {
if _, err := os.Stat(h.configPath); err == nil {
configExists = true
}
}
if !configExists {
for i := range cfg.ModelList {
cfg.ModelList[i].APIKey = ""
cfg.ModelList[i].AuthMethod = ""
}
}
return cfg, nil
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.loadFilteredConfig()
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
@ -74,6 +48,9 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
}
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@ -94,6 +71,20 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func execAllowRemoteOmitted(body []byte) bool {
var raw struct {
Tools *struct {
Exec *struct {
AllowRemote *bool `json:"allow_remote"`
} `json:"exec"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//

View file

@ -0,0 +1,88 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
}
},
"model_list": [
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
"api_key": "sk-default"
}
]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
}
}
func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
}
},
"model_list": [
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
"api_key": "sk-default"
}
]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := cfg.ModelList[0].APIBase; got != "" {
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
}
}

View file

@ -7,8 +7,11 @@ import (
// GatewayEvent represents a state change event for the gateway process.
type GatewayEvent struct {
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error"
Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error"
PID int `json:"pid,omitempty"`
BootDefaultModel string `json:"boot_default_model,omitempty"`
ConfigDefaultModel string `json:"config_default_model,omitempty"`
RestartRequired bool `json:"gateway_restart_required,omitempty"`
}
// EventBroadcaster manages SSE client subscriptions and broadcasts events.

View file

@ -10,7 +10,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
@ -19,23 +18,41 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
)
// gateway holds the state for the managed gateway process.
var gateway = struct {
mu sync.Mutex
cmd *exec.Cmd
bootDefaultModel string
runtimeStatus string
startupDeadline time.Time
logs *LogBuffer
events *EventBroadcaster
}{
runtimeStatus: "stopped",
logs: NewLogBuffer(200),
events: NewEventBroadcaster(),
}
var (
gatewayStartupWindow = 15 * time.Second
gatewayRestartGracePeriod = 5 * time.Second
gatewayRestartForceKillWindow = 3 * time.Second
gatewayRestartPollInterval = 100 * time.Millisecond
)
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
client := http.Client{Timeout: timeout}
return client.Get(url)
}
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
@ -64,7 +81,7 @@ func (h *Handler) TryAutoStartGateway() {
return
}
pid, err := h.startGatewayLocked()
pid, err := h.startGatewayLocked("starting")
if err != nil {
log.Printf("Failed to auto-start gateway: %v", err)
return
@ -89,11 +106,12 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
strings.TrimSpace(modelCfg.AuthMethod) != ""
if !hasCredential {
if !hasModelConfiguration(*modelCfg) {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
}
if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
}
return true, "", nil
}
@ -129,11 +147,124 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
return cmd.Process.Signal(syscall.Signal(0)) == nil
}
func (h *Handler) startGatewayLocked() (int, error) {
func setGatewayRuntimeStatusLocked(status string) {
gateway.runtimeStatus = status
if status == "starting" || status == "restarting" {
gateway.startupDeadline = time.Now().Add(gatewayStartupWindow)
return
}
gateway.startupDeadline = time.Time{}
}
func gatewayStatusOnHealthFailureLocked() string {
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
return gateway.runtimeStatus
}
return "error"
}
if gateway.runtimeStatus == "running" {
return "running"
}
if gateway.runtimeStatus == "error" {
return "error"
}
return "error"
}
func currentGatewayStatusLocked(processAlive bool) string {
if !processAlive {
if gateway.runtimeStatus == "restarting" {
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
return "restarting"
}
return "error"
}
if gateway.runtimeStatus == "error" {
return "error"
}
return "stopped"
}
return gatewayStatusOnHealthFailureLocked()
}
func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
if cmd == nil || cmd.Process == nil {
return true
}
deadline := time.Now().Add(timeout)
for {
if !isCmdProcessAliveLocked(cmd) {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(gatewayRestartPollInterval)
}
}
func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) {
return nil
}
var stopErr error
if runtime.GOOS == "windows" {
stopErr = cmd.Process.Kill()
} else {
stopErr = cmd.Process.Signal(syscall.SIGTERM)
}
if stopErr != nil && isCmdProcessAliveLocked(cmd) {
return fmt.Errorf("failed to stop existing gateway: %w", stopErr)
}
if waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) {
return nil
}
if runtime.GOOS != "windows" {
killErr := cmd.Process.Signal(syscall.SIGKILL)
if killErr != nil && isCmdProcessAliveLocked(cmd) {
return fmt.Errorf("failed to force-stop existing gateway: %w", killErr)
}
if waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) {
return nil
}
}
return fmt.Errorf("existing gateway did not exit before restart")
}
func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool {
return status == "running" &&
bootDefaultModel != "" &&
configDefaultModel != "" &&
bootDefaultModel != configDefaultModel
}
func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return 0, fmt.Errorf("failed to load config: %w", err)
}
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
// Locate the picoclaw executable
execPath := findPicoclawBinary()
execPath := utils.FindPicoclawBinary()
cmd := exec.Command(execPath, "gateway")
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
// config file without requiring a --config flag on the gateway subcommand.
if h.configPath != "" {
cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath)
}
if host := h.gatewayHostOverride(); host != "" {
cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
@ -159,11 +290,19 @@ func (h *Handler) startGatewayLocked() (int, error) {
}
gateway.cmd = cmd
gateway.bootDefaultModel = defaultModelName
setGatewayRuntimeStatusLocked(initialStatus)
pid := cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
// Broadcast starting event
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid})
// Broadcast the launch state immediately so clients can reflect it without polling.
gateway.events.Broadcast(GatewayEvent{
Status: initialStatus,
PID: pid,
BootDefaultModel: defaultModelName,
ConfigDefaultModel: defaultModelName,
RestartRequired: false,
})
// Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs)
@ -178,13 +317,23 @@ func (h *Handler) startGatewayLocked() (int, error) {
}
gateway.mu.Lock()
shouldBroadcastStopped := false
if gateway.cmd == cmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
if gateway.runtimeStatus != "restarting" {
setGatewayRuntimeStatusLocked("stopped")
shouldBroadcastStopped = true
}
}
gateway.mu.Unlock()
// Broadcast stopped event
gateway.events.Broadcast(GatewayEvent{Status: "stopped"})
if shouldBroadcastStopped {
gateway.events.Broadcast(GatewayEvent{
Status: "stopped",
RestartRequired: false,
})
}
}()
// Start a goroutine to probe health and broadcast "running" once ready
@ -201,21 +350,28 @@ func (h *Handler) startGatewayLocked() (int, error) {
if err != nil {
continue
}
healthHost := "127.0.0.1"
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
healthHost = cfg.Gateway.Host
}
healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
healthPort := cfg.Gateway.Port
if healthPort == 0 {
healthPort = 18790
}
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
client := http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(healthURL)
resp, err := gatewayHealthGet(healthURL, 1*time.Second)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid})
gateway.mu.Lock()
if gateway.cmd == cmd {
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
gateway.events.Broadcast(GatewayEvent{
Status: "running",
PID: pid,
BootDefaultModel: defaultModelName,
ConfigDefaultModel: defaultModelName,
RestartRequired: false,
})
return
}
}
@ -244,6 +400,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
setGatewayRuntimeStatusLocked("stopped")
}
ready, reason, err := h.gatewayStartReady()
@ -265,7 +422,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
return
}
pid, err := h.startGatewayLocked()
pid, err := h.startGatewayLocked("starting")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
@ -321,78 +478,162 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
//
// POST /api/gateway/restart
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
ready, reason, err := h.gatewayStartReady()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
http.StatusInternalServerError,
)
return
}
if !ready {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "precondition_failed",
"message": reason,
})
return
}
// Stop existing process if running
if gateway.cmd != nil && gateway.cmd.Process != nil {
if isCmdProcessAliveLocked(gateway.cmd) {
// Process is alive, send SIGTERM
if runtime.GOOS == "windows" {
gateway.cmd.Process.Kill()
gateway.mu.Lock()
previousCmd := gateway.cmd
setGatewayRuntimeStatusLocked("restarting")
gateway.events.Broadcast(GatewayEvent{
Status: "restarting",
RestartRequired: false,
})
gateway.mu.Unlock()
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
gateway.mu.Lock()
if gateway.cmd == previousCmd {
if isCmdProcessAliveLocked(previousCmd) {
setGatewayRuntimeStatusLocked("running")
} else {
gateway.cmd.Process.Signal(syscall.SIGTERM)
}
// Wait briefly for it to exit
gateway.mu.Unlock()
time.Sleep(2 * time.Second)
gateway.mu.Lock()
}
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("error")
}
}
gateway.mu.Unlock()
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
return
}
gateway.mu.Lock()
if gateway.cmd == previousCmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
pid, err := h.startGatewayLocked("restarting")
if err != nil {
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("error")
}
gateway.mu.Unlock()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
return
}
// Start fresh via the existing handler
h.handleGatewayStart(w, r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
}
// handleGatewayClearLogs clears the in-memory gateway log buffer.
//
// POST /api/gateway/logs/clear
func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) {
gateway.logs.Clear()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "cleared",
"log_total": 0,
"log_run_id": gateway.logs.RunID(),
})
}
// handleGatewayStatus returns the gateway run status, health info, and logs.
//
// GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
data := h.gatewayStatusData(r, true)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[string]any {
data := map[string]any{}
cfg, cfgErr := config.LoadConfig(h.configPath)
configDefaultModel := ""
if cfgErr == nil && cfg != nil {
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if configDefaultModel != "" {
data["config_default_model"] = configDefaultModel
}
}
// Check process state
gateway.mu.Lock()
processAlive := isGatewayProcessAliveLocked()
bootDefaultModel := ""
if processAlive {
data["pid"] = gateway.cmd.Process.Pid
if gateway.bootDefaultModel != "" {
data["boot_default_model"] = gateway.bootDefaultModel
bootDefaultModel = gateway.bootDefaultModel
}
}
gateway.mu.Unlock()
if !processAlive {
data["gateway_status"] = "stopped"
gateway.mu.Lock()
data["gateway_status"] = currentGatewayStatusLocked(false)
gateway.mu.Unlock()
} else {
// Process is alive — probe its health endpoint
cfg, err := config.LoadConfig(h.configPath)
host := "127.0.0.1"
port := 18790
if err == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
if cfgErr == nil && cfg != nil {
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port
}
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
resp, err := gatewayHealthGet(url, 2*time.Second)
if err != nil {
data["gateway_status"] = "starting"
gateway.mu.Lock()
data["gateway_status"] = currentGatewayStatusLocked(true)
gateway.mu.Unlock()
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
gateway.mu.Unlock()
data["gateway_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
gateway.mu.Unlock()
data["gateway_status"] = "error"
} else {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
for k, v := range healthData {
data[k] = v
}
@ -402,6 +643,13 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
}
}
status, _ := data["gateway_status"].(string)
data["gateway_restart_required"] = gatewayRestartRequired(
status,
bootDefaultModel,
configDefaultModel,
)
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
@ -413,11 +661,11 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
}
}
// Append incremental log data
if includeLogs {
appendGatewayLogs(r, data)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
return data
}
// appendGatewayLogs reads log_offset and log_run_id query params from the request
@ -503,48 +751,11 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
// currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock()
defer gateway.mu.Unlock()
data := map[string]any{
"gateway_status": "stopped",
}
if isGatewayProcessAliveLocked() {
data["gateway_status"] = "running"
data["pid"] = gateway.cmd.Process.Pid
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
data := h.gatewayStatusData(nil, false)
encoded, _ := json.Marshal(data)
return string(encoded)
}
// findPicoclawBinary locates the picoclaw executable.
// Tries the same directory as the current executable first, then falls back to $PATH.
func findPicoclawBinary() string {
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return "picoclaw"
}
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
func scanPipe(r io.Reader, buf *LogBuffer) {
scanner := bufio.NewScanner(r)

View file

@ -0,0 +1,66 @@
package api
import (
"net"
"net/http"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
func (h *Handler) effectiveLauncherPublic() bool {
if h.serverPublicExplicit {
return h.serverPublic
}
cfg, err := h.loadLauncherConfig()
if err == nil {
return cfg.Public
}
return h.serverPublic
}
func (h *Handler) gatewayHostOverride() string {
if h.effectiveLauncherPublic() {
return "0.0.0.0"
}
return ""
}
func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
if override := h.gatewayHostOverride(); override != "" {
return override
}
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.Gateway.Host)
}
func gatewayProbeHost(bindHost string) string {
if bindHost == "" || bindHost == "0.0.0.0" {
return "127.0.0.1"
}
return bindHost
}
func requestHostName(r *http.Request) string {
reqHost, _, err := net.SplitHostPort(r.Host)
if err == nil {
return reqHost
}
if strings.TrimSpace(r.Host) != "" {
return r.Host
}
return "127.0.0.1"
}
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
}
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}

View file

@ -0,0 +1,59 @@
package api
import (
"net/http/httptest"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: false,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, true, true, nil)
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
}
}
func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws")
}
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
}
}

View file

@ -2,15 +2,76 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
)
func startLongRunningProcess(t *testing.T) *exec.Cmd {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30")
} else {
cmd = exec.Command("sleep", "30")
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
return cmd
}
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("TERM handling differs on Windows")
}
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30")
if err := cmd.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
return cmd
}
func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
gateway.mu.Lock()
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
})
}
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@ -31,7 +92,8 @@ func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "missing-model"
if err := config.SaveConfig(configPath, cfg); err != nil {
err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@ -53,7 +115,8 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@ -73,7 +136,8 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@ -90,6 +154,195 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
}
}
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
return false
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
}}
cfg.Agents.Defaults.ModelName = "local-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false without a running local service")
}
if !strings.Contains(reason, "not reachable") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable")
}
}
func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}}
cfg.Agents.Defaults.ModelName = "local-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason)
}
}
func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID)
return false
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "remote-vllm",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
APIKey: "remote-key",
}}
cfg.Agents.Defaults.ModelName = "remote-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason)
}
}
func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetModelProbeHooks(t)
probeOllamaModelFunc = func(apiBase, modelID string) bool {
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "local-ollama",
Model: "ollama/llama3",
}}
cfg.Agents.Defaults.ModelName = "local-ollama"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason)
}
}
func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []config.ModelConfig{{
ModelName: "openai-oauth",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
}}
cfg.Agents.Defaults.ModelName = "openai-oauth"
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false without stored credential")
}
if !strings.Contains(reason, "no credentials configured") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
}
err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
AccessToken: "openai-token",
Provider: oauthProviderOpenAI,
AuthMethod: "oauth",
})
if err != nil {
t.Fatalf("SetCredential() error = %v", err)
}
ready, reason, err = h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason)
}
}
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@ -120,3 +373,428 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
}
}
func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
// Simulate a process that has already reached the running state.
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return nil, errors.New("probe failed")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
}
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
setGatewayRuntimeStatusLocked("starting")
gateway.startupDeadline = time.Now().Add(-time.Second)
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return nil, errors.New("probe failed")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "error" {
t.Fatalf("gateway_status = %#v, want %q", got, "error")
}
}
func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "restarting" {
t.Fatalf("gateway_status = %#v, want %q", got, "restarting")
}
}
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "previous-model"
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.WriteString(`{"ok":true}`)
return rec.Result(), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_restart_required"]; got != true {
t.Fatalf("gateway_restart_required = %#v, want true", got)
}
}
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
gateway.mu.Unlock()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
gateway.mu.Lock()
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
gateway.mu.Unlock()
if !stillRunning {
t.Fatalf("gateway process was stopped when restart preconditions failed")
}
}
func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startIgnoringTermProcess(t)
t.Cleanup(func() {
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
gateway.mu.Unlock()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gatewayRestartGracePeriod = 150 * time.Millisecond
gatewayRestartForceKillWindow = 150 * time.Millisecond
gatewayRestartPollInterval = 10 * time.Millisecond
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
gateway.mu.Lock()
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
status := gateway.runtimeStatus
gateway.mu.Unlock()
if !stillRunning {
t.Fatalf("gateway process was replaced before the old process exited")
}
if status != "running" {
t.Fatalf("runtimeStatus = %q, want %q", status, "running")
}
}
func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw")
if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("PICOCLAW_BINARY", invalidBinaryPath)
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
statusRec := httptest.NewRecorder()
statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "error" {
t.Fatalf("gateway_status = %#v, want %q", got, "error")
}
}
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
gateway.logs.Clear()
gateway.logs.Append("first line")
gateway.logs.Append("second line")
previousRunID := gateway.logs.RunID()
clearRec := httptest.NewRecorder()
clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil)
mux.ServeHTTP(clearRec, clearReq)
if clearRec.Code != http.StatusOK {
t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK)
}
var clearBody map[string]any
if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil {
t.Fatalf("unmarshal clear response: %v", err)
}
if got := clearBody["status"]; got != "cleared" {
t.Fatalf("clear status body = %#v, want %q", got, "cleared")
}
clearRunID, ok := clearBody["log_run_id"].(float64)
if !ok {
t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"])
}
if int(clearRunID) <= previousRunID {
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
}
statusRec := httptest.NewRecorder()
statusReq := httptest.NewRequest(
http.MethodGet,
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
nil,
)
mux.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
}
var statusBody map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
t.Fatalf("unmarshal status response: %v", err)
}
logs, ok := statusBody["logs"].([]any)
if !ok {
t.Fatalf("logs missing or not array: %#v", statusBody["logs"])
}
if len(logs) != 0 {
t.Fatalf("logs len = %d, want 0", len(logs))
}
if got := statusBody["log_total"]; got != float64(0) {
t.Fatalf("log_total = %#v, want 0", got)
}
}
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
// Create a temporary file to act as the mock binary
tmpDir := t.TempDir()
mockBinary := filepath.Join(tmpDir, "picoclaw-mock")
if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("PICOCLAW_BINARY", mockBinary)
got := utils.FindPicoclawBinary()
if got != mockBinary {
t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary)
}
}
func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
got := utils.FindPicoclawBinary()
// Should not return the invalid path; falls back to "picoclaw" or another found path
if got == "/nonexistent/picoclaw-binary" {
t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got)
}
}

Some files were not shown because too many files have changed in this diff Show more