Merge remote-tracking branch 'upstream/main' into merge-upstream
# Conflicts: # pkg/providers/factory_provider.go # pkg/providers/openai_compat/provider.go # pkg/tools/edit.go # pkg/tools/edit_test.go # pkg/tools/filesystem.go # pkg/tools/filesystem_test.go # pkg/tools/registry_test.go # pkg/utils/string_test.go
This commit is contained in:
commit
2c00e4537f
28 changed files with 1047 additions and 534 deletions
7
Makefile
7
Makefile
|
|
@ -24,6 +24,7 @@ GOLANGCI_LINT?=golangci-lint
|
|||
INSTALL_PREFIX?=$(HOME)/.local
|
||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||
INSTALL_TMP_SUFFIX=.new
|
||||
|
||||
# Workspace and Skills
|
||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||
|
|
@ -105,8 +106,10 @@ build-all: generate
|
|||
install: build
|
||||
@echo "Installing $(BINARY_NAME)..."
|
||||
@mkdir -p $(INSTALL_BIN_DIR)
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
# Copy binary with temporary suffix to ensure atomic update
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Installation complete!"
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
## 📢 Actualités
|
||||
|
||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
|
||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
|
||||
|
||||
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
||||
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
||||
|
|
|
|||
29
README.ja.md
29
README.ja.md
|
|
@ -162,7 +162,7 @@ docker compose --profile gateway up -d
|
|||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
|
|
@ -193,14 +193,34 @@ picoclaw onboard
|
|||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_TAVILY_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. API キーの取得**
|
||||
|
||||
- **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) · [Qwen](https://dashscope.console.aliyun.com)
|
||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
- **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)
|
||||
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
|
|
@ -1039,7 +1059,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
|||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||
|
||||
Web 検索を有効にするには:
|
||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||
1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||
2. `~/.picoclaw/config.json` に追加:
|
||||
```json
|
||||
{
|
||||
|
|
@ -1077,5 +1097,6 @@ Web 検索を有効にするには:
|
|||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||
|
|
|
|||
153
README.md
153
README.md
|
|
@ -14,7 +14,8 @@
|
|||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -42,16 +43,17 @@
|
|||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
>
|
||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||
>
|
||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||
|
||||
|
||||
## 📢 News
|
||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
|
||||
|
||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
||||
|
||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||
|
||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||
|
|
@ -100,9 +102,12 @@
|
|||
</table>
|
||||
|
||||
### 📱 Run on old Android Phones
|
||||
|
||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||
|
||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||
2. **Execute cmds**
|
||||
|
||||
```bash
|
||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||
|
|
@ -110,6 +115,7 @@ chmod +x picoclaw-linux-arm64
|
|||
pkg install proot
|
||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||
```
|
||||
|
||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -248,7 +259,7 @@ picoclaw onboard
|
|||
**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.
|
||||
|
||||
|
|
@ -376,7 +387,6 @@ When `web_app_url` is set, PicoClaw skips Tailscale auto-detection and serves th
|
|||
* (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**
|
||||
|
||||
|
|
@ -478,7 +488,6 @@ picoclaw gateway
|
|||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -574,7 +583,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||
* Go to WeCom Admin Console → App Management → Create App
|
||||
* Copy **AgentId** and **Secret**
|
||||
* Go to "My Company" page, copy **CorpID**
|
||||
|
||||
**2. Configure receive message**
|
||||
|
||||
* In App details, click "Receive Message" → "Set API"
|
||||
|
|
@ -658,23 +666,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
|||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||
|
||||
#### Protected Tools
|
||||
|
||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||
|
||||
| Tool | Function | Restriction |
|
||||
|------|----------|-------------|
|
||||
| `read_file` | Read files | Only files within workspace |
|
||||
| `write_file` | Write files | Only files within workspace |
|
||||
| `list_dir` | List directories | Only directories within workspace |
|
||||
| `edit_file` | Edit files | Only files within workspace |
|
||||
| `append_file` | Append to files | Only files within workspace |
|
||||
| `exec` | Execute commands | Command paths must be within workspace |
|
||||
| Tool | Function | Restriction |
|
||||
| ------------- | ---------------- | -------------------------------------- |
|
||||
| `read_file` | Read files | Only files within workspace |
|
||||
| `write_file` | Write files | Only files within workspace |
|
||||
| `list_dir` | List directories | Only directories within workspace |
|
||||
| `edit_file` | Edit files | Only files within workspace |
|
||||
| `append_file` | Append to files | Only files within workspace |
|
||||
| `exec` | Execute commands | Command paths must be within workspace |
|
||||
|
||||
#### Additional Exec Protection
|
||||
|
||||
|
|
@ -727,11 +735,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
|||
|
||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||
|
||||
| Execution Path | Security Boundary |
|
||||
|----------------|-------------------|
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Execution Path | Security Boundary |
|
||||
| ---------------- | ---------------------------- |
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||
|
||||
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
|
||||
|
||||
|
|
@ -757,21 +765,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
|||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||
| **Independent context** | Subagent has its own context, no session history |
|
||||
| **message tool** | Subagent communicates with user directly via message tool |
|
||||
| **Non-blocking** | After spawning, heartbeat continues to next task |
|
||||
| Feature | Description |
|
||||
| ----------------------- | --------------------------------------------------------- |
|
||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||
| **Independent context** | Subagent has its own context, no session history |
|
||||
| **message tool** | Subagent communicates with user directly via message tool |
|
||||
| **Non-blocking** | After spawning, heartbeat continues to next task |
|
||||
|
||||
#### How Subagent Communication Works
|
||||
|
||||
|
|
@ -802,10 +812,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `enabled` | `true` | Enable/disable heartbeat |
|
||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ---------------------------------- |
|
||||
| `enabled` | `true` | Enable/disable heartbeat |
|
||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
|
|
@ -817,17 +827,17 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||
> [!NOTE]
|
||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| Provider | Purpose | Get API Key |
|
||||
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
|
|
@ -842,25 +852,25 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
|
||||
#### 📋 All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
|--------|----------------|------------------|----------|---------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
|
|
@ -894,6 +904,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
#### Vendor-Specific Examples
|
||||
|
||||
**OpenAI**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
|
|
@ -903,6 +914,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
|
|
@ -912,6 +924,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```
|
||||
|
||||
**DeepSeek**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
|
|
@ -921,6 +934,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```
|
||||
|
||||
**Anthropic (with API key)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
|
|
@ -928,9 +942,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
"api_key": "sk-ant-your-key"
|
||||
}
|
||||
```
|
||||
|
||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||
|
||||
**Ollama (local)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
|
|
@ -939,6 +955,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```
|
||||
|
||||
**Custom Proxy/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
|
|
@ -976,6 +993,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
|||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||
|
||||
**Old Config (deprecated):**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
|
|
@ -994,6 +1012,7 @@ The old `providers` configuration is **deprecated** but still supported for back
|
|||
```
|
||||
|
||||
**New Config (recommended):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
|
|
@ -1212,13 +1231,13 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
|||
|
||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
Roadmap coming soon...
|
||||
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||
|
||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
||||
Developer group building, join after your first merged PR!
|
||||
|
||||
User Groups:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
29
README.zh.md
29
README.zh.md
|
|
@ -52,7 +52,7 @@
|
|||
|
||||
## 📢 新闻 (News)
|
||||
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
|
||||
|
||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||
|
|
@ -205,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)**
|
||||
|
||||
|
|
@ -246,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
|
||||
}
|
||||
},
|
||||
|
|
@ -262,8 +263,8 @@ picoclaw onboard
|
|||
|
||||
**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 请求/月)
|
||||
* **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)
|
||||
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||
|
||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||
|
||||
|
|
@ -795,7 +796,7 @@ 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
|
||||
|
|
@ -828,10 +829,10 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
|
||||
## 📝 API Key 对比
|
||||
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| ---------------- | -------------- | ----------------------------- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@
|
|||
"volcengine": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"mistral": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.mistral.ai/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
|
||||
|
||||
**Hello, PicoClaw Community!**
|
||||
|
||||
First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
|
||||
|
||||
PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
|
||||
|
||||
This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
|
||||
|
||||
### 🎁 Community Perks
|
||||
|
||||
To show our appreciation, developers who officially join our community operations will receive:
|
||||
|
||||
* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
|
||||
* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
|
||||
|
||||
### 🎥 Calling All Content Creators!
|
||||
|
||||
Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
|
||||
|
||||
* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
|
||||
* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
|
||||
We will be rewarding high-quality content creators with the same perks as our community developers!
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Urgent Volunteer Roles
|
||||
|
||||
We are looking for experts in the following areas:
|
||||
|
||||
1. **Issue/PR Reviewers**
|
||||
* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
|
||||
* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
|
||||
|
||||
|
||||
2. **Resource Optimization Experts**
|
||||
* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
|
||||
* **Focus:** Analyzing resource growth between releases and trimming redundancy.
|
||||
* **Priority:** **RAM usage optimization** > Binary size reduction.
|
||||
|
||||
|
||||
3. **Security Audit & Bug Fixes**
|
||||
* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
|
||||
* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
|
||||
|
||||
|
||||
4. **Documentation & DX (Developer Experience)**
|
||||
* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
|
||||
* **Focus:** Creating clear, user-friendly documentation for both setup and development.
|
||||
|
||||
|
||||
5. **AI-Powered CI/CD Optimization**
|
||||
* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
|
||||
* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
|
||||
|
||||
**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
|
||||
Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
|
||||
|
||||
---
|
||||
|
||||
## 📍 The Roadmap
|
||||
|
||||
Interested in a specific feature? You can "claim" these tasks and start building:
|
||||
|
||||
###
|
||||
* **Provider:**
|
||||
* **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
|
||||
* You can still submit code; Daming will merge it into the new implementation.
|
||||
* **Channels:**
|
||||
* Support for OneBot, additional platforms
|
||||
* attachments (images, audio, video, files).
|
||||
* **Skills:**
|
||||
* Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
|
||||
* **Operations:** * MCP Support.
|
||||
* Android operations (e.g., botdrop).
|
||||
* Browser automation via CDP or ActionBook.
|
||||
|
||||
|
||||
* **Multi-Agent Ecosystem:**
|
||||
* **Basic Model-Agent**
|
||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
||||
* **Swarm Mode.**
|
||||
* **AIEOS Integration.**
|
||||
|
||||
|
||||
* **Branding:**
|
||||
* **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
|
||||
|
||||
|
||||
We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
|
||||
This list will be updated continuously as we progress.
|
||||
If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
|
||||
|
||||
---
|
||||
|
||||
## 🤝 How to Join
|
||||
|
||||
**Everything is open to your creativity!** If you have a wild idea, just PR it.
|
||||
|
||||
1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
|
||||
2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
|
||||
> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
|
||||
> Include the role you're interested in and any evidence of your development experience.
|
||||
|
||||
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
|
||||
|
||||
**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
|
||||
|
|
@ -169,6 +169,10 @@ func registerSharedTools(
|
|||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||
|
|
|
|||
|
|
@ -327,6 +327,7 @@ type ProvidersConfig struct {
|
|||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||
Antigravity ProviderConfig `json:"antigravity"`
|
||||
Qwen ProviderConfig `json:"qwen"`
|
||||
Mistral ProviderConfig `json:"mistral"`
|
||||
}
|
||||
|
||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||
|
|
@ -348,7 +349,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
|||
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||
|
|
@ -422,6 +424,13 @@ type BraveConfig struct {
|
|||
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"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||
|
|
@ -435,6 +444,7 @@ type PerplexityConfig struct {
|
|||
|
||||
type WebToolsConfig struct {
|
||||
Brave BraveConfig `json:"brave"`
|
||||
Tavily TavilyConfig `json:"tavily"`
|
||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||
Perplexity PerplexityConfig `json:"perplexity"`
|
||||
}
|
||||
|
|
@ -653,7 +663,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.
|
||||
|
|
|
|||
|
|
@ -256,6 +256,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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
|
|||
"qwen": true,
|
||||
"deepseek": true,
|
||||
"github_copilot": true,
|
||||
"mistral": true,
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
|
|
|
|||
|
|
@ -175,6 +175,15 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
|||
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 != "" {
|
||||
|
|
@ -278,6 +287,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
|||
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
|
||||
|
|
|
|||
|
|
@ -107,7 +107,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)
|
||||
|
|
@ -210,6 +210,8 @@ func getDefaultAPIBase(protocol string) string {
|
|||
return "http://localhost:8000/v1"
|
||||
case "minimax":
|
||||
return "https://api.minimax.io/v1"
|
||||
case "mistral":
|
||||
return "https://api.mistral.ai/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ func normalizeModel(model, apiBase string) string {
|
|||
|
||||
prefix := strings.ToLower(model[:idx])
|
||||
switch prefix {
|
||||
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "minimax":
|
||||
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "minimax", "mistral":
|
||||
return model[idx+1:]
|
||||
default:
|
||||
return model
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ 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]any `json:"arguments,omitempty"`
|
||||
Name string `json:"-"`
|
||||
Arguments map[string]any `json:"-"`
|
||||
ThoughtSignature string `json:"-"` // Internal use only
|
||||
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"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 {
|
||||
|
|
@ -70,28 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
return ErrorResult("new_text is required")
|
||||
}
|
||||
|
||||
if t.restrict {
|
||||
return executeInRoot(t.allowedDir, path, func(root *os.Root, relPath string) (*ToolResult, error) {
|
||||
if err := editFileInRoot(root, relPath, oldText, newText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("File edited: %s", path)), nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := editFile(&hostRW{}, path, oldText, newText); err != nil {
|
||||
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
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 {
|
||||
|
|
@ -125,32 +121,21 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
|
|||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
appendContent, ok := args["content"].(string)
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("content is required")
|
||||
}
|
||||
|
||||
var rw fileReadWriter
|
||||
if t.restrict {
|
||||
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
|
||||
if err := appendFileWithRW(&rootRW{root: root}, relPath, appendContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("Appended to %s", path)), nil
|
||||
})
|
||||
}
|
||||
|
||||
rw = &hostRW{}
|
||||
if err := appendFileWithRW(rw, path, appendContent); err != nil {
|
||||
if err := appendFile(t.fs, path, content); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||
}
|
||||
|
||||
// editFile reads the file via rw, performs the replacement, and writes back.
|
||||
// It uses a fileReadWriter, allowing the same logic for both restricted and unrestricted modes.
|
||||
func editFile(rw fileReadWriter, path, oldText, newText string) error {
|
||||
content, err := rw.Read(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
|
||||
}
|
||||
|
|
@ -160,58 +145,18 @@ func editFile(rw fileReadWriter, path, oldText, newText string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return rw.Write(path, newContent)
|
||||
return sysFs.WriteFile(path, newContent)
|
||||
}
|
||||
|
||||
// editFileInRoot performs an in-place edit within an os.Root using a single open call.
|
||||
// By opening with O_RDWR and reusing the same file descriptor for both read and write,
|
||||
// we narrow the TOCTOU window compared to two separate open calls.
|
||||
func editFileInRoot(root *os.Root, relPath, oldText, newText string) error {
|
||||
f, err := root.OpenFile(relPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read file: file not found: %w", err)
|
||||
}
|
||||
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") {
|
||||
return fmt.Errorf("failed to read file: access denied: %w", err)
|
||||
}
|
||||
return fmt.Errorf("failed to open file for editing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file content: %w", err)
|
||||
}
|
||||
|
||||
newContent, err := replaceEditContent(content, oldText, newText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Truncate the file and seek back to the beginning before writing.
|
||||
if err := f.Truncate(0); err != nil {
|
||||
return fmt.Errorf("failed to truncate file for in-place edit: %w", err)
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek to beginning of file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.Write(newContent); err != nil {
|
||||
return fmt.Errorf("failed to write edited content: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendFileWithRW reads the existing content (if any) via rw, appends new content, and writes back.
|
||||
func appendFileWithRW(rw fileReadWriter, path, appendContent string) error {
|
||||
content, err := rw.Read(path)
|
||||
// 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 rw.Write(path, newContent)
|
||||
return sysFs.WriteFile(path, newContent)
|
||||
}
|
||||
|
||||
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
|
||||
|
|
|
|||
|
|
@ -158,8 +158,13 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
|
|||
// Should mention outside allowed directory
|
||||
// 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)
|
||||
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
|
||||
|
|
@ -340,19 +345,24 @@ func TestReplaceEditContent(t *testing.T) {
|
|||
|
||||
// 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.
|
||||
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFile + sandboxFs.
|
||||
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tool := NewAppendFileTool(workspace, true)
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]interface{}{
|
||||
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)
|
||||
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"))
|
||||
|
|
@ -365,12 +375,12 @@ func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
|
|||
func TestAppendFileTool_Restricted_Success(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
testFile := "existing.txt"
|
||||
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0644)
|
||||
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
tool := NewAppendFileTool(workspace, true)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
"content": " appended",
|
||||
}
|
||||
|
|
@ -385,16 +395,16 @@ func TestAppendFileTool_Restricted_Success(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
|
||||
// correctly edits a file using the single-open editFileInRoot path.
|
||||
// correctly edits a file using the sandboxFs 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"), 0644)
|
||||
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]interface{}{
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
"old_text": "World",
|
||||
"new_text": "Go",
|
||||
|
|
@ -409,13 +419,13 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
|
|||
assert.Equal(t, "Hello Go", string(data))
|
||||
}
|
||||
|
||||
// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
|
||||
// TestEditFileTool_Restricted_FileNotFound verifies that editFile 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]interface{}{
|
||||
args := map[string]any{
|
||||
"path": "no_such_file.txt",
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -13,7 +14,7 @@ import (
|
|||
// Used by shell.go for working directory validation.
|
||||
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)
|
||||
|
|
@ -78,16 +79,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 {
|
||||
|
|
@ -117,17 +123,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
if t.restrict {
|
||||
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
|
||||
content, err := (&rootRW{root: root}).Read(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewToolResult(string(content)), nil
|
||||
})
|
||||
}
|
||||
|
||||
content, err := (&hostRW{}).Read(path)
|
||||
content, err := t.fs.ReadFile(path)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
|
@ -135,12 +131,17 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -179,16 +180,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
|||
return ErrorResult("content is required")
|
||||
}
|
||||
|
||||
if t.restrict {
|
||||
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
|
||||
if err := (&rootRW{root: root}).Write(relPath, []byte(content)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("File written: %s", path)), nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := (&hostRW{}).Write(path, []byte(content)); err != nil {
|
||||
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
|
|
@ -196,12 +188,17 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -231,28 +228,11 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
path = "."
|
||||
}
|
||||
|
||||
if !t.restrict {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||
}
|
||||
return formatDirEntries(entries)
|
||||
entries, err := t.fs.ReadDir(path)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return executeInRoot(t.workspace, path, func(root *os.Root, relPath string) (*ToolResult, error) {
|
||||
f, err := root.Open(relPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open directory: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entries, err := f.ReadDir(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
return formatDirEntries(entries), nil
|
||||
})
|
||||
return formatDirEntries(entries)
|
||||
}
|
||||
|
||||
func formatDirEntries(entries []os.DirEntry) *ToolResult {
|
||||
|
|
@ -267,17 +247,18 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult {
|
|||
return NewToolResult(result.String())
|
||||
}
|
||||
|
||||
// fileReadWriter abstracts reading and writing files, allowing both unrestricted
|
||||
// (host filesystem) and sandbox (os.Root) implementations to share the same logic.
|
||||
type fileReadWriter interface {
|
||||
Read(path string) ([]byte, error)
|
||||
Write(path string, data []byte) error
|
||||
// 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)
|
||||
}
|
||||
|
||||
// hostRW is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
||||
type hostRW struct{}
|
||||
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
||||
type hostFs struct{}
|
||||
|
||||
func (h *hostRW) Read(path string) ([]byte, error) {
|
||||
func (h *hostFs) ReadFile(path string) ([]byte, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
|
|
@ -291,14 +272,22 @@ func (h *hostRW) Read(path string) ([]byte, error) {
|
|||
return content, nil
|
||||
}
|
||||
|
||||
func (h *hostRW) Write(path string, data []byte) error {
|
||||
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, 0755); err != nil {
|
||||
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, 0644); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -309,110 +298,109 @@ func (h *hostRW) Write(path string, data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// rootRW is a sandboxed fileReadWriter that operates within an os.Root boundary.
|
||||
// All paths passed to Read/Write must be relative to the root.
|
||||
type rootRW struct {
|
||||
root *os.Root
|
||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
||||
type sandboxFs struct {
|
||||
workspace string
|
||||
}
|
||||
|
||||
func (r *rootRW) Read(path string) ([]byte, error) {
|
||||
content, err := r.root.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, 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 nil, fmt.Errorf("failed to read file: access denied: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
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")
|
||||
}
|
||||
return content, nil
|
||||
|
||||
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 *rootRW) Write(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "/" {
|
||||
// Use native root.MkdirAll which handles the "file exists at path" check internally.
|
||||
if err := r.root.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
tmpRelPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
|
||||
fw, err := r.root.Create(tmpRelPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file for writing: %w", 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)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
fw.Close()
|
||||
r.root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to write to temp file: %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 := fw.Close(); err != nil {
|
||||
r.root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
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 := r.root.Rename(tmpRelPath, path); err != nil {
|
||||
r.root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to rename temp file over target: %w", err)
|
||||
}
|
||||
return nil
|
||||
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 empty and not defined")
|
||||
return "", fmt.Errorf("workspace is not defined")
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
|
||||
// If absolute, make it relative to workspace
|
||||
// os.Root only accepts relative paths
|
||||
if filepath.IsAbs(path) {
|
||||
rel, err := filepath.Rel(workspace, path)
|
||||
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)
|
||||
}
|
||||
path = rel
|
||||
}
|
||||
|
||||
// Check for escape manually (defense-in-depth, as os.Root also rejects paths that escape the root)
|
||||
if path == ".." || strings.HasPrefix(path, "../") {
|
||||
if !filepath.IsLocal(rel) {
|
||||
return "", fmt.Errorf("path escapes workspace: %s", path)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// executeInRoot executes a function within the safety of os.Root
|
||||
func executeInRoot(workspace string, path string, fn func(root *os.Root, relPath string) (*ToolResult, error)) *ToolResult {
|
||||
if workspace == "" {
|
||||
return ErrorResult("workspace is not defined")
|
||||
}
|
||||
|
||||
// 1. Open the Root
|
||||
root, err := os.OpenRoot(workspace)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to open workspace root: %v", err))
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
// 2. Calculate relative path
|
||||
relPath, err := getSafeRelPath(workspace, path)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// 3. Execute the operation
|
||||
result, err := fn(root, relPath)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return result
|
||||
return rel, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
|||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||
|
||||
tool := &ReadFileTool{}
|
||||
tool := NewReadFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
|
@ -44,7 +44,7 @@ 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]any{
|
||||
"path": "/nonexistent_file_12345.txt",
|
||||
|
|
@ -87,7 +87,7 @@ 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]any{
|
||||
"path": testFile,
|
||||
|
|
@ -126,7 +126,7 @@ 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]any{
|
||||
"path": testFile,
|
||||
|
|
@ -152,7 +152,7 @@ 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]any{
|
||||
"content": "test",
|
||||
|
|
@ -168,7 +168,7 @@ 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]any{
|
||||
"path": "/tmp/test.txt",
|
||||
|
|
@ -195,7 +195,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
|||
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]any{
|
||||
"path": tmpDir,
|
||||
|
|
@ -219,7 +219,7 @@ 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]any{
|
||||
"path": "/nonexistent_directory_12345",
|
||||
|
|
@ -240,7 +240,7 @@ 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]any{}
|
||||
|
||||
|
|
@ -281,7 +281,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
|||
// 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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -292,7 +293,7 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
|||
// 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"), 0600)
|
||||
os.WriteFile(secretFile, []byte("secret data"), 0o600)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": secretFile,
|
||||
|
|
@ -305,7 +306,7 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
|||
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:
|
||||
// TestRootMkdirAll verifies that root.MkdirAll (used by sandboxFs.WriteFile) 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()
|
||||
|
|
@ -316,25 +317,25 @@ func TestRootMkdirAll(t *testing.T) {
|
|||
defer root.Close()
|
||||
|
||||
// Case 1: Single directory
|
||||
err = root.MkdirAll("dir1", 0755)
|
||||
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", 0755)
|
||||
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", 0755)
|
||||
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"), 0644)
|
||||
err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
|
||||
assert.NoError(t, err)
|
||||
err = root.MkdirAll("file_exists", 0755)
|
||||
err = root.MkdirAll("file_exists", 0o755)
|
||||
assert.Error(t, err, "expected error when a file exists at the directory path")
|
||||
}
|
||||
|
||||
|
|
@ -360,51 +361,51 @@ func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
|
|||
assert.Equal(t, content, string(data))
|
||||
}
|
||||
|
||||
// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
|
||||
func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
||||
// TestHostFs_Read_PermissionDenied verifies that hostFs.ReadFile surfaces access denied errors.
|
||||
func TestHostFs_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"), 0000)
|
||||
err := os.WriteFile(protected, []byte("secret"), 0o000)
|
||||
assert.NoError(t, err)
|
||||
defer os.Chmod(protected, 0644) // ensure cleanup
|
||||
defer os.Chmod(protected, 0o644) // ensure cleanup
|
||||
|
||||
_, err = (&hostRW{}).Read(protected)
|
||||
_, 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) {
|
||||
// TestHostFs_Read_Directory verifies that hostFs.ReadFile returns an error when given a directory path.
|
||||
func TestHostFs_Read_Directory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
_, err := (&hostRW{}).Read(tmpDir)
|
||||
_, 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) {
|
||||
// TestSandboxFs_Read_Directory verifies that sandboxFs.ReadFile returns an error when given a directory.
|
||||
func TestSandboxFs_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", 0755)
|
||||
err = root.Mkdir("subdir", 0o755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = (&rootRW{root: root}).Read("subdir")
|
||||
_, 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) {
|
||||
// TestHostFs_Write_ParentDirMissing verifies that hostFs.WriteFile creates parent dirs automatically.
|
||||
func TestHostFs_Write_ParentDirMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
|
||||
|
||||
err := (&hostRW{}).Write(target, []byte("hello"))
|
||||
err := (&hostFs{}).WriteFile(target, []byte("hello"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(target)
|
||||
|
|
@ -412,16 +413,13 @@ func TestHostRW_Write_ParentDirMissing(t *testing.T) {
|
|||
assert.Equal(t, "hello", string(data))
|
||||
}
|
||||
|
||||
// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
|
||||
// TestSandboxFs_Write_ParentDirMissing verifies that sandboxFs.WriteFile creates
|
||||
// nested parent directories automatically within the sandbox.
|
||||
func TestRootRW_Write_ParentDirMissing(t *testing.T) {
|
||||
func TestSandboxFs_Write_ParentDirMissing(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
root, err := os.OpenRoot(workspace)
|
||||
assert.NoError(t, err)
|
||||
defer root.Close()
|
||||
|
||||
relPath := "x/y/z/file.txt"
|
||||
err = (&rootRW{root: root}).Write(relPath, []byte("nested"))
|
||||
err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, relPath))
|
||||
|
|
@ -429,13 +427,13 @@ func TestRootRW_Write_ParentDirMissing(t *testing.T) {
|
|||
assert.Equal(t, "nested", string(data))
|
||||
}
|
||||
|
||||
// TestHostRW_Write verifies the hostRW.Write helper function
|
||||
func TestHostRW_Write(t *testing.T) {
|
||||
// TestHostFs_Write verifies the hostFs.WriteFile helper function
|
||||
func TestHostFs_Write(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "atomic_test.txt")
|
||||
testData := []byte("atomic test content")
|
||||
|
||||
err := (&hostRW{}).Write(testFile, testData)
|
||||
err := (&hostFs{}).WriteFile(testFile, testData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
|
|
@ -444,7 +442,7 @@ func TestHostRW_Write(t *testing.T) {
|
|||
|
||||
// Verify it overwrites correctly
|
||||
newData := []byte("new atomic content")
|
||||
err = (&hostRW{}).Write(testFile, newData)
|
||||
err = (&hostFs{}).WriteFile(testFile, newData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err = os.ReadFile(testFile)
|
||||
|
|
@ -452,36 +450,39 @@ func TestHostRW_Write(t *testing.T) {
|
|||
assert.Equal(t, newData, content)
|
||||
}
|
||||
|
||||
// TestRootRW_Write verifies the rootRW.Write helper function
|
||||
func TestRootRW_Write(t *testing.T) {
|
||||
// TestSandboxFs_Write verifies the sandboxFs.WriteFile helper function
|
||||
func TestSandboxFs_Write(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
root, err := os.OpenRoot(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
defer root.Close()
|
||||
|
||||
relPath := "atomic_root_test.txt"
|
||||
testData := []byte("atomic root test content")
|
||||
|
||||
erw := &rootRW{root: root}
|
||||
err = erw.Write(relPath, testData)
|
||||
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)
|
||||
f.Close()
|
||||
assert.Equal(t, testData, content)
|
||||
|
||||
// Verify it overwrites correctly
|
||||
newData := []byte("new root atomic content")
|
||||
err = erw.Write(relPath, newData)
|
||||
err = erw.WriteFile(relPath, newData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
f, err = root.Open(relPath)
|
||||
f2, err := root.Open(relPath)
|
||||
assert.NoError(t, err)
|
||||
content, err = io.ReadAll(f)
|
||||
defer f2.Close()
|
||||
|
||||
content, err = io.ReadAll(f2)
|
||||
assert.NoError(t, err)
|
||||
f.Close()
|
||||
assert.Equal(t, newData, content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,62 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// stubTool is a minimal Tool implementation for testing.
|
||||
type stubTool struct {
|
||||
name string
|
||||
// --- mock types ---
|
||||
|
||||
type mockRegistryTool struct {
|
||||
name string
|
||||
desc string
|
||||
params map[string]any
|
||||
result *ToolResult
|
||||
}
|
||||
|
||||
func (s *stubTool) Name() string { return s.name }
|
||||
func (s *stubTool) Description() string { return "stub" }
|
||||
func (s *stubTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
|
||||
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
|
||||
}
|
||||
func (s *stubTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
return &ToolResult{ForLLM: "ok"}
|
||||
|
||||
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 TestNormalizeToolName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
|
|
@ -39,21 +78,43 @@ func TestNormalizeToolName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegistryGet_ExactMatch(t *testing.T) {
|
||||
func TestNewToolRegistry(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(&stubTool{name: "read_file"})
|
||||
|
||||
tool, ok := r.Get("read_file")
|
||||
if !ok || tool.Name() != "read_file" {
|
||||
t.Errorf("exact match failed")
|
||||
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 TestRegistryGet_FuzzyMatch(t *testing.T) {
|
||||
func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(&stubTool{name: "read_file"})
|
||||
r.Register(&stubTool{name: "edit_file"})
|
||||
r.Register(&stubTool{name: "web_search"})
|
||||
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_Get_FuzzyMatch(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(newMockTool("read_file", "reads a file"))
|
||||
r.Register(newMockTool("edit_file", "edits a file"))
|
||||
r.Register(newMockTool("web_search", "searches the web"))
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
|
|
@ -79,12 +140,261 @@ func TestRegistryGet_FuzzyMatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegistryGet_NotFound(t *testing.T) {
|
||||
func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(&stubTool{name: "read_file"})
|
||||
r.Register(newMockTool("dup", "first"))
|
||||
r.Register(newMockTool("dup", "second"))
|
||||
|
||||
_, ok := r.Get("totally_unknown")
|
||||
if ok {
|
||||
t.Errorf("Get(totally_unknown) should return false")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -256,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
|
||||
|
|
@ -267,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 {
|
||||
|
|
@ -278,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 {
|
||||
|
|
|
|||
|
|
@ -333,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ func DetectRepetitionLoop(text string) bool {
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -188,10 +188,104 @@ func TestTailPad_WrapPushesOldLines(t *testing.T) {
|
|||
// --- Truncate ---
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := Truncate("hello", 10); got != "hello" {
|
||||
t.Errorf("Truncate short = %q", got)
|
||||
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...",
|
||||
},
|
||||
}
|
||||
if got := Truncate("hello world!", 8); got != "hello..." {
|
||||
t.Errorf("Truncate long = %q", got)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue