Merge branch 'sipeed:main' into main

This commit is contained in:
DOM CHAROENYOS 2026-03-19 12:26:41 +07:00 committed by GitHub
commit a54635e11a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1863 additions and 49 deletions

249
README.it.md Normal file
View file

@ -0,0 +1,249 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
<h3>Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾我们走</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<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>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></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.md) | **Italiano**
</div>
---
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com). È scritto interamente in **Go** — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
🦐 PicoClaw è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot), riscritto da zero in Go attraverso un processo di auto-bootstrapping, in cui l'agente IA stesso ha guidato l'intera migrazione architetturale e l'ottimizzazione del codice.
⚡️ Funziona su hardware da $10 con meno di 10MB di RAM: il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **🚨 SICUREZZA & CANALI UFFICIALI**
>
> * **NESSUNA CRYPTO:** PicoClaw non ha **NESSUN** token/coin ufficiale. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **TRUFFA**.
>
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**.
> * **Attenzione:** Molti domini `.ai/.org/.com/.net/...` sono registrati da terze parti.
> * **Attenzione:** PicoClaw è in fase di sviluppo iniziale e potrebbe avere problemi di sicurezza di rete non risolti. Non distribuire in ambienti di produzione prima della release v1.0.
> * **Nota:** PicoClaw ha recentemente unito molte PR, il che potrebbe comportare un'impronta di memoria maggiore (1020MB) nelle ultime versioni. Prevediamo di dare priorità all'ottimizzazione delle risorse non appena il set di funzionalità corrente raggiungerà uno stato stabile.
## 📢 Novità
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), tracciamento dello stato dei sub-agent (`spawn_status`), hot-reload sperimentale del gateway, gate di sicurezza per cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K ⭐**!
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e launcher Web UI.
2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.
<details>
<summary>Notizie precedenti...</summary>
2026-02-16 🎉 PicoClaw ha raggiunto 12K stelle in una settimana! Ruoli di maintainer della community e [roadmap](ROADMAP.md) pubblicati ufficialmente.
2026-02-13 🎉 PicoClaw ha raggiunto 5000 stelle in 4 giorni! Roadmap del progetto e gruppo sviluppatori in fase di avvio.
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli agenti IA su hardware da $10 con <10MB di RAM. 🦐 PicoClaw, andiamo!
</details>
## ✨ Caratteristiche
🪶 **Ultra-Leggero**: Impronta di memoria <10MB il 99% più piccolo delle funzionalità principali di OpenClaw.*
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
⚡️ **Avvio Fulmineo**: Tempo di avvio 400 volte più veloce, boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
🌍 **Vera Portabilità**: Singolo binario autonomo per RISC-V, ARM, MIPS e x86. Un click e si parte!
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo.
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent.
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'agent — codifica base64 automatica per LLM multimodali.
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
_*Le versioni recenti potrebbero usare 1020MB a causa delle fusioni rapide di funzionalità. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
| | OpenClaw | NanoBot | **PicoClaw** |
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Linguaggio** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux </br>~$50 | **Qualsiasi scheda Linux**</br>**A partire da $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
## 🦾 Dimostrazione
### 🛠️ Flussi di Lavoro Standard dell'Assistente
<table align="center">
<tr align="center">
<th><p align="center">🧩 Ingegnere Full-Stack</p></th>
<th><p align="center">🗂️ Gestione Log & Pianificazione</p></th>
<th><p align="center">🔎 Ricerca Web & Apprendimento</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Sviluppa • Distribuisci • Scala</td>
<td align="center">Pianifica • Automatizza • Memorizza</td>
<td align="center">Scopri • Analizza • Tendenze</td>
</tr>
</table>
### 📱 Usa su vecchi telefoni Android
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. Avvio rapido:
1. **Installa [Termux](https://github.com/termux/termux-app)** (Scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play).
2. **Esegui i comandi**
```bash
# Scarica l'ultima release da https://github.com/sipeed/picoclaw/releases
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard
```
Poi segui le istruzioni nella sezione "Avvio Rapido" per completare la configurazione!
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 Deploy Innovativo a Bassa Impronta
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un Assistente Domotico Minimale
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) per la Manutenzione Automatizzata dei Server
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) per il Monitoraggio Intelligente
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Molti altri scenari di deploy ti aspettano!
## 📦 Installazione
### Installa con binario precompilato
Scarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases).
### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Compila, senza installare
make build
# Compila per più piattaforme
make build-all
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Compila e Installa
make install
```
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
## 📚 Documentazione
Per guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido.
| Argomento | Descrizione |
|-----------|-------------|
| 🐳 [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent, configurazione rapida |
| 💬 [App di Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e altro |
| ⚙️ [Configurazione](docs/it/configuration.md) | Variabili d'ambiente, struttura del workspace, sorgenti delle skill, sandbox di sicurezza, heartbeat |
| 🔌 [Provider & Modelli](docs/providers.md) | 20+ provider LLM, routing dei modelli, configurazione model_list, architettura dei provider |
| 🔄 [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
| 🐛 [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
| 🔧 [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec |
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Riferimento CLI
| Comando | Descrizione |
| ------------------------- | ---------------------------------- |
| `picoclaw onboard` | Inizializza config & workspace |
| `picoclaw agent -m "..."` | Chatta con l'agent |
| `picoclaw agent` | Modalità chat interattiva |
| `picoclaw gateway` | Avvia il gateway |
| `picoclaw status` | Mostra lo stato |
| `picoclaw version` | Mostra le info sulla versione |
| `picoclaw cron list` | Elenca tutti i job pianificati |
| `picoclaw cron add ...` | Aggiunge un job pianificato |
| `picoclaw cron disable` | Disabilita un job pianificato |
| `picoclaw cron remove` | Rimuove un job pianificato |
| `picoclaw skills list` | Elenca le skill installate |
| `picoclaw skills install` | Installa una skill |
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
| `picoclaw auth login` | Autenticazione con i provider |
### Task Pianificati / Promemoria
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
* **Promemoria una tantum**: "Ricordami tra 10 minuti" → si attiva una volta dopo 10 min
* **Task ricorrenti**: "Ricordami ogni 2 ore" → si attiva ogni 2 ore
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" → usa un'espressione cron
## 🤝 Contribuisci & Roadmap
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completa.
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
Gruppi utenti:
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></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) | [Italiano](README.it.md) | **English**
</div>

View file

@ -122,7 +122,8 @@
"verification_token": "",
"allow_from": [],
"reasoning_channel_id": "",
"random_reaction_emoji": []
"random_reaction_emoji": [],
"is_lark": false
},
"dingtalk": {
"enabled": false,

View file

@ -13,14 +13,15 @@
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
"allow_from": [],
"is_lark": false
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------------ | ------ | ---- | -------------------------------- |
| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ |
| enabled | bool | 是 | 是否启用飞书频道 |
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
| app_secret | string | 是 | 飞书应用的 App Secret |
@ -28,10 +29,11 @@
| verification_token | string | 否 | 用于Webhook事件验证的Token |
| allow_from | array | 否 | 用户ID白名单空表示所有用户 |
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
| is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn` |
## 设置流程
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/)创建应用程序
2. 获取 App ID 和 App Secret
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)

View file

@ -42,13 +42,15 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── state/ # Persistent state (last channel, etc.)
├── cron/ # Scheduled jobs database
├── skills/ # Custom skills
├── AGENTS.md # Agent behavior guide
├── AGENT.md # Agent behavior guide
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul
└── USER.md # User preferences
```
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
### Skill Sources
By default, skills are loaded from:

View file

@ -42,13 +42,14 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── state/ # État persistant (dernier canal, etc.)
├── cron/ # Base de données des tâches planifiées
├── skills/ # Compétences personnalisées
├── AGENTS.md # Guide de comportement de l'agent
├── AGENT.md # Guide de comportement de l'agent
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'agent
├── SOUL.md # Âme de l'agent
└── USER.md # Préférences utilisateur
```
> **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête.
### Sources de Compétences
Par défaut, les compétences sont chargées depuis :

219
docs/it/configuration.md Normal file
View file

@ -0,0 +1,219 @@
# ⚙️ Guida alla Configurazione
> Torna al [README](../../README.md)
## ⚙️ Configurazione
File di configurazione: `~/.picoclaw/config.json`
### Variabili d'Ambiente
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
| Variabile | Descrizione | Percorso Predefinito |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
**Esempi:**
```bash
# Esegui picoclaw usando un file di configurazione specifico
# Il percorso del workspace verrà letto da quel file di configurazione
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
# Il workspace verrà creato in /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Usa entrambi per un setup completamente personalizzato
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Struttura del Workspace
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
```
~/.picoclaw/workspace/
├── sessions/ # Sessioni di conversazione e cronologia
├── memory/ # Memoria a lungo termine (MEMORY.md)
├── state/ # Stato persistente (ultimo canale, ecc.)
├── cron/ # Database dei job pianificati
├── skills/ # Skill personalizzate
├── AGENTS.md # Guida al comportamento dell'agent
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
├── IDENTITY.md # Identità dell'agent
├── SOUL.md # Anima dell'agent
└── USER.md # Preferenze dell'utente
```
> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
### Sorgenti delle Skill
Per impostazione predefinita, le skill vengono caricate da:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (globale)
3. `<current-working-directory>/skills` (builtin)
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Politica Unificata di Esecuzione dei Comandi
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
### 🔒 Sandbox di Sicurezza
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
#### Configurazione Predefinita
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true
}
}
}
```
| Opzione | Predefinito | Descrizione |
| ----------------------- | ----------------------- | ---------------------------------------------------- |
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
#### Strumenti Protetti
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
| Strumento | Funzione | Restrizione |
| ------------- | ------------------------- | ---------------------------------------------------- |
| `read_file` | Legge file | Solo file all'interno del workspace |
| `write_file` | Scrive file | Solo file all'interno del workspace |
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
| `edit_file` | Modifica file | Solo file all'interno del workspace |
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
#### Protezione Exec Aggiuntiva
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
* `format`, `mkfs`, `diskpart` — Formattazione del disco
* `dd if=` — Imaging del disco
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
* Fork bomb `:(){ :|:& };:`
### Controllo Accesso ai File
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
### Sicurezza Exec
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
Per ambienti ad alto rischio:
* Esamina gli script di build prima dell'esecuzione.
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
#### Esempi di Errore
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
```
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
```
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
**Metodo 1: File di configurazione**
```json
{
"agents": {
"defaults": {
"restrict_to_workspace": false
}
}
}
```
**Metodo 2: Variabile d'ambiente**
```bash
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
```
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
#### Coerenza dei Confini di Sicurezza
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
| Percorso di esecuzione | Confine di sicurezza |
| ---------------------- | --------------------------------- |
| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
### Heartbeat (Task Periodici)
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
```markdown
# Periodic Tasks
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
```
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
#### Task Asincroni con Spawn
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
```markdown
# Periodic Tasks
```

View file

@ -42,13 +42,15 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── state/ # 永続化状態 (最後のチャネルなど)
├── cron/ # スケジュールジョブデータベース
├── skills/ # カスタムスキル
├── AGENTS.md # Agent 動作ガイド
├── AGENT.md # Agent 動作ガイド
├── HEARTBEAT.md # 定期タスクプロンプト (30 分ごとにチェック)
├── IDENTITY.md # Agent アイデンティティ
├── SOUL.md # Agent ソウル/性格
└── USER.md # ユーザー設定
```
> **注意:** `AGENT.md``SOUL.md``USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻mtimeの追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。
### スキルソース
デフォルトでは、スキルは以下の順序で読み込まれます:

View file

@ -42,13 +42,15 @@ O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/wo
├── state/ # Estado persistente (último canal, etc.)
├── cron/ # Banco de dados de tarefas agendadas
├── skills/ # Skills personalizadas
├── AGENTS.md # Guia de comportamento do agente
├── AGENT.md # Guia de comportamento do agente
├── HEARTBEAT.md # Prompts de tarefas periódicas (verificados a cada 30 min)
├── IDENTITY.md # Identidade do agente
├── SOUL.md # Alma do agente
└── USER.md # Preferências do usuário
```
> **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição.
### Fontes de Skills
Por padrão, as skills são carregadas de:

View file

@ -42,13 +42,15 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── state/ # Trạng thái bền vững (kênh cuối, v.v.)
├── cron/ # Cơ sở dữ liệu tác vụ lên lịch
├── skills/ # Skill tùy chỉnh
├── AGENTS.md # Hướng dẫn hành vi agent
├── AGENT.md # Hướng dẫn hành vi agent
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính agent
├── SOUL.md # Linh hồn agent
└── USER.md # Tùy chọn người dùng
```
> **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md``memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo.
### Nguồn Skill
Mặc định, skill được tải từ:

View file

@ -42,13 +42,15 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── state/ # 持久化状态 (最后一次频道等)
├── cron/ # 定时任务数据库
├── skills/ # 自定义技能
├── AGENTS.md # Agent 行为指南
├── AGENT.md # Agent 行为指南
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格
└── USER.md # 用户偏好
```
> **提示:**`AGENT.md``SOUL.md``USER.md``memory/MEMORY.md` 的修改会通过文件修改时间mtime在运行时自动检测。**无需重启 gateway**Agent 将在下一次请求时自动加载最新内容。
### 技能来源 (Skill Sources)
默认情况下,技能会按以下顺序加载:

View file

@ -239,6 +239,11 @@ func registerSharedTools(
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Clone the parent's tool registry so subagents can use all
// tools registered so far (file, web, etc.) but NOT spawn/
// spawn_status which are added below — preventing recursive
// subagent spawning.
subagentManager.SetTools(agent.Tools.Clone())
if spawnEnabled {
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID

View file

@ -54,11 +54,15 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
)
tc := newTokenCache()
opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)}
if cfg.IsLark {
opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl))
}
ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithTokenCache(tc)),
client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),
}
ch.SetOwner(ch)
return ch, nil
@ -83,10 +87,15 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
c.mu.Lock()
c.cancel = cancel
domain := lark.FeishuBaseUrl
if c.config.IsLark {
domain = lark.LarkBaseUrl
}
c.wsClient = larkws.NewClient(
c.config.AppID,
c.config.AppSecret,
larkws.WithEventHandler(dispatcher),
larkws.WithDomain(domain),
)
wsClient := c.wsClient
c.mu.Unlock()

View file

@ -325,6 +325,7 @@ type FeishuConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
}
type DiscordConfig struct {
@ -603,8 +604,10 @@ type ModelConfig struct {
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // API authentication key
APIKey string `json:"api_key"` // API authentication key (single key)
APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
// Special providers (CLI-based, OAuth, etc.)
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
@ -873,6 +876,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
@ -919,14 +925,25 @@ func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelCo
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
// Also resolves api_keys array if present.
func resolveAPIKeys(models []ModelConfig, configDir string) error {
cr := credential.NewResolver(configDir)
for i := range models {
// Resolve single APIKey
resolved, err := cr.Resolve(models[i].APIKey)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
}
models[i].APIKey = resolved
// Resolve APIKeys array
for j, key := range models[i].APIKeys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err)
}
models[i].APIKeys[j] = resolved
}
}
return nil
}
@ -1097,6 +1114,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
return all
}
// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.
//
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
// Becomes:
// - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
// - {"model_name": "gpt-4__key_1", "api_key": "k2"}
// - {"model_name": "gpt-4__key_2", "api_key": "k3"}
func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
var expanded []ModelConfig
for _, m := range models {
keys := MergeAPIKeys(m.APIKey, m.APIKeys)
// Single key or no keys: keep as-is
if len(keys) <= 1 {
// Ensure APIKey is set from APIKeys if needed
if m.APIKey == "" && len(keys) == 1 {
m.APIKey = keys[0]
}
m.APIKeys = nil // Clear APIKeys to avoid confusion
expanded = append(expanded, m)
continue
}
// Multiple keys: expand
originalName := m.ModelName
// Create entries for additional keys (key_1, key_2, ...)
var fallbackNames []string
for i := 1; i < len(keys); i++ {
suffix := fmt.Sprintf("__key_%d", i)
expandedName := originalName + suffix
// Create a copy for the additional key
additionalEntry := ModelConfig{
ModelName: expandedName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[i],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
}
// Create the primary entry with first key and fallbacks
primaryEntry := ModelConfig{
ModelName: originalName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[0],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}
// Prepend new fallbacks to existing ones
if len(fallbackNames) > 0 {
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
} else if len(m.Fallbacks) > 0 {
primaryEntry.Fallbacks = m.Fallbacks
}
expanded = append(expanded, primaryEntry)
}
return expanded
}
func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":

291
pkg/config/multikey_test.go Normal file
View file

@ -0,0 +1,291 @@
package config
import (
"testing"
)
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "single-key",
},
}
result := ExpandMultiKeyModels(models)
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
}
if result[0].ModelName != "gpt-4" {
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
}
if result[0].APIKey != "single-key" {
t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey)
}
if len(result[0].Fallbacks) != 0 {
t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks)
}
}
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
models := []ModelConfig{
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://api.example.com",
APIKeys: []string{"key1", "key2", "key3"},
},
}
result := ExpandMultiKeyModels(models)
// Should expand to 3 models
if len(result) != 3 {
t.Fatalf("expected 3 models, got %d", len(result))
}
// First entry should be the primary with key1 and fallbacks
primary := result[2] // Primary is added last
if primary.ModelName != "glm-4.7" {
t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName)
}
if primary.APIKey != "key1" {
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
}
if primary.Fallbacks[0] != "glm-4.7__key_1" {
t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0])
}
if primary.Fallbacks[1] != "glm-4.7__key_2" {
t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1])
}
// Second entry should be key2
second := result[0]
if second.ModelName != "glm-4.7__key_1" {
t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName)
}
if second.APIKey != "key2" {
t.Errorf("expected second api_key 'key2', got %q", second.APIKey)
}
// Third entry should be key3
third := result[1]
if third.ModelName != "glm-4.7__key_2" {
t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName)
}
if third.APIKey != "key3" {
t.Errorf("expected third api_key 'key3', got %q", third.APIKey)
}
}
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "key0",
APIKeys: []string{"key1", "key2"},
},
}
result := ExpandMultiKeyModels(models)
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
if len(result) != 3 {
t.Fatalf("expected 3 models, got %d", len(result))
}
// Primary should use key0
primary := result[2]
if primary.APIKey != "key0" {
t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
}
}
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKeys: []string{"key1", "key2"},
Fallbacks: []string{"claude-3"},
},
}
result := ExpandMultiKeyModels(models)
primary := result[1]
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
if len(primary.Fallbacks) != 2 {
t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks)
}
// Key fallbacks should come first, then existing fallbacks
if primary.Fallbacks[0] != "gpt-4__key_1" {
t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0])
}
if primary.Fallbacks[1] != "claude-3" {
t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1])
}
}
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "",
APIKeys: []string{},
},
}
result := ExpandMultiKeyModels(models)
// Should keep as-is with no changes
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
}
if result[0].ModelName != "gpt-4" {
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
}
}
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "key1",
APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
},
}
result := ExpandMultiKeyModels(models)
// Should only create 2 models (deduplicated keys)
if len(result) != 2 {
t.Fatalf("expected 2 models (deduplicated), got %d", len(result))
}
primary := result[1]
if primary.APIKey != "key1" {
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 1 {
t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks))
}
}
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIBase: "https://api.example.com",
APIKeys: []string{"key1", "key2"},
Proxy: "http://proxy:8080",
RPM: 60,
MaxTokensField: "max_completion_tokens",
RequestTimeout: 30,
ThinkingLevel: "high",
},
}
result := ExpandMultiKeyModels(models)
// Check primary entry preserves all fields
primary := result[1]
if primary.APIBase != "https://api.example.com" {
t.Errorf("expected api_base preserved, got %q", primary.APIBase)
}
if primary.Proxy != "http://proxy:8080" {
t.Errorf("expected proxy preserved, got %q", primary.Proxy)
}
if primary.RPM != 60 {
t.Errorf("expected rpm preserved, got %d", primary.RPM)
}
if primary.MaxTokensField != "max_completion_tokens" {
t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField)
}
if primary.RequestTimeout != 30 {
t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout)
}
if primary.ThinkingLevel != "high" {
t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel)
}
// Check additional entry also preserves fields
additional := result[0]
if additional.APIBase != "https://api.example.com" {
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
}
if additional.RPM != 60 {
t.Errorf("expected additional rpm preserved, got %d", additional.RPM)
}
}
func TestMergeAPIKeys(t *testing.T) {
tests := []struct {
name string
apiKey string
apiKeys []string
expected []string
}{
{
name: "both empty",
apiKey: "",
apiKeys: nil,
expected: nil,
},
{
name: "only apiKey",
apiKey: "key1",
apiKeys: nil,
expected: []string{"key1"},
},
{
name: "only apiKeys",
apiKey: "",
apiKeys: []string{"key1", "key2"},
expected: []string{"key1", "key2"},
},
{
name: "both with overlap",
apiKey: "key1",
apiKeys: []string{"key1", "key2", "key3"},
expected: []string{"key1", "key2", "key3"},
},
{
name: "with whitespace",
apiKey: " key1 ",
apiKeys: []string{" key2 ", " key1 "},
expected: []string{"key1", "key2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
if len(result) != len(tt.expected) {
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
}
for i, k := range result {
if k != tt.expected[i] {
t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k)
}
}
})
}
}

View file

@ -117,17 +117,19 @@ func (fc *FallbackChain) Execute(
return nil, context.Canceled
}
// Check cooldown.
if !fc.cooldown.IsAvailable(candidate.Provider) {
remaining := fc.cooldown.CooldownRemaining(candidate.Provider)
// Check cooldown (per provider/model, not just provider).
// This allows multi-key failover where different keys use different model names.
cooldownKey := ModelKey(candidate.Provider, candidate.Model)
if !fc.cooldown.IsAvailable(cooldownKey) {
remaining := fc.cooldown.CooldownRemaining(cooldownKey)
result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider,
Model: candidate.Model,
Skipped: true,
Reason: FailoverRateLimit,
Error: fmt.Errorf(
"provider %s in cooldown (%s remaining)",
candidate.Provider,
"%s in cooldown (%s remaining)",
cooldownKey,
remaining.Round(time.Second),
),
})
@ -141,7 +143,7 @@ func (fc *FallbackChain) Execute(
if err == nil {
// Success.
fc.cooldown.MarkSuccess(candidate.Provider)
fc.cooldown.MarkSuccess(cooldownKey)
result.Response = resp
result.Provider = candidate.Provider
result.Model = candidate.Model
@ -187,7 +189,7 @@ func (fc *FallbackChain) Execute(
}
// Retriable error: mark failure and continue to next candidate.
fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason)
fc.cooldown.MarkFailure(cooldownKey, failErr.Reason)
result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider,
Model: candidate.Model,

View file

@ -0,0 +1,384 @@
package providers
import (
"context"
"errors"
"testing"
)
// TestMultiKeyFailover tests the complete failover flow with multiple API keys.
// This simulates the config expansion scenario where api_keys: ["key1", "key2", "key3"]
// is expanded into primary + fallbacks.
func TestMultiKeyFailover(t *testing.T) {
// Simulate expanded config: primary with 2 fallbacks
// This is what ExpandMultiKeyModels would produce for api_keys: ["key1", "key2", "key3"]
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates)
}
// Create fallback chain
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first call fails with 429, second succeeds
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
if callCount == 1 {
// First call: simulate rate limit
return nil, errors.New("http error: status 429 - rate limit exceeded")
}
// Second call: success
return &LLMResponse{
Content: "Hello from key2!",
}, nil
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after failover, got error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Response.Content != "Hello from key2!" {
t.Errorf("expected response from key2, got: %s", result.Response.Content)
}
if callCount != 2 {
t.Errorf("expected 2 calls (1 fail + 1 success), got %d", callCount)
}
// Verify first attempt was recorded
if len(result.Attempts) != 1 {
t.Errorf("expected 1 failed attempt recorded, got %d", len(result.Attempts))
}
if result.Attempts[0].Reason != FailoverRateLimit {
t.Errorf(
"expected first attempt reason to be rate_limit, got: %s",
result.Attempts[0].Reason,
)
}
}
// TestMultiKeyFailoverAllFail tests when all keys hit rate limit
func TestMultiKeyFailoverAllFail(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: all calls fail with rate limit
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
return nil, errors.New("status: 429 - too many requests")
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err == nil {
t.Fatal("expected error when all keys fail, got nil")
}
if result != nil {
t.Errorf("expected nil result on failure, got: %v", result)
}
if callCount != 3 {
t.Errorf("expected 3 calls (all fail), got %d", callCount)
}
// Verify error type
var exhausted *FallbackExhaustedError
if !errors.As(err, &exhausted) {
t.Errorf("expected FallbackExhaustedError, got: %T - %v", err, err)
}
if len(exhausted.Attempts) != 3 {
t.Errorf("expected 3 attempts in exhausted error, got %d", len(exhausted.Attempts))
}
}
// TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped
func TestMultiKeyFailoverCooldown(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Put the first model in cooldown (using ModelKey now, not just provider)
cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model)
cooldown.MarkFailure(cooldownKey, FailoverRateLimit)
// Verify it's not available
if cooldown.IsAvailable(cooldownKey) {
t.Fatal("expected first model to be in cooldown")
}
// Mock run function: only second should be called
callCount := 0
calledProviders := []string{}
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
calledProviders = append(calledProviders, provider+"/"+model)
return &LLMResponse{Content: "success"}, nil
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// First provider should have been skipped
if callCount != 1 {
t.Errorf("expected 1 call (first skipped due to cooldown), got %d", callCount)
}
// Should have called the second provider/model
if len(calledProviders) != 1 ||
calledProviders[0] != candidates[1].Provider+"/"+candidates[1].Model {
t.Errorf("expected second model to be called, got: %v", calledProviders)
}
// Verify first attempt was recorded as skipped
if len(result.Attempts) != 1 {
t.Fatalf("expected 1 attempt (skipped), got %d", len(result.Attempts))
}
if !result.Attempts[0].Skipped {
t.Error("expected first attempt to be marked as skipped")
}
}
// TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable
func TestMultiKeyFailoverWithFormatError(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first call fails with format error (bad request)
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
return nil, errors.New("invalid request format: tool_use.id missing")
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err == nil {
t.Fatal("expected error for format failure, got nil")
}
// Format errors should NOT trigger failover (non-retriable)
// So we should only have 1 call
if callCount != 1 {
t.Errorf("expected 1 call (format error is non-retriable), got %d", callCount)
}
// Verify the error is a FailoverError with format reason
var failoverErr *FailoverError
if !errors.As(err, &failoverErr) {
t.Errorf("expected FailoverError, got: %T - %v", err, err)
}
if failoverErr.Reason != FailoverFormat {
t.Errorf("expected FailoverFormat reason, got: %s", failoverErr.Reason)
}
_ = result // result should be nil
}
// TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback.
// This simulates the scenario: api_keys: ["k1", "k2"] + fallbacks: ["minimax"]
// Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax
func TestMultiKeyWithModelFallback(t *testing.T) {
// Simulate expanded config from:
// { "model_name": "glm-4.7", "api_keys": ["k1", "k2"], "fallbacks": ["minimax"] }
// After ExpandMultiKeyModels, primaryEntry.Fallbacks = ["glm-4.7__key_1", "minimax"]
// Note: In production, "minimax" would be resolved via model lookup to "minimax/minimax"
// In this test, we use the full format to avoid needing a lookup function.
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "minimax/minimax"},
}
candidates := ResolveCandidates(cfg, "zhipu")
// Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax)
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates)
}
// Verify candidate order
if candidates[0].Model != "glm-4.7" || candidates[0].Provider != "zhipu" {
t.Errorf(
"expected first candidate to be zhipu/glm-4.7, got: %s/%s",
candidates[0].Provider,
candidates[0].Model,
)
}
if candidates[1].Model != "glm-4.7__key_1" || candidates[1].Provider != "zhipu" {
t.Errorf(
"expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s",
candidates[1].Provider,
candidates[1].Model,
)
}
if candidates[2].Model != "minimax" || candidates[2].Provider != "minimax" {
t.Errorf(
"expected third candidate to be minimax/minimax, got: %s/%s",
candidates[2].Provider,
candidates[2].Model,
)
}
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first two fail, third succeeds (model fallback)
callCount := 0
calledModels := []string{}
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
calledModels = append(calledModels, provider+"/"+model)
switch callCount {
case 1:
// k1: rate limit
return nil, errors.New("status: 429 - rate limit")
case 2:
// k2: also rate limit (all zhipu keys exhausted)
return nil, errors.New("status: 429 - rate limit")
case 3:
// minimax: success
return &LLMResponse{Content: "success from minimax"}, nil
default:
return nil, errors.New("unexpected call")
}
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after failover to model fallback, got error: %v", err)
}
if callCount != 3 {
t.Errorf("expected 3 calls (k1 fail + k2 fail + minimax success), got %d", callCount)
}
if result.Response.Content != "success from minimax" {
t.Errorf("expected response from minimax, got: %s", result.Response.Content)
}
// Verify call order
if len(calledModels) != 3 {
t.Fatalf("expected 3 called models, got %d", len(calledModels))
}
if calledModels[0] != "zhipu/glm-4.7" {
t.Errorf("expected first call to zhipu/glm-4.7, got: %s", calledModels[0])
}
if calledModels[1] != "zhipu/glm-4.7__key_1" {
t.Errorf("expected second call to zhipu/glm-4.7__key_1, got: %s", calledModels[1])
}
if calledModels[2] != "minimax/minimax" {
t.Errorf("expected third call to minimax/minimax, got: %s", calledModels[2])
}
// Verify 2 failed attempts recorded
if len(result.Attempts) != 2 {
t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts))
}
// Both should be rate limit
for i, attempt := range result.Attempts {
if attempt.Reason != FailoverRateLimit {
t.Errorf("expected attempt %d to be rate_limit, got: %s", i, attempt.Reason)
}
}
}
// TestMultiKeyFailoverMixedErrors tests failover with different error types
func TestMultiKeyFailoverMixedErrors(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: different errors for each key
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
switch callCount {
case 1:
// First: rate limit (retriable)
return nil, errors.New("status: 429 - rate limit")
case 2:
// Second: timeout (retriable)
return nil, errors.New("context deadline exceeded")
case 3:
// Third: success
return &LLMResponse{Content: "success from key3"}, nil
default:
return nil, errors.New("unexpected call")
}
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after 2 failovers, got error: %v", err)
}
if callCount != 3 {
t.Errorf("expected 3 calls, got %d", callCount)
}
// Verify both failed attempts were recorded
if len(result.Attempts) != 2 {
t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts))
}
// First should be rate limit
if result.Attempts[0].Reason != FailoverRateLimit {
t.Errorf("expected first attempt to be rate_limit, got: %s", result.Attempts[0].Reason)
}
// Second should be timeout
if result.Attempts[1].Reason != FailoverTimeout {
t.Errorf("expected second attempt to be timeout, got: %s", result.Attempts[1].Reason)
}
}

View file

@ -157,8 +157,8 @@ func TestFallback_CooldownSkip(t *testing.T) {
ct, _ := newTestTracker(now)
fc := NewFallbackChain(ct)
// Put openai in cooldown
ct.MarkFailure("openai", FailoverRateLimit)
// Put openai/gpt-4 in cooldown (using ModelKey now)
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"),
@ -195,9 +195,9 @@ func TestFallback_AllInCooldown(t *testing.T) {
ct := NewCooldownTracker()
fc := NewFallbackChain(ct)
// Put all providers in cooldown
ct.MarkFailure("openai", FailoverRateLimit)
ct.MarkFailure("anthropic", FailoverBilling)
// Put all models in cooldown (using ModelKey now)
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
ct.MarkFailure(ModelKey("anthropic", "claude"), FailoverBilling)
candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"),
@ -273,12 +273,13 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
modelKey := ModelKey("openai", "gpt-4")
attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
attempt++
if attempt == 1 {
ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere
ct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere
}
return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
}
@ -287,7 +288,7 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ct.IsAvailable("openai") {
if !ct.IsAvailable(modelKey) {
t.Error("success should reset cooldown")
}
}

View file

@ -496,7 +496,7 @@ func (t *WriteFileTool) Name() string {
}
func (t *WriteFileTool) Description() string {
return "Write content to a file"
return "Write content to a file. If the file already exists, you must set overwrite=true to replace it."
}
func (t *WriteFileTool) Parameters() map[string]any {
@ -511,6 +511,11 @@ func (t *WriteFileTool) Parameters() map[string]any {
"type": "string",
"description": "Content to write to the file",
},
"overwrite": map[string]any{
"type": "boolean",
"description": "Must be set to true to overwrite an existing file.",
"default": false,
},
},
"required": []string{"path", "content"},
}
@ -527,6 +532,14 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult("content is required")
}
overwrite, _ := args["overwrite"].(bool)
if !overwrite {
if _, err := t.fs.Open(path); err == nil {
return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path))
}
}
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}

View file

@ -189,6 +189,121 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
}
}
// TestFilesystemTool_WriteFile_OverwriteDefaultBlocked verifies that writing to an
// existing file without overwrite=true returns an error.
func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
})
assert.True(t, result.IsError, "expected error when overwriting without overwrite=true")
assert.Contains(t, result.ForLLM, "already exists")
assert.Contains(t, result.ForLLM, "overwrite=true")
// Original content must be untouched
data, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "original", string(data))
}
// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting
// overwrite=true replaces the existing file.
func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "replaced",
"overwrite": true,
})
assert.False(t, result.IsError, "expected success with overwrite=true, got: %s", result.ForLLM)
data, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "replaced", string(data))
}
// TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag verifies that a new (non-existing)
// file can be written without setting overwrite=true.
func TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
tool := NewWriteFileTool("", false)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "brand new",
})
assert.False(t, result.IsError, "expected success for new file, got: %s", result.ForLLM)
data, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "brand new", string(data))
}
// TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked verifies that
// explicitly passing overwrite=false also blocks overwriting.
func TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
"overwrite": false,
})
assert.True(t, result.IsError, "expected error when overwrite=false")
assert.Contains(t, result.ForLLM, "already exists")
data, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "original", string(data))
}
// TestFilesystemTool_WriteFile_OverwriteSandboxed verifies the overwrite guard
// works correctly in restricted (sandbox) mode.
func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
workspace := t.TempDir()
testFile := "file.txt"
os.WriteFile(filepath.Join(workspace, testFile), []byte("original"), 0o644)
tool := NewWriteFileTool(workspace, true)
// Without overwrite=true → blocked
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
})
assert.True(t, result.IsError, "expected error in sandbox mode without overwrite=true")
assert.Contains(t, result.ForLLM, "already exists")
// With overwrite=true → allowed
result = tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "replaced in sandbox",
"overwrite": true,
})
assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM)
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
assert.Equal(t, "replaced in sandbox", string(data))
}
// TestFilesystemTool_ListDir_Success verifies successful directory listing
func TestFilesystemTool_ListDir_Success(t *testing.T) {
tmpDir := t.TempDir()

View file

@ -188,6 +188,27 @@ func (r *ToolRegistry) ExecuteWithContext(
// The callback is a call parameter, not mutable state on the tool instance.
var result *ToolResult
start := time.Now()
// Use recover to catch any panics during tool execution
// This prevents tool crashes from killing the entire agent
func() {
defer func() {
if re := recover(); re != nil {
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
logger.ErrorCF("tool", "Tool execution panic recovered",
map[string]any{
"tool": name,
"panic": fmt.Sprintf("%v", re),
})
result = &ToolResult{
ForLLM: errMsg,
ForUser: errMsg,
IsError: true,
Err: fmt.Errorf("panic: %v", re),
}
}
}()
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
map[string]any{
@ -197,6 +218,18 @@ func (r *ToolRegistry) ExecuteWithContext(
} else {
result = tool.Execute(ctx, args)
}
}()
// Handle nil result (should not happen, but defensive)
if result == nil {
result = &ToolResult{
ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
IsError: true,
Err: fmt.Errorf("nil result from tool"),
}
}
duration := time.Since(start)
// Log based on result type
@ -303,6 +336,28 @@ func (r *ToolRegistry) List() []string {
return r.sortedToolNames()
}
// Clone creates an independent copy of the registry containing the same tool
// entries (shallow copy of each ToolEntry). This is used to give subagents a
// snapshot of the parent agent's tools without sharing the same registry —
// tools registered on the parent after cloning (e.g. spawn, spawn_status)
// will NOT be visible to the clone, preventing recursive subagent spawning.
// The version counter is reset to 0 in the clone as it's a new independent registry.
func (r *ToolRegistry) Clone() *ToolRegistry {
r.mu.RLock()
defer r.mu.RUnlock()
clone := &ToolRegistry{
tools: make(map[string]*ToolEntry, len(r.tools)),
}
for name, entry := range r.tools {
clone.tools[name] = &ToolEntry{
Tool: entry.Tool,
IsCore: entry.IsCore,
TTL: entry.TTL,
}
}
return clone
}
// Count returns the number of registered tools.
func (r *ToolRegistry) Count() int {
r.mu.RLock()

View file

@ -2,6 +2,7 @@ package tools
import (
"context"
"errors"
"strings"
"sync"
"testing"
@ -335,6 +336,96 @@ func TestToolToSchema(t *testing.T) {
}
}
func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files"))
r.Register(newMockTool("exec", "runs commands"))
r.Register(newMockTool("web_search", "searches the web"))
clone := r.Clone()
// Clone should have the same tools
if clone.Count() != 3 {
t.Errorf("expected clone to have 3 tools, got %d", clone.Count())
}
for _, name := range []string{"read_file", "exec", "web_search"} {
if _, ok := clone.Get(name); !ok {
t.Errorf("expected clone to have tool %q", name)
}
}
// Registering on parent should NOT affect clone
r.Register(newMockTool("spawn", "spawns subagent"))
if r.Count() != 4 {
t.Errorf("expected parent to have 4 tools, got %d", r.Count())
}
if clone.Count() != 3 {
t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count())
}
if _, ok := clone.Get("spawn"); ok {
t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning")
}
// Registering on clone should NOT affect parent
clone.Register(newMockTool("custom", "custom tool"))
if clone.Count() != 4 {
t.Errorf("expected clone to have 4 tools, got %d", clone.Count())
}
if _, ok := r.Get("custom"); ok {
t.Error("expected parent NOT to have 'custom' tool registered on clone")
}
}
func TestToolRegistry_Clone_Empty(t *testing.T) {
r := NewToolRegistry()
clone := r.Clone()
if clone.Count() != 0 {
t.Errorf("expected empty clone, got count %d", clone.Count())
}
}
func TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) {
r := NewToolRegistry()
r.RegisterHidden(newMockTool("mcp_tool", "dynamic MCP tool"))
clone := r.Clone()
// Hidden tools with TTL=0 should not be gettable (same behavior as parent)
if _, ok := clone.Get("mcp_tool"); ok {
t.Error("expected hidden tool with TTL=0 to be invisible in clone")
}
// But the entry should exist (count includes hidden tools)
if clone.Count() != 1 {
t.Errorf("expected clone count 1 (hidden entry exists), got %d", clone.Count())
}
}
func TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) {
r := NewToolRegistry()
r.RegisterHidden(newMockTool("ttl_tool", "tool with TTL"))
// Manually set a non-zero TTL on the entry
r.mu.RLock()
if entry, ok := r.tools["ttl_tool"]; ok {
entry.TTL = 5
}
r.mu.RUnlock()
clone := r.Clone()
// Verify TTL value is preserved in the clone
clone.mu.RLock()
defer clone.mu.RUnlock()
entry, ok := clone.tools["ttl_tool"]
if !ok {
t.Fatal("expected ttl_tool to exist in clone")
}
if entry.TTL != 5 {
t.Errorf("expected TTL=5 in clone, got %d", entry.TTL)
}
}
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
r := NewToolRegistry()
var wg sync.WaitGroup
@ -358,3 +449,175 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) {
t.Error("expected tools to be registered after concurrent access")
}
}
// --- Panic and abnormal exit tests ---
// mockPanicTool is a tool that panics during execution
type mockPanicTool struct {
name string
panicValue any
}
func (m *mockPanicTool) Name() string { return m.name }
func (m *mockPanicTool) Description() string { return "a tool that panics" }
func (m *mockPanicTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
panic(m.panicValue)
}
// mockNilResultTool is a tool that returns nil
type mockNilResultTool struct {
name string
}
func (m *mockNilResultTool) Name() string { return m.name }
func (m *mockNilResultTool) Description() string { return "a tool that returns nil" }
func (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return nil
}
func TestToolRegistry_Execute_PanicRecovery(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{
name: "panic_tool",
panicValue: "something went terribly wrong",
})
// Should not panic, should return error result
result := r.Execute(context.Background(), "panic_tool", nil)
if result == nil {
t.Fatal("expected non-nil result after panic recovery")
}
if !result.IsError {
t.Error("expected IsError=true after panic")
}
if !strings.Contains(result.ForLLM, "panic") {
t.Errorf("expected 'panic' in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "panic_tool") {
t.Errorf("expected tool name in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "something went terribly wrong") {
t.Errorf("expected panic value in error message, got %q", result.ForLLM)
}
if result.Err == nil {
t.Error("expected Err to be set")
}
}
func TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) {
r := NewToolRegistry()
// Test with error type panic
r.Register(&mockPanicTool{
name: "error_panic_tool",
panicValue: errors.New("custom error panic"),
})
result := r.Execute(context.Background(), "error_panic_tool", nil)
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "custom error panic") {
t.Errorf("expected error message in ForLLM, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) {
r := NewToolRegistry()
// Test with int type panic
r.Register(&mockPanicTool{
name: "int_panic_tool",
panicValue: 42,
})
result := r.Execute(context.Background(), "int_panic_tool", nil)
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "42") {
t.Errorf("expected panic value '42' in ForLLM, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_NilResultHandling(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockNilResultTool{name: "nil_tool"})
result := r.Execute(context.Background(), "nil_tool", nil)
if result == nil {
t.Fatal("expected non-nil result when tool returns nil")
}
if !result.IsError {
t.Error("expected IsError=true for nil result")
}
if !strings.Contains(result.ForLLM, "nil_tool") {
t.Errorf("expected tool name in error message, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "nil result") {
t.Errorf("expected 'nil result' in error message, got %q", result.ForLLM)
}
if result.Err == nil {
t.Error("expected Err to be set")
}
}
func TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{
name: "ctx_panic_tool",
panicValue: "context panic test",
})
// Should not panic even with context
result := r.ExecuteWithContext(
context.Background(),
"ctx_panic_tool",
map[string]any{"key": "value"},
"telegram",
"chat-123",
nil,
)
if result == nil {
t.Fatal("expected non-nil result")
}
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, "context panic test") {
t.Errorf("expected panic message, got %q", result.ForLLM)
}
}
func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockPanicTool{name: "bad_tool", panicValue: "boom"})
r.Register(&mockRegistryTool{
name: "good_tool",
desc: "works fine",
params: map[string]any{},
result: SilentResult("success"),
})
// First, trigger the panic
result1 := r.Execute(context.Background(), "bad_tool", nil)
if !result1.IsError {
t.Error("expected error from panic tool")
}
// Then, verify the good tool still works
result2 := r.Execute(context.Background(), "good_tool", nil)
if result2.IsError {
t.Errorf("expected success from good tool, got error: %s", result2.ForLLM)
}
if result2.ForLLM != "success" {
t.Errorf("expected 'success', got %q", result2.ForLLM)
}
}

View file

@ -311,13 +311,30 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
if err != nil {
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
if output != "" {
msg += "\n\nPartial output before timeout:\n" + output
}
return &ToolResult{
ForLLM: msg,
ForUser: msg,
IsError: true,
Err: fmt.Errorf("command timeout: %w", err),
}
}
output += fmt.Sprintf("\nExit code: %v", err)
// Extract detailed exit information
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode := exitErr.ExitCode()
output += fmt.Sprintf("\n\n[Command exited with code %d]", exitCode)
// Add signal information if killed by signal (Unix)
if exitCode == -1 {
output += " (killed by signal)"
}
} else {
output += fmt.Sprintf("\n\n[Command failed: %v]", err)
}
}
if output == "" {

View file

@ -489,6 +489,69 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {
}
}
// TestShellTool_ExitCodeDetails verifies that exit codes are captured with details
func TestShellTool_ExitCodeDetails(t *testing.T) {
tool, err := NewExecTool("", false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
ctx := context.Background()
args := map[string]any{
"command": "sh -c 'exit 42'",
}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("expected error for non-zero exit code")
}
// Should contain the exit code in the message (new format: "exited with code 42")
if !strings.Contains(result.ForLLM, "42") {
t.Errorf("expected exit code 42 in error message, got: %s", result.ForLLM)
}
// Verify the new detailed message format
if !strings.Contains(result.ForLLM, "exited with code") {
t.Errorf("expected 'exited with code' in message, got: %s", result.ForLLM)
}
// Err field is set by the exec system (may or may not be set depending on implementation)
// The important thing is that IsError=true
t.Logf("Exit code result: %s", result.ForLLM)
}
// TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output
func TestShellTool_TimeoutWithPartialOutput(t *testing.T) {
tool, err := NewExecTool("", false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
tool.SetTimeout(1 * time.Second) // Give more time for echo to complete
ctx := context.Background()
// Use a command that outputs immediately then sleeps
args := map[string]any{
"command": "echo 'partial output before timeout' && sleep 30",
}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("expected error for timeout")
}
// Should mention timeout
if !strings.Contains(result.ForLLM, "timed out") {
t.Errorf("expected 'timed out' in message, got: %s", result.ForLLM)
}
// Log the result for debugging (partial output depends on shell behavior)
t.Logf("Timeout result: %s", result.ForLLM)
}
// TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt
// commands from deny pattern checks.
func TestShellTool_CustomAllowPatterns(t *testing.T) {

View file

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput } from "@/components/shared-form"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface FeishuFormProps {
@ -16,6 +16,10 @@ function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asBool(value: unknown): boolean {
return typeof value === "boolean" ? value : false
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
@ -98,6 +102,12 @@ export function FeishuForm({
)}
/>
</Field>
<SwitchCardField
label={t("channels.field.isLark")}
hint={t("channels.form.desc.isLark")}
checked={asBool(config.is_lark)}
onCheckedChange={(checked) => onChange("is_lark", checked)}
/>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}

View file

@ -259,6 +259,7 @@
"placeholderText": "Placeholder Text",
"groupTriggerMentionOnly": "Group Mention Only",
"groupTriggerPrefixes": "Group Trigger Prefixes",
"isLark": "Lark (International)",
"allowFrom": "Allow From",
"allowFromPlaceholder": "e.g. 123456, 789012",
"allowOrigins": "Allow Origins",
@ -290,6 +291,7 @@
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
"allowFrom": "Allowed user or group IDs, separated by commas.",
"allowOrigins": "Allowed origin domains, separated by commas.",
"wsUrl": "WebSocket service URL.",

View file

@ -259,6 +259,7 @@
"placeholderText": "占位文案",
"groupTriggerMentionOnly": "群聊仅提及时响应",
"groupTriggerPrefixes": "群聊触发前缀",
"isLark": "Lark国际版",
"allowFrom": "允许来源",
"allowFromPlaceholder": "例如 123456, 789012",
"allowOrigins": "允许来源域名",
@ -290,6 +291,7 @@
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息。",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。",
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。",
"isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn。",
"allowFrom": "允许访问的用户或群组 ID多个值用逗号分隔。",
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔。",
"wsUrl": "WebSocket 服务地址。",