diff --git a/README.it.md b/README.it.md
new file mode 100644
index 000000000..1f5acadcf
--- /dev/null
+++ b/README.it.md
@@ -0,0 +1,249 @@
+
+
+---
+
+> **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!
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!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 (10–20MB) 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à.
+
+
+Notizie precedenti...
+
+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!
+
+
+
+## ✨ 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 10–20MB 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**(core 0,8 GHz) | >500s | >30s | **<1s** |
+| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux****A partire da $10** |
+
+
+
+## 🦾 Dimostrazione
+
+### 🛠️ Flussi di Lavoro Standard dell'Assistente
+
+
+
+ 🧩 Ingegnere Full-Stack |
+ 🗂️ Gestione Log & Pianificazione |
+ 🔎 Ricerca Web & Apprendimento |
+
+
+ 
|
+ 
|
+ 
|
+
+
+ | Sviluppa • Distribuisci • Scala |
+ Pianifica • Automatizza • Memorizza |
+ Scopri • Analizza • Tendenze |
+
+
+
+### 📱 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!
+
+
+
+### 🐜 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
+
+
+
+🌟 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 |
+
+##
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:
+
+
diff --git a/README.md b/README.md
index 00fb0fd68..2420df864 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-[中文](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**
diff --git a/config/config.example.json b/config/config.example.json
index 167ba7d59..c214f26fa 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -122,7 +122,8 @@
"verification_token": "",
"allow_from": [],
"reasoning_channel_id": "",
- "random_reaction_emoji": []
+ "random_reaction_emoji": [],
+ "is_lark": false
},
"dingtalk": {
"enabled": false,
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
index 3fafffb7d..db7eb56eb 100644
--- a/docs/channels/feishu/README.zh.md
+++ b/docs/channels/feishu/README.zh.md
@@ -13,25 +13,27 @@
"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 |
-| encrypt_key | string | 否 | 事件回调加密密钥 |
-| verification_token | string | 否 | 用于Webhook事件验证的Token |
-| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
-| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
+| 字段 | 类型 | 必填 | 描述 |
+| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ |
+| enabled | bool | 是 | 是否启用飞书频道 |
+| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
+| app_secret | string | 是 | 飞书应用的 App Secret |
+| encrypt_key | string | 否 | 事件回调加密密钥 |
+| verification_token | string | 否 | 用于Webhook事件验证的Token |
+| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
+| 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. 设置加密(可选,生产环境建议启用)
diff --git a/docs/configuration.md b/docs/configuration.md
index 9d503f44f..202ad4f59 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -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:
diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md
index c813fe25b..ef02acf8a 100644
--- a/docs/fr/configuration.md
+++ b/docs/fr/configuration.md
@@ -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 :
diff --git a/docs/it/configuration.md b/docs/it/configuration.md
new file mode 100644
index 000000000..6a79a9543
--- /dev/null
+++ b/docs/it/configuration.md
@@ -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. `/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
+```
diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md
index bfd574a4d..c0f68f85b 100644
--- a/docs/ja/configuration.md
+++ b/docs/ja/configuration.md
@@ -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 は次のリクエスト時に最新の内容を自動的に読み込みます。
+
### スキルソース
デフォルトでは、スキルは以下の順序で読み込まれます:
diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md
index bf4833da4..e7e2c7ec0 100644
--- a/docs/pt-br/configuration.md
+++ b/docs/pt-br/configuration.md
@@ -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:
diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md
index 22b9bd509..847f28e60 100644
--- a/docs/vi/configuration.md
+++ b/docs/vi/configuration.md
@@ -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` và `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ừ:
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index d3f810208..a2bf8fce2 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -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)
默认情况下,技能会按以下顺序加载:
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 86994c360..33da33e92 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -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
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index c503e2993..3aea67b12 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -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()
diff --git a/pkg/config/config.go b/pkg/config/config.go
index dd4e86319..739f8d373 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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 {
@@ -602,9 +603,11 @@ type ModelConfig struct {
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
// HTTP-based providers
- APIBase string `json:"api_base,omitempty"` // API endpoint URL
- APIKey string `json:"api_key"` // API authentication key
- Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
+ APIBase string `json:"api_base,omitempty"` // API endpoint URL
+ 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":
diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go
new file mode 100644
index 000000000..b899b991c
--- /dev/null
+++ b/pkg/config/multikey_test.go
@@ -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)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
index 7ba563b66..549ec7837 100644
--- a/pkg/providers/fallback.go
+++ b/pkg/providers/fallback.go
@@ -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,
diff --git a/pkg/providers/fallback_multikey_test.go b/pkg/providers/fallback_multikey_test.go
new file mode 100644
index 000000000..9ed8fa73c
--- /dev/null
+++ b/pkg/providers/fallback_multikey_test.go
@@ -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)
+ }
+}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
index 1783ebcb5..1a1118e33 100644
--- a/pkg/providers/fallback_test.go
+++ b/pkg/providers/fallback_test.go
@@ -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")
}
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index ae356f248..39d45013d 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -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())
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 5ebf38df2..0b4dd310b 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -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()
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 0635f47d7..0b0f51cc1 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -188,15 +188,48 @@ func (r *ToolRegistry) ExecuteWithContext(
// The callback is a call parameter, not mutable state on the tool instance.
var result *ToolResult
start := time.Now()
- if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
- logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
- map[string]any{
- "tool": name,
- })
- result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
- } else {
- result = tool.Execute(ctx, args)
+
+ // 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{
+ "tool": name,
+ })
+ result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
+ } 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()
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 92d7d5abd..967758dfa 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -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)
+ }
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 0dc85ae21..78ad2b26d 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -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 == "" {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index c4553020f..f8f83ea74 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -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) {
diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx
index a834a65f9..386adf9a5 100644
--- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx
+++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx
@@ -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({
)}
/>
+ onChange("is_lark", checked)}
+ />