diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 499613625..9b89b69ae 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,7 +2,7 @@ name: build
on:
push:
- branches: ["main"]
+ branches: [ "main" ]
jobs:
build:
@@ -16,10 +16,5 @@ jobs:
with:
go-version-file: go.mod
- - name: fmt
- run: |
- make fmt
- git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
-
- name: Build
run: make build-all
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 55bf77e00..be1c10c52 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -24,48 +24,9 @@ jobs:
with:
version: v2.10.1
- # TODO: Remove once linter is properly configured
- fmt-check:
- name: Formatting
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Go
- uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - name: Check formatting
- run: |
- make fmt
- git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
-
- # TODO: Remove once linter is properly configured
- vet:
- name: Vet
- runs-on: ubuntu-latest
- needs: fmt-check
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Go
- uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - name: Run go generate
- run: go generate ./...
-
- - name: Run go vet
- run: go vet ./...
-
test:
name: Tests
runs-on: ubuntu-latest
- needs: fmt-check
steps:
- name: Checkout
uses: actions/checkout@v6
diff --git a/.golangci.yaml b/.golangci.yaml
index 80e54ac1c..d45d69e67 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -47,7 +47,6 @@ linters:
- godox
- goprintffuncname
- gosec
- - govet
- ineffassign
- lll
- maintidx
@@ -160,12 +159,11 @@ issues:
formatters:
enable:
+ - gci
+ - gofmt
+ - gofumpt
- goimports
- # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- # - gci
- # - gofmt
- # - gofumpt
- # - golines
+ - golines
settings:
gci:
sections:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..88227f493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,302 @@
+# Contributing to PicoClaw
+
+Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
+
+PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
+
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [Ways to Contribute](#ways-to-contribute)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Making Changes](#making-changes)
+- [AI-Assisted Contributions](#ai-assisted-contributions)
+- [Pull Request Process](#pull-request-process)
+- [Branch Strategy](#branch-strategy)
+- [Code Review](#code-review)
+- [Communication](#communication)
+
+---
+
+## Code of Conduct
+
+We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
+
+---
+
+## Ways to Contribute
+
+- **Bug reports** — Open an issue using the bug report template.
+- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
+- **Code** — Fix bugs or implement features. See the workflow below.
+- **Documentation** — Improve READMEs, docs, inline comments, or translations.
+- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
+
+For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
+
+---
+
+## Getting Started
+
+1. **Fork** the repository on GitHub.
+2. **Clone** your fork locally:
+ ```bash
+ git clone https://github.com/
@@ -194,7 +200,7 @@ docker compose --profile gateway up -d
> [!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 [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
+> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -234,6 +240,11 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
"duckduckgo": {
"enabled": true,
"max_results": 5
@@ -243,12 +254,12 @@ picoclaw onboard
}
```
-> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details.
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -323,7 +334,6 @@ picoclaw gateway
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
-
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
@@ -425,7 +435,6 @@ picoclaw gateway
```bash
picoclaw gateway
```
-
diff --git a/README.pt-br.md b/README.pt-br.md
index ec8fe8e1c..900ee7932 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -50,7 +50,7 @@
## 📢 Novidades
-2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
diff --git a/README.vi.md b/README.vi.md
index 161842933..29ff12bb0 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -50,7 +50,7 @@
## 📢 Tin tức
-2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
diff --git a/README.zh.md b/README.zh.md
index 0989770ca..17a736fec 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,8 @@
@@ -101,9 +103,12 @@
### 📱 在手机上轻松运行
+
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+
1. 先去应用商店下载安装Termux
2. 打开后执行指令
+
```bash
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
-然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
-
-
-
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@@ -202,7 +205,7 @@ docker compose --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)
-> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@@ -243,8 +246,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
- "duckduckgo": {
- "enabled": true,
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
@@ -253,15 +257,14 @@ picoclaw onboard
}
}
}
-
```
-> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。
+> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
**3. 获取 API Key**
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -278,260 +281,28 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
-
-| 渠道 | 设置难度 |
-| --- | --- |
-| **Telegram** | 简单 (仅需 token) |
-| **Discord** | 简单 (bot token + intents) |
-| **QQ** | 简单 (AppID + AppSecret) |
-| **钉钉 (DingTalk)** | 中等 (应用凭证) |
-| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
-
-
@@ -995,8 +772,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
-1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
+
```json
{
"tools": {
@@ -1013,11 +791,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
}
}
}
-
```
-
-
### 遇到内容过滤错误 (Content Filtering Errors)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
@@ -1035,5 +810,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
-| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
\ No newline at end of file
diff --git a/assets/wechat.png b/assets/wechat.png
index 8fc41ea7d..a34217c33 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
index cee9f68ec..8658c9d32 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/cmd_agent.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/chzyer/readline"
+
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -74,10 +75,10 @@ func agentCmd() {
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
- "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
- "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
+ map[string]any{
+ "tools_count": startupInfo["tools"].(map[string]any)["count"],
+ "skills_total": startupInfo["skills"].(map[string]any)["total"],
+ "skills_available": startupInfo["skills"].(map[string]any)["available"],
})
if message != "" {
@@ -104,7 +105,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
-
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
@@ -148,7 +148,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
+ fmt.Printf("%s You: ", logo)
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
index 5bed7f116..729c56177 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/cmd_auth.go
@@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) {
os.Exit(1)
}
- if err := auth.SetCredential("openai", cred); err != nil {
+ if err = auth.SetCredential("openai", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() {
fmt.Printf("Project: %s\n", projectID)
}
- if err := auth.SetCredential("google-antigravity", cred); err != nil {
+ if err = auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) {
os.Exit(1)
}
- if err := auth.SetCredential(provider, cred); err != nil {
+ if err = auth.SetCredential(provider, cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 1f1bf5491..28ef76ad3 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -10,6 +10,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strings"
"time"
"github.com/sipeed/picoclaw/pkg/agent"
@@ -60,8 +61,8 @@ func gatewayCmd() {
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
- toolsInfo := startupInfo["tools"].(map[string]interface{})
- skillsInfo := startupInfo["skills"].(map[string]interface{})
+ toolsInfo := startupInfo["tools"].(map[string]any)
+ skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n",
skillsInfo["available"],
@@ -69,7 +70,7 @@ func gatewayCmd() {
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
+ map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
@@ -77,7 +78,14 @@ func gatewayCmd() {
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
+ cronService := setupCronTool(
+ agentLoop,
+ msgBus,
+ cfg.WorkspacePath(),
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ execTimeout,
+ cfg,
+ )
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -91,7 +99,8 @@ func gatewayCmd() {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
- response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ var response string
+ response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
@@ -113,8 +122,17 @@ func gatewayCmd() {
agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber
- if cfg.Providers.Groq.APIKey != "" {
- transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
+ groqAPIKey := cfg.Providers.Groq.APIKey
+ if groqAPIKey == "" {
+ for _, mc := range cfg.ModelList {
+ if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
+ groqAPIKey = mc.APIKey
+ break
+ }
+ }
+ }
+ if groqAPIKey != "" {
+ transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
}
@@ -181,7 +199,7 @@ func gatewayCmd() {
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
@@ -203,7 +221,14 @@ func gatewayCmd() {
fmt.Println("✓ Gateway stopped")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService {
+func setupCronTool(
+ agentLoop *agent.AgentLoop,
+ msgBus *bus.MessageBus,
+ workspace string,
+ restrict bool,
+ execTimeout time.Duration,
+ cfg *config.Config,
+) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go
index 6e61e3267..1a9ebad61 100644
--- a/cmd/picoclaw/cmd_onboard.go
+++ b/cmd/picoclaw/cmd_onboard.go
@@ -55,7 +55,7 @@ func onboard() {
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
@@ -85,12 +85,12 @@ func copyEmbeddedToTarget(targetDir string) error {
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
index 32b7c62b8..0814494b3 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/cmd_skills.go
@@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
- if _, err := os.Stat(targetDir); err == nil {
+ if _, err = os.Stat(targetDir); err == nil {
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
os.Exit(1)
}
@@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
- if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
+ if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
os.Exit(1)
}
@@ -193,7 +193,7 @@ func skillsInstallBuiltinCmd(workspace string) {
continue
}
- if err := os.MkdirAll(workspacePath, 0755); err != nil {
+ if err := os.MkdirAll(workspacePath, 0o755); err != nil {
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
continue
}
diff --git a/config/config.example.json b/config/config.example.json
index 77a8c0683..e814fcbb8 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -196,6 +196,10 @@
"volcengine": {
"api_key": "",
"api_base": ""
+ },
+ "mistral": {
+ "api_key": "",
+ "api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
diff --git a/docker-compose.yml b/docker-compose.yml
index 32e8ee339..c268b01cd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,9 @@ services:
container_name: picoclaw-agent
profiles:
- agent
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
@@ -29,6 +32,9 @@ services:
restart: unless-stopped
profiles:
- gateway
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
# Configuration file
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
new file mode 100644
index 000000000..1e445d0b0
--- /dev/null
+++ b/docs/channels/dingtalk/README.zh.md
@@ -0,0 +1,33 @@
+# 钉钉
+
+钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用钉钉频道 |
+| client_id | string | 是 | 钉钉应用的 Client ID |
+| client_secret | string | 是 | 钉钉应用的 Client Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
+2. 创建一个企业内部应用
+3. 从应用设置中获取 Client ID 和 Client Secret
+4. 配置OAuth和事件订阅(如需要)
+5. 将 Client ID 和 Client Secret 填入配置文件中
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
new file mode 100644
index 000000000..5b597eced
--- /dev/null
+++ b/docs/channels/discord/README.zh.md
@@ -0,0 +1,35 @@
+# Discord
+
+Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 Discord 频道 |
+| token | string | 是 | Discord 机器人 Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
+
+## 设置流程
+
+1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
+2. 启用 Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. 获取 Bot Token
+4. 将 Bot Token 填入配置文件中
+5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
new file mode 100644
index 000000000..310827723
--- /dev/null
+++ b/docs/channels/feishu/README.zh.md
@@ -0,0 +1,37 @@
+# 飞书
+
+飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用飞书频道 |
+| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
+| app_secret | string | 是 | 飞书应用的 App Secret |
+| encrypt_key | string | 否 | 事件回调加密密钥 |
+| verification_token | string | 否 | 用于Webhook事件验证的Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
+2. 获取 App ID 和 App Secret
+3. 配置事件订阅和Webhook URL
+4. 设置加密(可选,生产环境建议启用)
+5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
new file mode 100644
index 000000000..fd3aa80da
--- /dev/null
+++ b/docs/channels/line/README.zh.md
@@ -0,0 +1,41 @@
+# Line
+
+PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------------- | ------ | ---- | ------------------------------------------ |
+| enabled | bool | 是 | 是否启用 LINE Channel |
+| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
+| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
+| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
+| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
+| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
+2. 获取 Channel Secret 和 Channel Access Token
+3. 配置Webhook:
+ - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
+ - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
+ - 启用 Webhook 并验证 URL
+4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
new file mode 100644
index 000000000..8d53d4bef
--- /dev/null
+++ b/docs/channels/maixcam/README.zh.md
@@ -0,0 +1,31 @@
+# MaixCam
+
+MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "server_address": "0.0.0.0:8899",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 MaixCam 频道 |
+| server_address | string | 是 | TCP 服务器监听地址和端口 |
+| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
+
+## 使用场景
+
+MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
+
+- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析
+- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应
+- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
new file mode 100644
index 000000000..6195f1c98
--- /dev/null
+++ b/docs/channels/onebot/README.zh.md
@@ -0,0 +1,31 @@
+# OneBot
+
+OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 OneBot 频道 |
+| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
+| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 部署一个 OneBot 兼容的实现(例如napcat)
+2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
+3. 将 WebSocket URL 和访问令牌填入配置文件中
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
new file mode 100644
index 000000000..bd774960f
--- /dev/null
+++ b/docs/channels/qq/README.zh.md
@@ -0,0 +1,32 @@
+# QQ
+
+PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 QQ Channel |
+| app_id | string | 是 | QQ 机器人应用的 App ID |
+| app_secret | string | 是 | QQ 机器人应用的 App Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
+2. 通过仪表盘获取 App ID 和 App Secret
+3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
+4. 将 App ID 和 App Secret 填入配置文件中
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
new file mode 100644
index 000000000..58ebcb566
--- /dev/null
+++ b/docs/channels/slack/README.zh.md
@@ -0,0 +1,33 @@
+# Slack
+
+Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Slack 频道 |
+| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
+| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
+2. 启用 Socket Mode 并获取 App Level Token
+3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)
+4. 安装应用到工作区并获取 Bot User OAuth Token
+5. 将 Bot Token 和 App Token 填入配置文件中
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
new file mode 100644
index 000000000..d453c68fa
--- /dev/null
+++ b/docs/channels/telegram/README.zh.md
@@ -0,0 +1,33 @@
+# Telegram
+
+Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | --------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Telegram 频道 |
+| token | string | 是 | Telegram 机器人 API Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
+
+## 设置流程
+
+1. 在 Telegram 中搜索 `@BotFather`
+2. 发送 `/newbot` 命令并按照提示创建新机器人
+3. 获取 HTTP API Token
+4. 将 Token 填入配置文件中
+5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
new file mode 100644
index 000000000..1e6a0e2b3
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -0,0 +1,47 @@
+# 企业微信自建应用
+
+企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | 是 | 企业 ID |
+| corp_secret | string | 是 | 应用程序密钥 |
+| agent_id | int | 是 | 应用程序代理 ID |
+| token | string | 是 | 回调验证令牌 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
+| webhook_host | string | 否 | HTTP 服务器绑定地址 |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
+| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
+| allow_from | array | 否 | 用户 ID 白名单 |
+| reply_timeout | int | 否 | 回复超时时间(秒) |
+
+## 设置流程
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
+2. 进入“应用管理” -> “创建应用”
+3. 获取企业 ID (CorpID) 和应用 Secret
+4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
+5. 设置回调 URL 为 `http://%s", escaped))
+ text = strings.ReplaceAll(
+ text,
+ fmt.Sprintf("\x00CB%d\x00", i),
+ fmt.Sprintf("%s", escaped),
+ )
}
return text
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
index df245e156..a084b641b 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/telegram_commands.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/mymmrac/telego"
+
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -35,6 +36,7 @@ func commandArgs(text string) string {
}
return strings.TrimSpace(parts[1])
}
+
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
msg := `/start - Start the bot
/help - Show this help message
@@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
})
return err
}
+
func (c *cmd) List(ctx context.Context, message telego.Message) error {
args := commandArgs(message.Text)
if args == "" {
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 064568243..f8daf89de 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -134,7 +134,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
}
c.setRunning(true)
- logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
+ logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
@@ -142,7 +142,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
+ logger.ErrorCF("wecom", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
@@ -178,7 +178,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("wecom channel not running")
}
- logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
+ logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -230,7 +230,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
- logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -272,8 +272,8 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
- logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
+ logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
@@ -292,7 +292,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
- logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -302,7 +302,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Parse decrypted JSON message (AIBOT uses JSON format)
var msg WeComBotMessage
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
- logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
@@ -320,8 +320,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// processMessage processes the received message
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
// Skip unsupported message types
- if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" {
- logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
+ if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
+ msg.MsgType != "mixed" {
+ logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
"msg_type": msg.MsgType,
})
return
@@ -332,7 +333,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
- logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
+ logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
@@ -399,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
metadata["sender_id"] = senderID
}
- logger.DebugCF("wecom", "Received message", map[string]interface{}{
+ logger.DebugCF("wecom", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"peer_kind": peerKind,
@@ -468,7 +469,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
// handleHealth handles health check requests
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
- status := map[string]interface{}{
+ status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
}
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 63a1dd815..715c48707 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -145,7 +145,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Get initial access token
if err := c.refreshAccessToken(); err != nil {
- logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
"error": err.Error(),
})
}
@@ -171,7 +171,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
}
c.setRunning(true)
- logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
+ logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
@@ -179,7 +179,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
@@ -218,7 +218,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("no valid access token available")
}
- logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -231,7 +231,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
// Log all incoming requests for debugging
- logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
"method": r.Method,
"url": r.URL.String(),
"path": r.URL.Path,
@@ -250,7 +250,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
return
}
- logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
"method": r.Method,
})
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -264,7 +264,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
nonce := query.Get("nonce")
echostr := query.Get("echostr")
- logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
@@ -280,7 +280,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
- logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"token": c.config.Token,
"msg_signature": msgSignature,
"timestamp": timestamp,
@@ -294,13 +294,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
- logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
- logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
@@ -309,7 +309,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
return
}
- logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
"decrypted": decryptedEchoStr,
})
@@ -348,8 +348,8 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
- logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
+ logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
@@ -367,7 +367,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// For WeCom App (自建应用), receiveid should be corp_id
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
- logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -377,7 +377,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Parse decrypted XML message
var msg WeComXMLMessage
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
- logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
@@ -396,7 +396,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
// Skip non-text messages for now (can be extended)
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
- logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
"msg_type": msg.MsgType,
})
return
@@ -408,7 +408,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
- logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
@@ -441,7 +441,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
content := msg.Content
- logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"preview": utils.Truncate(content, 50),
@@ -462,7 +462,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
return
case <-ticker.C:
if err := c.refreshAccessToken(); err != nil {
- logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
"error": err.Error(),
})
}
@@ -628,7 +628,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken,
// handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
- status := map[string]interface{}{
+ status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
"has_token": c.getAccessToken() != "",
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
index bc40806bb..abf15c52b 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom_app_test.go
@@ -399,7 +399,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -429,7 +433,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -481,7 +489,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -510,7 +522,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, "")
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ strings.NewReader("invalid xml"),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -532,7 +548,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -646,7 +666,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encoded)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
@@ -669,7 +693,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
@@ -824,19 +852,6 @@ func TestWeComAppMessageStructures(t *testing.T) {
}
})
- t.Run("WeComImageMessage structure", func(t *testing.T) {
- msg := WeComImageMessage{
- ToUser: "user123",
- MsgType: "image",
- AgentID: 1000002,
- }
- msg.Image.MediaID = "media_123456"
-
- if msg.Image.MediaID != "media_123456" {
- t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
- }
- })
-
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
jsonData := `{
"errcode": 0,
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
index c3f889c64..8afa7e8c3 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom_test.go
@@ -198,10 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
- base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
chEmpty := &WeComBotChannel{
- BaseChannel: base,
- config: cfgEmpty,
+ config: cfgEmpty,
}
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
@@ -358,7 +356,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -388,7 +390,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -437,7 +443,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -479,7 +489,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -508,7 +522,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, "")
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ strings.NewReader("invalid xml"),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -530,7 +548,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -625,7 +647,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encoded)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
@@ -648,7 +674,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go
index 065424e0c..958d850bb 100644
--- a/pkg/channels/whatsapp.go
+++ b/pkg/channels/whatsapp.go
@@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("whatsapp connection not established")
}
- payload := map[string]interface{}{
+ payload := map[string]any{
"type": "message",
"to": msg.ChatID,
"content": msg.Content,
@@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
continue
}
- var msg map[string]interface{}
+ var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue
@@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
}
}
-func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
+func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
senderID, ok := msg["from"].(string)
if !ok {
return
@@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
}
var mediaPaths []string
- if mediaData, ok := msg["media"].([]interface{}); ok {
+ if mediaData, ok := msg["media"].([]any); ok {
mediaPaths = make([]string, 0, len(mediaData))
for _, m := range mediaData {
if path, ok := m.(string); ok {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 7d4753b30..a8bcf3863 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -26,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
}
// Try []interface{} to handle mixed types
- var raw []interface{}
+ var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
@@ -167,16 +167,16 @@ type SessionConfig struct {
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -195,114 +195,114 @@ type ChannelsConfig struct {
}
type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
}
type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
- WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+ WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
}
type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
}
type HeartbeatConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
@@ -324,6 +324,7 @@ type ProvidersConfig struct {
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
+ Mistral ProviderConfig `json:"mistral"`
PicoLM PicoLMProviderConfig `json:"picolm"`
}
@@ -354,6 +355,7 @@ func (p ProvidersConfig) IsEmpty() bool {
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.PicoLM.Binary == "" && p.PicoLM.Model == ""
}
@@ -368,11 +370,11 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -425,24 +427,32 @@ type GatewayConfig struct {
}
type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
+type TavilyConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
type DuckDuckGoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
+ Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
}
@@ -470,7 +480,7 @@ type SkillsToolsConfig struct {
}
type SearchCacheConfig struct {
- MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
+ MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
@@ -479,14 +489,14 @@ type SkillsRegistriesConfig struct {
}
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"`
- AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
- SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
- SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
- DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
- Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
- MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
+ AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
+ SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
+ DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
+ Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
+ MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
@@ -529,11 +539,11 @@ func SaveConfig(path string, cfg *Config) error {
}
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
@@ -648,7 +658,8 @@ func (c *Config) HasProvidersConfig() bool {
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
- v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
+ v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
+ v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
}
// ValidateModelList validates all ModelConfig entries in the model_list.
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 7e706d8ce..0898217d6 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -55,7 +55,7 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
if err != nil {
t.Fatalf("marshal: %v", err)
}
- var result map[string]interface{}
+ var result map[string]any
json.Unmarshal(data, &result)
if result["primary"] != "claude-opus" {
t.Errorf("primary = %v", result["primary"])
@@ -319,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("config file has permission %04o, want 0600", perm)
}
}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 7654326e7..065273c28 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -255,6 +255,14 @@ func DefaultConfig() *Config {
APIKey: "ollama",
},
+ // Mistral AI - https://console.mistral.ai/api-keys
+ {
+ ModelName: "mistral-small",
+ Model: "mistral/mistral-small-latest",
+ APIBase: "https://api.mistral.ai/v1",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index c28dace0a..eb10f27d2 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -324,6 +324,22 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"mistral"},
+ protocol: "mistral",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "mistral",
+ Model: "mistral/mistral-small-latest",
+ APIKey: p.Mistral.APIKey,
+ APIBase: p.Mistral.APIBase,
+ Proxy: p.Mistral.Proxy,
+ }, true
+ },
+ },
{
providerNames: []string{"picolm"},
protocol: "picolm",
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index b9a333f9e..42165cb71 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
+ Mistral: ProviderConfig{APIKey: "key18"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 17 providers should be converted
- if len(result) != 17 {
- t.Errorf("len(result) = %d, want 17", len(result))
+ // All 18 providers should be converted
+ if len(result) != 18 {
+ t.Errorf("len(result) = %d, want 18", len(result))
}
}
@@ -361,7 +362,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: tt.providerAlias,
- Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]),
+ Model: strings.TrimPrefix(
+ tt.expectedModel,
+ tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
+ ),
},
},
Providers: ProvidersConfig{},
@@ -382,7 +386,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
}
// Need to fix the model name in config
- cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1])
+ cfg.Agents.Defaults.Model = strings.TrimPrefix(
+ tt.expectedModel,
+ tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
+ )
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
@@ -515,7 +522,11 @@ func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
if result != "openrouter/claude-sonnet-4.6" {
- t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6")
+ t.Errorf(
+ "buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
+ result,
+ "openrouter/claude-sonnet-4.6",
+ )
}
}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index 9f62c743b..e699a44b5 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error {
func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
- return os.WriteFile(cs.storePath, data, 0600)
+ return os.WriteFile(cs.storePath, data, 0o600)
}
-func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
+func (cs *CronService) AddJob(
+ name string,
+ schedule CronSchedule,
+ message string,
+ deliver bool,
+ channel, to string,
+) (*CronJob, error) {
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
return enabled
}
-func (cs *CronService) Status() map[string]interface{} {
+func (cs *CronService) Status() map[string]any {
cs.mu.RLock()
defer cs.mu.RUnlock()
@@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} {
}
}
- return map[string]interface{}{
+ return map[string]any{
"enabled": cs.running,
"jobs": len(cs.store.Jobs),
"nextWakeAtMS": cs.getNextWakeMS(),
diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go
index 53d69f6a9..1a0dd1829 100644
--- a/pkg/cron/service_test.go
+++ b/pkg/cron/service_test.go
@@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("cron store has permission %04o, want 0600", perm)
}
}
diff --git a/pkg/devices/service.go b/pkg/devices/service.go
index 05a254729..1541d3c57 100644
--- a/pkg/devices/service.go
+++ b/pkg/devices/service.go
@@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error {
for _, src := range s.sources {
eventCh, err := src.Start(s.ctx)
if err != nil {
- logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
+ logger.ErrorCF("devices", "Failed to start source", map[string]any{
"kind": src.Kind(),
"error": err.Error(),
})
continue
}
go s.handleEvents(src.Kind(), eventCh)
- logger.InfoCF("devices", "Device source started", map[string]interface{}{
+ logger.InfoCF("devices", "Device source started", map[string]any{
"kind": src.Kind(),
})
}
@@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
lastChannel := s.state.GetLastChannel()
if lastChannel == "" {
- logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
+ logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{
"event": ev.FormatMessage(),
})
return
@@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
Content: msg,
})
- logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
+ logger.InfoCF("devices", "Device notification sent", map[string]any{
"kind": ev.Kind,
"action": ev.Action,
"to": platform,
diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go
index 1f6c068b3..be0193cfb 100644
--- a/pkg/devices/sources/usb_linux.go
+++ b/pkg/devices/sources/usb_linux.go
@@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
}
if err := scanner.Err(); err != nil {
- logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()})
}
cmd.Wait()
}()
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index dfdaef58b..75d6248b9 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
if result.Async {
hs.logInfo("Async task started: %s", result.ForLLM)
logger.InfoCF("heartbeat", "Async heartbeat task started",
- map[string]interface{}{
+ map[string]any{
"message": result.ForLLM,
})
return
@@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line:
`
- if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
+ if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
} else {
hs.logInfo("Created default HEARTBEAT.md template")
@@ -354,7 +354,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
// log writes a message to the heartbeat log file
func (hs *HeartbeatService) log(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log")
- f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index a2b59e350..a4dfa7a72 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
// Execute heartbeat directly (internal method for testing)
hs.executeHeartbeat()
@@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
@@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
@@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
// Should not panic with nil result
hs.executeHeartbeat()
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 22f66829f..54de66bf9 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -41,12 +41,12 @@ type Logger struct {
}
type LogEntry struct {
- Level string `json:"level"`
- Timestamp string `json:"timestamp"`
- Component string `json:"component,omitempty"`
- Message string `json:"message"`
- Fields map[string]interface{} `json:"fields,omitempty"`
- Caller string `json:"caller,omitempty"`
+ 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() {
@@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
- file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ file, 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)
}
@@ -96,7 +96,7 @@ func DisableFileLogging() {
}
}
-func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) {
+func logMessage(level LogLevel, component string, message string, fields map[string]any) {
if level < currentLevel {
return
}
@@ -150,7 +150,7 @@ func formatComponent(component string) string {
return fmt.Sprintf(" %s:", component)
}
-func formatFields(fields map[string]interface{}) string {
+func formatFields(fields map[string]any) string {
var parts []string
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
@@ -166,11 +166,11 @@ func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil)
}
-func DebugF(message string, fields map[string]interface{}) {
+func DebugF(message string, fields map[string]any) {
logMessage(DEBUG, "", message, fields)
}
-func DebugCF(component string, message string, fields map[string]interface{}) {
+func DebugCF(component string, message string, fields map[string]any) {
logMessage(DEBUG, component, message, fields)
}
@@ -182,11 +182,11 @@ func InfoC(component string, message string) {
logMessage(INFO, component, message, nil)
}
-func InfoF(message string, fields map[string]interface{}) {
+func InfoF(message string, fields map[string]any) {
logMessage(INFO, "", message, fields)
}
-func InfoCF(component string, message string, fields map[string]interface{}) {
+func InfoCF(component string, message string, fields map[string]any) {
logMessage(INFO, component, message, fields)
}
@@ -198,11 +198,11 @@ func WarnC(component string, message string) {
logMessage(WARN, component, message, nil)
}
-func WarnF(message string, fields map[string]interface{}) {
+func WarnF(message string, fields map[string]any) {
logMessage(WARN, "", message, fields)
}
-func WarnCF(component string, message string, fields map[string]interface{}) {
+func WarnCF(component string, message string, fields map[string]any) {
logMessage(WARN, component, message, fields)
}
@@ -214,11 +214,11 @@ func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil)
}
-func ErrorF(message string, fields map[string]interface{}) {
+func ErrorF(message string, fields map[string]any) {
logMessage(ERROR, "", message, fields)
}
-func ErrorCF(component string, message string, fields map[string]interface{}) {
+func ErrorCF(component string, message string, fields map[string]any) {
logMessage(ERROR, component, message, fields)
}
@@ -230,10 +230,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil)
}
-func FatalF(message string, fields map[string]interface{}) {
+func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields)
}
-func FatalCF(component string, message string, fields map[string]interface{}) {
+func FatalCF(component string, message string, fields map[string]any) {
logMessage(FATAL, component, message, fields)
}
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
index 9b9c96820..6e6f8dfa8 100644
--- a/pkg/logger/logger_test.go
+++ b/pkg/logger/logger_test.go
@@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) {
name string
component string
message string
- fields map[string]interface{}
+ fields map[string]any
}{
{"Simple message", "test", "Hello, world!", nil},
{"Message with component", "discord", "Discord message", nil},
- {"Message with fields", "telegram", "Telegram message", map[string]interface{}{
+ {"Message with fields", "telegram", "Telegram message", map[string]any{
"user_id": "12345",
"count": 42,
}},
@@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) {
Error("This should log")
InfoC("test", "Component message")
- InfoF("Fields message", map[string]interface{}{"key": "value"})
+ InfoF("Fields message", map[string]any{"key": "value"})
WarnC("test", "Warning with component")
- ErrorF("Error with fields", map[string]interface{}{"error": "test"})
+ ErrorF("Error with fields", map[string]any{"error": "test"})
SetLevel(DEBUG)
DebugC("test", "Debug with component")
- WarnF("Warning with fields", map[string]interface{}{"key": "value"})
+ WarnF("Warning with fields", map[string]any{"key": "value"})
}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index b01bb80e3..24ce33e94 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
"qwen": true,
"deepseek": true,
"github_copilot": true,
+ "mistral": true,
}
var supportedChannels = map[string]bool{
@@ -47,26 +48,26 @@ func findOpenClawConfig(openclawHome string) (string, error) {
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
}
-func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
+func LoadOpenClawConfig(configPath string) (map[string]any, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
}
- var raw map[string]interface{}
+ var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
}
converted := convertKeysToSnake(raw)
- result, ok := converted.(map[string]interface{})
+ result, ok := converted.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected config format")
}
return result, nil
}
-func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
+func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
cfg := config.DefaultConfig()
var warnings []string
@@ -92,7 +93,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if providers, ok := getMap(data, "providers"); ok {
for name, val := range providers {
- pMap, ok := val.(map[string]interface{})
+ pMap, ok := val.(map[string]any)
if !ok {
continue
}
@@ -131,7 +132,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if channels, ok := getMap(data, "channels"); ok {
for name, val := range channels {
- cMap, ok := val.(map[string]interface{})
+ cMap, ok := val.(map[string]any)
if !ok {
continue
}
@@ -318,16 +319,16 @@ func camelToSnake(s string) string {
return result.String()
}
-func convertKeysToSnake(data interface{}) interface{} {
+func convertKeysToSnake(data any) any {
switch v := data.(type) {
- case map[string]interface{}:
- result := make(map[string]interface{}, len(v))
+ case map[string]any:
+ result := make(map[string]any, len(v))
for key, val := range v {
result[camelToSnake(key)] = convertKeysToSnake(val)
}
return result
- case []interface{}:
- result := make([]interface{}, len(v))
+ case []any:
+ result := make([]any, len(v))
for i, val := range v {
result[i] = convertKeysToSnake(val)
}
@@ -342,16 +343,16 @@ func rewriteWorkspacePath(path string) string {
return path
}
-func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
+func getMap(data map[string]any, key string) (map[string]any, bool) {
v, ok := data[key]
if !ok {
return nil, false
}
- m, ok := v.(map[string]interface{})
+ m, ok := v.(map[string]any)
return m, ok
}
-func getString(data map[string]interface{}, key string) (string, bool) {
+func getString(data map[string]any, key string) (string, bool) {
v, ok := data[key]
if !ok {
return "", false
@@ -360,7 +361,7 @@ func getString(data map[string]interface{}, key string) (string, bool) {
return s, ok
}
-func getFloat(data map[string]interface{}, key string) (float64, bool) {
+func getFloat(data map[string]any, key string) (float64, bool) {
v, ok := data[key]
if !ok {
return 0, false
@@ -369,7 +370,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) {
return f, ok
}
-func getBool(data map[string]interface{}, key string) (bool, bool) {
+func getBool(data map[string]any, key string) (bool, bool) {
v, ok := data[key]
if !ok {
return false, false
@@ -378,19 +379,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
return b, ok
}
-func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool {
+func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
if v, ok := getBool(data, key); ok {
return v
}
return defaultVal
}
-func getStringSlice(data map[string]interface{}, key string) []string {
+func getStringSlice(data map[string]any, key string) []string {
v, ok := data[key]
if !ok {
return []string{}
}
- arr, ok := v.([]interface{})
+ arr, ok := v.([]any)
if !ok {
return []string{}
}
diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go
index 921f821cb..cfa82b7d7 100644
--- a/pkg/migrate/migrate.go
+++ b/pkg/migrate/migrate.go
@@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
return nil, err
}
- if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
+ if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
}
@@ -161,7 +161,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
}
case ActionCreateDir:
- if err := os.MkdirAll(action.Destination, 0755); err != nil {
+ if err := os.MkdirAll(action.Destination, 0o755); err != nil {
result.Errors = append(result.Errors, err)
} else {
result.DirsCreated++
@@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
continue
}
result.BackupsCreated++
- fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
+ fmt.Printf(
+ " ✓ Backed up %s -> %s.bak\n",
+ filepath.Base(action.Destination),
+ filepath.Base(action.Destination),
+ )
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
@@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
}
case ActionCopy:
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
@@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e
incoming = MergeConfig(existing, incoming)
}
- if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
return err
}
return config.SaveConfig(dstConfigPath, incoming)
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index 759fc9024..b6b3d70aa 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -40,43 +40,43 @@ func TestCamelToSnake(t *testing.T) {
}
func TestConvertKeysToSnake(t *testing.T) {
- input := map[string]interface{}{
+ input := map[string]any{
"apiKey": "test-key",
"apiBase": "https://example.com",
- "nested": map[string]interface{}{
+ "nested": map[string]any{
"maxTokens": float64(8192),
- "allowFrom": []interface{}{"user1", "user2"},
- "deeperLevel": map[string]interface{}{
+ "allowFrom": []any{"user1", "user2"},
+ "deeperLevel": map[string]any{
"clientId": "abc",
},
},
}
result := convertKeysToSnake(input)
- m, ok := result.(map[string]interface{})
+ m, ok := result.(map[string]any)
if !ok {
t.Fatal("expected map[string]interface{}")
}
- if _, ok := m["api_key"]; !ok {
+ if _, ok = m["api_key"]; !ok {
t.Error("expected key 'api_key' after conversion")
}
- if _, ok := m["api_base"]; !ok {
+ if _, ok = m["api_base"]; !ok {
t.Error("expected key 'api_base' after conversion")
}
- nested, ok := m["nested"].(map[string]interface{})
+ nested, ok := m["nested"].(map[string]any)
if !ok {
t.Fatal("expected nested map")
}
- if _, ok := nested["max_tokens"]; !ok {
+ if _, ok = nested["max_tokens"]; !ok {
t.Error("expected key 'max_tokens' in nested map")
}
- if _, ok := nested["allow_from"]; !ok {
+ if _, ok = nested["allow_from"]; !ok {
t.Error("expected key 'allow_from' in nested map")
}
- deeper, ok := nested["deeper_level"].(map[string]interface{})
+ deeper, ok := nested["deeper_level"].(map[string]any)
if !ok {
t.Fatal("expected deeper_level map")
}
@@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- openclawConfig := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ openclawConfig := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-test123",
"apiBase": "https://api.anthropic.com",
},
},
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"maxTokens": float64(4096),
"model": "claude-3-opus",
},
@@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(configPath, data, 0644); err != nil {
+ if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
@@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Fatalf("LoadOpenClawConfig: %v", err)
}
- providers, ok := result["providers"].(map[string]interface{})
+ providers, ok := result["providers"].(map[string]any)
if !ok {
t.Fatal("expected providers map")
}
- anthropic, ok := providers["anthropic"].(map[string]interface{})
+ anthropic, ok := providers["anthropic"].(map[string]any)
if !ok {
t.Fatal("expected anthropic map")
}
@@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
}
- agents, ok := result["agents"].(map[string]interface{})
+ agents, ok := result["agents"].(map[string]any)
if !ok {
t.Fatal("expected agents map")
}
- defaults, ok := agents["defaults"].(map[string]interface{})
+ defaults, ok := agents["defaults"].(map[string]any)
if !ok {
t.Fatal("expected defaults map")
}
@@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
func TestConvertConfig(t *testing.T) {
t.Run("providers mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"api_key": "sk-ant-test",
"api_base": "https://api.anthropic.com",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"api_key": "sk-or-test",
},
- "groq": map[string]interface{}{
+ "groq": map[string]any{
"api_key": "gsk-test",
},
},
@@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported provider warning", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "unknown_provider": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "unknown_provider": map[string]any{
"api_key": "sk-test",
},
},
@@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("channels mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-token-123",
- "allow_from": []interface{}{"user1"},
+ "allow_from": []any{"user1"},
},
- "discord": map[string]interface{}{
+ "discord": map[string]any{
"enabled": true,
"token": "disc-token-456",
},
@@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported channel warning", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "email": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "email": map[string]any{
"enabled": true,
},
},
@@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("agent defaults", func(t *testing.T) {
- data := map[string]interface{}{
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ data := map[string]any{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"model": "claude-3-opus",
"max_tokens": float64(4096),
"temperature": 0.5,
@@ -287,7 +287,7 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("empty config", func(t *testing.T) {
- data := map[string]interface{}{}
+ data := map[string]any{}
cfg, warnings, err := ConvertConfig(data)
if err != nil {
@@ -389,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -420,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -443,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
if err != nil {
@@ -463,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
memDir := filepath.Join(srcDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -494,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
skillDir := filepath.Join(srcDir, "skills", "weather")
- os.MkdirAll(skillDir, 0755)
- os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
+ os.MkdirAll(skillDir, 0o755)
+ os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -518,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("finds openclaw.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -532,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("falls back to config.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -546,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
tmpDir := t.TempDir()
openclawPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(openclawPath, []byte("{}"), 0644)
- os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
+ os.WriteFile(openclawPath, []byte("{}"), 0o644)
+ os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -593,19 +593,19 @@ func TestRunDryRun(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "test-key",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
DryRun: true,
@@ -634,33 +634,33 @@ func TestRunFullMigration(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
memDir := filepath.Join(wsDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-migrate-test",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"apiKey": "sk-or-migrate-test",
},
},
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-migrate-test",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -754,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
func TestBackupFile(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.md")
- os.WriteFile(filePath, []byte("original content"), 0644)
+ os.WriteFile(filePath, []byte("original content"), 0o644)
if err := backupFile(filePath); err != nil {
t.Fatalf("backupFile: %v", err)
@@ -775,7 +775,7 @@ func TestCopyFile(t *testing.T) {
srcPath := filepath.Join(tmpDir, "src.md")
dstPath := filepath.Join(tmpDir, "dst.md")
- os.WriteFile(srcPath, []byte("file content"), 0644)
+ os.WriteFile(srcPath, []byte("file content"), 0o644)
if err := copyFile(srcPath, dstPath); err != nil {
t.Fatalf("copyFile: %v", err)
@@ -795,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-config-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -835,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ws-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index a27a25a2d..35f6b8f62 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -9,16 +9,19 @@ import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
+
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+)
const defaultBaseURL = "https://api.anthropic.com"
@@ -61,7 +64,13 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
return p
}
-func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
var opts []option.RequestOption
if p.tokenSource != nil {
tok, err := p.tokenSource()
@@ -92,7 +101,12 @@ func (p *Provider) BaseURL() string {
return p.baseURL
}
-func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
+func buildParams(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (anthropic.MessageNewParams, error) {
var system []anthropic.TextBlockParam
var anthropicMessages []anthropic.MessageParam
@@ -170,7 +184,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
if desc := t.Function.Description; desc != "" {
tool.Description = anthropic.String(desc)
}
- if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
+ if req, ok := t.Function.Parameters["required"].([]any); ok {
required := make([]string, 0, len(req))
for _, r := range req {
if s, ok := r.(string); ok {
@@ -195,10 +209,10 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
content += tb.Text
case "tool_use":
tu := block.AsToolUse()
- var args map[string]interface{}
+ var args map[string]any
if err := json.Unmarshal(tu.Input, &args); err != nil {
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
- args = map[string]interface{}{"raw": string(tu.Input)}
+ args = map[string]any{"raw": string(tu.Input)}
}
toolCalls = append(toolCalls, ToolCall{
ID: tu.ID,
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
index 08ac9c829..3d21c1d0b 100644
--- a/pkg/providers/anthropic/provider_test.go
+++ b/pkg/providers/anthropic/provider_test.go
@@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{
"max_tokens": 1024,
})
if err != nil {
@@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
{
ID: "call_1",
Name: "get_weather",
- Arguments: map[string]interface{}{"city": "SF"},
+ Arguments: map[string]any{"city": "SF"},
},
},
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -84,17 +84,17 @@ func TestBuildParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a city",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
- "required": []interface{}{"city"},
+ "required": []any{"city"},
},
},
},
}
- params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -154,19 +154,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -221,19 +221,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "ok"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 1,
"output_tokens": 1,
},
@@ -247,7 +247,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return "refreshed-token", nil
}, server.URL)
- _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "claude-sonnet-4.6",
+ map[string]any{},
+ )
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index 6c6bf7830..cff67c88c 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -45,7 +45,13 @@ func NewAntigravityProvider() *AntigravityProvider {
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
// The v1internal endpoint wraps the standard Gemini request in an envelope with
// project, model, request, requestType, userAgent, and requestId fields.
-func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *AntigravityProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
accessToken, projectID, err := p.tokenSource()
if err != nil {
return nil, fmt.Errorf("antigravity auth: %w", err)
@@ -58,7 +64,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
model = strings.TrimPrefix(model, "google-antigravity/")
model = strings.TrimPrefix(model, "antigravity/")
- logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
+ logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{
"model": model,
"project": projectID,
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
@@ -68,7 +74,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
innerRequest := p.buildRequest(messages, tools, model, options)
// Wrap in v1internal envelope (matches pi-ai SDK format)
- envelope := map[string]interface{}{
+ envelope := map[string]any{
"project": projectID,
"model": model,
"request": innerRequest,
@@ -115,7 +121,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
}
if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
+ logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{
"status_code": resp.StatusCode,
"response": string(respBody),
"model": model,
@@ -133,7 +139,9 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
// Check for empty response (some models might return valid success but empty text)
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
- return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
+ return nil, fmt.Errorf(
+ "antigravity: model returned an empty response (this model might be invalid or restricted)",
+ )
}
return llmResp, nil
@@ -167,13 +175,13 @@ type antigravityPart struct {
}
type antigravityFunctionCall struct {
- Name string `json:"name"`
- Args map[string]interface{} `json:"args"`
+ Name string `json:"name"`
+ Args map[string]any `json:"args"`
}
type antigravityFunctionResponse struct {
- Name string `json:"name"`
- Response map[string]interface{} `json:"response"`
+ Name string `json:"name"`
+ Response map[string]any `json:"response"`
}
type antigravityTool struct {
@@ -181,9 +189,9 @@ type antigravityTool struct {
}
type antigravityFuncDecl struct {
- Name string `json:"name"`
- Description string `json:"description,omitempty"`
- Parameters interface{} `json:"parameters,omitempty"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters any `json:"parameters,omitempty"`
}
type antigravitySystemPrompt struct {
@@ -195,7 +203,12 @@ type antigravityGenConfig struct {
Temperature float64 `json:"temperature,omitempty"`
}
-func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
+func (p *AntigravityProvider) buildRequest(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) antigravityRequest {
req := antigravityRequest{}
toolCallNames := make(map[string]string)
@@ -215,7 +228,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
- Response: map[string]interface{}{
+ Response: map[string]any{
"result": msg.Content,
},
},
@@ -237,9 +250,13 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
for _, tc := range msg.ToolCalls {
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
if toolName == "" {
- logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
- "tool_call_id": tc.ID,
- })
+ logger.WarnCF(
+ "provider.antigravity",
+ "Skipping tool call with empty name in history",
+ map[string]any{
+ "tool_call_id": tc.ID,
+ },
+ )
continue
}
if tc.ID != "" {
@@ -264,7 +281,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
- Response: map[string]interface{}{
+ Response: map[string]any{
"result": msg.Content,
},
},
@@ -311,7 +328,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
return req
}
-func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
+func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
name := tc.Name
args := tc.Arguments
thoughtSignature := ""
@@ -324,11 +341,11 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, strin
}
if args == nil {
- args = map[string]interface{}{}
+ args = map[string]any{}
}
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
args = parsed
}
@@ -483,9 +500,12 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args,
Function: &FunctionCall{
- Name: part.FunctionCall.Name,
- Arguments: string(argumentsJSON),
- ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
+ Name: part.FunctionCall.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: extractPartThoughtSignature(
+ part.ThoughtSignature,
+ part.ThoughtSignatureSnake,
+ ),
},
})
}
@@ -556,24 +576,24 @@ var geminiUnsupportedKeywords = map[string]bool{
"maxProperties": true,
}
-func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
+func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
- result := make(map[string]interface{})
+ result := make(map[string]any)
for k, v := range schema {
if geminiUnsupportedKeywords[k] {
continue
}
// Recursively sanitize nested objects
switch val := v.(type) {
- case map[string]interface{}:
+ case map[string]any:
result[k] = sanitizeSchemaForGemini(val)
- case []interface{}:
- sanitized := make([]interface{}, len(val))
+ case []any:
+ sanitized := make([]any, len(val))
for i, item := range val {
- if m, ok := item.(map[string]interface{}); ok {
+ if m, ok := item.(map[string]any); ok {
sanitized[i] = sanitizeSchemaForGemini(m)
} else {
sanitized[i] = item
@@ -604,7 +624,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
return "", "", fmt.Errorf("loading auth credentials: %w", err)
}
if cred == nil {
- return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
+ return "", "", fmt.Errorf(
+ "no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity",
+ )
}
// Refresh if needed
@@ -625,7 +647,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
}
if cred.IsExpired() {
- return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
+ return "", "", fmt.Errorf(
+ "antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity",
+ )
}
projectID := cred.ProjectID
@@ -633,7 +657,7 @@ func createAntigravityTokenSource() func() (string, string, error) {
// Try to fetch project ID from API
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
- logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
+ logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{
"error": err.Error(),
})
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
@@ -650,8 +674,8 @@ func createAntigravityTokenSource() func() (string, string, error) {
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
func FetchAntigravityProjectID(accessToken string) (string, error) {
- reqBody, _ := json.Marshal(map[string]interface{}{
- "metadata": map[string]interface{}{
+ reqBody, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
@@ -695,7 +719,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
- reqBody, _ := json.Marshal(map[string]interface{}{
+ reqBody, _ := json.Marshal(map[string]any{
"project": projectID,
})
@@ -717,16 +741,20 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
+ return nil, fmt.Errorf(
+ "fetchAvailableModels failed (HTTP %d): %s",
+ resp.StatusCode,
+ truncateString(string(body), 200),
+ )
}
var result struct {
Models map[string]struct {
DisplayName string `json:"displayName"`
QuotaInfo struct {
- RemainingFraction interface{} `json:"remainingFraction"`
- ResetTime string `json:"resetTime"`
- IsExhausted bool `json:"isExhausted"`
+ RemainingFraction any `json:"remainingFraction"`
+ ResetTime string `json:"resetTime"`
+ IsExhausted bool `json:"isExhausted"`
} `json:"quotaInfo"`
} `json:"models"`
}
@@ -797,10 +825,10 @@ func randomString(n int) string {
func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
var errResp struct {
Error struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Status string `json:"status"`
- Details []map[string]interface{} `json:"details"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Status string `json:"status"`
+ Details []map[string]any `json:"details"`
} `json:"error"`
}
@@ -813,7 +841,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
// Try to extract quota reset info
for _, detail := range errResp.Error.Details {
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
- if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
+ if metadata, ok := detail["metadata"].(map[string]any); ok {
if delay, ok := metadata["quotaResetDelay"].(string); ok {
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
}
diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go
index 58ba3647d..74ec33b98 100644
--- a/pkg/providers/claude_cli_provider.go
+++ b/pkg/providers/claude_cli_provider.go
@@ -24,7 +24,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
}
// Chat implements LLMProvider.Chat by executing the claude CLI.
-func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *ClaudeCliProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
systemPrompt := p.buildSystemPrompt(messages, tools)
prompt := p.messagesToPrompt(messages)
@@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
- sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
+ sb.WriteString(
+ `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
+ )
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go
index 9d1131ac4..f6e0d787a 100644
--- a/pkg/providers/claude_cli_provider_integration_test.go
+++ b/pkg/providers/claude_cli_provider_integration_test.go
@@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) {
resp, err := p.Chat(ctx, []Message{
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with real CLI error = %v", err)
}
@@ -75,7 +74,6 @@ func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
{Role: "user", Content: "What is 2+2?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go
index 945f5bd4f..3a3cafaca 100644
--- a/pkg/providers/claude_cli_provider_test.go
+++ b/pkg/providers/claude_cli_provider_test.go
@@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
dir := t.TempDir()
if stdout != "" {
- if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil {
t.Fatal(err)
}
}
if stderr != "" {
- if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -51,7 +51,7 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
script := filepath.Join(dir, "claude")
- if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
dir := t.TempDir()
script := filepath.Join(dir, "claude")
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -88,7 +88,7 @@ cat <<'EOFMOCK'
{"type":"result","result":"ok","session_id":"test"}
EOFMOCK
`, argsFile)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "Hello"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "What's the weather?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "Hello"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with empty workspace error = %v", err)
}
@@ -622,10 +619,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a location",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "location": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "location": map[string]any{"type": "string"},
},
},
},
diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go
index 3ca54d5a3..60639ca18 100644
--- a/pkg/providers/claude_provider.go
+++ b/pkg/providers/claude_provider.go
@@ -29,7 +29,9 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string,
}
}
-func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider {
+func NewClaudeProviderWithTokenSourceAndBaseURL(
+ token string, tokenSource func() (string, error), apiBase string,
+) *ClaudeProvider {
return &ClaudeProvider{
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
}
@@ -39,7 +41,9 @@ func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *Claude
return &ClaudeProvider{delegate: delegate}
}
-func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *ClaudeProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
if err != nil {
return nil, err
diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go
index b1bcd8b40..98e07bb80 100644
--- a/pkg/providers/claude_provider_test.go
+++ b/pkg/providers/claude_provider_test.go
@@ -8,6 +8,7 @@ import (
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
@@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -48,7 +49,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
provider := newClaudeProviderWithDelegate(delegate)
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go
index 7ad39ce8e..40f3ee2a1 100644
--- a/pkg/providers/codex_cli_credentials.go
+++ b/pkg/providers/codex_cli_credentials.go
@@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
}
var auth CodexCliAuth
- if err := json.Unmarshal(data, &auth); err != nil {
+ if err = json.Unmarshal(data, &auth); err != nil {
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
}
@@ -59,7 +59,9 @@ func CreateCodexCliTokenSource() func() (string, string, error) {
}
if time.Now().After(expiresAt) {
- return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
+ return "", "", fmt.Errorf(
+ "codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login",
+ )
}
return token, accountID, nil
diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go
index 3267f2d16..43b21700a 100644
--- a/pkg/providers/codex_cli_credentials_test.go
+++ b/pkg/providers/codex_cli_credentials_test.go
@@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
"account_id": "org-test123"
}
}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
authPath := filepath.Join(tmpDir, "auth.json")
- if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
tmpDir := t.TempDir()
customDir := filepath.Join(tmpDir, "custom-codex")
- if err := os.MkdirAll(customDir, 0755); err != nil {
+ if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatal(err)
}
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
- if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go
index 8886406b4..4c783ece5 100644
--- a/pkg/providers/codex_cli_provider.go
+++ b/pkg/providers/codex_cli_provider.go
@@ -25,7 +25,9 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider {
}
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
-func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *CodexCliProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
if p.command == "" {
return nil, fmt.Errorf("codex command not configured")
}
@@ -133,7 +135,9 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
- sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
+ sb.WriteString(
+ `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
+ )
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/codex_cli_provider_integration_test.go
index 0267c730f..17a8305ad 100644
--- a/pkg/providers/codex_cli_provider_integration_test.go
+++ b/pkg/providers/codex_cli_provider_integration_test.go
@@ -27,7 +27,6 @@ func TestIntegration_RealCodexCLI(t *testing.T) {
resp, err := p.Chat(ctx, []Message{
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with real CLI error = %v", err)
}
@@ -64,7 +63,6 @@ func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
{Role: "user", Content: "What is 2+2?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go
index 7e4e1bc15..414e0844d 100644
--- a/pkg/providers/codex_cli_provider_test.go
+++ b/pkg/providers/codex_cli_provider_test.go
@@ -292,10 +292,10 @@ func TestBuildPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get current weather",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
},
},
@@ -409,7 +409,7 @@ func createMockCodexCLI(t *testing.T, events []string) string {
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
}
- if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
t.Fatal(err)
}
return scriptPath
@@ -480,7 +480,7 @@ echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
echo '{"type":"turn.completed"}'`
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
@@ -522,7 +522,7 @@ func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
scriptPath := filepath.Join(tmpDir, "codex")
script := "#!/bin/bash\nsleep 60"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index e3526cfb5..ecc983642 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -10,12 +10,15 @@ import (
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
+
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger"
)
-const codexDefaultModel = "gpt-5.2"
-const codexDefaultInstructions = "You are Codex, a coding assistant."
+const (
+ codexDefaultModel = "gpt-5.2"
+ codexDefaultInstructions = "You are Codex, a coding assistant."
+)
type CodexProvider struct {
client *openai.Client
@@ -44,22 +47,30 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
}
}
-func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider {
+func NewCodexProviderWithTokenSource(
+ token, accountID string, tokenSource func() (string, string, error),
+) *CodexProvider {
p := NewCodexProvider(token, accountID)
p.tokenSource = tokenSource
return p
}
-func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *CodexProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
var opts []option.RequestOption
accountID := p.accountID
resolvedModel, fallbackReason := resolveCodexModel(model)
if fallbackReason != "" {
- logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
- "requested_model": model,
- "resolved_model": resolvedModel,
- "reason": fallbackReason,
- })
+ logger.WarnCF(
+ "provider.codex",
+ "Requested model is not compatible with Codex backend, using fallback",
+ map[string]any{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ "reason": fallbackReason,
+ },
+ )
}
if p.tokenSource != nil {
tok, accID, err := p.tokenSource()
@@ -74,10 +85,14 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
if accountID != "" {
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
} else {
- logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
- "requested_model": model,
- "resolved_model": resolvedModel,
- })
+ logger.WarnCF(
+ "provider.codex",
+ "No account id found for Codex request; backend may reject with 400",
+ map[string]any{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ },
+ )
}
params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch)
@@ -98,7 +113,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
}
err := stream.Err()
if err != nil {
- fields := map[string]interface{}{
+ fields := map[string]any{
"requested_model": model,
"resolved_model": resolvedModel,
"messages_count": len(messages),
@@ -124,7 +139,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
return nil, fmt.Errorf("codex API call: %w", err)
}
if resp == nil {
- fields := map[string]interface{}{
+ fields := map[string]any{
"requested_model": model,
"resolved_model": resolvedModel,
"messages_count": len(messages),
@@ -184,7 +199,9 @@ func resolveCodexModel(model string) (string, string) {
return codexDefaultModel, "unsupported model family"
}
-func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams {
+func buildCodexParams(
+ messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool,
+) responses.ResponseNewParams {
var inputItems responses.ResponseInputParam
var instructions string
@@ -197,7 +214,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID,
- Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
+ Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
+ OfString: openai.Opt(msg.Content),
+ },
},
})
} else {
@@ -221,7 +240,7 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
for _, tc := range msg.ToolCalls {
name, args, ok := resolveCodexToolCall(tc)
if !ok {
- logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{
+ logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{
"call_id": tc.ID,
})
continue
@@ -246,7 +265,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID,
- Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
+ Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
+ OfString: openai.Opt(msg.Content),
+ },
},
})
}
@@ -341,9 +362,9 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
}
}
case "function_call":
- var args map[string]interface{}
+ var args map[string]any
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
- args = map[string]interface{}{"raw": item.Arguments}
+ args = map[string]any{"raw": item.Arguments}
}
toolCalls = append(toolCalls, ToolCall{
ID: item.CallID,
diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go
index 92e276165..4157e53e9 100644
--- a/pkg/providers/codex_provider_test.go
+++ b/pkg/providers/codex_provider_test.go
@@ -16,7 +16,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{
"max_tokens": 2048,
"temperature": 0.7,
}, true)
@@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, true)
if !params.Instructions.Valid() {
t.Fatal("Instructions should be set")
}
@@ -54,12 +54,12 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
{
Role: "assistant",
ToolCalls: []ToolCall{
- {ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}},
+ {ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}},
},
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
if params.Input.OfInputItemList == nil {
t.Fatal("Input.OfInputItemList should not be nil")
}
@@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
if params.Input.OfInputItemList == nil {
t.Fatal("Input.OfInputItemList should not be nil")
}
@@ -114,16 +114,16 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
},
},
},
}
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, false)
if len(params.Tools) != 1 {
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
}
@@ -136,14 +136,14 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
}
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, false)
if !params.Store.Valid() || params.Store.Or(true) != false {
t.Error("Store should be explicitly set to false")
}
}
func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, true)
if len(params.Tools) != 1 {
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
}
@@ -151,7 +151,11 @@ func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
t.Fatal("Tool should include built-in web_search")
}
if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {
- t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch)
+ t.Errorf(
+ "Web search tool type = %q, want %q",
+ params.Tools[0].OfWebSearch.Type,
+ responses.WebSearchToolTypeWebSearch,
+ )
}
}
@@ -162,7 +166,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "web_search",
Description: "local web search",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
},
},
@@ -172,14 +176,14 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "read_file",
Description: "read file",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
},
},
},
}
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, true)
if len(params.Tools) != 2 {
t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
}
@@ -296,7 +300,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -309,38 +313,38 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
return
}
- toolsAny, ok := reqBody["tools"].([]interface{})
+ toolsAny, ok := reqBody["tools"].([]any)
if !ok || len(toolsAny) != 1 {
http.Error(w, "missing default web search tool", http.StatusBadRequest)
return
}
- toolObj, ok := toolsAny[0].(map[string]interface{})
+ toolObj, ok := toolsAny[0].(map[string]any)
if !ok || toolObj["type"] != "web_search" {
http.Error(w, "expected web_search tool", http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 12,
"output_tokens": 6,
"total_tokens": 18,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -351,7 +355,7 @@ func TestCodexProvider_ChatRoundTrip(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-4o", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -373,7 +377,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -383,27 +387,27 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 4,
"output_tokens": 3,
"total_tokens": 7,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -415,7 +419,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(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-4o", map[string]interface{}{})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -439,7 +443,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -465,27 +469,27 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 8,
"output_tokens": 4,
"total_tokens": 12,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -499,7 +503,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
}
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"temperature": 0.7})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -515,7 +519,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -533,27 +537,27 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 8,
"output_tokens": 4,
"total_tokens": 12,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -588,7 +592,12 @@ func TestResolveCodexModel(t *testing.T) {
wantFallback bool
}{
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
- {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
+ {
+ name: "unsupported namespace",
+ input: "anthropic/claude-3.5",
+ wantModel: codexDefaultModel,
+ 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: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
@@ -622,8 +631,8 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
return &c
}
-func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
- event := map[string]interface{}{
+func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
+ event := map[string]any{
"type": "response.completed",
"sequence_number": 1,
"response": response,
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index b4ad677ee..9cb143fe7 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -178,6 +178,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.model = "deepseek-chat"
}
}
+ case "mistral":
+ if cfg.Providers.Mistral.APIKey != "" {
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
+ }
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@@ -281,6 +290,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
+ case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 413afc4c7..c84e23c37 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen":
+ "volcengine", "vllm", "qwen", "mistral":
// 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)
@@ -193,6 +193,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "vllm":
return "http://localhost:8000/v1"
+ case "mistral":
+ return "https://api.mistral.ai/v1"
default:
return ""
}
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
index 9b07f9153..ecd451ec9 100644
--- a/pkg/providers/fallback.go
+++ b/pkg/providers/fallback.go
@@ -110,7 +110,11 @@ func (fc *FallbackChain) Execute(
Model: candidate.Model,
Skipped: true,
Reason: FailoverRateLimit,
- Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
+ Error: fmt.Errorf(
+ "provider %s in cooldown (%s remaining)",
+ candidate.Provider,
+ remaining.Round(time.Second),
+ ),
})
continue
}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
index ea81e0d48..e872c672e 100644
--- a/pkg/providers/fallback_test.go
+++ b/pkg/providers/fallback_test.go
@@ -462,7 +462,13 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
func TestFallbackExhaustedError_Message(t *testing.T) {
e := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
- {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
+ {
+ Provider: "openai",
+ Model: "gpt-4",
+ Error: errors.New("rate limited"),
+ Reason: FailoverRateLimit,
+ Duration: 500 * time.Millisecond,
+ },
{Provider: "anthropic", Model: "claude", Skipped: true},
},
}
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 5058819f5..6124881f7 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -2,10 +2,9 @@ package providers
import (
"context"
+ "encoding/json"
"fmt"
- json "encoding/json"
-
copilot "github.com/github/copilot-sdk/go"
)
@@ -17,7 +16,6 @@ type GitHubCopilotProvider struct {
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
-
var session *copilot.Session
if connectMode == "" {
connectMode = "grpc"
@@ -25,13 +23,15 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
switch connectMode {
case "stdio":
- //todo
+ // todo
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
- return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
+ return nil, fmt.Errorf(
+ "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
+ )
}
defer client.Stop()
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
@@ -49,7 +49,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
}
// Chat sends a chat request to GitHub Copilot
-func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *GitHubCopilotProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -73,10 +75,8 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
FinishReason: "stop",
Content: content,
}, nil
-
}
func (p *GitHubCopilotProvider) GetDefaultModel() string {
-
return "gpt-4.1"
}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index eeaa9690a..d0c4344f3 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -28,7 +28,13 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st
}
}
-func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *HTTPProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
return p.delegate.Chat(ctx, messages, tools, model, options)
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 6bc43a470..236a048c4 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -15,15 +15,17 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
-type ExtraContent = protocoltypes.ExtraContent
-type GoogleExtra = protocoltypes.GoogleExtra
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+ ExtraContent = protocoltypes.ExtraContent
+ GoogleExtra = protocoltypes.GoogleExtra
+)
type Provider struct {
apiKey string
@@ -60,14 +62,20 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
}
}
-func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
model = normalizeModel(model, p.apiBase)
- requestBody := map[string]interface{}{
+ requestBody := map[string]any{
"model": model,
"messages": messages,
}
@@ -83,7 +91,8 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
if fieldName == "" {
// Fallback: detect from model name for backward compatibility
lowerModel := strings.ToLower(model)
- if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
+ if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
+ strings.Contains(lowerModel, "gpt-5") {
fieldName = "max_completion_tokens"
} else {
fieldName = "max_tokens"
@@ -173,7 +182,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
- arguments := make(map[string]interface{})
+ arguments := make(map[string]any)
name := ""
// Extract thought_signature from Gemini/Google-specific extra content
@@ -231,14 +240,14 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(model[:idx])
switch prefix {
- case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
+ case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return model[idx+1:]
default:
return model
}
}
-func asInt(v interface{}) (int, bool) {
+func asInt(v any) (int, bool) {
switch val := v.(type) {
case int:
return val, true
@@ -253,7 +262,7 @@ func asInt(v interface{}) (int, bool) {
}
}
-func asFloat(v interface{}) (float64, bool) {
+func asFloat(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 94779b39c..42f9d42ab 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -9,7 +9,7 @@ import (
)
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
- var requestBody map[string]interface{}
+ var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
@@ -20,10 +20,10 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -34,7 +34,13 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
defer server.Close()
p := NewProvider("key", server.URL, "")
- _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234})
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "glm-4.7",
+ map[string]any{"max_tokens": 1234},
+ )
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -49,16 +55,16 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
func TestProviderChat_ParsesToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{
+ "message": map[string]any{
"content": "",
- "tool_calls": []map[string]interface{}{
+ "tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
- "function": map[string]interface{}{
+ "function": map[string]any{
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}",
},
@@ -68,7 +74,7 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
"finish_reason": "tool_calls",
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
@@ -109,17 +115,17 @@ func TestProviderChat_HTTPError(t *testing.T) {
}
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
- var requestBody map[string]interface{}
+ 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]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -135,7 +141,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
[]Message{{Role: "user", Content: "hi"}},
nil,
"moonshot/kimi-k2.5",
- map[string]interface{}{"temperature": 0.3},
+ map[string]any{"temperature": 0.3},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -174,17 +180,17 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- var requestBody map[string]interface{}
+ 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]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -227,17 +233,17 @@ func TestProvider_ProxyConfigured(t *testing.T) {
}
func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
- var requestBody map[string]interface{}
+ 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]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -253,7 +259,7 @@ func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
[]Message{{Role: "user", Content: "hi"}},
nil,
"gpt-4o",
- map[string]interface{}{"max_tokens": float64(512), "temperature": 1},
+ map[string]any{"max_tokens": float64(512), "temperature": 1},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index b7e7062b9..5e1c6d397 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -1,13 +1,13 @@
package protocoltypes
type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type,omitempty"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
- ThoughtSignature string `json:"-"` // Internal use only
- ExtraContent *ExtraContent `json:"extra_content,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"-"`
+ Arguments map[string]any `json:"-"`
+ ThoughtSignature string `json:"-"` // Internal use only
+ ExtraContent *ExtraContent `json:"extra_content,omitempty"`
}
type ExtraContent struct {
@@ -50,7 +50,7 @@ type ToolDefinition struct {
}
type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
}
diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go
index 97a219283..7ddea0e99 100644
--- a/pkg/providers/tool_call_extract.go
+++ b/pkg/providers/tool_call_extract.go
@@ -38,7 +38,7 @@ func extractToolCallsFromText(text string) []ToolCall {
var result []ToolCall
for _, tc := range wrapper.ToolCalls {
- var args map[string]interface{}
+ var args map[string]any
json.Unmarshal([]byte(tc.Function.Arguments), &args)
result = append(result, ToolCall{
diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go
index c7c35ef42..49218b1b1 100644
--- a/pkg/providers/toolcall_utils.go
+++ b/pkg/providers/toolcall_utils.go
@@ -20,12 +20,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
// Ensure Arguments is not nil
if normalized.Arguments == nil {
- normalized.Arguments = map[string]interface{}{}
+ normalized.Arguments = map[string]any{}
}
// Parse Arguments from Function.Arguments if not already set
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index e783e6348..f711e7803 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -7,18 +7,26 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
-type ExtraContent = protocoltypes.ExtraContent
-type GoogleExtra = protocoltypes.GoogleExtra
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+ ExtraContent = protocoltypes.ExtraContent
+ GoogleExtra = protocoltypes.GoogleExtra
+)
type LLMProvider interface {
- Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
+ Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*LLMResponse, error)
GetDefaultModel() string
}
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 12bf33df0..08f0b0ad2 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager {
}
if storage != "" {
- os.MkdirAll(storage, 0755)
+ os.MkdirAll(storage, 0o755)
sm.loadSessions()
}
@@ -214,7 +214,7 @@ func (sm *SessionManager) Save(key string) error {
_ = tmpFile.Close()
return err
}
- if err := tmpFile.Chmod(0644); err != nil {
+ if err := tmpFile.Chmod(0o644); err != nil {
_ = tmpFile.Close()
return err
}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index e2a940afd..f78197bbe 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -214,7 +214,10 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill
// DownloadAndInstall fetches metadata (with fallback), resolves version,
// downloads the skill ZIP, and extracts it to targetDir.
// Returns an InstallResult for the caller to use for moderation decisions.
-func (c *ClawHubRegistry) DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) {
+func (c *ClawHubRegistry) DownloadAndInstall(
+ ctx context.Context,
+ slug, version, targetDir string,
+) (*InstallResult, error) {
if err := utils.ValidateSkillIdentifier(slug); err != nil {
return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
}
diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go
index d12e19504..65ee638da 100644
--- a/pkg/skills/clawhub_registry_test.go
+++ b/pkg/skills/clawhub_registry_test.go
@@ -11,9 +11,10 @@ import (
"path/filepath"
"testing"
- "github.com/sipeed/picoclaw/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func newTestRegistry(serverURL, authToken string) *ClawHubRegistry {
@@ -162,7 +163,7 @@ func TestExtractZipPathTraversal(t *testing.T) {
// Write to temp file for extractZipFile.
tmpZip := filepath.Join(t.TempDir(), "bad.zip")
- require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0644))
+ require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644))
tmpDir := t.TempDir()
err = utils.ExtractZipFile(tmpZip, tmpDir)
@@ -179,7 +180,7 @@ func TestExtractZipWithSubdirectories(t *testing.T) {
// Write to temp file for extractZipFile.
tmpZip := filepath.Join(t.TempDir(), "test.zip")
- require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0644))
+ require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644))
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "my-skill")
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index 0856254e8..3210509df 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -59,12 +59,12 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
return fmt.Errorf("failed to read response: %w", err)
}
- if err := os.MkdirAll(skillDir, 0755); err != nil {
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
- if err := os.WriteFile(skillPath, body, 0644); err != nil {
+ if err := os.WriteFile(skillPath, body, 0o644); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index bb0abbdcc..eb0d5f322 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -254,7 +254,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath)
if err != nil {
logger.WarnCF("skills", "Failed to read skill metadata",
- map[string]interface{}{
+ map[string]any{
"skill_path": skillPath,
"error": err.Error(),
})
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index efadcdbf2..aca901d33 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -117,8 +117,20 @@ func TestExtractFrontmatter(t *testing.T) {
// Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
yamlMeta := sl.parseSimpleYAML(frontmatter)
- assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
- assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
+ assert.Equal(
+ t,
+ tc.expectedName,
+ yamlMeta["name"],
+ "Name should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
+ assert.Equal(
+ t,
+ tc.expectedDesc,
+ yamlMeta["description"],
+ "Description should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
})
}
}
@@ -173,7 +185,13 @@ func TestStripFrontmatter(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
result := sl.stripFrontmatter(tc.content)
- assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType)
+ assert.Equal(
+ t,
+ tc.expectedContent,
+ result,
+ "Frontmatter should be stripped correctly for %s",
+ tc.lineEndingType,
+ )
})
}
}
diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go
index daecd5a59..a4694bd43 100644
--- a/pkg/skills/registry_test.go
+++ b/pkg/skills/registry_test.go
@@ -6,8 +6,9 @@ import (
"testing"
"time"
- "github.com/sipeed/picoclaw/pkg/utils"
"github.com/stretchr/testify/assert"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// mockRegistry is a test double implementing SkillRegistry.
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 0bb9cd497..1a92f82ed 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -38,7 +38,7 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
- os.MkdirAll(stateDir, 0755)
+ os.MkdirAll(stateDir, 0o755)
sm := &Manager{
workspace: workspace,
@@ -139,7 +139,7 @@ func (sm *Manager) saveAtomic() error {
}
// Write to temp file
- if err := os.WriteFile(tempFile, data, 0644); err != nil {
+ if err := os.WriteFile(tempFile, data, 0o644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go
index ce3dd7215..f717a5bb4 100644
--- a/pkg/state/state_test.go
+++ b/pkg/state/state_test.go
@@ -98,7 +98,7 @@ func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) {
// Simulate a crash scenario by manually creating a corrupted temp file
tempFile := filepath.Join(tmpDir, "state", "state.json.tmp")
- err = os.WriteFile(tempFile, []byte("corrupted data"), 0644)
+ err = os.WriteFile(tempFile, []byte("corrupted data"), 0o644)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
diff --git a/pkg/tools/base.go b/pkg/tools/base.go
index b13174633..770d8cb04 100644
--- a/pkg/tools/base.go
+++ b/pkg/tools/base.go
@@ -6,8 +6,8 @@ import "context"
type Tool interface {
Name() string
Description() string
- Parameters() map[string]interface{}
- Execute(ctx context.Context, args map[string]interface{}) *ToolResult
+ Parameters() map[string]any
+ Execute(ctx context.Context, args map[string]any) *ToolResult
}
// ContextualTool is an optional interface that tools can implement
@@ -69,10 +69,10 @@ type AsyncTool interface {
SetCallback(cb AsyncCallback)
}
-func ToolToSchema(tool Tool) map[string]interface{} {
- return map[string]interface{}{
+func ToolToSchema(tool Tool) map[string]any {
+ return map[string]any{
"type": "function",
- "function": map[string]interface{}{
+ "function": map[string]any{
"name": tool.Name(),
"description": tool.Description(),
"parameters": tool.Parameters(),
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index e2764d8ac..562fffc84 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -30,7 +30,10 @@ type CronTool struct {
// NewCronTool creates a new CronTool
// execTimeout: 0 means no timeout, >0 sets the timeout duration
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool {
+func NewCronTool(
+ cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
+ execTimeout time.Duration, config *config.Config,
+) *CronTool {
execTool := NewExecToolWithConfig(workspace, restrict, config)
execTool.SetTimeout(execTimeout)
return &CronTool{
@@ -52,40 +55,40 @@ func (t *CronTool) Description() string {
}
// Parameters returns the tool parameters schema
-func (t *CronTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *CronTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"add", "list", "remove", "enable", "disable"},
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
},
- "message": map[string]interface{}{
+ "message": map[string]any{
"type": "string",
"description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.",
},
- "command": map[string]interface{}{
+ "command": 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.",
},
- "at_seconds": map[string]interface{}{
+ "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'.",
},
- "every_seconds": map[string]interface{}{
+ "every_seconds": map[string]any{
"type": "integer",
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
},
- "cron_expr": map[string]interface{}{
+ "cron_expr": map[string]any{
"type": "string",
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
},
- "job_id": map[string]interface{}{
+ "job_id": map[string]any{
"type": "string",
"description": "Job ID (for remove/enable/disable)",
},
- "deliver": map[string]interface{}{
+ "deliver": map[string]any{
"type": "boolean",
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
},
@@ -103,7 +106,7 @@ func (t *CronTool) SetContext(channel, chatID string) {
}
// Execute runs the tool with the given arguments
-func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required")
@@ -125,7 +128,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *To
}
}
-func (t *CronTool) addJob(args map[string]interface{}) *ToolResult {
+func (t *CronTool) addJob(args map[string]any) *ToolResult {
t.mu.RLock()
channel := t.channel
chatID := t.chatID
@@ -233,7 +236,7 @@ func (t *CronTool) listJobs() *ToolResult {
return SilentResult(result)
}
-func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult {
+func (t *CronTool) removeJob(args map[string]any) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for remove")
@@ -245,7 +248,7 @@ func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
-func (t *CronTool) enableJob(args map[string]interface{}, enable bool) *ToolResult {
+func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for enable/disable")
@@ -279,7 +282,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// Execute command if present
if job.Payload.Command != "" {
- args := map[string]interface{}{
+ args := map[string]any{
"command": job.Payload.Command,
}
@@ -320,7 +323,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
channel,
chatID,
)
-
if err != nil {
return fmt.Sprintf("Error: %v", err)
}
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 1e7c33b45..d3ab267bf 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -2,24 +2,27 @@ package tools
import (
"context"
+ "errors"
"fmt"
- "os"
+ "io/fs"
"strings"
)
// EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
- allowedDir string
- restrict bool
+ fs fileSystem
}
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
-func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
- return &EditFileTool{
- allowedDir: allowedDir,
- restrict: restrict,
+func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
}
+ return &EditFileTool{fs: fs}
}
func (t *EditFileTool) Name() string {
@@ -30,19 +33,19 @@ func (t *EditFileTool) Description() string {
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
}
-func (t *EditFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *EditFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "The file path to edit",
},
- "old_text": map[string]interface{}{
+ "old_text": map[string]any{
"type": "string",
"description": "The exact text to find and replace",
},
- "new_text": map[string]interface{}{
+ "new_text": map[string]any{
"type": "string",
"description": "The text to replace with",
},
@@ -51,7 +54,7 @@ func (t *EditFileTool) Parameters() map[string]interface{} {
}
}
-func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -67,47 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult("new_text is required")
}
- resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
- if err != nil {
+ if err := editFile(t.fs, path, oldText, newText); err != nil {
return ErrorResult(err.Error())
}
-
- if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
- return ErrorResult(fmt.Sprintf("file not found: %s", path))
- }
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
- contentStr := string(content)
-
- if !strings.Contains(contentStr, oldText) {
- return ErrorResult("old_text not found in file. Make sure it matches exactly")
- }
-
- count := strings.Count(contentStr, oldText)
- if count > 1 {
- return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count))
- }
-
- newContent := strings.Replace(contentStr, oldText, newText, 1)
-
- if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File edited: %s", path))
}
type AppendFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
- return &AppendFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &AppendFileTool{fs: fs}
}
func (t *AppendFileTool) Name() string {
@@ -118,15 +98,15 @@ func (t *AppendFileTool) Description() string {
return "Append content to the end of a file"
}
-func (t *AppendFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *AppendFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "The file path to append to",
},
- "content": map[string]interface{}{
+ "content": map[string]any{
"type": "string",
"description": "The content to append",
},
@@ -135,7 +115,7 @@ func (t *AppendFileTool) Parameters() map[string]interface{} {
}
}
-func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -146,20 +126,52 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := appendFile(t.fs, path, content); err != nil {
return ErrorResult(err.Error())
}
-
- f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
- }
- defer f.Close()
-
- if _, err := f.WriteString(content); err != nil {
- return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("Appended to %s", path))
}
+
+// editFile reads the file via sysFs, performs the replacement, and writes back.
+// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
+func editFile(sysFs fileSystem, path, oldText, newText string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ newContent, err := replaceEditContent(content, oldText, newText)
+ if err != nil {
+ return err
+ }
+
+ return sysFs.WriteFile(path, newContent)
+}
+
+// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
+func appendFile(sysFs fileSystem, path, appendContent string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+
+ newContent := append(content, []byte(appendContent)...)
+ return sysFs.WriteFile(path, newContent)
+}
+
+// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
+func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, oldText) {
+ return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly")
+ }
+
+ count := strings.Count(contentStr, oldText)
+ if count > 1 {
+ return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
+ }
+
+ newContent := strings.Replace(contentStr, oldText, newText, 1)
+ return []byte(newContent), nil
+}
diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go
index c4c02772d..83a7e778c 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/edit_test.go
@@ -6,17 +6,19 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestEditTool_EditFile_Success verifies successful file editing
func TestEditTool_EditFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0644)
+ os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "World",
"new_text": "Universe",
@@ -60,7 +62,7 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "old",
"new_text": "new",
@@ -83,11 +85,11 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Hello World"), 0644)
+ os.WriteFile(testFile, []byte("Hello World"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "Goodbye",
"new_text": "Hello",
@@ -110,11 +112,11 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test test test"), 0644)
+ os.WriteFile(testFile, []byte("test test test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "test",
"new_text": "done",
@@ -138,11 +140,11 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
tmpDir := t.TempDir()
otherDir := t.TempDir()
testFile := filepath.Join(otherDir, "test.txt")
- os.WriteFile(testFile, []byte("content"), 0644)
+ os.WriteFile(testFile, []byte("content"), 0o644)
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "content",
"new_text": "new",
@@ -151,21 +153,25 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
result := tool.Execute(ctx, args)
// Should return error result
- if !result.IsError {
- t.Errorf("Expected error when path is outside allowed directory")
- }
+ assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
// Should mention outside allowed directory
- if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") {
- t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM)
- }
+ // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
+ // We check ForLLM as it's the primary error channel.
+ assert.True(
+ t,
+ strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
+ strings.Contains(result.ForLLM, "escapes"),
+ "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
+ result.ForLLM,
+ )
}
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
func TestEditTool_EditFile_MissingPath(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"old_text": "old",
"new_text": "new",
}
@@ -182,7 +188,7 @@ func TestEditTool_EditFile_MissingPath(t *testing.T) {
func TestEditTool_EditFile_MissingOldText(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
"new_text": "new",
}
@@ -199,7 +205,7 @@ func TestEditTool_EditFile_MissingOldText(t *testing.T) {
func TestEditTool_EditFile_MissingNewText(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
"old_text": "old",
}
@@ -216,11 +222,11 @@ func TestEditTool_EditFile_MissingNewText(t *testing.T) {
func TestEditTool_AppendFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Initial content"), 0644)
+ os.WriteFile(testFile, []byte("Initial content"), 0o644)
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "\nAppended content",
}
@@ -260,7 +266,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) {
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "test",
}
@@ -276,7 +282,7 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) {
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
}
@@ -287,3 +293,145 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
t.Errorf("Expected error when content is missing")
}
}
+
+// TestReplaceEditContent verifies the helper function replaceEditContent
+func TestReplaceEditContent(t *testing.T) {
+ tests := []struct {
+ name string
+ content []byte
+ oldText string
+ newText string
+ expected []byte
+ expectError bool
+ }{
+ {
+ name: "successful replacement",
+ content: []byte("hello world"),
+ oldText: "world",
+ newText: "universe",
+ expected: []byte("hello universe"),
+ expectError: false,
+ },
+ {
+ name: "old text not found",
+ content: []byte("hello world"),
+ oldText: "golang",
+ newText: "rust",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "multiple matches found",
+ content: []byte("test text test"),
+ oldText: "test",
+ newText: "done",
+ expected: nil,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, result)
+ }
+ })
+ }
+}
+
+// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
+// can append to a file that does not yet exist — it should silently create the file.
+// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
+func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+
+ args := map[string]any{
+ "path": "brand_new_file.txt",
+ "content": "first content",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(
+ t,
+ result.IsError,
+ "Expected success when appending to non-existent file in restricted mode, got: %s",
+ result.ForLLM,
+ )
+
+ // Verify the file was created with correct content
+ data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
+ assert.NoError(t, err)
+ assert.Equal(t, "first content", string(data))
+}
+
+// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
+// correctly appends to an existing file within the sandbox.
+func TestAppendFileTool_Restricted_Success(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "existing.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "content": " appended",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "initial appended", string(data))
+}
+
+// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
+// correctly edits a file using the single-open editFileInRoot path.
+func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "edit_target.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "old_text": "World",
+ "new_text": "Go",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "Hello Go", string(data))
+}
+
+// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
+// error message when the target file does not exist.
+func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": "no_such_file.txt",
+ "old_text": "old",
+ "new_text": "new",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not found")
+}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 09063ea0a..37db8b4ae 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -3,15 +3,17 @@ package tools
import (
"context"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
+ "time"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
- return path, nil
+ return path, fmt.Errorf("workspace is not defined")
}
absWorkspace, err := filepath.Abs(workspace)
@@ -34,17 +36,19 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
+ var resolved string
workspaceReal := absWorkspace
- if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
- if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
- if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
+ var parentResolved string
+ if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
@@ -74,16 +78,21 @@ func resolveExistingAncestor(path string) (string, error) {
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+ return err == nil && filepath.IsLocal(rel)
}
type ReadFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
- return &ReadFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ReadFileTool{fs: fs}
}
func (t *ReadFileTool) Name() string {
@@ -94,11 +103,11 @@ func (t *ReadFileTool) Description() string {
return "Read the contents of a file"
}
-func (t *ReadFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ReadFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to the file to read",
},
@@ -107,32 +116,31 @@ func (t *ReadFileTool) Parameters() map[string]interface{} {
}
}
-func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
+ content, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
return NewToolResult(string(content))
}
type WriteFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
- return &WriteFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &WriteFileTool{fs: fs}
}
func (t *WriteFileTool) Name() string {
@@ -143,15 +151,15 @@ func (t *WriteFileTool) Description() string {
return "Write content to a file"
}
-func (t *WriteFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WriteFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to the file to write",
},
- "content": map[string]interface{}{
+ "content": map[string]any{
"type": "string",
"description": "Content to write to the file",
},
@@ -160,7 +168,7 @@ func (t *WriteFileTool) Parameters() map[string]interface{} {
}
}
-func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -171,30 +179,25 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
- dir := filepath.Dir(resolvedPath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
- }
-
- if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File written: %s", path))
}
type ListDirTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
- return &ListDirTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ListDirTool{fs: fs}
}
func (t *ListDirTool) Name() string {
@@ -205,11 +208,11 @@ func (t *ListDirTool) Description() string {
return "List files and directories in a path"
}
-func (t *ListDirTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ListDirTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to list",
},
@@ -218,30 +221,185 @@ func (t *ListDirTool) Parameters() map[string]interface{} {
}
}
-func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
path = "."
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
- return ErrorResult(err.Error())
- }
-
- entries, err := os.ReadDir(resolvedPath)
+ entries, err := t.fs.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
+ return formatDirEntries(entries)
+}
- result := ""
+func formatDirEntries(entries []os.DirEntry) *ToolResult {
+ var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
- result += "DIR: " + entry.Name() + "\n"
+ result.WriteString("DIR: " + entry.Name() + "\n")
} else {
- result += "FILE: " + entry.Name() + "\n"
+ result.WriteString("FILE: " + entry.Name() + "\n")
+ }
+ }
+ return NewToolResult(result.String())
+}
+
+// fileSystem abstracts reading, writing, and listing files, allowing both
+// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
+type fileSystem interface {
+ ReadFile(path string) ([]byte, error)
+ WriteFile(path string, data []byte) error
+ ReadDir(path string) ([]os.DirEntry, error)
+}
+
+// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
+type hostFs struct{}
+
+func (h *hostFs) ReadFile(path string) ([]byte, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ if os.IsPermission(err) {
+ return nil, fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return nil, fmt.Errorf("failed to read file: %w", err)
+ }
+ return content, nil
+}
+
+func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+func (h *hostFs) WriteFile(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("failed to replace original file: %w", err)
+ }
+ return nil
+}
+
+// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
+type sandboxFs struct {
+ workspace string
+}
+
+func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
+ if r.workspace == "" {
+ return fmt.Errorf("workspace is not defined")
+ }
+
+ root, err := os.OpenRoot(r.workspace)
+ if err != nil {
+ return fmt.Errorf("failed to open workspace: %w", err)
+ }
+ defer root.Close()
+
+ relPath, err := getSafeRelPath(r.workspace, path)
+ if err != nil {
+ return err
+ }
+
+ return fn(root, relPath)
+}
+
+func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
+ var content []byte
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ fileContent, err := root.ReadFile(relPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ // os.Root returns "escapes from parent" for paths outside the root
+ if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
+ strings.Contains(err.Error(), "permission denied") {
+ return fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return fmt.Errorf("failed to read file: %w", err)
+ }
+ content = fileContent
+ return nil
+ })
+ return content, err
+}
+
+func (r *sandboxFs) WriteFile(path string, data []byte) error {
+ return r.execute(path, func(root *os.Root, relPath string) error {
+ dir := filepath.Dir(relPath)
+ if dir != "." && dir != "/" {
+ if err := root.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+
+ if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
+ root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write to temp file: %w", err)
+ }
+
+ if err := root.Rename(tmpRelPath, relPath); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to rename temp file over target: %w", err)
+ }
+ return nil
+ })
+}
+
+func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
+ var entries []os.DirEntry
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ dirEntries, err := fs.ReadDir(root.FS(), relPath)
+ if err != nil {
+ return err
+ }
+ entries = dirEntries
+ return nil
+ })
+ return entries, err
+}
+
+// Helper to get a safe relative path for os.Root usage
+func getSafeRelPath(workspace, path string) (string, error) {
+ if workspace == "" {
+ return "", fmt.Errorf("workspace is not defined")
+ }
+
+ rel := filepath.Clean(path)
+ if filepath.IsAbs(rel) {
+ var err error
+ rel, err = filepath.Rel(workspace, rel)
+ if err != nil {
+ return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
}
- return NewToolResult(result)
+ if !filepath.IsLocal(rel) {
+ return "", fmt.Errorf("path escapes workspace: %s", path)
+ }
+
+ return rel, nil
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 958036419..6f896e22d 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -2,21 +2,24 @@ package tools
import (
"context"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestFilesystemTool_ReadFile_Success verifies successful file reading
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test content"), 0644)
+ os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
}
@@ -41,9 +44,9 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/nonexistent_file_12345.txt",
}
@@ -64,7 +67,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
tool := &ReadFileTool{}
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -84,9 +87,9 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "hello world",
}
@@ -123,9 +126,9 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "test",
}
@@ -149,9 +152,9 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "test",
}
@@ -165,9 +168,9 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
}
@@ -179,7 +182,8 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
}
// Should mention required parameter
- if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") {
+ if !strings.Contains(result.ForLLM, "content is required") &&
+ !strings.Contains(result.ForUser, "content is required") {
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
}
}
@@ -187,13 +191,13 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
// TestFilesystemTool_ListDir_Success verifies successful directory listing
func TestFilesystemTool_ListDir_Success(t *testing.T) {
tmpDir := t.TempDir()
- os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644)
- os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0644)
- os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755)
+ os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
+ os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
+ os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": tmpDir,
}
@@ -215,9 +219,9 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/nonexistent_directory_12345",
}
@@ -236,9 +240,9 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -250,15 +254,14 @@ func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
// Block paths that look inside workspace but point outside via symlink.
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
-
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
secret := filepath.Join(root, "secret.txt")
- if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil {
+ if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
@@ -268,14 +271,218 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
}
tool := NewReadFileTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"path": link,
})
if !result.IsError {
t.Fatalf("expected symlink escape to be blocked")
}
- if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ // os.Root might return different errors depending on platform/implementation
+ // but it definitely should error.
+ // Our wrapper returns "access denied or file not found"
+ if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
}
+
+func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
+ tool := NewReadFileTool("", true) // restrict=true but workspace=""
+
+ // Try to read a sensitive file (simulated by a temp file outside workspace)
+ tmpDir := t.TempDir()
+ secretFile := filepath.Join(tmpDir, "shadow")
+ os.WriteFile(secretFile, []byte("secret data"), 0o600)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": secretFile,
+ })
+
+ // We EXPECT IsError=true (access blocked due to empty workspace)
+ assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+
+ // Verify it failed for the right reason
+ assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+}
+
+// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
+// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
+func TestRootMkdirAll(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ if err != nil {
+ t.Fatalf("failed to open root: %v", err)
+ }
+ defer root.Close()
+
+ // Case 1: Single directory
+ err = root.MkdirAll("dir1", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "dir1"))
+ assert.NoError(t, err)
+
+ // Case 2: Deeply nested directory
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
+ assert.NoError(t, err)
+
+ // Case 3: Already exists — must be idempotent
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+
+ // Case 4: A regular file blocks directory creation — must error
+ err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
+ assert.NoError(t, err)
+ err = root.MkdirAll("file_exists", 0o755)
+ assert.Error(t, err, "expected error when a file exists at the directory path")
+}
+
+func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewWriteFileTool(workspace, true)
+ ctx := context.Background()
+
+ testFile := "deep/nested/path/to/file.txt"
+ content := "deep content"
+ args := map[string]any{
+ "path": testFile,
+ "content": content,
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+
+ // Verify file content
+ actualPath := filepath.Join(workspace, testFile)
+ data, err := os.ReadFile(actualPath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(data))
+}
+
+// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
+func TestHostRW_Read_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test: running as root")
+ }
+ tmpDir := t.TempDir()
+ protected := filepath.Join(tmpDir, "protected.txt")
+ err := os.WriteFile(protected, []byte("secret"), 0o000)
+ assert.NoError(t, err)
+ defer os.Chmod(protected, 0o644) // ensure cleanup
+
+ _, err = (&hostFs{}).ReadFile(protected)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "access denied")
+}
+
+// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
+func TestHostRW_Read_Directory(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := (&hostFs{}).ReadFile(tmpDir)
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
+func TestRootRW_Read_Directory(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ // Create a subdirectory
+ err = root.Mkdir("subdir", 0o755)
+ assert.NoError(t, err)
+
+ _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
+func TestHostRW_Write_ParentDirMissing(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
+
+ err := (&hostFs{}).WriteFile(target, []byte("hello"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(target)
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", string(data))
+}
+
+// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
+// nested parent directories automatically within the sandbox.
+func TestRootRW_Write_ParentDirMissing(t *testing.T) {
+ workspace := t.TempDir()
+
+ relPath := "x/y/z/file.txt"
+ err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(workspace, relPath))
+ assert.NoError(t, err)
+ assert.Equal(t, "nested", string(data))
+}
+
+// TestHostRW_Write verifies the hostRW.Write helper function
+func TestHostRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "atomic_test.txt")
+ testData := []byte("atomic test content")
+
+ err := (&hostFs{}).WriteFile(testFile, testData)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new atomic content")
+ err = (&hostFs{}).WriteFile(testFile, newData)
+ assert.NoError(t, err)
+
+ content, err = os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
+
+// TestRootRW_Write verifies the rootRW.Write helper function
+func TestRootRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ relPath := "atomic_root_test.txt"
+ testData := []byte("atomic root test content")
+
+ erw := &sandboxFs{workspace: tmpDir}
+ err := erw.WriteFile(relPath, testData)
+ assert.NoError(t, err)
+
+ root, err := os.OpenRoot(tmpDir)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ f, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f.Close()
+
+ content, err := io.ReadAll(f)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new root atomic content")
+ err = erw.WriteFile(relPath, newData)
+ assert.NoError(t, err)
+
+ f2, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f2.Close()
+
+ content, err = io.ReadAll(f2)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go
index abca5ec1e..0387a26d3 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/i2c.go
@@ -24,37 +24,37 @@ func (t *I2CTool) Description() string {
return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only."
}
-func (t *I2CTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *I2CTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"detect", "scan", "read", "write"},
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
},
- "bus": map[string]interface{}{
+ "bus": map[string]any{
"type": "string",
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
},
- "address": map[string]interface{}{
+ "address": map[string]any{
"type": "integer",
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
},
- "register": map[string]interface{}{
+ "register": map[string]any{
"type": "integer",
"description": "Register address to read from or write to. If set, sends register byte before read/write.",
},
- "data": map[string]interface{}{
+ "data": map[string]any{
"type": "array",
- "items": map[string]interface{}{"type": "integer"},
+ "items": map[string]any{"type": "integer"},
"description": "Bytes to write (0-255 each). Required for write action.",
},
- "length": map[string]interface{}{
+ "length": map[string]any{
"type": "integer",
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
},
- "confirm": map[string]interface{}{
+ "confirm": map[string]any{
"type": "boolean",
"description": "Must be true for write operations. Safety guard to prevent accidental writes.",
},
@@ -63,7 +63,7 @@ func (t *I2CTool) Parameters() map[string]interface{} {
}
}
-func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" {
return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
}
@@ -95,7 +95,9 @@ func (t *I2CTool) detect() *ToolResult {
}
if len(matches) == 0 {
- return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)")
+ return SilentResult(
+ "No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)",
+ )
}
type busInfo struct {
@@ -122,7 +124,7 @@ func isValidBusID(id string) bool {
}
// parseI2CAddress extracts and validates an I2C address from args
-func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
+func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
@@ -135,7 +137,7 @@ func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
-func parseI2CBus(args map[string]interface{}) (string, *ToolResult) {
+func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go
index 294f7ecbc..4eaaf8f09 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/i2c_linux.go
@@ -74,7 +74,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool {
// scan probes valid 7-bit addresses on a bus for connected devices.
// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
-func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) scan(args map[string]any) *ToolResult {
bus, errResult := parseI2CBus(args)
if errResult != nil {
return errResult
@@ -99,7 +99,9 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
if !hasQuick && !hasReadByte {
- return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath))
+ return ErrorResult(
+ fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
+ )
}
type deviceEntry struct {
@@ -133,7 +135,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"bus": devPath,
"devices": found,
"count": len(found),
@@ -142,7 +144,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
}
// readDevice reads bytes from an I2C device, optionally at a specific register
-func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
bus, errResult := parseI2CBus(args)
if errResult != nil {
return errResult
@@ -180,7 +182,7 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
if reg < 0 || reg > 255 {
return ErrorResult("register must be between 0x00 and 0xFF")
}
- _, err := syscall.Write(fd, []byte{byte(reg)})
+ _, err = syscall.Write(fd, []byte{byte(reg)})
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
}
@@ -201,7 +203,7 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(buf[i])
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"bus": devPath,
"address": fmt.Sprintf("0x%02x", addr),
"bytes": intBytes,
@@ -212,10 +214,12 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
}
// writeDevice writes bytes to an I2C device, optionally at a specific register
-func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool)
if !confirm {
- return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.")
+ return ErrorResult(
+ "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
+ )
}
bus, errResult := parseI2CBus(args)
@@ -228,7 +232,7 @@ func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
return errResult
}
- dataRaw, ok := args["data"].([]interface{})
+ dataRaw, ok := args["data"].([]any)
if !ok || len(dataRaw) == 0 {
return ErrorResult("data is required for write (array of byte values 0-255)")
}
diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go
index d1d581348..7becf8339 100644
--- a/pkg/tools/i2c_other.go
+++ b/pkg/tools/i2c_other.go
@@ -3,16 +3,16 @@
package tools
// scan is a stub for non-Linux platforms.
-func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) scan(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
// readDevice is a stub for non-Linux platforms.
-func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
// writeDevice is a stub for non-Linux platforms.
-func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
index abedb1316..15ef4ff73 100644
--- a/pkg/tools/message.go
+++ b/pkg/tools/message.go
@@ -26,19 +26,19 @@ func (t *MessageTool) Description() string {
return "Send a message to user on a chat channel. Use this when you want to communicate something."
}
-func (t *MessageTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *MessageTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "content": map[string]interface{}{
+ "properties": map[string]any{
+ "content": map[string]any{
"type": "string",
"description": "The message content to send",
},
- "channel": map[string]interface{}{
+ "channel": map[string]any{
"type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)",
},
- "chat_id": map[string]interface{}{
+ "chat_id": map[string]any{
"type": "string",
"description": "Optional: target chat/user ID",
},
@@ -62,7 +62,7 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
t.sendCallback = callback
}
-func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
content, ok := args["content"].(string)
if !ok {
return &ToolResult{ForLLM: "content is required", IsError: true}
diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go
index 4bedbe79b..717c1117b 100644
--- a/pkg/tools/message_test.go
+++ b/pkg/tools/message_test.go
@@ -19,7 +19,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Hello, world!",
}
@@ -70,7 +70,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
"channel": "custom-channel",
"chat_id": "custom-chat-id",
@@ -104,7 +104,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -136,7 +136,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
tool.SetContext("test-channel", "test-chat-id")
ctx := context.Background()
- args := map[string]interface{}{} // content missing
+ args := map[string]any{} // content missing
result := tool.Execute(ctx, args)
@@ -158,7 +158,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -179,7 +179,7 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
// No SetSendCallback called
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -219,7 +219,7 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected type 'object'")
}
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Expected properties to be a map")
}
@@ -231,7 +231,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check content property
- contentProp, ok := props["content"].(map[string]interface{})
+ contentProp, ok := props["content"].(map[string]any)
if !ok {
t.Error("Expected 'content' property")
}
@@ -240,7 +240,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check channel property (optional)
- channelProp, ok := props["channel"].(map[string]interface{})
+ channelProp, ok := props["channel"].(map[string]any)
if !ok {
t.Error("Expected 'channel' property")
}
@@ -249,7 +249,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check chat_id property (optional)
- chatIDProp, ok := props["chat_id"].(map[string]interface{})
+ chatIDProp, ok := props["chat_id"].(map[string]any)
if !ok {
t.Error("Expected 'chat_id' property")
}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index c8cf92863..6ecb8ae7c 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -34,16 +34,22 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
return tool, ok
}
-func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) *ToolResult {
+func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
}
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
// If the tool implements AsyncTool and a non-nil callback is provided,
// the callback will be set on the tool before execution.
-func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string, asyncCallback AsyncCallback) *ToolResult {
+func (r *ToolRegistry) ExecuteWithContext(
+ ctx context.Context,
+ name string,
+ args map[string]any,
+ channel, chatID string,
+ asyncCallback AsyncCallback,
+) *ToolResult {
logger.InfoCF("tool", "Tool execution started",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"args": args,
})
@@ -51,7 +57,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
tool, ok := r.Get(name)
if !ok {
logger.ErrorCF("tool", "Tool not found",
- map[string]interface{}{
+ map[string]any{
"tool": name,
})
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
@@ -66,7 +72,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
asyncTool.SetCallback(asyncCallback)
logger.DebugCF("tool", "Async callback injected",
- map[string]interface{}{
+ map[string]any{
"tool": name,
})
}
@@ -78,20 +84,20 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
// Log based on result type
if result.IsError {
logger.ErrorCF("tool", "Tool execution failed",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
"error": result.ForLLM,
})
} else if result.Async {
logger.InfoCF("tool", "Tool started (async)",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
})
} else {
logger.InfoCF("tool", "Tool execution completed",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration_ms": duration.Milliseconds(),
"result_length": len(result.ForLLM),
@@ -101,11 +107,11 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
return result
}
-func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
+func (r *ToolRegistry) GetDefinitions() []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]map[string]interface{}, 0, len(r.tools))
+ definitions := make([]map[string]any, 0, len(r.tools))
for _, tool := range r.tools {
definitions = append(definitions, ToolToSchema(tool))
}
@@ -123,14 +129,14 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
- fn, ok := schema["function"].(map[string]interface{})
+ fn, ok := schema["function"].(map[string]any)
if !ok {
continue
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
- params, _ := fn["parameters"].(map[string]interface{})
+ params, _ := fn["parameters"].(map[string]any)
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
new file mode 100644
index 000000000..8ae13b20c
--- /dev/null
+++ b/pkg/tools/registry_test.go
@@ -0,0 +1,350 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// --- mock types ---
+
+type mockRegistryTool struct {
+ name string
+ desc string
+ params map[string]any
+ result *ToolResult
+}
+
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
+ return m.result
+}
+
+type mockCtxTool struct {
+ mockRegistryTool
+ channel string
+ chatID string
+}
+
+func (m *mockCtxTool) SetContext(channel, chatID string) {
+ m.channel = channel
+ m.chatID = chatID
+}
+
+type mockAsyncRegistryTool struct {
+ mockRegistryTool
+ cb AsyncCallback
+}
+
+func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
+ m.cb = cb
+}
+
+// --- helpers ---
+
+func newMockTool(name, desc string) *mockRegistryTool {
+ return &mockRegistryTool{
+ name: name,
+ desc: desc,
+ params: map[string]any{"type": "object"},
+ result: SilentResult("ok"),
+ }
+}
+
+// --- tests ---
+
+func TestNewToolRegistry(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected empty registry, got count %d", r.Count())
+ }
+ if len(r.List()) != 0 {
+ t.Errorf("expected empty list, got %v", r.List())
+ }
+}
+
+func TestToolRegistry_RegisterAndGet(t *testing.T) {
+ r := NewToolRegistry()
+ tool := newMockTool("echo", "echoes input")
+ r.Register(tool)
+
+ got, ok := r.Get("echo")
+ if !ok {
+ t.Fatal("expected to find registered tool")
+ }
+ if got.Name() != "echo" {
+ t.Errorf("expected name 'echo', got %q", got.Name())
+ }
+}
+
+func TestToolRegistry_Get_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ _, ok := r.Get("nonexistent")
+ if ok {
+ t.Error("expected ok=false for unregistered tool")
+ }
+}
+
+func TestToolRegistry_RegisterOverwrite(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("dup", "first"))
+ r.Register(newMockTool("dup", "second"))
+
+ if r.Count() != 1 {
+ t.Errorf("expected count 1 after overwrite, got %d", r.Count())
+ }
+ tool, _ := r.Get("dup")
+ if tool.Description() != "second" {
+ t.Errorf("expected overwritten description 'second', got %q", tool.Description())
+ }
+}
+
+func TestToolRegistry_Execute_Success(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(&mockRegistryTool{
+ name: "greet",
+ desc: "says hello",
+ params: map[string]any{},
+ result: SilentResult("hello"),
+ })
+
+ result := r.Execute(context.Background(), "greet", nil)
+ if result.IsError {
+ t.Errorf("expected success, got error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "hello" {
+ t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
+ }
+}
+
+func TestToolRegistry_Execute_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ result := r.Execute(context.Background(), "missing", nil)
+ if !result.IsError {
+ t.Error("expected error for missing tool")
+ }
+ if !strings.Contains(result.ForLLM, "not found") {
+ t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
+ }
+ if result.Err == nil {
+ t.Error("expected Err to be set via WithError")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.channel != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", ct.channel)
+ }
+ if ct.chatID != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
+
+ if ct.channel != "" || ct.chatID != "" {
+ t.Error("SetContext should not be called with empty channel/chatID")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
+ r := NewToolRegistry()
+ at := &mockAsyncRegistryTool{
+ mockRegistryTool: *newMockTool("async_tool", "async work"),
+ }
+ at.result = AsyncResult("started")
+ r.Register(at)
+
+ called := false
+ cb := func(_ context.Context, _ *ToolResult) { called = true }
+
+ result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
+ if at.cb == nil {
+ t.Error("expected SetCallback to have been called")
+ }
+ if !result.Async {
+ t.Error("expected async result")
+ }
+
+ at.cb(context.Background(), SilentResult("done"))
+ if !called {
+ t.Error("expected callback to be invoked")
+ }
+}
+
+func TestToolRegistry_GetDefinitions(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("alpha", "tool A"))
+
+ defs := r.GetDefinitions()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 definition, got %d", len(defs))
+ }
+ if defs[0]["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", defs[0]["type"])
+ }
+ fn, ok := defs[0]["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' key to be a map")
+ }
+ if fn["name"] != "alpha" {
+ t.Errorf("expected name 'alpha', got %v", fn["name"])
+ }
+ if fn["description"] != "tool A" {
+ t.Errorf("expected description 'tool A', got %v", fn["description"])
+ }
+}
+
+func TestToolRegistry_ToProviderDefs(t *testing.T) {
+ r := NewToolRegistry()
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
+ r.Register(&mockRegistryTool{
+ name: "beta",
+ desc: "tool B",
+ params: params,
+ result: SilentResult("ok"),
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 provider def, got %d", len(defs))
+ }
+
+ want := providers.ToolDefinition{
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "beta",
+ Description: "tool B",
+ Parameters: params,
+ },
+ }
+ got := defs[0]
+ if got.Type != want.Type {
+ t.Errorf("Type: want %q, got %q", want.Type, got.Type)
+ }
+ if got.Function.Name != want.Function.Name {
+ t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
+ }
+ if got.Function.Description != want.Function.Description {
+ t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
+ }
+}
+
+func TestToolRegistry_List(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("x", ""))
+ r.Register(newMockTool("y", ""))
+
+ names := r.List()
+ if len(names) != 2 {
+ t.Fatalf("expected 2 names, got %d", len(names))
+ }
+
+ nameSet := map[string]bool{}
+ for _, n := range names {
+ nameSet[n] = true
+ }
+ if !nameSet["x"] || !nameSet["y"] {
+ t.Errorf("expected names {x, y}, got %v", names)
+ }
+}
+
+func TestToolRegistry_Count(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected 0, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", ""))
+ r.Register(newMockTool("b", ""))
+ if r.Count() != 2 {
+ t.Errorf("expected 2, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", "replaced"))
+ if r.Count() != 2 {
+ t.Errorf("expected 2 after overwrite, got %d", r.Count())
+ }
+}
+
+func TestToolRegistry_GetSummaries(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("read_file", "Reads a file"))
+
+ summaries := r.GetSummaries()
+ if len(summaries) != 1 {
+ t.Fatalf("expected 1 summary, got %d", len(summaries))
+ }
+ if !strings.Contains(summaries[0], "`read_file`") {
+ t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
+ }
+ if !strings.Contains(summaries[0], "Reads a file") {
+ t.Errorf("expected description in summary, got %q", summaries[0])
+ }
+}
+
+func TestToolToSchema(t *testing.T) {
+ tool := newMockTool("demo", "demo tool")
+ schema := ToolToSchema(tool)
+
+ if schema["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", schema["type"])
+ }
+ fn, ok := schema["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' to be a map")
+ }
+ if fn["name"] != "demo" {
+ t.Errorf("expected name 'demo', got %v", fn["name"])
+ }
+ if fn["description"] != "demo tool" {
+ t.Errorf("expected description 'demo tool', got %v", fn["description"])
+ }
+ if fn["parameters"] == nil {
+ t.Error("expected parameters to be set")
+ }
+}
+
+func TestToolRegistry_ConcurrentAccess(t *testing.T) {
+ r := NewToolRegistry()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ name := string(rune('A' + n%26))
+ r.Register(newMockTool(name, "concurrent"))
+ r.Get(name)
+ r.Count()
+ r.List()
+ r.GetDefinitions()
+ }(i)
+ }
+
+ wg.Wait()
+
+ if r.Count() == 0 {
+ t.Error("expected tools to be registered after concurrent access")
+ }
+}
diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go
index bc798cd70..a234e33f3 100644
--- a/pkg/tools/result_test.go
+++ b/pkg/tools/result_test.go
@@ -192,7 +192,7 @@ func TestToolResultJSONStructure(t *testing.T) {
}
// Verify JSON structure
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 297ce0b88..a1ee0b6e1 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -119,15 +119,15 @@ func (t *ExecTool) Description() string {
return "Execute a shell command and return its output. Use with caution."
}
-func (t *ExecTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ExecTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "command": map[string]interface{}{
+ "properties": map[string]any{
+ "command": map[string]any{
"type": "string",
"description": "The shell command to execute",
},
- "working_dir": map[string]interface{}{
+ "working_dir": map[string]any{
"type": "string",
"description": "Optional working directory for the command",
},
@@ -136,7 +136,7 @@ func (t *ExecTool) Parameters() map[string]interface{} {
}
}
-func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
command, ok := args["command"].(string)
if !ok {
return ErrorResult("command is required")
@@ -144,7 +144,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
- cwd = wd
+ if t.restrictToWorkspace && t.workingDir != "" {
+ resolvedWD, err := validatePath(wd, t.workingDir, true)
+ if err != nil {
+ return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
+ }
+ cwd = resolvedWD
+ } else {
+ cwd = wd
+ }
}
if cwd == "" {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index c06468a39..6d35815e8 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -14,7 +14,7 @@ func TestShellTool_Success(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "echo 'hello world'",
}
@@ -41,7 +41,7 @@ func TestShellTool_Failure(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "ls /nonexistent_directory_12345",
}
@@ -69,7 +69,7 @@ func TestShellTool_Timeout(t *testing.T) {
tool.SetTimeout(100 * time.Millisecond)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "sleep 10",
}
@@ -91,12 +91,12 @@ func TestShellTool_WorkingDir(t *testing.T) {
// Create temp directory
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test content"), 0644)
+ os.WriteFile(testFile, []byte("test content"), 0o644)
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "cat test.txt",
"working_dir": tmpDir,
}
@@ -117,7 +117,7 @@ func TestShellTool_DangerousCommand(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "rm -rf /",
}
@@ -138,7 +138,7 @@ func TestShellTool_MissingCommand(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -153,7 +153,7 @@ func TestShellTool_StderrCapture(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "sh -c 'echo stdout; echo stderr >&2'",
}
@@ -174,7 +174,7 @@ func TestShellTool_OutputTruncation(t *testing.T) {
ctx := context.Background()
// Generate long output (>10000 chars)
- args := map[string]interface{}{
+ args := map[string]any{
"command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000),
}
@@ -186,6 +186,66 @@ func TestShellTool_OutputTruncation(t *testing.T) {
}
}
+// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly
+func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ outsideDir := filepath.Join(root, "outside")
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(outsideDir, 0o755); err != nil {
+ t.Fatalf("failed to create outside dir: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]any{
+ "command": "pwd",
+ "working_dir": outsideDir,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
+// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace
+// pointing outside cannot be used as working_dir to escape the sandbox.
+func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ secretDir := filepath.Join(root, "secret")
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(secretDir, 0o755); err != nil {
+ t.Fatalf("failed to create secret dir: %v", err)
+ }
+ os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644)
+
+ // symlink lives inside the workspace but resolves to secretDir outside it
+ link := filepath.Join(workspace, "escape")
+ if err := os.Symlink(secretDir, link); err != nil {
+ t.Skipf("symlinks not supported in this environment: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]any{
+ "command": "cat secret.txt",
+ "working_dir": link,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
@@ -193,7 +253,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
tool.SetRestrictToWorkspace(true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "cat ../../etc/passwd",
}
@@ -205,6 +265,10 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
}
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") {
- t.Errorf("Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ t.Errorf(
+ "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index 4c6388b9b..04ef8e441 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -25,7 +25,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
tool := NewExecTool(t.TempDir(), false)
tool.SetTimeout(500 * time.Millisecond)
- args := map[string]interface{}{
+ args := map[string]any{
// Spawn a child process that would outlive the shell unless process-group kill is used.
"command": "sleep 60 & echo $! > child.pid; wait",
}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
index 6b05918ce..55c0b678d 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/skills_install.go
@@ -42,23 +42,23 @@ func (t *InstallSkillTool) Description() string {
return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
-func (t *InstallSkillTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *InstallSkillTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "slug": map[string]interface{}{
+ "properties": map[string]any{
+ "slug": map[string]any{
"type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
},
- "version": map[string]interface{}{
+ "version": map[string]any{
"type": "string",
"description": "Specific version to install (optional, defaults to latest)",
},
- "registry": map[string]interface{}{
+ "registry": map[string]any{
"type": "string",
"description": "Registry to install from (required, e.g., 'clawhub')",
},
- "force": map[string]interface{}{
+ "force": map[string]any{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
@@ -67,7 +67,7 @@ func (t *InstallSkillTool) Parameters() map[string]interface{} {
}
}
-func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
// Install lock to prevent concurrent directory operations.
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
t.mu.Lock()
@@ -94,7 +94,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
if !force {
if _, err := os.Stat(targetDir); err == nil {
- return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir))
+ return ErrorResult(
+ fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
+ )
}
} else {
// Force: remove existing if present.
@@ -108,7 +110,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
}
// Ensure skills directory exists.
- if err := os.MkdirAll(skillsDir, 0755); err != nil {
+ if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
}
@@ -119,7 +121,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
@@ -133,7 +135,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
@@ -145,7 +147,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
// Write origin metadata.
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"error": err.Error(),
"target": targetDir,
@@ -195,5 +197,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err
}
- return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0644)
+ return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
}
diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go
index e6941a950..676fcecc0 100644
--- a/pkg/tools/skills_install_test.go
+++ b/pkg/tools/skills_install_test.go
@@ -6,9 +6,10 @@ import (
"path/filepath"
"testing"
- "github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
)
func TestInstallSkillToolName(t *testing.T) {
@@ -18,14 +19,14 @@ func TestInstallSkillToolName(t *testing.T) {
func TestInstallSkillToolMissingSlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{})
+ result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
}
func TestInstallSkillToolEmptySlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": " ",
})
assert.True(t, result.IsError)
@@ -42,7 +43,7 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
}
for _, slug := range cases {
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": slug,
})
assert.True(t, result.IsError, "slug %q should be rejected", slug)
@@ -53,10 +54,10 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
func TestInstallSkillToolAlreadyExists(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "existing-skill")
- require.NoError(t, os.MkdirAll(skillDir, 0755))
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "existing-skill",
"registry": "clawhub",
})
@@ -67,7 +68,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) {
func TestInstallSkillToolRegistryNotFound(t *testing.T) {
workspace := t.TempDir()
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill",
"registry": "nonexistent",
})
@@ -80,7 +81,7 @@ func TestInstallSkillToolParameters(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
params := tool.Parameters()
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
assert.True(t, ok)
assert.Contains(t, props, "slug")
assert.Contains(t, props, "version")
@@ -95,7 +96,7 @@ func TestInstallSkillToolParameters(t *testing.T) {
func TestInstallSkillToolMissingRegistry(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill",
})
assert.True(t, result.IsError)
diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go
index b12949ec2..2b6cffd38 100644
--- a/pkg/tools/skills_search.go
+++ b/pkg/tools/skills_search.go
@@ -32,15 +32,15 @@ func (t *FindSkillsTool) Description() string {
return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill."
}
-func (t *FindSkillsTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *FindSkillsTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{
+ "properties": map[string]any{
+ "query": map[string]any{
"type": "string",
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
},
- "limit": map[string]interface{}{
+ "limit": map[string]any{
"type": "integer",
"description": "Maximum number of results to return (1-20, default 5)",
"minimum": 1.0,
@@ -51,7 +51,7 @@ func (t *FindSkillsTool) Parameters() map[string]interface{} {
}
}
-func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
query = strings.ToLower(strings.TrimSpace(query))
if !ok || query == "" {
diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go
index 7e07b2775..0e5387cf5 100644
--- a/pkg/tools/skills_search_test.go
+++ b/pkg/tools/skills_search_test.go
@@ -4,8 +4,9 @@ import (
"context"
"testing"
- "github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
)
func TestFindSkillsToolName(t *testing.T) {
@@ -15,14 +16,14 @@ func TestFindSkillsToolName(t *testing.T) {
func TestFindSkillsToolMissingQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
- result := tool.Execute(context.Background(), map[string]interface{}{})
+ result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "query is required")
}
func TestFindSkillsToolEmptyQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"query": " ",
})
assert.True(t, result.IsError)
@@ -35,7 +36,7 @@ func TestFindSkillsToolCacheHit(t *testing.T) {
})
tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"query": "github",
})
@@ -48,7 +49,7 @@ func TestFindSkillsToolParameters(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
params := tool.Parameters()
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
assert.True(t, ok)
assert.Contains(t, props, "query")
assert.Contains(t, props, "limit")
@@ -71,7 +72,14 @@ func TestFormatSearchResultsEmpty(t *testing.T) {
func TestFormatSearchResultsWithData(t *testing.T) {
results := []skills.SearchResult{
- {Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"},
+ {
+ Slug: "github",
+ Score: 0.95,
+ DisplayName: "GitHub",
+ Summary: "GitHub API integration",
+ Version: "1.0.0",
+ RegistryName: "clawhub",
+ },
}
output := formatSearchResults("github", results, false)
assert.Contains(t, output, "github")
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index f01372467..73d385cb0 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -34,19 +34,19 @@ func (t *SpawnTool) Description() string {
return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done."
}
-func (t *SpawnTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SpawnTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "task": map[string]interface{}{
+ "properties": map[string]any{
+ "task": map[string]any{
"type": "string",
"description": "The task for subagent to complete",
},
- "label": map[string]interface{}{
+ "label": map[string]any{
"type": "string",
"description": "Optional short label for the task (for display)",
},
- "agent_id": map[string]interface{}{
+ "agent_id": map[string]any{
"type": "string",
"description": "Optional target agent ID to delegate the task to",
},
@@ -64,7 +64,7 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
t.allowlistCheck = check
}
-func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
if !ok {
return ErrorResult("task is required")
diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go
index 4805d6a35..d6a88a5b0 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/spi.go
@@ -24,41 +24,41 @@ func (t *SPITool) Description() string {
return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only."
}
-func (t *SPITool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SPITool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"list", "transfer", "read"},
"description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
},
- "device": map[string]interface{}{
+ "device": map[string]any{
"type": "string",
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
},
- "speed": map[string]interface{}{
+ "speed": map[string]any{
"type": "integer",
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
},
- "mode": map[string]interface{}{
+ "mode": map[string]any{
"type": "integer",
"description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.",
},
- "bits": map[string]interface{}{
+ "bits": map[string]any{
"type": "integer",
"description": "Bits per word. Default: 8.",
},
- "data": map[string]interface{}{
+ "data": map[string]any{
"type": "array",
- "items": map[string]interface{}{"type": "integer"},
+ "items": map[string]any{"type": "integer"},
"description": "Bytes to send (0-255 each). Required for transfer action.",
},
- "length": map[string]interface{}{
+ "length": map[string]any{
"type": "integer",
"description": "Number of bytes to read (1-4096). Required for read action.",
},
- "confirm": map[string]interface{}{
+ "confirm": map[string]any{
"type": "boolean",
"description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
},
@@ -67,7 +67,7 @@ func (t *SPITool) Parameters() map[string]interface{} {
}
}
-func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" {
return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.")
}
@@ -97,7 +97,9 @@ func (t *SPITool) list() *ToolResult {
}
if len(matches) == 0 {
- return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded")
+ return SilentResult(
+ "No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded",
+ )
}
type devInfo struct {
@@ -118,7 +120,7 @@ func (t *SPITool) list() *ToolResult {
}
// parseSPIArgs extracts and validates common SPI parameters
-func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
+func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)"
diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go
index 12b696007..9def73662 100644
--- a/pkg/tools/spi_linux.go
+++ b/pkg/tools/spi_linux.go
@@ -66,10 +66,12 @@ func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *T
}
// transfer performs a full-duplex SPI transfer
-func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+func (t *SPITool) transfer(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool)
if !confirm {
- return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.")
+ return ErrorResult(
+ "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
+ )
}
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
@@ -77,7 +79,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
return ErrorResult(errMsg)
}
- dataRaw, ok := args["data"].([]interface{})
+ dataRaw, ok := args["data"].([]any)
if !ok || len(dataRaw) == 0 {
return ErrorResult("data is required for transfer (array of byte values 0-255)")
}
@@ -130,7 +132,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
intBytes[i] = int(b)
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"device": devPath,
"sent": len(txBuf),
"received": intBytes,
@@ -140,7 +142,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
}
// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
-func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *SPITool) readDevice(args map[string]any) *ToolResult {
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
if errMsg != "" {
return ErrorResult(errMsg)
@@ -186,7 +188,7 @@ func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(b)
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"device": devPath,
"bytes": intBytes,
"hex": hexBytes,
diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go
index 6dfc86fd1..5d078ac3f 100644
--- a/pkg/tools/spi_other.go
+++ b/pkg/tools/spi_other.go
@@ -3,11 +3,11 @@
package tools
// transfer is a stub for non-Linux platforms.
-func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+func (t *SPITool) transfer(args map[string]any) *ToolResult {
return ErrorResult("SPI is only supported on Linux")
}
// readDevice is a stub for non-Linux platforms.
-func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *SPITool) readDevice(args map[string]any) *ToolResult {
return ErrorResult("SPI is only supported on Linux")
}
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 294ba6ea8..91ebff636 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -38,7 +38,11 @@ type SubagentManager struct {
nextID int
}
-func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
+func NewSubagentManager(
+ provider providers.LLMProvider,
+ defaultModel, workspace string,
+ bus *bus.MessageBus,
+) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
@@ -76,7 +80,11 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
sm.tools.Register(tool)
}
-func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) {
+func (sm *SubagentManager) Spawn(
+ ctx context.Context,
+ task, label, agentID, originChannel, originChatID string,
+ callback AsyncCallback,
+) (string, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -194,7 +202,12 @@ After completing the task, provide a clear summary of what was done.`
task.Status = "completed"
task.Result = loopResult.Content
result = &ToolResult{
- ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
+ ForLLM: fmt.Sprintf(
+ "Subagent '%s' completed (iterations: %d): %s",
+ task.Label,
+ loopResult.Iterations,
+ loopResult.Content,
+ ),
ForUser: loopResult.Content,
Silent: false,
IsError: false,
@@ -258,15 +271,15 @@ func (t *SubagentTool) Description() string {
return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM."
}
-func (t *SubagentTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SubagentTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "task": map[string]interface{}{
+ "properties": map[string]any{
+ "task": map[string]any{
"type": "string",
"description": "The task for subagent to complete",
},
- "label": map[string]interface{}{
+ "label": map[string]any{
"type": "string",
"description": "Optional short label for the task (for display)",
},
@@ -280,7 +293,7 @@ func (t *SubagentTool) SetContext(channel, chatID string) {
t.originChatID = chatID
}
-func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
if !ok {
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go
index f960a7fda..59bfdffae 100644
--- a/pkg/tools/subagent_tool_test.go
+++ b/pkg/tools/subagent_tool_test.go
@@ -11,10 +11,16 @@ import (
// MockLLMProvider is a test implementation of LLMProvider
type MockLLMProvider struct {
- lastOptions map[string]interface{}
+ lastOptions map[string]any
}
-func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *MockLLMProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+) (*providers.LLMResponse, error) {
m.lastOptions = options
// Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- {
@@ -47,7 +53,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
tool.SetContext("cli", "direct")
ctx := context.Background()
- args := map[string]interface{}{"task": "Do something"}
+ args := map[string]any{"task": "Do something"}
result := tool.Execute(ctx, args)
if result == nil || result.IsError {
@@ -108,13 +114,13 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Check properties
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Properties should be a map")
}
// Verify task parameter
- task, ok := props["task"].(map[string]interface{})
+ task, ok := props["task"].(map[string]any)
if !ok {
t.Fatal("Task parameter should exist")
}
@@ -123,7 +129,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Verify label parameter
- label, ok := props["label"].(map[string]interface{})
+ label, ok := props["label"].(map[string]any)
if !ok {
t.Fatal("Label parameter should exist")
}
@@ -163,7 +169,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
tool.SetContext("telegram", "chat-123")
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Write a haiku about coding",
"label": "haiku-task",
}
@@ -218,7 +224,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test task without label",
}
@@ -241,7 +247,7 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"label": "test",
}
@@ -268,7 +274,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
tool := NewSubagentTool(nil)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "test task",
}
@@ -297,7 +303,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
tool.SetContext(channel, chatID)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test context passing",
}
@@ -324,7 +330,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a task that will generate long response
longTask := strings.Repeat("This is a very long task description. ", 100)
- args := map[string]interface{}{
+ args := map[string]any{
"task": longTask,
"label": "long-test",
}
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 08f14cc92..cdfe0d6ce 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -33,7 +33,12 @@ type ToolLoopResult struct {
// RunToolLoop executes the LLM + tool call iteration loop.
// This is the core agent logic that can be reused by both main agent and subagents.
-func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []providers.Message, channel, chatID string) (*ToolLoopResult, error) {
+func RunToolLoop(
+ ctx context.Context,
+ config ToolLoopConfig,
+ messages []providers.Message,
+ channel, chatID string,
+) (*ToolLoopResult, error) {
iteration := 0
var finalContent string
diff --git a/pkg/tools/types.go b/pkg/tools/types.go
index f8205b8bd..a6015cde3 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/types.go
@@ -10,11 +10,11 @@ type Message struct {
}
type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]any `json:"arguments,omitempty"`
}
type FunctionCall struct {
@@ -36,7 +36,13 @@ type UsageInfo struct {
}
type LLMProvider interface {
- Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
+ Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*LLMResponse, error)
GetDefaultModel() string
}
@@ -46,7 +52,7 @@ type ToolDefinition struct {
}
type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
}
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 1f5c58ea5..452e95e0f 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -1,6 +1,7 @@
package tools
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -84,6 +85,88 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
return strings.Join(lines, "\n"), nil
}
+type TavilySearchProvider struct {
+ apiKey string
+ baseURL string
+}
+
+func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := p.baseURL
+ if searchURL == "" {
+ searchURL = "https://api.tavily.com/search"
+ }
+
+ payload := map[string]any{
+ "api_key": p.apiKey,
+ "query": query,
+ "search_depth": "advanced",
+ "include_answer": false,
+ "include_images": false,
+ "include_raw_content": false,
+ "max_results": count,
+ }
+
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var searchResp struct {
+ Results []struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Content string `json:"content"`
+ } `json:"results"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ results := searchResp.Results
+ if len(results) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query))
+ for i, item := range results {
+ if i >= count {
+ break
+ }
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Content != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Content))
+ }
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
type DuckDuckGoSearchProvider struct{}
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
@@ -183,11 +266,17 @@ type PerplexitySearchProvider struct {
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := "https://api.perplexity.ai/chat/completions"
- payload := map[string]interface{}{
+ payload := map[string]any{
"model": "sonar",
"messages": []map[string]string{
- {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."},
- {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)},
+ {
+ "role": "system",
+ "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
+ },
+ {
+ "role": "user",
+ "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
+ },
},
"max_tokens": 1000,
}
@@ -250,6 +339,10 @@ type WebSearchToolOptions struct {
BraveAPIKey string
BraveMaxResults int
BraveEnabled bool
+ TavilyAPIKey string
+ TavilyBaseURL string
+ TavilyMaxResults int
+ TavilyEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
PerplexityAPIKey string
@@ -261,7 +354,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
- // Priority: Perplexity > Brave > DuckDuckGo
+ // Priority: Perplexity > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
if opts.PerplexityMaxResults > 0 {
@@ -272,6 +365,14 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
}
+ } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
+ provider = &TavilySearchProvider{
+ apiKey: opts.TavilyAPIKey,
+ baseURL: opts.TavilyBaseURL,
+ }
+ if opts.TavilyMaxResults > 0 {
+ maxResults = opts.TavilyMaxResults
+ }
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
@@ -295,15 +396,15 @@ func (t *WebSearchTool) Description() string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
}
-func (t *WebSearchTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WebSearchTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{
+ "properties": map[string]any{
+ "query": map[string]any{
"type": "string",
"description": "Search query",
},
- "count": map[string]interface{}{
+ "count": map[string]any{
"type": "integer",
"description": "Number of results (1-10)",
"minimum": 1.0,
@@ -314,7 +415,7 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
}
}
-func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")
@@ -359,15 +460,15 @@ func (t *WebFetchTool) Description() string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
}
-func (t *WebFetchTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WebFetchTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "url": map[string]interface{}{
+ "properties": map[string]any{
+ "url": map[string]any{
"type": "string",
"description": "URL to fetch",
},
- "maxChars": map[string]interface{}{
+ "maxChars": map[string]any{
"type": "integer",
"description": "Maximum characters to extract",
"minimum": 100.0,
@@ -377,7 +478,7 @@ func (t *WebFetchTool) Parameters() map[string]interface{} {
}
}
-func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
urlStr, ok := args["url"].(string)
if !ok {
return ErrorResult("url is required")
@@ -442,7 +543,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
var text, extractor string
if strings.Contains(contentType, "application/json") {
- var jsonData interface{}
+ var jsonData any
if err := json.Unmarshal(body, &jsonData); err == nil {
formatted, _ := json.MarshalIndent(jsonData, "", " ")
text = string(formatted)
@@ -465,7 +566,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
text = text[:maxChars]
}
- result := map[string]interface{}{
+ result := map[string]any{
"url": urlStr,
"status": resp.StatusCode,
"extractor": extractor,
@@ -477,7 +578,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
resultJSON, _ := json.MarshalIndent(result, "", " ")
return &ToolResult{
- ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated),
+ ForLLM: fmt.Sprintf(
+ "Fetched %d bytes from %s (extractor: %s, truncated: %v)",
+ len(text),
+ urlStr,
+ extractor,
+ truncated,
+ ),
ForUser: string(resultJSON),
}
}
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 7e6d62213..75e0d8d16 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -20,7 +20,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -56,7 +56,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -77,7 +77,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": "not-a-valid-url",
}
@@ -98,7 +98,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": "ftp://example.com/file.txt",
}
@@ -119,7 +119,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
func TestWebTool_WebFetch_MissingURL(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -147,7 +147,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
tool := NewWebFetchTool(1000) // Limit to 1000 chars
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -159,7 +159,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
// ForUser should contain truncated content (not the full 20000 chars)
- resultMap := make(map[string]interface{})
+ resultMap := make(map[string]any)
json.Unmarshal([]byte(result.ForUser), &resultMap)
if text, ok := resultMap["text"].(string); ok {
if len(text) > 1100 { // Allow some margin
@@ -191,7 +191,7 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -206,13 +206,17 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
- w.Write([]byte(`Content
`)) + w.Write( + []byte( + `Content
`, + ), + ) })) defer server.Close() tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": server.URL, } @@ -251,7 +255,8 @@ func TestWebFetchTool_extractText(t *testing.T) { if len(lines) < 2 { t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) } - if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") { + if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || + !strings.Contains(got, "Paragraph 2") { t.Errorf("Missing expected text: %q", got) } }, @@ -312,7 +317,7 @@ func TestWebFetchTool_extractText(t *testing.T) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool := NewWebFetchTool(50000) ctx := context.Background() - args := map[string]interface{}{ + args := map[string]any{ "url": "https://", } @@ -328,3 +333,75 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) } } + +// TestWebTool_TavilySearch_Success verifies successful Tavily search +func TestWebTool_TavilySearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + // Verify payload + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["api_key"] != "test-key" { + t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) + } + if payload["query"] != "test query" { + t.Errorf("Expected query 'test query', got %v", payload["query"]) + } + + // Return mock response + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Test Result 1", + "url": "https://example.com/1", + "content": "Content for result 1", + }, + { + "title": "Test Result 2", + "url": "https://example.com/2", + "content": "Content for result 2", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKey: "test-key", + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain result titles and URLs + if !strings.Contains(result.ForUser, "Test Result 1") || + !strings.Contains(result.ForUser, "https://example.com/1") { + t.Errorf("Expected results in output, got: %s", result.ForUser) + } + + // Should mention via Tavily + if !strings.Contains(result.ForUser, "via Tavily") { + t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) + } +} diff --git a/pkg/utils/download.go b/pkg/utils/download.go index 9fa7fbfa7..5d9a13a30 100644 --- a/pkg/utils/download.go +++ b/pkg/utils/download.go @@ -27,7 +27,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, // Attach context. req = req.WithContext(ctx) - logger.DebugCF("download", "Starting download", map[string]interface{}{ + logger.DebugCF("download", "Starting download", map[string]any{ "url": req.URL.String(), "max_bytes": maxBytes, }) @@ -52,7 +52,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, } tmpPath := tmpFile.Name() - logger.DebugCF("download", "Streaming to temp file", map[string]interface{}{ + logger.DebugCF("download", "Streaming to temp file", map[string]any{ "path": tmpPath, }) @@ -84,7 +84,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, return "", fmt.Errorf("failed to close temp file: %w", err) } - logger.DebugCF("download", "Download complete", map[string]interface{}{ + logger.DebugCF("download", "Download complete", map[string]any{ "path": tmpPath, "bytes_written": written, }) diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 2b184f2ec..a34889fb8 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/logger" ) @@ -65,8 +66,8 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { } mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") - if err := os.MkdirAll(mediaDir, 0700); err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]interface{}{ + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), }) return "" @@ -79,7 +80,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { // Create HTTP request req, err := http.NewRequest("GET", url, nil) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{ "error": err.Error(), }) return "" @@ -93,7 +94,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { client := &http.Client{Timeout: opts.Timeout} resp, err := client.Do(req) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{ "error": err.Error(), "url": url, }) @@ -102,7 +103,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{ "status": resp.StatusCode, "url": url, }) @@ -111,7 +112,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { out, err := os.Create(localPath) if err != nil { - logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]any{ "error": err.Error(), }) return "" @@ -121,13 +122,13 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { if _, err := io.Copy(out, resp.Body); err != nil { out.Close() os.Remove(localPath) - logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]interface{}{ + logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{ "error": err.Error(), }) return "" } - logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]interface{}{ + logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{ "path": localPath, }) diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 7a6aa37cc..62d9beee0 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -4,6 +4,9 @@ package utils // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } runes := []rune(s) if len(runes) <= maxLen { return s diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go new file mode 100644 index 000000000..a44ead228 --- /dev/null +++ b/pkg/utils/string_test.go @@ -0,0 +1,106 @@ +package utils + +import "testing" + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + { + name: "short string unchanged", + input: "hi", + maxLen: 10, + want: "hi", + }, + { + name: "exact length unchanged", + input: "hello", + maxLen: 5, + want: "hello", + }, + { + name: "long string truncated with ellipsis", + input: "hello world", + maxLen: 8, + want: "hello...", + }, + { + name: "maxLen equals 4 leaves 1 char plus ellipsis", + input: "abcdef", + maxLen: 4, + want: "a...", + }, + { + name: "maxLen 3 returns first 3 chars without ellipsis", + input: "abcdef", + maxLen: 3, + want: "abc", + }, + { + name: "maxLen 2 returns first 2 chars", + input: "abcdef", + maxLen: 2, + want: "ab", + }, + { + name: "maxLen 1 returns first char", + input: "abcdef", + maxLen: 1, + want: "a", + }, + { + name: "maxLen 0 returns empty", + input: "hello", + maxLen: 0, + want: "", + }, + { + name: "negative maxLen returns empty", + input: "hello", + maxLen: -1, + want: "", + }, + { + name: "empty string unchanged", + input: "", + maxLen: 5, + want: "", + }, + { + name: "empty string with zero maxLen", + input: "", + maxLen: 0, + want: "", + }, + { + name: "unicode truncated correctly", + input: "\U0001f600\U0001f601\U0001f602\U0001f603\U0001f604", + maxLen: 4, + want: "\U0001f600...", + }, + { + name: "unicode short enough", + input: "\u00e9\u00e8", + maxLen: 5, + want: "\u00e9\u00e8", + }, + { + name: "mixed ascii and unicode", + input: "Go\U0001f680\U0001f525\U0001f4a5\U0001f30d", + maxLen: 5, + want: "Go...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} diff --git a/pkg/utils/zip.go b/pkg/utils/zip.go index cad91e420..919ce5a20 100644 --- a/pkg/utils/zip.go +++ b/pkg/utils/zip.go @@ -22,13 +22,13 @@ func ExtractZipFile(zipPath string, targetDir string) error { } defer reader.Close() - logger.DebugCF("zip", "Extracting ZIP", map[string]interface{}{ + logger.DebugCF("zip", "Extracting ZIP", map[string]any{ "zip_path": zipPath, "target_dir": targetDir, "entries": len(reader.File), }) - if err := os.MkdirAll(targetDir, 0755); err != nil { + if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("failed to create target dir: %w", err) } @@ -43,7 +43,8 @@ func ExtractZipFile(zipPath string, targetDir string) error { // Double-check the resolved path is within target directory (defense-in-depth). targetDirClean := filepath.Clean(targetDir) - if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && filepath.Clean(destPath) != targetDirClean { + if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && + filepath.Clean(destPath) != targetDirClean { return fmt.Errorf("zip entry escapes target dir: %q", f.Name) } @@ -55,14 +56,14 @@ func ExtractZipFile(zipPath string, targetDir string) error { } if f.FileInfo().IsDir() { - if err := os.MkdirAll(destPath, 0755); err != nil { + if err := os.MkdirAll(destPath, 0o755); err != nil { return err } continue } // Ensure parent directory exists. - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return err } @@ -98,7 +99,7 @@ func extractSingleFile(f *zip.File, destPath string) error { defer func() { if cerr := outFile.Close(); cerr != nil { _ = os.Remove(destPath) - logger.ErrorCF("zip", "Failed to close file", map[string]interface{}{ + logger.ErrorCF("zip", "Failed to close file", map[string]any{ "dest_path": destPath, "error": cerr.Error(), }) diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 9af2ea6bb..f973e77fe 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -29,7 +29,7 @@ type TranscriptionResponse struct { } func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""}) + logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) apiBase := "https://api.groq.com/openai/v1" return &GroqTranscriber{ @@ -42,22 +42,22 @@ func NewGroqTranscriber(apiKey string) *GroqTranscriber { } func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath}) + logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to open audio file: %w", err) } defer audioFile.Close() fileInfo, err := audioFile.Stat() if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err}) + logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to get file info: %w", err) } - logger.DebugCF("voice", "Audio file details", map[string]interface{}{ + logger.DebugCF("voice", "Audio file details", map[string]any{ "size_bytes": fileInfo.Size(), "file_name": filepath.Base(audioFilePath), }) @@ -67,44 +67,44 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create form file: %w", err) } copied, err := io.Copy(part, audioFile) if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) return nil, fmt.Errorf("failed to copy file content: %w", err) } - logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied}) + logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - if err := writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err}) + if err = writer.WriteField("model", "whisper-large-v3"); err != nil { + logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } - if err := writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err}) + if err = writer.WriteField("response_format", "json"); err != nil { + logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } - if err := writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err}) + if err = writer.Close(); err != nil { + logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } url := t.apiBase + "/audio/transcriptions" req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+t.apiKey) - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{ + logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ "url": url, "request_size_bytes": requestBody.Len(), "file_size_bytes": fileInfo.Size(), @@ -112,37 +112,37 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) resp, err := t.httpClient.Do(req) if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]interface{}{ + logger.ErrorCF("voice", "API error", map[string]any{ "status_code": resp.StatusCode, "response": string(body), }) return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } - logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{ + logger.DebugCF("voice", "Received response from Groq API", map[string]any{ "status_code": resp.StatusCode, "response_size_bytes": len(body), }) var result TranscriptionResponse if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err}) + logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{ + logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ "text_length": len(result.Text), "language": result.Language, "duration_seconds": result.Duration, @@ -154,6 +154,6 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) func (t *GroqTranscriber) IsAvailable() bool { available := t.apiKey != "" - logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available}) + logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available}) return available }