mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge branch 'main' into feat/provider-extra-body-config
This commit is contained in:
+2
-1
@@ -60,5 +60,6 @@ cmd/telegram/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
.claude/
|
||||
|
||||
docker/data
|
||||
docker/data
|
||||
|
||||
+462
-133
@@ -3,7 +3,7 @@
|
||||
|
||||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||
|
||||
<h3>Matériel à $10 · <10 Mo de RAM · Démarrage en <1s · 皮皮虾,我们走!</h3>
|
||||
<h3>Matériel à $10 · 10 Mo de RAM · Démarrage en ms · Let's Go, PicoClaw!</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">
|
||||
@@ -24,147 +24,138 @@
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com). Il est entièrement écrit en **Go** — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet.
|
||||
> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com), entièrement écrit en **Go** à partir de zéro — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet.
|
||||
|
||||
🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.
|
||||
**PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot). Il a été entièrement reconstruit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — l'Agent IA lui-même a piloté la migration architecturale et l'optimisation du code.
|
||||
|
||||
**Fonctionne sur du matériel à $10 avec <10 Mo de RAM** — c'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
|
||||
|
||||
⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **$10** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'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>
|
||||
<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]
|
||||
> **🚨 SÉCURITÉ & CANAUX OFFICIELS**
|
||||
> **Avis de sécurité**
|
||||
>
|
||||
> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**.
|
||||
>
|
||||
> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**.
|
||||
> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers.
|
||||
> * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0.
|
||||
> * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé.
|
||||
> * **PAS DE CRYPTO :** PicoClaw n'a **pas** émis de tokens officiels ni de cryptomonnaie. Toute affirmation sur `pump.fun` ou d'autres plateformes de trading est une **arnaque**.
|
||||
> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATTENTION :** De nombreux domaines `.ai/.org/.com/.net/...` ont été enregistrés par des tiers. Ne leur faites pas confiance.
|
||||
> * **NOTE :** PicoClaw est en développement rapide précoce. Des problèmes de sécurité non résolus peuvent exister. Ne pas déployer en production avant la v1.0.
|
||||
> * **NOTE :** PicoClaw a récemment fusionné de nombreuses PRs. Les builds récents peuvent utiliser 10-20 Mo de RAM. L'optimisation des ressources est prévue après la stabilisation des fonctionnalités.
|
||||
|
||||
## 📢 Actualités
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 publié !** Interface système tray (Windows & Linux), suivi de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du gateway, portes de sécurité cron, et 2 correctifs de sécurité. PicoClaw atteint **25K ⭐** !
|
||||
2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** !
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Plus grande mise à jour !** Support du protocole MCP, 4 nouveaux canaux (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux fournisseurs (Kimi/Minimax/Avian), pipeline de vision, stockage mémoire JSONL, et routage de modèles.
|
||||
2026-03-09 🎉 **v0.2.1 — La plus grande mise à jour à ce jour !** Support du protocole MCP, 4 nouveaux channels (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux providers (Kimi/Minimax/Avian), pipeline vision, stockage mémoire JSONL, routage de modèles.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** publié avec support Docker Compose et lanceur Web UI.
|
||||
2026-02-28 📦 **v0.2.0** publiée avec support Docker Compose et Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw a atteint **20K étoiles** en seulement 17 jours ! L'orchestration automatique des canaux et les interfaces de capacités sont arrivées.
|
||||
2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles.
|
||||
|
||||
<details>
|
||||
<summary>Actualités précédentes...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Les rôles de mainteneurs communautaires et la [feuille de route](ROADMAP.md) sont officiellement publiés.
|
||||
2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](ROADMAP.md) officiellement lancés.
|
||||
|
||||
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! La Feuille de Route du Projet et le Groupe de Développeurs sont en cours de mise en place.
|
||||
2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw est lancé !** Construit en 1 jour pour apporter les Agents IA au matériel à $10 avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti !
|
||||
2026-02-09 🎉 **PicoClaw publié !** Construit en 1 jour pour apporter les Agents IA sur du matériel à $10 avec <10 Mo de RAM. Let's Go, PicoClaw !
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que les fonctionnalités essentielles d'OpenClaw.*
|
||||
🪶 **Ultra-léger** : Empreinte mémoire du cœur <10 Mo — 99% plus petit qu'OpenClaw.*
|
||||
|
||||
💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini.
|
||||
💰 **Coût minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini.
|
||||
|
||||
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en <1 seconde même sur un cœur unique à 0,6 GHz.
|
||||
⚡️ **Démarrage ultra-rapide** : 400x plus rapide au démarrage. Démarre en <1s même sur un processeur monocœur à 0,6 GHz.
|
||||
|
||||
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
|
||||
🌍 **Vraiment portable** : Binaire unique pour les architectures RISC-V, ARM, MIPS et x86. Un seul binaire, fonctionne partout !
|
||||
|
||||
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
||||
🤖 **Auto-amorcé par IA** : Implémentation native pure Go — 95% du code principal a été généré par un Agent et affiné via une révision humaine en boucle.
|
||||
|
||||
🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'agent.
|
||||
🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'Agent.
|
||||
|
||||
👁️ **Pipeline de Vision** : Envoyez des images et fichiers directement à l'agent — encodage base64 automatique pour les LLM multimodaux.
|
||||
👁️ **Pipeline vision** : Envoyez des images et des fichiers directement à l'Agent — encodage base64 automatique pour les LLMs multimodaux.
|
||||
|
||||
🧠 **Routage Intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API.
|
||||
🧠 **Routage intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API.
|
||||
|
||||
_*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapides de fonctionnalités. L'optimisation des ressources est prévue. La comparaison de démarrage est basée sur des benchmarks à cœur unique 0,8 GHz (voir tableau ci-dessous)._
|
||||
_*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de PRs. L'optimisation des ressources est prévue. Comparaison de vitesse de démarrage basée sur des benchmarks monocœur à 0,8 GHz (voir tableau ci-dessous)._
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Langage** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1 Go | >100 Mo | **< 10 Mo*** |
|
||||
| **Démarrage**</br>(cœur 0,8 GHz) | >500s | >30s | **<1s** |
|
||||
| **Coût** | Mac Mini $599 | La plupart des SBC Linux </br>~$50 | **N'importe quelle carte Linux**</br>**À partir de $10** |
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Langage** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1 Go | >100 Mo | **< 10 Mo*** |
|
||||
| **Temps de démarrage**</br>(cœur 0,8 GHz) | >500s | >30s | **<1s** |
|
||||
| **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**</br>**à partir de $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[Liste de Compatibilité Matérielle](docs/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
|
||||
</div>
|
||||
|
||||
> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Démonstration
|
||||
|
||||
### 🛠️ Flux de Travail Standard de l'Assistant
|
||||
### 🛠️ Flux de travail standard de l'assistant
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Ingénieur Full-Stack</p></th>
|
||||
<th><p align="center">🗂️ Gestion des Logs & Planification</p></th>
|
||||
<th><p align="center">🔎 Recherche Web & Apprentissage</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">Développer • Déployer • Mettre à l'échelle</td>
|
||||
<td align="center">Planifier • Automatiser • Mémoriser</td>
|
||||
<td align="center">Découvrir • Analyser • Tendances</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<th><p align="center">Mode Ingénieur Full-Stack</p></th>
|
||||
<th><p align="center">Journalisation & Planification</p></th>
|
||||
<th><p align="center">Recherche Web & Apprentissage</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">Développer · Déployer · Mettre à l'échelle</td>
|
||||
<td align="center">Planifier · Automatiser · Mémoriser</td>
|
||||
<td align="center">Découvrir · Analyser · Tendances</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Utiliser sur d'anciens téléphones Android
|
||||
|
||||
Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide :
|
||||
|
||||
1. **Installez [Termux](https://github.com/termux/termux-app)** (Téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou recherchez sur F-Droid / Google Play).
|
||||
2. **Exécutez les commandes**
|
||||
|
||||
```bash
|
||||
# Téléchargez la dernière version depuis 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 # chroot fournit une disposition standard du système de fichiers Linux
|
||||
```
|
||||
|
||||
Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Déploiement Innovant à Faible Empreinte
|
||||
### 🐜 Déploiement innovant à faible empreinte
|
||||
|
||||
PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
|
||||
|
||||
- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste
|
||||
- 30~$50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs
|
||||
- 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) édition E(Ethernet) ou W(WiFi6), pour un assistant domestique minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), pour des opérations serveur automatisées
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), pour la surveillance intelligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Encore plus de scénarios de déploiement vous attendent !
|
||||
🌟 D'autres cas de déploiement vous attendent !
|
||||
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Télécharger depuis picoclaw.io (Recommandé)
|
||||
|
||||
Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et propose un téléchargement en un clic. Pas besoin de choisir manuellement une architecture.
|
||||
Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et fournit un téléchargement en un clic. Pas besoin de choisir manuellement une architecture.
|
||||
|
||||
### Télécharger le binaire précompilé
|
||||
|
||||
@@ -178,80 +169,418 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compiler, pas besoin d'installer
|
||||
# Compiler le binaire principal
|
||||
make build
|
||||
|
||||
# Compiler le Web UI Launcher (requis pour le mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compiler pour plusieurs plateformes
|
||||
make build-all
|
||||
|
||||
# Compiler pour Raspberry Pi Zero 2 W (32-bit : make build-linux-arm ; 64-bit : make build-linux-arm64)
|
||||
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Compiler et Installer
|
||||
# Compiler et installer
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32-bit → `make build-linux-arm` ; 64-bit → `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux.
|
||||
**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32 bits -> `make build-linux-arm` ; 64 bits -> `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux.
|
||||
|
||||
## 📚 Documentation
|
||||
## 🚀 Guide de démarrage rapide
|
||||
|
||||
Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne couvre que le démarrage rapide.
|
||||
### 🌐 WebUI Launcher (Recommandé pour le bureau)
|
||||
|
||||
| Sujet | Description |
|
||||
|-------|-------------|
|
||||
| 🐳 [Docker & Démarrage Rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent, configuration rapide |
|
||||
| 💬 [Applications de Chat](docs/fr/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, et plus |
|
||||
| ⚙️ [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sources de compétences, bac à sable de sécurité, heartbeat |
|
||||
| 🔌 [Fournisseurs & Modèles](docs/fr/providers.md) | 20+ fournisseurs LLM, routage de modèles, configuration model_list, architecture des fournisseurs |
|
||||
| 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents |
|
||||
| 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
|
||||
| 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec |
|
||||
| 📋 [Compatibilité Matérielle](docs/hardware-compatibility.md) | Cartes testées, exigences minimales, comment ajouter votre carte |
|
||||
Le WebUI Launcher fournit une interface basée sur navigateur pour la configuration et le chat. C'est la façon la plus simple de démarrer — aucune connaissance de la ligne de commande requise.
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le Réseau Social d'Agents
|
||||
**Option 1 : Double-clic (Bureau)**
|
||||
|
||||
Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
||||
Après téléchargement depuis [picoclaw.io](https://picoclaw.io), double-cliquez sur `picoclaw-launcher` (ou `picoclaw-launcher.exe` sous Windows). Votre navigateur s'ouvrira automatiquement sur `http://localhost:18800`.
|
||||
|
||||
**Option 2 : Ligne de commande**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Ouvrez http://localhost:18800 dans votre navigateur
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Accès distant / Docker / VM :** Ajoutez le flag `-public` pour écouter sur toutes les interfaces :
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Pour commencer :**
|
||||
|
||||
Ouvrez le WebUI, puis : **1)** Configurez un Provider (ajoutez votre clé API LLM) -> **2)** Configurez un Channel (ex. Telegram) -> **3)** Démarrez le Gateway -> **4)** Chattez !
|
||||
|
||||
Pour la documentation détaillée du WebUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternative)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Cloner ce dépôt
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête
|
||||
# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Le conteneur affiche "First-run setup complete." et s'arrête.
|
||||
|
||||
# 3. Définir vos clés API
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Démarrer
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Ouvrez http://localhost:18800
|
||||
```
|
||||
|
||||
> **Utilisateurs Docker / VM :** Le Gateway écoute sur `127.0.0.1` par défaut. Définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou utilisez le flag `-public` pour le rendre accessible depuis l'hôte.
|
||||
|
||||
```bash
|
||||
# Vérifier les logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Arrêter
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Mettre à jour
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
|
||||
|
||||
Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Pour commencer :**
|
||||
|
||||
Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
|
||||
|
||||
Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
||||
|
||||
**Option 1 : Termux (disponible maintenant)**
|
||||
|
||||
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
|
||||
2. Exécutez les commandes suivantes :
|
||||
|
||||
```bash
|
||||
# Télécharger la dernière version
|
||||
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 # chroot fournit une arborescence Linux standard
|
||||
```
|
||||
|
||||
Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Option 2 : Installation APK (bientôt disponible)**
|
||||
|
||||
Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute !
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
||||
|
||||
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
||||
|
||||
**1. Initialiser**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||
|
||||
**2. Configurer** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Voir `config/config.example.json` dans le dépôt pour un modèle de configuration complet avec toutes les options disponibles.
|
||||
|
||||
**3. Chatter**
|
||||
|
||||
```bash
|
||||
# Question ponctuelle
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Mode interactif
|
||||
picoclaw agent
|
||||
|
||||
# Démarrer le gateway pour l'intégration d'applications de chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Utilisez le format `protocole/modèle` :
|
||||
|
||||
| Provider | Protocole | Clé API | Notes |
|
||||
|----------|-----------|---------|-------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Requise | GPT-5.4, GPT-4o, o3, etc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Requise | Claude Opus 4.6, Sonnet 4.6, etc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Requise | Gemini 3 Flash, 2.5 Pro, etc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Requise | 200+ modèles, API unifiée |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Requise | GLM-4.7, GLM-5, etc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Requise | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Requise | Modèles Doubao, Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Requise | Qwen3, Qwen-Max, etc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Requise | Inférence rapide (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Requise | Modèles Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Requise | Modèles MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Requise | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Requise | Modèles hébergés NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Requise | Inférence rapide |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Requise | Divers modèles open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Non requise | Modèles locaux, auto-hébergé |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non requise | Déploiement local, compatible OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variable | Proxy pour 100+ providers |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Requise | Déploiement Azure entreprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Connexion par code appareil |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Déploiement local (Ollama, vLLM, etc.)</b></summary>
|
||||
|
||||
**Ollama :**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM :**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Applications de chat)
|
||||
|
||||
Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
|
||||
|
||||
| Channel | Configuration | Protocole | Docs |
|
||||
|---------|---------------|-----------|------|
|
||||
| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) |
|
||||
| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) |
|
||||
| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) |
|
||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) |
|
||||
| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) |
|
||||
| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) |
|
||||
| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) |
|
||||
| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) |
|
||||
| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) |
|
||||
| **WeCom Bot** | Moyen (URL webhook) | Webhook | [Guide](docs/channels/wecom/wecom_bot/README.fr.md) |
|
||||
| **WeCom App** | Moyen (identifiants corp) | Webhook | [Guide](docs/channels/wecom/wecom_app/README.fr.md) |
|
||||
| **WeCom AI Bot** | Moyen (token + clé AES) | WebSocket / Webhook | [Guide](docs/channels/wecom/wecom_aibot/README.fr.md) |
|
||||
| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) |
|
||||
| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) |
|
||||
| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) |
|
||||
| **Pico** | Facile (activer) | Protocole natif | Intégré |
|
||||
| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré |
|
||||
|
||||
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
||||
|
||||
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||
|
||||
## 🔧 Outils
|
||||
|
||||
### 🔍 Recherche Web
|
||||
|
||||
PicoClaw peut effectuer des recherches sur le web pour fournir des informations à jour. Configurez dans `tools.web` :
|
||||
|
||||
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
||||
|--------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois |
|
||||
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
|
||||
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
|
||||
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Non requise | Auto-hébergé | Métamoteur de recherche gratuit |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Requise | Variable | Recherche web Zhipu |
|
||||
|
||||
### ⚙️ Autres outils
|
||||
|
||||
PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Les Skills sont des capacités modulaires qui étendent votre Agent. Elles sont chargées depuis les fichiers `SKILL.md` dans votre workspace.
|
||||
|
||||
**Installer des Skills depuis ClawHub :**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configurer le token ClawHub** (optionnel, pour des limites de débit plus élevées) :
|
||||
|
||||
Ajoutez à votre `config.json` :
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de votre Agent avec des outils et sources de données externes.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le réseau social des Agents
|
||||
|
||||
Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
||||
|
||||
**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Référence CLI
|
||||
|
||||
| Commande | Description |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `picoclaw onboard` | Initialiser la config & le workspace |
|
||||
| `picoclaw agent -m "..."` | Discuter avec l'agent |
|
||||
| `picoclaw agent` | Mode chat interactif |
|
||||
| `picoclaw gateway` | Démarrer le gateway |
|
||||
| `picoclaw status` | Afficher le statut |
|
||||
| `picoclaw version` | Afficher les infos de version |
|
||||
| `picoclaw cron list` | Lister les tâches planifiées |
|
||||
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
|
||||
| `picoclaw cron disable` | Désactiver une tâche planifiée |
|
||||
| `picoclaw cron remove` | Supprimer une tâche planifiée |
|
||||
| `picoclaw skills list` | Lister les compétences installées |
|
||||
| `picoclaw skills install` | Installer une compétence |
|
||||
| `picoclaw migrate` | Migrer les données des anciennes versions |
|
||||
| `picoclaw auth login` | S'authentifier auprès des fournisseurs |
|
||||
| `picoclaw model` | Voir ou changer le modèle par défaut |
|
||||
| Commande | Description |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Initialiser la config & le workspace |
|
||||
| `picoclaw onboard weixin` | Connecter un compte WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chatter avec l'agent |
|
||||
| `picoclaw agent` | Mode chat interactif |
|
||||
| `picoclaw gateway` | Démarrer le gateway |
|
||||
| `picoclaw status` | Afficher le statut |
|
||||
| `picoclaw version` | Afficher les informations de version |
|
||||
| `picoclaw model` | Voir ou changer le modèle par défaut |
|
||||
| `picoclaw cron list` | Lister toutes les tâches planifiées |
|
||||
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
|
||||
| `picoclaw cron disable` | Désactiver une tâche planifiée |
|
||||
| `picoclaw cron remove` | Supprimer une tâche planifiée |
|
||||
| `picoclaw skills list` | Lister les Skills installées |
|
||||
| `picoclaw skills install` | Installer une Skill |
|
||||
| `picoclaw migrate` | Migrer les données depuis d'anciennes versions |
|
||||
| `picoclaw auth login` | S'authentifier auprès des providers |
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
### ⏰ Tâches planifiées / Rappels
|
||||
|
||||
PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :
|
||||
PicoClaw supporte les rappels planifiés et les tâches récurrentes via l'outil `cron` :
|
||||
|
||||
* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min
|
||||
* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures
|
||||
* **Expressions cron** : « Rappelle-moi à 9h chaque jour » → utilise une expression cron
|
||||
* **Rappels ponctuels** : "Rappelle-moi dans 10 minutes" -> se déclenche une fois après 10 min
|
||||
* **Tâches récurrentes** : "Rappelle-moi toutes les 2 heures" -> se déclenche toutes les 2 heures
|
||||
* **Expressions cron** : "Rappelle-moi à 9h chaque jour" -> utilise une expression cron
|
||||
|
||||
## 🤝 Contribuer & Feuille de Route
|
||||
## 📚 Documentation
|
||||
|
||||
Les PR sont les bienvenues ! Le code est intentionnellement petit et lisible. 🤗
|
||||
Pour des guides détaillés au-delà de ce README :
|
||||
|
||||
Consultez notre [Feuille de Route Communautaire](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) complète.
|
||||
| Sujet | Description |
|
||||
|-------|-------------|
|
||||
| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent |
|
||||
| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels |
|
||||
| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité |
|
||||
| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list |
|
||||
| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones |
|
||||
| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
|
||||
| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
|
||||
| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
|
||||
| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
|
||||
| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills |
|
||||
| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales |
|
||||
|
||||
Groupe de développeurs en construction, rejoignez-nous après votre première PR fusionnée !
|
||||
## 🤝 Contribuer & Roadmap
|
||||
|
||||
Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible.
|
||||
|
||||
Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives.
|
||||
|
||||
Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée !
|
||||
|
||||
Groupes d'utilisateurs :
|
||||
|
||||
discord : <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord : <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat :
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
+453
-123
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||
|
||||
<h3>Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!</h3>
|
||||
<h3>Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!</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">
|
||||
@@ -24,135 +24,125 @@
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
|
||||
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
|
||||
|
||||
🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode.
|
||||
**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode.
|
||||
|
||||
⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini!
|
||||
**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari 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>
|
||||
<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]
|
||||
> **🚨 KEAMANAN & SALURAN RESMI**
|
||||
>
|
||||
> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**.
|
||||
> **Peringatan Keamanan**
|
||||
>
|
||||
> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**.
|
||||
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
|
||||
> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga.
|
||||
> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0.
|
||||
> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil.
|
||||
> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
|
||||
> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
|
||||
> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil.
|
||||
|
||||
## 📢 Berita
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**!
|
||||
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model.
|
||||
2026-03-09 🎉 **v0.2.1 — Update terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, routing model.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI.
|
||||
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.
|
||||
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
||||
|
||||
<details>
|
||||
<summary>Berita lama...</summary>
|
||||
<summary>Berita sebelumnya...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting.
|
||||
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
|
||||
|
||||
2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan.
|
||||
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!
|
||||
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Fitur
|
||||
|
||||
🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.*
|
||||
🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari OpenClaw.*
|
||||
|
||||
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
|
||||
|
||||
⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz.
|
||||
⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz.
|
||||
|
||||
🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan!
|
||||
🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
|
||||
|
||||
🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
|
||||
🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
|
||||
|
||||
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent.
|
||||
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent.
|
||||
|
||||
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal.
|
||||
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal.
|
||||
|
||||
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
|
||||
|
||||
_*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._
|
||||
_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Bahasa** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Startup**</br>(0,8GHz core) | >500d | >30d | **<1d** |
|
||||
| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC </br>~$50 | **Semua Board Linux**</br>**Mulai dari $10** |
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Bahasa** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
||||
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Demonstrasi
|
||||
|
||||
### 🛠️ Alur Kerja Asisten Standar
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Full-Stack Engineer</p></th>
|
||||
<th><p align="center">🗂️ Pencatatan & Manajemen Perencanaan</p></th>
|
||||
<th><p align="center">🔎 Pencarian Web & Pembelajaran</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">Develop • Deploy • Scale</td>
|
||||
<td align="center">Jadwal • Otomasi • Memori</td>
|
||||
<td align="center">Penemuan • Wawasan • Tren</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<th><p align="center">Mode Full-Stack Engineer</p></th>
|
||||
<th><p align="center">Pencatatan & Perencanaan</p></th>
|
||||
<th><p align="center">Pencarian Web & Pembelajaran</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">Develop · Deploy · Scale</td>
|
||||
<td align="center">Jadwal · Otomasi · Ingat</td>
|
||||
<td align="center">Temukan · Wawasan · Tren</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Jalankan di HP Android Lama
|
||||
|
||||
Berikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat:
|
||||
|
||||
1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play).
|
||||
2. **Jalankan perintah**
|
||||
|
||||
```bash
|
||||
# Unduh rilis terbaru dari 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
|
||||
```
|
||||
|
||||
Kemudian ikuti instruksi di bagian "Panduan Cepat" untuk menyelesaikan konfigurasi!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Deploy Inovatif dengan Footprint Rendah
|
||||
|
||||
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), untuk pengawasan cerdas
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
@@ -160,11 +150,15 @@ PicoClaw dapat di-deploy di hampir semua perangkat Linux!
|
||||
|
||||
## 📦 Instalasi
|
||||
|
||||
### Instal dengan binary yang sudah dikompilasi
|
||||
### Unduh dari picoclaw.io (Direkomendasikan)
|
||||
|
||||
Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
|
||||
|
||||
### Instal dari source (fitur terbaru, disarankan untuk pengembangan)
|
||||
### Unduh binary yang sudah dikompilasi
|
||||
|
||||
Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Build dari source (untuk pengembangan)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
@@ -172,78 +166,414 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build, tidak perlu instal
|
||||
# Build binary inti
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Build untuk berbagai platform
|
||||
make build-all
|
||||
|
||||
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build dan Instal
|
||||
# Build dan instal
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
|
||||
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
|
||||
|
||||
## 📚 Dokumentasi
|
||||
## 🚀 Panduan Memulai Cepat
|
||||
|
||||
Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat.
|
||||
### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
|
||||
|
||||
| Topik | Deskripsi |
|
||||
|-------|-----------|
|
||||
| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat |
|
||||
| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya |
|
||||
| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat |
|
||||
| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider |
|
||||
| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||
| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||
| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec |
|
||||
WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
|
||||
|
||||
**Opsi 1: Klik dua kali (Desktop)**
|
||||
|
||||
Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
|
||||
|
||||
**Opsi 2: Command line**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Buka http://localhost:18800 di browser Anda
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternatif)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone repo ini
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
|
||||
# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Container mencetak "First-run setup complete." dan berhenti.
|
||||
|
||||
# 3. Atur API key Anda
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Mulai
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Buka http://localhost:18800
|
||||
```
|
||||
|
||||
> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
|
||||
|
||||
```bash
|
||||
# Cek log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Hentikan
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Update
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||
|
||||
**Opsi 1: Termux (tersedia sekarang)**
|
||||
|
||||
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||
2. Jalankan perintah berikut:
|
||||
|
||||
```bash
|
||||
# Unduh rilis terbaru
|
||||
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 # chroot menyediakan tata letak filesystem Linux standar
|
||||
```
|
||||
|
||||
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opsi 2: Instal APK (segera hadir)**
|
||||
|
||||
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||
|
||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||
|
||||
**1. Inisialisasi**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||
|
||||
**2. Konfigurasi** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
|
||||
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
# Pertanyaan satu kali
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Mode interaktif
|
||||
picoclaw agent
|
||||
|
||||
# Mulai gateway untuk integrasi aplikasi chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
|
||||
|
||||
| Provider | Protocol | API Key | Catatan |
|
||||
|----------|----------|---------|---------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy lokal (Ollama, vLLM, dll.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Aplikasi Chat)
|
||||
|
||||
Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||
|
||||
| Channel | Pengaturan | Protocol | Dokumentasi |
|
||||
|---------|------------|----------|-------------|
|
||||
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Sedang (webhook URL) | Webhook | [Panduan](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Sedang (corp credentials) | Webhook | [Panduan](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Sedang (token + AES key) | WebSocket / Webhook | [Panduan](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
||||
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
||||
|
||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||
|
||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 Pencarian Web
|
||||
|
||||
PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
|
||||
|
||||
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||
|--------------|---------|-------------|--------|
|
||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
|
||||
|
||||
### ⚙️ Tools Lainnya
|
||||
|
||||
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
|
||||
|
||||
**Instal skills dari ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
|
||||
|
||||
Tambahkan ke `config.json` Anda:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
||||
|
||||
Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi.
|
||||
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
||||
|
||||
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Referensi CLI
|
||||
|
||||
| Perintah | Deskripsi |
|
||||
| ------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||
| Perintah | Deskripsi |
|
||||
| -------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||
| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||
| `picoclaw agent` | Mode chat interaktif |
|
||||
| `picoclaw gateway` | Mulai gateway |
|
||||
| `picoclaw status` | Tampilkan status |
|
||||
| `picoclaw version` | Tampilkan info versi |
|
||||
| `picoclaw cron list` | Daftar semua tugas terjadwal |
|
||||
| `picoclaw cron add ...` | Tambah tugas terjadwal |
|
||||
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
|
||||
| `picoclaw cron remove` | Hapus tugas terjadwal |
|
||||
| `picoclaw skills list` | Daftar skill yang terinstal |
|
||||
| `picoclaw skills install` | Instal skill |
|
||||
| `picoclaw migrate` | Migrasi data dari versi lama |
|
||||
| `picoclaw auth login` | Autentikasi dengan provider |
|
||||
| `picoclaw agent` | Mode chat interaktif |
|
||||
| `picoclaw gateway` | Mulai gateway |
|
||||
| `picoclaw status` | Tampilkan status |
|
||||
| `picoclaw version` | Tampilkan info versi |
|
||||
| `picoclaw model` | Lihat atau ganti model default |
|
||||
| `picoclaw cron list` | Daftar semua tugas terjadwal |
|
||||
| `picoclaw cron add ...` | Tambah tugas terjadwal |
|
||||
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
|
||||
| `picoclaw cron remove` | Hapus tugas terjadwal |
|
||||
| `picoclaw skills list` | Daftar skill yang terinstal |
|
||||
| `picoclaw skills install` | Instal skill |
|
||||
| `picoclaw migrate` | Migrasi data dari versi lama |
|
||||
| `picoclaw auth login` | Autentikasi dengan provider |
|
||||
|
||||
### Tugas Terjadwal / Pengingat
|
||||
### ⏰ Tugas Terjadwal / Pengingat
|
||||
|
||||
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
|
||||
|
||||
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit
|
||||
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam
|
||||
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron
|
||||
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit
|
||||
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam
|
||||
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron
|
||||
|
||||
## 📚 Dokumentasi
|
||||
|
||||
Untuk panduan lengkap di luar README ini:
|
||||
|
||||
| Topik | Deskripsi |
|
||||
|-------|-----------|
|
||||
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
||||
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
||||
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
||||
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
||||
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
||||
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
||||
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
||||
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
||||
|
||||
## 🤝 Kontribusi & Roadmap
|
||||
|
||||
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗
|
||||
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
||||
|
||||
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami.
|
||||
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||
|
||||
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
||||
|
||||
Grup Pengguna:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
+435
-106
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||
|
||||
<h3>Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾,我们走!</h3>
|
||||
<h3>Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!</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">
|
||||
@@ -24,135 +24,125 @@
|
||||
|
||||
---
|
||||
|
||||
> **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 progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — 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.
|
||||
**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la 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!
|
||||
**Funziona su hardware da $10 con <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>
|
||||
<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**
|
||||
> **Avviso di Sicurezza**
|
||||
>
|
||||
> * **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.
|
||||
> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. 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 stati registrati da terze parti. Non fidarti di essi.
|
||||
> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
|
||||
> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
|
||||
|
||||
## 📢 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-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
|
||||
|
||||
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-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.
|
||||
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
||||
|
||||
<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-16 🎉 PicoClaw supera 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-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi 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!
|
||||
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Caratteristiche
|
||||
|
||||
🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo delle funzionalità principali di OpenClaw.*
|
||||
🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a 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.
|
||||
⚡️ **Avvio Fulmineo**: 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!
|
||||
🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
|
||||
|
||||
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo.
|
||||
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
|
||||
|
||||
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent.
|
||||
🔌 **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.
|
||||
👁️ **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)._
|
||||
_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. 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** |
|
||||
<div align="center">
|
||||
|
||||
| | 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 ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 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>
|
||||
<tr align="center">
|
||||
<th><p align="center">Modalità Ingegnere Full-Stack</p></th>
|
||||
<th><p align="center">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
|
||||
- $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 la sorveglianza intelligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
@@ -160,11 +150,15 @@ PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
|
||||
|
||||
## 📦 Installazione
|
||||
|
||||
### Installa con binario precompilato
|
||||
### Scarica da picoclaw.io (Consigliato)
|
||||
|
||||
Scarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
|
||||
|
||||
### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo)
|
||||
### Scarica il binario precompilato
|
||||
|
||||
In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Compila dai sorgenti (per lo sviluppo)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
@@ -172,34 +166,348 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compila, senza installare
|
||||
# Compila il binario core
|
||||
make build
|
||||
|
||||
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||
make build-launcher
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
**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
|
||||
## 🚀 Guida Rapida
|
||||
|
||||
Per guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido.
|
||||
### 🌐 WebUI Launcher (Consigliato per Desktop)
|
||||
|
||||
| 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 |
|
||||
Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
|
||||
|
||||
**Opzione 1: Doppio clic (Desktop)**
|
||||
|
||||
Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
|
||||
|
||||
**Opzione 2: Riga di comando**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Apri http://localhost:18800 nel browser
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
|
||||
|
||||
Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternativa)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clona questo repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
|
||||
# (si attiva solo quando sia config.json che workspace/ sono assenti)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Il container stampa "First-run setup complete." e si ferma.
|
||||
|
||||
# 3. Imposta le tue API key
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Avvia
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Apri http://localhost:18800
|
||||
```
|
||||
|
||||
> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
|
||||
|
||||
```bash
|
||||
# Controlla i log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Ferma
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Aggiorna
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||
|
||||
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||
|
||||
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||
|
||||
**Opzione 1: Termux (disponibile ora)**
|
||||
|
||||
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 seguenti comandi:
|
||||
|
||||
```bash
|
||||
# Scarica l'ultima release
|
||||
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 # chroot fornisce un layout standard del filesystem Linux
|
||||
```
|
||||
|
||||
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opzione 2: APK Install (prossimamente)**
|
||||
|
||||
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||
|
||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||
|
||||
**1. Inizializza**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||
|
||||
**2. Configura** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
|
||||
|
||||
**3. Chatta**
|
||||
|
||||
```bash
|
||||
# Domanda singola
|
||||
picoclaw agent -m "Quanto fa 2+2?"
|
||||
|
||||
# Modalità interattiva
|
||||
picoclaw agent
|
||||
|
||||
# Avvia il gateway per l'integrazione con app di chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Provider (LLM)
|
||||
|
||||
PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
|
||||
|
||||
| Provider | Protocollo | API Key | Note |
|
||||
|----------|------------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy locale (Ollama, vLLM, ecc.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channel (App di Chat)
|
||||
|
||||
Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||
|
||||
| Channel | Configurazione | Protocollo | Docs |
|
||||
|---------|----------------|------------|------|
|
||||
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
|
||||
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Medio (webhook URL) | Webhook | [Guida](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Medio (credenziali aziendali) | Webhook | [Guida](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Medio (token + AES key) | WebSocket / Webhook | [Guida](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
||||
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
||||
|
||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||
|
||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Strumenti
|
||||
|
||||
### 🔍 Ricerca Web
|
||||
|
||||
PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
|
||||
|
||||
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||
|-------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
|
||||
|
||||
### ⚙️ Altri Strumenti
|
||||
|
||||
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
|
||||
|
||||
## 🎯 Skill
|
||||
|
||||
Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
|
||||
|
||||
**Installa skill da ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
|
||||
|
||||
Aggiungi al tuo `config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
||||
|
||||
@@ -212,11 +520,13 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
||||
| Comando | Descrizione |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `picoclaw onboard` | Inizializza config & workspace |
|
||||
| `picoclaw onboard weixin` | Connetti account WeChat tramite QR |
|
||||
| `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 model` | Visualizza o cambia il modello predefinito |
|
||||
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||
@@ -226,24 +536,43 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
||||
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
|
||||
| `picoclaw auth login` | Autenticazione con i provider |
|
||||
|
||||
### Task Pianificati / Promemoria
|
||||
### ⏰ 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
|
||||
* **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
|
||||
|
||||
## 📚 Documentazione
|
||||
|
||||
Per guide dettagliate oltre questo README:
|
||||
|
||||
| Argomento | Descrizione |
|
||||
|-----------|-------------|
|
||||
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
||||
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
||||
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
|
||||
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
||||
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
||||
|
||||
## 🤝 Contribuisci & Roadmap
|
||||
|
||||
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗
|
||||
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.
|
||||
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
|
||||
|
||||
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
||||
|
||||
Gruppi utenti:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
+405
-84
@@ -3,7 +3,7 @@
|
||||
|
||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||
|
||||
<h3>$10 ハードウェア · <10MB RAM · <1秒起動 · 行くぜ、シャコ!</h3>
|
||||
<h3>$10 ハードウェア · 10MB RAM · ms 起動 · Let's Go, PicoClaw!</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">
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
> **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。
|
||||
|
||||
🦐 PicoClaw は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
||||
**PicoClaw** は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。**Go** でゼロからリビルドされ、「セルフブートストラッピング」プロセスで構築されました — AI Agent 自身がアーキテクチャの移行とコード最適化を推進しました。
|
||||
|
||||
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||
**$10 のハードウェアで 10MB 未満の RAM で動作** — OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
@@ -46,24 +46,23 @@
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **🚨 セキュリティ&公式チャンネル**
|
||||
> **セキュリティに関する注意**
|
||||
>
|
||||
> * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。
|
||||
>
|
||||
> * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。
|
||||
> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。
|
||||
> * **注意:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。
|
||||
> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。信頼しないでください。
|
||||
> * **注記:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。
|
||||
> * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります(10〜20MB)。機能セットが安定次第、リソース最適化を優先する予定です。
|
||||
|
||||
## 📢 ニュース
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的ゲートウェイホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
|
||||
2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート!** MCP プロトコル対応、4 つの新チャネル(Matrix/IRC/WeCom/Discord Proxy)、3 つの新プロバイダー(Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。
|
||||
2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート!** MCP プロトコル対応、4 つの新 Channel(Matrix/IRC/WeCom/Discord Proxy)、3 つの新 Provider(Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。
|
||||
|
||||
2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI ランチャー。
|
||||
2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI Launcher。
|
||||
|
||||
2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!チャネル自動オーケストレーションとケイパビリティインターフェースが実装されました。
|
||||
2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。
|
||||
|
||||
<details>
|
||||
<summary>過去のニュース...</summary>
|
||||
@@ -72,82 +71,71 @@
|
||||
|
||||
2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。
|
||||
|
||||
2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
|
||||
2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI Agent を 1 日で構築。Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
🪶 **超軽量**: メモリフットプリント 10MB 未満 — OpenClaw のコア機能より 99% 小さい。*
|
||||
🪶 **超軽量**: コアメモリフットプリント 10MB 未満 — OpenClaw より 99% 小さい。*
|
||||
|
||||
💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
|
||||
|
||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒未満で起動。
|
||||
⚡️ **超高速起動**: 起動時間 400 倍高速。0.6GHz シングルコアでも 1 秒未満で起動。
|
||||
|
||||
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。どこでも動く!
|
||||
|
||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||
🤖 **AI ブートストラップ**: 純粋な Go ネイティブ実装 — コアコードの 95% が Agent によって生成され、人間によるレビューで調整。
|
||||
|
||||
🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続してエージェント機能を拡張。
|
||||
🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続して Agent 機能を拡張。
|
||||
|
||||
👁️ **ビジョンパイプライン**: 画像やファイルをエージェントに直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。
|
||||
👁️ **ビジョンパイプライン**: 画像やファイルを Agent に直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。
|
||||
|
||||
🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。
|
||||
|
||||
_*最近のバージョンでは急速な機能マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_
|
||||
_*最近のバージョンでは急速な PR マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **言語** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||
| **コスト** | Mac Mini $599 | 大半の Linux SBC </br>~$50 | **あらゆる Linux ボード**</br>**最安 $10** |
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **言語** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||
| **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**</br>**最安 $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[ハードウェア互換性リスト](docs/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
|
||||
</div>
|
||||
|
||||
> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 デモンストレーション
|
||||
|
||||
### 🛠️ スタンダードアシスタントワークフロー
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 フルスタックエンジニア</p></th>
|
||||
<th><p align="center">🗂️ ログ&計画管理</p></th>
|
||||
<th><p align="center">🔎 Web 検索&学習</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">開発 · デプロイ · スケール</td>
|
||||
<td align="center">スケジュール · 自動化 · メモリ</td>
|
||||
<td align="center">発見 · インサイト · トレンド</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<th><p align="center">フルスタックエンジニアモード</p></th>
|
||||
<th><p align="center">ログ&計画管理</p></th>
|
||||
<th><p align="center">Web 検索&学習</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">開発 · デプロイ · スケール</td>
|
||||
<td align="center">スケジュール · 自動化 · メモリ</td>
|
||||
<td align="center">発見 · インサイト · トレンド</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 古い Android スマホで動かす
|
||||
|
||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。クイックスタート:
|
||||
|
||||
1. **[Termux](https://github.com/termux/termux-app) をインストール**([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)。
|
||||
2. **コマンドを実行**
|
||||
|
||||
```bash
|
||||
# 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 # chroot で標準的な Linux ファイルシステムレイアウトを提供
|
||||
```
|
||||
|
||||
その後「クイックスタート」セクションの手順に従って設定を完了してください!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 革新的な省フットプリントデプロイ
|
||||
|
||||
PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||
@@ -178,9 +166,12 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# ビルド(インストール不要)
|
||||
# コアバイナリをビルド
|
||||
make build
|
||||
|
||||
# Web UI Launcher をビルド(WebUI モードに必要)
|
||||
make build-launcher
|
||||
|
||||
# 複数プラットフォーム向けビルド
|
||||
make build-all
|
||||
|
||||
@@ -193,20 +184,330 @@ make install
|
||||
|
||||
**Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください:32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。
|
||||
|
||||
## 📚 ドキュメント
|
||||
## 🚀 クイックスタートガイド
|
||||
|
||||
詳細なガイドは以下のドキュメントを参照してください。この README はクイックスタートのみをカバーしています。
|
||||
### 🌐 WebUI Launcher(デスクトップ向け推奨)
|
||||
|
||||
| トピック | 説明 |
|
||||
|---------|------|
|
||||
| 🐳 [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード、クイックスタート設定 |
|
||||
| 💬 [チャットアプリ](docs/ja/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、DingTalk、LINE、Feishu、WeCom など |
|
||||
| ⚙️ [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、スキルソース、セキュリティサンドボックス、ハートビート |
|
||||
| 🔌 [プロバイダー&モデル](docs/ja/providers.md) | 20 以上の LLM プロバイダー、モデルルーティング、model_list 設定、プロバイダーアーキテクチャ |
|
||||
| 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
|
||||
| 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
|
||||
| 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー |
|
||||
| 📋 [ハードウェア互換性](docs/hardware-compatibility.md) | テスト済みボード、最小要件、ボードの追加方法 |
|
||||
WebUI Launcher はブラウザベースの設定・チャットインターフェースを提供します。コマンドラインの知識不要で、最も簡単に始められる方法です。
|
||||
|
||||
**オプション 1: ダブルクリック(デスクトップ)**
|
||||
|
||||
[picoclaw.io](https://picoclaw.io) からダウンロード後、`picoclaw-launcher`(Windows では `picoclaw-launcher.exe`)をダブルクリックしてください。ブラウザが自動的に `http://localhost:18800` を開きます。
|
||||
|
||||
**オプション 2: コマンドライン**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# ブラウザで http://localhost:18800 を開く
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **リモートアクセス / Docker / VM:** すべてのインターフェースでリッスンするには `-public` フラグを追加してください:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**始め方:**
|
||||
|
||||
WebUI を開いたら:**1)** Provider を設定(LLM API キーを追加)→ **2)** Channel を設定(例:Telegram)→ **3)** Gateway を起動 → **4)** チャット!
|
||||
|
||||
WebUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
|
||||
|
||||
<details>
|
||||
<summary><b>Docker(代替手段)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. このリポジトリをクローン
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 初回実行 — docker/data/config.json を自動生成して終了
|
||||
# (config.json と workspace/ の両方が存在しない場合のみ実行)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# コンテナが "First-run setup complete." を出力して停止します。
|
||||
|
||||
# 3. API キーを設定
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. 起動
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# http://localhost:18800 を開く
|
||||
```
|
||||
|
||||
> **Docker / VM ユーザー:** Gateway はデフォルトで `127.0.0.1` でリッスンします。ホストからアクセスできるようにするには `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`-public` フラグを使用してください。
|
||||
|
||||
```bash
|
||||
# ログを確認
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# 停止
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# 更新
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨)
|
||||
|
||||
TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**始め方:**
|
||||
|
||||
TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
|
||||
|
||||
TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
|
||||
|
||||
### 📱 Android
|
||||
|
||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
||||
|
||||
**オプション 1: Termux(現在利用可能)**
|
||||
|
||||
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
|
||||
2. 以下のコマンドを実行:
|
||||
|
||||
```bash
|
||||
# 最新リリースをダウンロード
|
||||
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 # chroot で標準的な Linux ファイルシステムレイアウトを提供
|
||||
```
|
||||
|
||||
その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**オプション 2: APK インストール(近日公開)**
|
||||
|
||||
内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(リソース制約環境向け)</b></summary>
|
||||
|
||||
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
`~/.picoclaw/config.json` とワークスペースディレクトリが作成されます。
|
||||
|
||||
**2. 設定** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 利用可能なすべてのオプションを含む完全な設定テンプレートは、リポジトリの `config/config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
|
||||
```bash
|
||||
# ワンショット質問
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# インタラクティブモード
|
||||
picoclaw agent
|
||||
|
||||
# チャットアプリ統合用 Gateway を起動
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Provider(LLM)
|
||||
|
||||
PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポートしています。`protocol/model` 形式を使用してください:
|
||||
|
||||
| Provider | Protocol | API キー | 備考 |
|
||||
|----------|----------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必須 | GPT-5.4、GPT-4o、o3 など |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必須 | Claude Opus 4.6、Sonnet 4.6 など |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必須 | Gemini 3 Flash、2.5 Pro など |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必須 | 200 以上のモデル、統合 API |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必須 | GLM-4.7、GLM-5 など |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必須 | DeepSeek-V3、DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | 必須 | Doubao、Ark モデル |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必須 | Qwen3、Qwen-Max など |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | 必須 | 高速推論(Llama、Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必須 | Kimi モデル |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必須 | MiniMax モデル |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必須 | Mistral Large、Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必須 | NVIDIA ホスティングモデル |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必須 | 高速推論 |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | 必須 | 各種オープンモデル |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | 不要 | ローカルモデル、セルフホスト |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | 不要 | ローカルデプロイ、OpenAI 互換 |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 場合による | 100 以上の Provider のプロキシ |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必須 | エンタープライズ Azure デプロイ |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | デバイスコードログイン |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>ローカルデプロイ(Ollama、vLLM など)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channel(チャットアプリ)
|
||||
|
||||
17 以上のメッセージングプラットフォームで PicoClaw と会話できます:
|
||||
|
||||
| Channel | セットアップ | Protocol | ドキュメント |
|
||||
|---------|------------|----------|------------|
|
||||
| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) |
|
||||
| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) |
|
||||
| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) |
|
||||
| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) |
|
||||
| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) |
|
||||
| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) |
|
||||
| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) |
|
||||
| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) |
|
||||
| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) |
|
||||
| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) |
|
||||
| **WeCom Bot** | 中級(webhook URL) | Webhook | [ガイド](docs/channels/wecom/wecom_bot/README.ja.md) |
|
||||
| **WeCom App** | 中級(corp 認証情報) | Webhook | [ガイド](docs/channels/wecom/wecom_app/README.ja.md) |
|
||||
| **WeCom AI Bot** | 中級(トークン + AES キー) | WebSocket / Webhook | [ガイド](docs/channels/wecom/wecom_aibot/README.ja.md) |
|
||||
| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) |
|
||||
| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) |
|
||||
| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) |
|
||||
| **Pico** | 簡単(有効化) | Native protocol | 内蔵 |
|
||||
| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 |
|
||||
|
||||
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
|
||||
|
||||
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
|
||||
|
||||
## 🔧 ツール
|
||||
|
||||
### 🔍 Web 検索
|
||||
|
||||
PicoClaw は最新情報を提供するために Web を検索できます。`tools.web` で設定してください:
|
||||
|
||||
| 検索エンジン | API キー | 無料枠 | リンク |
|
||||
|------------|---------|--------|-------|
|
||||
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 |
|
||||
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
|
||||
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
||||
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | 不要 | セルフホスト | 無料メタ検索エンジン |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | 必須 | 場合による | Zhipu Web 検索 |
|
||||
|
||||
### ⚙️ その他のツール
|
||||
|
||||
PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。
|
||||
|
||||
## 🎯 Skill
|
||||
|
||||
Skill は Agent を拡張するモジュール型の機能です。ワークスペース内の `SKILL.md` ファイルから読み込まれます。
|
||||
|
||||
**ClawHub から Skill をインストール:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**ClawHub トークンを設定**(オプション、レート制限を上げるため):
|
||||
|
||||
`config.json` に追加:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。
|
||||
|
||||
## 🔗 MCP(Model Context Protocol)
|
||||
|
||||
PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポートしています — 任意の MCP サーバーに接続して、外部ツールやデータソースで Agent の機能を拡張できます。
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> エージェントソーシャルネットワークに参加
|
||||
|
||||
@@ -219,22 +520,23 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
|
||||
| コマンド | 説明 |
|
||||
| ------------------------- | ------------------------------ |
|
||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||
| `picoclaw agent -m "..."` | エージェントとチャット |
|
||||
| `picoclaw onboard weixin` | WeChat アカウントを QR で接続 |
|
||||
| `picoclaw agent -m "..."` | Agent とチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | ゲートウェイを起動 |
|
||||
| `picoclaw gateway` | Gateway を起動 |
|
||||
| `picoclaw status` | ステータスを表示 |
|
||||
| `picoclaw version` | バージョン情報を表示 |
|
||||
| `picoclaw model` | デフォルトモデルの表示・切替 |
|
||||
| `picoclaw cron list` | スケジュールジョブ一覧 |
|
||||
| `picoclaw cron add ...` | スケジュールジョブを追加 |
|
||||
| `picoclaw cron disable` | スケジュールジョブを無効化 |
|
||||
| `picoclaw cron remove` | スケジュールジョブを削除 |
|
||||
| `picoclaw skills list` | インストール済みスキル一覧 |
|
||||
| `picoclaw skills install` | スキルをインストール |
|
||||
| `picoclaw skills list` | インストール済み Skill 一覧 |
|
||||
| `picoclaw skills install` | Skill をインストール |
|
||||
| `picoclaw migrate` | 旧バージョンからデータを移行 |
|
||||
| `picoclaw auth login` | プロバイダーへの認証 |
|
||||
| `picoclaw model` | デフォルトモデルの表示・切替 |
|
||||
| `picoclaw auth login` | Provider への認証 |
|
||||
|
||||
### スケジュールタスク / リマインダー
|
||||
### ⏰ スケジュールタスク / リマインダー
|
||||
|
||||
PicoClaw は `cron` ツールによるスケジュールリマインダーと定期タスクをサポートしています:
|
||||
|
||||
@@ -242,16 +544,35 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定
|
||||
* **定期タスク**: 「2時間ごとにリマインド」→ 2時間ごとにトリガー
|
||||
* **Cron 式**: 「毎日9時にリマインド」→ cron 式を使用
|
||||
|
||||
## 📚 ドキュメント
|
||||
|
||||
この README を超えた詳細なガイドについては:
|
||||
|
||||
| トピック | 説明 |
|
||||
|---------|------|
|
||||
| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード |
|
||||
| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド |
|
||||
| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス |
|
||||
| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 |
|
||||
| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
|
||||
| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
|
||||
| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 |
|
||||
| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
|
||||
| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
|
||||
| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill |
|
||||
| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 |
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。
|
||||
|
||||
完全な[コミュニティロードマップ](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)をご覧ください。
|
||||
[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
開発者グループ構築中、最初の PR がマージされたら参加できます!
|
||||
|
||||
ユーザーグループ:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
+459
-138
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||
|
||||
<h3>Hardware de $10 · <10MB de RAM · Boot em <1s · 皮皮虾,我们走!</h3>
|
||||
<h3>Hardware de $10 · 10MB de RAM · Boot em ms · Let's Go, PicoClaw!</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">
|
||||
@@ -24,149 +24,137 @@
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
|
||||
> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com), escrito inteiramente em **Go** do zero — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
|
||||
|
||||
🦐 PicoClaw é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em Go por meio de um processo de auto-inicialização (self-bootstrapping), onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
|
||||
**PicoClaw** é um assistente de IA pessoal ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot). Foi reconstruído do zero em **Go** por meio de um processo de "auto-bootstrapping" — o próprio AI Agent conduziu a migração de arquitetura e a otimização do código.
|
||||
|
||||
⚡️ Roda em hardware de $10 com <10MB de RAM: Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
|
||||
**Roda em hardware de $10 com menos de 10MB de RAM** — isso é 99% menos memória que o OpenClaw e 98% mais barato que um 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>
|
||||
<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]
|
||||
> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**
|
||||
> **Aviso de Segurança**
|
||||
>
|
||||
> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.
|
||||
>
|
||||
> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**
|
||||
> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros.
|
||||
> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.
|
||||
> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10–20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.
|
||||
> * **SEM CRIPTO:** O PicoClaw **não** emitiu nenhum token oficial ou criptomoeda. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **golpes**.
|
||||
> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATENÇÃO:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. Não confie neles.
|
||||
> * **NOTA:** O PicoClaw está em desenvolvimento rápido inicial. Podem existir problemas de segurança não resolvidos. Não implante em produção antes da v1.0.
|
||||
> * **NOTA:** O PicoClaw mesclou muitos PRs recentemente. Builds recentes podem usar 10-20MB de RAM. A otimização de recursos está planejada após a estabilização de funcionalidades.
|
||||
|
||||
## 📢 Novidades
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Lançado!** Interface de bandeja do sistema (Windows & Linux), rastreamento de status de sub-agentes (`spawn_status`), hot-reload experimental do gateway, portões de segurança para cron e 2 correções de segurança. PicoClaw agora com **25K ⭐**!
|
||||
2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos canais (Matrix/IRC/WeCom/Discord Proxy), 3 novos provedores (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL e roteamento de modelos.
|
||||
2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos channels (Matrix/IRC/WeCom/Discord Proxy), 3 novos providers (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL, roteamento de modelos.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** lançado com suporte a Docker Compose e launcher Web UI.
|
||||
2026-02-28 📦 **v0.2.0** lançada com suporte a Docker Compose e Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw atingiu **20K stars** em apenas 17 dias! Orquestração automática de canais e interfaces de capacidade implementadas.
|
||||
2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis.
|
||||
|
||||
<details>
|
||||
<summary>Novidades anteriores...</summary>
|
||||
<summary>Notícias anteriores...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Papéis de maintainers da comunidade e [roadmap](ROADMAP.md) publicados oficialmente.
|
||||
2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente.
|
||||
|
||||
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Roadmap do Projeto e Grupo de Desenvolvedores em preparação.
|
||||
2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
|
||||
2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para levar AI Agents a hardware de $10 com menos de 10MB de RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Funcionalidades
|
||||
|
||||
🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o OpenClaw para funcionalidades essenciais.*
|
||||
🪶 **Ultra-leve**: Footprint de memória do núcleo <10MB — 99% menor que o OpenClaw.*
|
||||
|
||||
💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
|
||||
💰 **Custo mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
|
||||
|
||||
⚡️ **Inicialização Relâmpago**: Tempo de inicialização 400X mais rápido, boot em <1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||
⚡️ **Boot ultrarrápido**: Inicialização 400x mais rápida. Boot em menos de 1s mesmo em um processador single-core de 0,6GHz.
|
||||
|
||||
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
|
||||
🌍 **Verdadeiramente portátil**: Binário único para arquiteturas RISC-V, ARM, MIPS e x86. Um binário, roda em qualquer lugar!
|
||||
|
||||
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||
🤖 **Bootstrapped por IA**: Implementação nativa pura em Go — 95% do código principal foi gerado por um Agent e refinado por revisão humana.
|
||||
|
||||
🔌 **Suporte MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do agente.
|
||||
🔌 **Suporte a MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do Agent.
|
||||
|
||||
👁️ **Pipeline de Visão**: Envie imagens e arquivos diretamente ao agente — codificação base64 automática para LLMs multimodais.
|
||||
👁️ **Pipeline de visão**: Envie imagens e arquivos diretamente ao Agent — codificação base64 automática para LLMs multimodais.
|
||||
|
||||
🧠 **Roteamento Inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.
|
||||
🧠 **Roteamento inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.
|
||||
|
||||
_*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalidades. Otimização de recursos está planejada. Comparação de inicialização baseada em benchmarks de single-core a 0.8GHz (veja tabela abaixo)._
|
||||
_*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimização de recursos está planejada. Comparação de velocidade de boot baseada em benchmarks de single-core a 0,8GHz (veja tabela abaixo)._
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Linguagem** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Inicialização**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Custo** | Mac Mini $599 | Maioria dos SBC Linux </br>~$50 | **Qualquer placa Linux**</br>**A partir de $10** |
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Linguagem** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Tempo de boot**</br>(core 0,8GHz) | >500s | >30s | **<1s** |
|
||||
| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**</br>**a partir de $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[Lista de Compatibilidade de Hardware](docs/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 a Raspberry Pi e telefones Android. Sua placa não está listada? Envie um PR!
|
||||
</div>
|
||||
|
||||
> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Demonstração
|
||||
|
||||
### 🛠️ Fluxos de Trabalho Padrão do Assistente
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Engenharia Full-Stack</p></th>
|
||||
<th><p align="center">🗂️ Gerenciamento de Logs & Planejamento</p></th>
|
||||
<th><p align="center">🔎 Busca Web & Aprendizado</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">Desenvolver • Implantar • Escalar</td>
|
||||
<td align="center">Agendar • Automatizar • Memorizar</td>
|
||||
<td align="center">Descobrir • Analisar • Tendências</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<th><p align="center">Modo Engenheiro Full-Stack</p></th>
|
||||
<th><p align="center">Registro e Planejamento</p></th>
|
||||
<th><p align="center">Busca na Web e Aprendizado</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">Desenvolver · Implantar · Escalar</td>
|
||||
<td align="center">Agendar · Automatizar · Lembrar</td>
|
||||
<td align="center">Descobrir · Insights · Tendências</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Rode em celulares Android antigos
|
||||
|
||||
Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:
|
||||
|
||||
1. **Instale o [Termux](https://github.com/termux/termux-app)** (Baixe em [GitHub Releases](https://github.com/termux/termux-app/releases), ou busque no F-Droid / Google Play).
|
||||
2. **Execute os comandos**
|
||||
|
||||
```bash
|
||||
# Baixe a versão mais recente em 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 # chroot fornece um layout padrão do sistema de arquivos Linux
|
||||
```
|
||||
|
||||
Depois siga as instruções na seção "Início Rápido" para completar a configuração!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Implantação Inovadora com Baixo Consumo
|
||||
### 🐜 Implantação Inovadora de Baixo Consumo
|
||||
|
||||
O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E(Ethernet) ou W(WiFi6), para Assistente Doméstico Minimalista
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) edição E(Ethernet) ou W(WiFi6), para um assistente doméstico mínimo
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), para operações automatizadas de servidor
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), para vigilância inteligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Mais cenários de implantação aguardam você!
|
||||
🌟 Mais Casos de Implantação Aguardam!
|
||||
|
||||
## 📦 Instalação
|
||||
|
||||
### Baixar de picoclaw.io (Recomendado)
|
||||
### Download pelo picoclaw.io (Recomendado)
|
||||
|
||||
Visite **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e oferece download com um clique. Sem necessidade de escolher manualmente a arquitetura.
|
||||
Acesse **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e fornece download com um clique. Não é necessário selecionar a arquitetura manualmente.
|
||||
|
||||
### Baixar binário pré-compilado
|
||||
### Download do binário pré-compilado
|
||||
|
||||
Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
@@ -178,80 +166,413 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build, sem necessidade de instalar
|
||||
# Compilar o binário principal
|
||||
make build
|
||||
|
||||
# Build para múltiplas plataformas
|
||||
# Compilar o Web UI Launcher (necessário para o modo WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compilar para múltiplas plataformas
|
||||
make build-all
|
||||
|
||||
# Build para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build e Instalar
|
||||
# Compilar e instalar
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Use o binário correspondente ao seu SO: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.
|
||||
**Raspberry Pi Zero 2 W:** Use o binário que corresponde ao seu SO: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.
|
||||
|
||||
## 📚 Documentação
|
||||
## 🚀 Guia de Início Rápido
|
||||
|
||||
Para guias detalhados, consulte a documentação abaixo. Este README cobre apenas o início rápido.
|
||||
### 🌐 WebUI Launcher (Recomendado para Desktop)
|
||||
|
||||
| Tópico | Descrição |
|
||||
|--------|-----------|
|
||||
| 🐳 [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração Docker Compose, modos Launcher/Agent, configuração de Início Rápido |
|
||||
| 💬 [Apps de Chat](docs/pt-br/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e mais |
|
||||
| ⚙️ [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, estrutura do workspace, fontes de skills, sandbox de segurança, heartbeat |
|
||||
| 🔌 [Provedores & Modelos](docs/pt-br/providers.md) | 20+ provedores LLM, roteamento de modelos, configuração model_list, arquitetura de provedores |
|
||||
| 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes |
|
||||
| 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
|
||||
| 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução |
|
||||
| 📋 [Compatibilidade de Hardware](docs/hardware-compatibility.md) | Placas testadas, requisitos mínimos, como adicionar sua placa |
|
||||
O WebUI Launcher fornece uma interface baseada em navegador para configuração e chat. Esta é a maneira mais fácil de começar — sem necessidade de conhecimento de linha de comando.
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se à Rede Social de Agentes
|
||||
**Opção 1: Duplo clique (Desktop)**
|
||||
|
||||
Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||
Após baixar de [picoclaw.io](https://picoclaw.io), dê duplo clique em `picoclaw-launcher` (ou `picoclaw-launcher.exe` no Windows). Seu navegador abrirá automaticamente em `http://localhost:18800`.
|
||||
|
||||
**Opção 2: Linha de comando**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Abra http://localhost:18800 no seu navegador
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Acesso remoto / Docker / VM:** Adicione a flag `-public` para escutar em todas as interfaces:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Primeiros passos:**
|
||||
|
||||
Abra o WebUI e então: **1)** Configure um Provider (adicione sua API key de LLM) -> **2)** Configure um Channel (ex.: Telegram) -> **3)** Inicie o Gateway -> **4)** Converse!
|
||||
|
||||
Para documentação detalhada do WebUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternativa)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone este repositório
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra
|
||||
# (só é acionado quando config.json e workspace/ estão ausentes)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# O container imprime "First-run setup complete." e para.
|
||||
|
||||
# 3. Configure suas API keys
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Iniciar
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Abra http://localhost:18800
|
||||
```
|
||||
|
||||
> **Usuários de Docker / VM:** O Gateway escuta em `127.0.0.1` por padrão. Defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou use a flag `-public` para torná-lo acessível pelo host.
|
||||
|
||||
```bash
|
||||
# Verificar logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Parar
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Atualizar
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recomendado para Headless / SSH)
|
||||
|
||||
O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Primeiros passos:**
|
||||
|
||||
Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
|
||||
|
||||
Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
||||
|
||||
**Opção 1: Termux (disponível agora)**
|
||||
|
||||
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
|
||||
2. Execute os seguintes comandos:
|
||||
|
||||
```bash
|
||||
# Baixar a versão mais recente
|
||||
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 # chroot fornece um layout padrão de sistema de arquivos Linux
|
||||
```
|
||||
|
||||
Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opção 2: Instalação via APK (em breve)**
|
||||
|
||||
Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
||||
|
||||
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
||||
|
||||
**1. Inicializar**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||
|
||||
**2. Configurar** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Veja `config/config.example.json` no repositório para um template de configuração completo com todas as opções disponíveis.
|
||||
|
||||
**3. Conversar**
|
||||
|
||||
```bash
|
||||
# Pergunta única
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Modo interativo
|
||||
picoclaw agent
|
||||
|
||||
# Iniciar gateway para integração com app de chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
O PicoClaw suporta mais de 30 providers de LLM através da configuração `model_list`. Use o formato `protocolo/modelo`:
|
||||
|
||||
| Provider | Protocolo | API Key | Notas |
|
||||
|----------|-----------|---------|-------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Obrigatória | GPT-5.4, GPT-4o, o3, etc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Obrigatória | Claude Opus 4.6, Sonnet 4.6, etc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Obrigatória | Gemini 3 Flash, 2.5 Pro, etc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Obrigatória | 200+ modelos, API unificada |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Obrigatória | GLM-4.7, GLM-5, etc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Obrigatória | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Obrigatória | Modelos Doubao, Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Obrigatória | Qwen3, Qwen-Max, etc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Obrigatória | Inferência rápida (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Obrigatória | Modelos Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Obrigatória | Modelos MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Obrigatória | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Obrigatória | Modelos hospedados pela NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Obrigatória | Inferência rápida |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Obrigatória | Vários modelos abertos |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Não necessária | Modelos locais, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Não necessária | Implantação local, compatível com OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varia | Proxy para 100+ providers |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Obrigatória | Implantação Azure Enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login por código de dispositivo |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Implantação local (Ollama, vLLM, etc.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Apps de Chat)
|
||||
|
||||
Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
|
||||
|
||||
| Channel | Configuração | Protocolo | Docs |
|
||||
|---------|--------------|-----------|------|
|
||||
| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) |
|
||||
| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) |
|
||||
| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) |
|
||||
| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) |
|
||||
| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) |
|
||||
| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) |
|
||||
| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) |
|
||||
| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) |
|
||||
| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) |
|
||||
| **WeCom Bot** | Médio (webhook URL) | Webhook | [Guia](docs/channels/wecom/wecom_bot/README.pt-br.md) |
|
||||
| **WeCom App** | Médio (credenciais corporativas) | Webhook | [Guia](docs/channels/wecom/wecom_app/README.pt-br.md) |
|
||||
| **WeCom AI Bot** | Médio (token + chave AES) | WebSocket / Webhook | [Guia](docs/channels/wecom/wecom_aibot/README.pt-br.md) |
|
||||
| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) |
|
||||
| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) |
|
||||
| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) |
|
||||
| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
|
||||
| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
|
||||
|
||||
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
||||
|
||||
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||
|
||||
## 🔧 Ferramentas
|
||||
|
||||
### 🔍 Busca na Web
|
||||
|
||||
O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Configure em `tools.web`:
|
||||
|
||||
| Motor de Busca | API Key | Nível Gratuito | Link |
|
||||
|----------------|---------|----------------|------|
|
||||
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
|
||||
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
|
||||
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Não necessária | Self-hosted | Metabuscador gratuito |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Obrigatória | Varia | Busca web Zhipu |
|
||||
|
||||
### ⚙️ Outras Ferramentas
|
||||
|
||||
O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills são capacidades modulares que estendem seu Agent. Elas são carregadas a partir de arquivos `SKILL.md` no seu workspace.
|
||||
|
||||
**Instalar skills do ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configurar token do ClawHub** (opcional, para limites de taxa mais altos):
|
||||
|
||||
Adicione ao seu `config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do seu Agent com ferramentas externas e fontes de dados.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se à Rede Social de Agents
|
||||
|
||||
Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||
|
||||
**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Referência CLI
|
||||
|
||||
| Comando | Descrição |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `picoclaw onboard` | Inicializar configuração & workspace |
|
||||
| `picoclaw agent -m "..."` | Conversar com o agente |
|
||||
| `picoclaw agent` | Modo de chat interativo |
|
||||
| `picoclaw gateway` | Iniciar o gateway |
|
||||
| `picoclaw status` | Mostrar status |
|
||||
| `picoclaw version` | Mostrar informações de versão |
|
||||
| `picoclaw cron list` | Listar todas as tarefas agendadas |
|
||||
| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
|
||||
| `picoclaw cron disable` | Desabilitar uma tarefa agendada |
|
||||
| `picoclaw cron remove` | Remover uma tarefa agendada |
|
||||
| `picoclaw skills list` | Listar skills instaladas |
|
||||
| `picoclaw skills install` | Instalar uma skill |
|
||||
| `picoclaw migrate` | Migrar dados de versões anteriores |
|
||||
| `picoclaw auth login` | Autenticar com provedores |
|
||||
| `picoclaw model` | Ver ou trocar o modelo padrão |
|
||||
| Comando | Descrição |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `picoclaw onboard` | Inicializar config e workspace |
|
||||
| `picoclaw onboard weixin` | Conectar conta WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Conversar com o agent |
|
||||
| `picoclaw agent` | Modo de chat interativo |
|
||||
| `picoclaw gateway` | Iniciar o gateway |
|
||||
| `picoclaw status` | Exibir status |
|
||||
| `picoclaw version` | Exibir informações de versão |
|
||||
| `picoclaw model` | Ver ou trocar o modelo padrão |
|
||||
| `picoclaw cron list` | Listar todos os jobs agendados |
|
||||
| `picoclaw cron add ...` | Adicionar um job agendado |
|
||||
| `picoclaw cron disable` | Desabilitar um job agendado |
|
||||
| `picoclaw cron remove` | Remover um job agendado |
|
||||
| `picoclaw skills list` | Listar skills instaladas |
|
||||
| `picoclaw skills install` | Instalar uma skill |
|
||||
| `picoclaw migrate` | Migrar dados de versões anteriores |
|
||||
| `picoclaw auth login` | Autenticar com providers |
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
### ⏰ Tarefas Agendadas / Lembretes
|
||||
|
||||
O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
|
||||
O PicoClaw suporta lembretes agendados e tarefas recorrentes através da ferramenta `cron`:
|
||||
|
||||
* **Lembretes únicos**: "Me lembre em 10 minutos" → dispara uma vez após 10min
|
||||
* **Tarefas recorrentes**: "Me lembre a cada 2 horas" → dispara a cada 2 horas
|
||||
* **Expressões Cron**: "Me lembre às 9h todos os dias" → usa expressão cron
|
||||
* **Lembretes únicos**: "Lembre-me em 10 minutos" -> dispara uma vez após 10min
|
||||
* **Tarefas recorrentes**: "Lembre-me a cada 2 horas" -> dispara a cada 2 horas
|
||||
* **Expressões cron**: "Lembre-me às 9h diariamente" -> usa expressão cron
|
||||
|
||||
## 📚 Documentação
|
||||
|
||||
Para guias detalhados além deste README:
|
||||
|
||||
| Tópico | Descrição |
|
||||
|--------|-----------|
|
||||
| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent |
|
||||
| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels |
|
||||
| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança |
|
||||
| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list |
|
||||
| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents |
|
||||
| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação |
|
||||
| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução |
|
||||
| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
|
||||
| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
|
||||
| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills |
|
||||
| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos |
|
||||
|
||||
## 🤝 Contribuir & Roadmap
|
||||
|
||||
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗
|
||||
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível.
|
||||
|
||||
Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completo.
|
||||
Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes.
|
||||
|
||||
Grupo de desenvolvedores em formação. Junte-se após seu primeiro PR com merge!
|
||||
Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado!
|
||||
|
||||
Grupos de usuários:
|
||||
Grupos de Usuários:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
+463
-142
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||
|
||||
<h3>Phần cứng $10 · <10MB RAM · Khởi động <1 giây · Nào, xuất phát!</h3>
|
||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động ms · Let's Go, PicoClaw!</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">
|
||||
@@ -24,153 +24,141 @@
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
|
||||
> **PicoClaw** là một dự án mã nguồn mở độc lập do [Sipeed](https://sipeed.com) khởi xướng, được viết hoàn toàn bằng **Go** từ đầu — không phải fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
|
||||
|
||||
🦐 PicoClaw là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng Go thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
|
||||
**PicoClaw** là trợ lý AI cá nhân siêu nhẹ lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot). Nó được xây dựng lại từ đầu bằng **Go** thông qua quá trình "tự khởi động" — chính AI Agent đã dẫn dắt quá trình di chuyển kiến trúc và tối ưu hóa mã nguồn.
|
||||
|
||||
⚡️ Chạy trên phần cứng chỉ $10 với RAM <10MB: Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
|
||||
**Chạy trên phần cứng $10 với <10MB RAM** — ít hơn 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với 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>
|
||||
<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]
|
||||
> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
|
||||
> **Thông báo Bảo mật**
|
||||
>
|
||||
> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
|
||||
>
|
||||
> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**
|
||||
> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký.
|
||||
> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
|
||||
> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
|
||||
> * **KHÔNG CÓ CRYPTO:** PicoClaw **chưa** phát hành bất kỳ token hay tiền điện tử chính thức nào. Mọi thông tin trên `pump.fun` hoặc các nền tảng giao dịch khác đều là **lừa đảo**.
|
||||
> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, và website công ty là **[sipeed.com](https://sipeed.com)**
|
||||
> * **CẢNH BÁO:** Nhiều domain `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. Đừng tin tưởng chúng.
|
||||
> * **LƯU Ý:** PicoClaw đang trong giai đoạn phát triển nhanh. Có thể còn các vấn đề bảo mật chưa được giải quyết. Không triển khai lên môi trường production trước v1.0.
|
||||
> * **LƯU Ý:** PicoClaw gần đây đã merge nhiều PR. Các bản build gần đây có thể dùng 10-20MB RAM. Tối ưu hóa tài nguyên được lên kế hoạch sau khi tính năng ổn định.
|
||||
|
||||
## 📢 Tin tức
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Phát hành!** Giao diện khay hệ thống (Windows & Linux), theo dõi trạng thái sub-agent (`spawn_status`), hot-reload gateway thử nghiệm, cổng bảo mật cron và 2 bản vá bảo mật. PicoClaw đạt **25K ⭐**!
|
||||
2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất!** Hỗ trợ giao thức MCP, 4 kênh mới (Matrix/IRC/WeCom/Discord Proxy), 3 nhà cung cấp mới (Kimi/Minimax/Avian), pipeline xử lý hình ảnh, bộ nhớ JSONL và định tuyến mô hình.
|
||||
2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất từ trước đến nay!** Hỗ trợ giao thức MCP, 4 Channel mới (Matrix/IRC/WeCom/Discord Proxy), 3 Provider mới (Kimi/Minimax/Avian), pipeline thị giác, bộ nhớ JSONL, định tuyến mô hình.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và launcher Web UI.
|
||||
2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw đạt **20K stars** chỉ trong 17 ngày! Tự động điều phối kênh và giao diện năng lực đã được triển khai.
|
||||
2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động.
|
||||
|
||||
<details>
|
||||
<summary>Tin tức cũ hơn...</summary>
|
||||
<summary>Tin tức trước đó...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Vai trò maintainer cộng đồng và [roadmap](ROADMAP.md) đã được công bố chính thức.
|
||||
2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt.
|
||||
|
||||
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Lộ trình dự án và Nhóm phát triển đang được thiết lập.
|
||||
2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw chính thức ra mắt!** Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
|
||||
2026-02-09 🎉 **PicoClaw ra mắt!** Được xây dựng trong 1 ngày để đưa AI Agent lên phần cứng $10 với <10MB RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Tính năng nổi bật
|
||||
## ✨ Tính năng
|
||||
|
||||
🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với OpenClaw (chức năng cốt lõi).*
|
||||
🪶 **Siêu nhẹ**: Bộ nhớ lõi <10MB — nhỏ hơn 99% so với OpenClaw.*
|
||||
|
||||
💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
|
||||
|
||||
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong <1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||
⚡️ **Khởi động cực nhanh**: Khởi động nhanh hơn 400 lần. Khởi động trong <1 giây ngay cả trên bộ xử lý đơn nhân 0.6GHz.
|
||||
|
||||
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
|
||||
🌍 **Thực sự di động**: Một binary duy nhất cho các kiến trúc RISC-V, ARM, MIPS và x86. Một binary, chạy mọi nơi!
|
||||
|
||||
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||
🤖 **Được AI khởi động**: Triển khai Go thuần túy — 95% mã lõi được tạo bởi Agent và tinh chỉnh qua quy trình human-in-the-loop.
|
||||
|
||||
🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ máy chủ MCP nào để mở rộng khả năng của agent.
|
||||
🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent.
|
||||
|
||||
👁️ **Pipeline Xử lý Hình ảnh**: Gửi hình ảnh và tệp trực tiếp cho agent — tự động mã hóa base64 cho các LLM đa phương thức.
|
||||
👁️ **Pipeline thị giác**: Gửi hình ảnh và tệp trực tiếp đến Agent — tự động mã hóa base64 cho LLM đa phương thức.
|
||||
|
||||
🧠 **Định tuyến Thông minh**: Định tuyến mô hình dựa trên quy tắc — truy vấn đơn giản chuyển đến mô hình nhẹ, tiết kiệm chi phí API.
|
||||
🧠 **Định tuyến thông minh**: Định tuyến mô hình dựa trên quy tắc — các truy vấn đơn giản đến mô hình nhẹ, tiết kiệm chi phí API.
|
||||
|
||||
_*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính năng nhanh chóng. Tối ưu tài nguyên đang được lên kế hoạch. So sánh thời gian khởi động dựa trên benchmark đơn nhân 0.8GHz (xem bảng bên dưới)._
|
||||
_*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối ưu hóa tài nguyên đang được lên kế hoạch. So sánh tốc độ khởi động dựa trên benchmark lõi đơn 0.8GHz (xem bảng bên dưới)._
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Ngôn ngữ** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Thời gian khởi động**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**</br>**Chỉ từ $10** |
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Ngôn ngữ** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Thời gian khởi động**</br>(lõi 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**</br>**từ $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[Danh Sách Tương Thích Phần Cứng](docs/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi và điện thoại Android. Board của bạn chưa có? Gửi PR!
|
||||
</div>
|
||||
|
||||
## 🦾 Demo
|
||||
> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
|
||||
|
||||
### 🛠️ Quy trình trợ lý tiêu chuẩn
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Minh họa
|
||||
|
||||
### 🛠️ Quy trình Trợ lý Tiêu chuẩn
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Lập trình Full-Stack</p></th>
|
||||
<th><p align="center">🗂️ Quản lý Nhật ký & Kế hoạch</p></th>
|
||||
<th><p align="center">🔎 Tìm kiếm Web & Học hỏi</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">Phát triển • Triển khai • Mở rộng</td>
|
||||
<td align="center">Lên lịch • Tự động hóa • Ghi nhớ</td>
|
||||
<td align="center">Khám phá • Phân tích • Xu hướng</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<th><p align="center">Chế độ Kỹ sư Full-Stack</p></th>
|
||||
<th><p align="center">Ghi nhật ký & Lập kế hoạch</p></th>
|
||||
<th><p align="center">Tìm kiếm Web & Học tập</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">Phát triển · Triển khai · Mở rộng</td>
|
||||
<td align="center">Lên lịch · Tự động hóa · Ghi nhớ</td>
|
||||
<td align="center">Khám phá · Thông tin · Xu hướng</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Chạy trên điện thoại Android cũ
|
||||
### 🐜 Triển khai Sáng tạo với Dấu chân Nhỏ
|
||||
|
||||
Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thành trợ lý AI thông minh với PicoClaw. Bắt đầu nhanh:
|
||||
PicoClaw có thể được triển khai trên hầu hết mọi thiết bị Linux!
|
||||
|
||||
1. **Cài đặt [Termux](https://github.com/termux/termux-app)** (Tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm trên F-Droid / Google Play).
|
||||
2. **Chạy các lệnh**
|
||||
|
||||
```bash
|
||||
# Tải phiên bản mới nhất từ 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 # chroot cung cấp bố cục hệ thống tệp Linux tiêu chuẩn
|
||||
```
|
||||
|
||||
Sau đó làm theo hướng dẫn trong phần "Bắt đầu nhanh" để hoàn tất cấu hình!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
|
||||
|
||||
PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), dùng làm Trợ lý Gia đình tối giản
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) dùng cho quản trị Server tự động
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) dùng cho Giám sát thông minh
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), cho trợ lý gia đình tối giản
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), cho vận hành máy chủ tự động
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), cho giám sát thông minh
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
|
||||
🌟 Còn nhiều trường hợp triển khai đang chờ đón!
|
||||
|
||||
## 📦 Cài đặt
|
||||
|
||||
### Tải từ picoclaw.io (Khuyến nghị)
|
||||
### Tải xuống từ picoclaw.io (Khuyến nghị)
|
||||
|
||||
Truy cập **[picoclaw.io](https://picoclaw.io)** — trang web chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
|
||||
Truy cập **[picoclaw.io](https://picoclaw.io)** — website chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
|
||||
|
||||
### Tải binary đã biên dịch sẵn
|
||||
### Tải xuống binary đã biên dịch sẵn
|
||||
|
||||
Hoặc tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Biên dịch từ mã nguồn (cho phát triển)
|
||||
### Xây dựng từ mã nguồn (để phát triển)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
@@ -178,80 +166,413 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build (không cần cài đặt)
|
||||
# Build core binary
|
||||
make build
|
||||
|
||||
# Build cho nhiều nền tảng
|
||||
# Build Web UI Launcher (required for WebUI mode)
|
||||
make build-launcher
|
||||
|
||||
# Build for multiple platforms
|
||||
make build-all
|
||||
|
||||
# Build cho Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build và cài đặt
|
||||
# Build and install
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để build cả hai.
|
||||
**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai.
|
||||
|
||||
## 📚 Tài liệu
|
||||
## 🚀 Hướng dẫn Khởi động Nhanh
|
||||
|
||||
Để xem hướng dẫn chi tiết, tham khảo tài liệu bên dưới. README này chỉ bao gồm phần bắt đầu nhanh.
|
||||
### 🌐 WebUI Launcher (Khuyến nghị cho Desktop)
|
||||
|
||||
| Chủ đề | Mô tả |
|
||||
|--------|-------|
|
||||
| 🐳 [Docker & Bắt đầu nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent, cấu hình Bắt đầu nhanh |
|
||||
| 💬 [Ứng dụng Chat](docs/vi/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom và nhiều hơn |
|
||||
| ⚙️ [Cấu hình](docs/vi/configuration.md) | Biến môi trường, cấu trúc workspace, nguồn skill, sandbox bảo mật, heartbeat |
|
||||
| 🔌 [Nhà cung cấp & Mô hình](docs/vi/providers.md) | 20+ nhà cung cấp LLM, định tuyến mô hình, cấu hình model_list, kiến trúc nhà cung cấp |
|
||||
| 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
|
||||
| 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
|
||||
| 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi |
|
||||
| 📋 [Tương Thích Phần Cứng](docs/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu, cách thêm board |
|
||||
WebUI Launcher cung cấp giao diện dựa trên trình duyệt để cấu hình và trò chuyện. Đây là cách dễ nhất để bắt đầu — không cần kiến thức dòng lệnh.
|
||||
|
||||
**Tùy chọn 1: Nhấp đúp (Desktop)**
|
||||
|
||||
Sau khi tải xuống từ [picoclaw.io](https://picoclaw.io), nhấp đúp vào `picoclaw-launcher` (hoặc `picoclaw-launcher.exe` trên Windows). Trình duyệt của bạn sẽ tự động mở tại `http://localhost:18800`.
|
||||
|
||||
**Tùy chọn 2: Dòng lệnh**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Mở http://localhost:18800 trong trình duyệt của bạn
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Truy cập từ xa / Docker / VM:** Thêm cờ `-public` để lắng nghe trên tất cả giao diện:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Bắt đầu:**
|
||||
|
||||
Mở WebUI, sau đó: **1)** Cấu hình Provider (thêm API key LLM của bạn) -> **2)** Cấu hình Channel (ví dụ: Telegram) -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||
|
||||
Để biết tài liệu WebUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (thay thế)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. First run — auto-generates docker/data/config.json then exits
|
||||
# (only triggers when both config.json and workspace/ are missing)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# The container prints "First-run setup complete." and stops.
|
||||
|
||||
# 3. Set your API keys
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Start
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Open http://localhost:18800
|
||||
```
|
||||
|
||||
> **Người dùng Docker / VM:** Gateway lắng nghe trên `127.0.0.1` theo mặc định. Đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` hoặc dùng cờ `-public` để có thể truy cập từ host.
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Update
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Bắt đầu:**
|
||||
|
||||
Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||
|
||||
Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
||||
|
||||
**Tùy chọn 1: Termux (có sẵn ngay)**
|
||||
|
||||
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
|
||||
2. Chạy các lệnh sau:
|
||||
|
||||
```bash
|
||||
# Download the latest release
|
||||
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 # chroot provides a standard Linux filesystem layout
|
||||
```
|
||||
|
||||
Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Tùy chọn 2: Cài đặt APK (sắp ra mắt)**
|
||||
|
||||
Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
||||
|
||||
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
||||
|
||||
**1. Khởi tạo**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||
|
||||
**2. Cấu hình** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Xem `config/config.example.json` trong repo để có mẫu cấu hình đầy đủ với tất cả các tùy chọn có sẵn.
|
||||
|
||||
**3. Trò chuyện**
|
||||
|
||||
```bash
|
||||
# One-shot question
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Interactive mode
|
||||
picoclaw agent
|
||||
|
||||
# Start gateway for chat app integration
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử dụng định dạng `protocol/model`:
|
||||
|
||||
| Provider | Protocol | API Key | Ghi chú |
|
||||
|----------|----------|---------|---------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Bắt buộc | GPT-5.4, GPT-4o, o3, v.v. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Bắt buộc | Claude Opus 4.6, Sonnet 4.6, v.v. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Bắt buộc | Gemini 3 Flash, 2.5 Pro, v.v. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Bắt buộc | 200+ mô hình, API thống nhất |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Bắt buộc | GLM-4.7, GLM-5, v.v. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Bắt buộc | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Bắt buộc | Doubao, Ark models |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Bắt buộc | Qwen3, Qwen-Max, v.v. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Bắt buộc | Suy luận nhanh (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Bắt buộc | Kimi models |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Bắt buộc | MiniMax models |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Bắt buộc | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Bắt buộc | Mô hình do NVIDIA lưu trữ |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Bắt buộc | Suy luận nhanh |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Bắt buộc | Nhiều mô hình mở |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Không cần | Mô hình cục bộ, tự lưu trữ |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Không cần | Triển khai cục bộ, tương thích OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Tùy | Proxy cho 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Bắt buộc | Triển khai Azure doanh nghiệp |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Đăng nhập bằng device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Triển khai cục bộ (Ollama, vLLM, v.v.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Ứng dụng Chat)
|
||||
|
||||
Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
|
||||
|
||||
| Channel | Thiết lập | Protocol | Tài liệu |
|
||||
|---------|-----------|----------|----------|
|
||||
| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) |
|
||||
| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) |
|
||||
| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) |
|
||||
| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) |
|
||||
| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) |
|
||||
| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) |
|
||||
| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) |
|
||||
| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) |
|
||||
| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) |
|
||||
| **WeCom Bot** | Trung bình (webhook URL) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_bot/README.vi.md) |
|
||||
| **WeCom App** | Trung bình (corp credentials) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_app/README.vi.md) |
|
||||
| **WeCom AI Bot** | Trung bình (token + AES key) | WebSocket / Webhook | [Hướng dẫn](docs/channels/wecom/wecom_aibot/README.vi.md) |
|
||||
| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) |
|
||||
| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) |
|
||||
| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) |
|
||||
| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
|
||||
| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
|
||||
|
||||
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
||||
|
||||
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 Tìm kiếm Web
|
||||
|
||||
PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. Cấu hình trong `tools.web`:
|
||||
|
||||
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|
||||
|------------------|---------|--------------|----------|
|
||||
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
|
||||
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
|
||||
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Không cần | Tự lưu trữ | Metasearch engine miễn phí |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Bắt buộc | Tùy | Tìm kiếm web Zhipu |
|
||||
|
||||
### ⚙️ Các Tools Khác
|
||||
|
||||
PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills là các khả năng mô-đun mở rộng Agent của bạn. Chúng được tải từ các tệp `SKILL.md` trong workspace của bạn.
|
||||
|
||||
**Cài đặt Skills từ ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Cấu hình token ClawHub** (tùy chọn, để có giới hạn tốc độ cao hơn):
|
||||
|
||||
Thêm vào `config.json` của bạn:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent của bạn với các tool và nguồn dữ liệu bên ngoài.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
|
||||
|
||||
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
|
||||
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp.
|
||||
|
||||
**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Tham chiếu CLI
|
||||
|
||||
| Lệnh | Mô tả |
|
||||
| -------------------------- | ------------------------------ |
|
||||
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||
| `picoclaw agent` | Chế độ chat tương tác |
|
||||
| `picoclaw gateway` | Khởi động gateway |
|
||||
| `picoclaw status` | Hiển thị trạng thái |
|
||||
| `picoclaw version` | Hiển thị thông tin phiên bản |
|
||||
| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
|
||||
| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
|
||||
| `picoclaw cron disable` | Tắt tác vụ định kỳ |
|
||||
| `picoclaw cron remove` | Xóa tác vụ định kỳ |
|
||||
| `picoclaw skills list` | Liệt kê các skill đã cài |
|
||||
| `picoclaw skills install` | Cài đặt một skill |
|
||||
| `picoclaw migrate` | Di chuyển dữ liệu từ phiên bản cũ |
|
||||
| `picoclaw auth login` | Xác thực với nhà cung cấp |
|
||||
| `picoclaw model` | Xem hoặc chuyển đổi model mặc định |
|
||||
| Lệnh | Mô tả |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||
| `picoclaw onboard weixin` | Kết nối tài khoản WeChat qua QR |
|
||||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||
| `picoclaw agent` | Chế độ trò chuyện tương tác |
|
||||
| `picoclaw gateway` | Khởi động gateway |
|
||||
| `picoclaw status` | Hiển thị trạng thái |
|
||||
| `picoclaw version` | Hiển thị thông tin phiên bản |
|
||||
| `picoclaw model` | Xem hoặc chuyển đổi mô hình mặc định |
|
||||
| `picoclaw cron list` | Liệt kê tất cả công việc đã lên lịch |
|
||||
| `picoclaw cron add ...` | Thêm công việc đã lên lịch |
|
||||
| `picoclaw cron disable` | Vô hiệu hóa công việc đã lên lịch |
|
||||
| `picoclaw cron remove` | Xóa công việc đã lên lịch |
|
||||
| `picoclaw skills list` | Liệt kê các Skill đã cài đặt |
|
||||
| `picoclaw skills install` | Cài đặt một Skill |
|
||||
| `picoclaw migrate` | Di chuyển dữ liệu từ các phiên bản cũ |
|
||||
| `picoclaw auth login` | Xác thực với các provider |
|
||||
|
||||
### Tác vụ định kỳ / Nhắc nhở
|
||||
### ⏰ Tác vụ Đã lên lịch / Nhắc nhở
|
||||
|
||||
PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
|
||||
PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ thông qua tool `cron`:
|
||||
|
||||
* **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" → kích hoạt một lần sau 10 phút
|
||||
* **Tác vụ lặp lại**: "Nhắc tôi mỗi 2 giờ" → kích hoạt mỗi 2 giờ
|
||||
* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng mỗi ngày" → sử dụng biểu thức cron
|
||||
* **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" -> kích hoạt một lần sau 10 phút
|
||||
* **Tác vụ định kỳ**: "Nhắc tôi mỗi 2 giờ" -> kích hoạt mỗi 2 giờ
|
||||
* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng hàng ngày" -> sử dụng biểu thức cron
|
||||
|
||||
## 📚 Tài liệu
|
||||
|
||||
Để biết các hướng dẫn chi tiết ngoài README này:
|
||||
|
||||
| Chủ đề | Mô tả |
|
||||
|--------|-------|
|
||||
| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent |
|
||||
| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel |
|
||||
| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật |
|
||||
| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list |
|
||||
| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
|
||||
| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
|
||||
| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
|
||||
| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
|
||||
| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills |
|
||||
| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu |
|
||||
|
||||
## 🤝 Đóng góp & Lộ trình
|
||||
|
||||
Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
|
||||
PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc.
|
||||
|
||||
Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) đầy đủ.
|
||||
Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn.
|
||||
|
||||
Nhóm phát triển đang được xây dựng. Tham gia sau khi có PR đầu tiên được merge!
|
||||
Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge!
|
||||
|
||||
Nhóm người dùng:
|
||||
Nhóm Người dùng:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
+368
-42
@@ -3,7 +3,7 @@
|
||||
|
||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||
|
||||
<h3>$10 硬件 · <10MB 内存 · <1s 启动 · 皮皮虾,我们走!</h3>
|
||||
<h3>$10 硬件 · 10MB 内存 · 毫秒启动 · 皮皮虾,我们走!</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">
|
||||
@@ -95,6 +95,8 @@
|
||||
|
||||
_*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测(见下方对比表)。_
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **语言** | TypeScript | Python | **Go** |
|
||||
@@ -104,7 +106,13 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[硬件兼容列表](docs/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
|
||||
</div>
|
||||
|
||||
> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 演示
|
||||
|
||||
@@ -128,25 +136,6 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 在手机上轻松运行
|
||||
|
||||
PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的 AI 助理!快速指南:
|
||||
|
||||
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid 等应用商店搜索)
|
||||
2. 打开后执行指令
|
||||
|
||||
```bash
|
||||
# 从 Release 页面下载最新版本
|
||||
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 # chroot 提供标准 Linux 文件系统布局
|
||||
```
|
||||
|
||||
然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
@@ -177,9 +166,12 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 构建(无需安装)
|
||||
# 构建核心二进制文件
|
||||
make build
|
||||
|
||||
# 构建 Web UI Launcher(WebUI 模式必需)
|
||||
make build-launcher
|
||||
|
||||
# 为多平台构建
|
||||
make build-all
|
||||
|
||||
@@ -192,20 +184,330 @@ make install
|
||||
|
||||
**Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。
|
||||
|
||||
## 📚 文档
|
||||
## 🚀 快速开始
|
||||
|
||||
详细指南请参阅以下文档,README 仅涵盖快速入门。
|
||||
### 🌐 WebUI Launcher(推荐桌面用户)
|
||||
|
||||
| 主题 | 说明 |
|
||||
|------|------|
|
||||
| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
|
||||
| 💬 [聊天应用配置](docs/zh/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、钉钉、LINE、飞书、企业微信等 |
|
||||
| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、技能来源、安全沙箱、心跳任务 |
|
||||
| 🔌 [提供商与模型配置](docs/zh/providers.md) | 20+ LLM 提供商、模型路由、model_list 配置、Provider 架构 |
|
||||
| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
|
||||
| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
|
||||
| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 |
|
||||
| 📋 [硬件兼容列表](docs/hardware-compatibility.md) | 已测试板卡、最低要求、如何添加你的板卡 |
|
||||
WebUI Launcher 提供基于浏览器的配置与聊天界面,是最简单的上手方式——无需命令行知识。
|
||||
|
||||
**方式一:双击启动(桌面)**
|
||||
|
||||
从 [picoclaw.io](https://picoclaw.io) 下载后,双击 `picoclaw-launcher`(Windows 上为 `picoclaw-launcher.exe`),浏览器将自动打开 `http://localhost:18800`。
|
||||
|
||||
**方式二:命令行**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# 在浏览器中打开 http://localhost:18800
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **远程访问 / Docker / 虚拟机:** 添加 `-public` 参数以监听所有网络接口:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**开始使用:**
|
||||
|
||||
打开 WebUI,然后:**1)** 配置 Provider(填入 LLM API Key)-> **2)** 配置 Channel(如 Telegram)-> **3)** 启动 Gateway -> **4)** 开始聊天!
|
||||
|
||||
详细 WebUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
|
||||
|
||||
<details>
|
||||
<summary><b>Docker(备选方案)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. 克隆本仓库
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 首次运行——自动生成 docker/data/config.json 后退出
|
||||
# (仅在 config.json 和 workspace/ 均不存在时触发)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# 容器打印 "First-run setup complete." 后停止。
|
||||
|
||||
# 3. 填写 API Key
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. 启动
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# 打开 http://localhost:18800
|
||||
```
|
||||
|
||||
> **Docker / 虚拟机用户:** Gateway 默认监听 `127.0.0.1`。设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或使用 `-public` 参数以允许从宿主机访问。
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# 停止
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# 更新
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher(推荐无头环境 / SSH)
|
||||
|
||||
TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**开始使用:**
|
||||
|
||||
通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天!
|
||||
|
||||
详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
|
||||
|
||||
### 📱 Android
|
||||
|
||||
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
||||
|
||||
**方式一:Termux(现已可用)**
|
||||
|
||||
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
|
||||
2. 执行以下命令:
|
||||
|
||||
```bash
|
||||
# 从 Release 页面下载最新版本
|
||||
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 # chroot 提供标准 Linux 文件系统布局
|
||||
```
|
||||
|
||||
然后跟随下面的"Terminal Launcher"章节继续配置。
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**方式二:APK 安装(即将推出)**
|
||||
|
||||
内置 WebUI 的独立 Android APK 正在开发中,敬请期待!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher(适用于资源受限环境)</b></summary>
|
||||
|
||||
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||
|
||||
**1. 初始化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
此命令会创建 `~/.picoclaw/config.json` 和工作区目录。
|
||||
|
||||
**2. 配置** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 完整配置模板请参阅仓库中的 `config/config.example.json`。
|
||||
|
||||
**3. 开始聊天**
|
||||
|
||||
```bash
|
||||
# 单次提问
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# 交互式对话模式
|
||||
picoclaw agent
|
||||
|
||||
# 启动 Gateway 以接入聊天应用
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模型` 格式:
|
||||
|
||||
| Provider | 协议 | API Key | 备注 |
|
||||
|----------|------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必填 | GPT-5.4、GPT-4o、o3 等 |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必填 | Claude Opus 4.6、Sonnet 4.6 等 |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必填 | Gemini 3 Flash、2.5 Pro 等 |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必填 | 200+ 模型,统一 API |
|
||||
| [智谱 (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必填 | GLM-4.7、GLM-5 等 |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必填 | DeepSeek-V3、DeepSeek-R1 |
|
||||
| [火山引擎](https://console.volcengine.com) | `volcengine/` | 必填 | 豆包、Ark 系列模型 |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必填 | Qwen3、Qwen-Max 等 |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | 必填 | 快速推理(Llama、Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必填 | Kimi 系列模型 |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必填 | MiniMax 系列模型 |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必填 | Mistral Large、Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必填 | NVIDIA 托管模型 |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必填 | 快速推理 |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | 必填 | 多种开源模型 |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | 无需 | 本地模型,自托管 |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | 无需 | 本地部署,兼容 OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 视情况 | 100+ Provider 代理 |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必填 | 企业级 Azure 部署 |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 设备码登录 |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>本地部署(Ollama、vLLM 等)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels(聊天应用)
|
||||
|
||||
通过 17+ 消息平台与你的 PicoClaw 对话:
|
||||
|
||||
| Channel | 配置难度 | 协议 | 文档 |
|
||||
|---------|----------|------|------|
|
||||
| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
|
||||
| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) |
|
||||
| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
|
||||
| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
|
||||
| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) |
|
||||
| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) |
|
||||
| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) |
|
||||
| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) |
|
||||
| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
|
||||
| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) |
|
||||
| **企业微信机器人** | 中等(webhook URL) | Webhook | [指南](docs/channels/wecom/wecom_bot/README.zh.md) |
|
||||
| **企业微信应用** | 中等(corp credentials) | Webhook | [指南](docs/channels/wecom/wecom_app/README.zh.md) |
|
||||
| **企业微信 AI 机器人** | 中等(token + AES key) | WebSocket / Webhook | [指南](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||
| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
|
||||
| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |
|
||||
| **Pico** | 简单(启用即可) | 原生协议 | 内置 |
|
||||
| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 |
|
||||
|
||||
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
|
||||
|
||||
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 网络搜索
|
||||
|
||||
PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
|
||||
|
||||
| 搜索引擎 | API Key | 免费额度 | 链接 |
|
||||
|---------|---------|---------|------|
|
||||
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 |
|
||||
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
||||
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
||||
| [Perplexity](https://www.perplexity.ai) | 必填 | 付费 | AI 驱动搜索(国内访问困难) |
|
||||
| [Brave Search](https://brave.com/search/api) | 必填 | 2000 次/月 | 快速且注重隐私(国内访问困难) |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | 无需 | 自托管 | 免费元搜索引擎 |
|
||||
|
||||
### ⚙️ 其他工具
|
||||
|
||||
PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills 是扩展 Agent 能力的模块化插件,从工作区的 `SKILL.md` 文件加载。
|
||||
|
||||
**从 ClawHub 安装 Skills:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**配置 ClawHub token**(可选,用于提高速率限制):
|
||||
|
||||
在 `config.json` 中添加:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 MCP 服务器,通过外部工具和数据源扩展 Agent 能力。
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
@@ -218,23 +520,23 @@ make install
|
||||
| 命令 | 说明 |
|
||||
| ------------------------- | ---------------------- |
|
||||
| `picoclaw onboard` | 初始化配置与工作区 |
|
||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式对话模式 |
|
||||
| `picoclaw gateway` | 启动网关 |
|
||||
| `picoclaw status` | 查看状态 |
|
||||
| `picoclaw version` | 查看版本信息 |
|
||||
| `picoclaw model` | 查看或切换默认模型 |
|
||||
| `picoclaw cron list` | 列出所有定时任务 |
|
||||
| `picoclaw cron add ...` | 添加定时任务 |
|
||||
| `picoclaw cron disable` | 禁用定时任务 |
|
||||
| `picoclaw cron remove` | 删除定时任务 |
|
||||
| `picoclaw skills list` | 列出已安装技能 |
|
||||
| `picoclaw skills install` | 安装技能 |
|
||||
| `picoclaw skills list` | 列出已安装 Skills |
|
||||
| `picoclaw skills install` | 安装 Skill |
|
||||
| `picoclaw migrate` | 从旧版本迁移数据 |
|
||||
| `picoclaw auth login` | 认证提供商 |
|
||||
| `picoclaw model` | 查看或切换默认模型 |
|
||||
| `picoclaw auth login` | 认证 Provider |
|
||||
|
||||
### 定时任务 / 提醒
|
||||
### ⏰ 定时任务 / 提醒
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
@@ -242,11 +544,29 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
* **重复任务**: "每2小时提醒我" → 每2小时触发
|
||||
* **Cron 表达式**: "每天上午9点提醒我" → 使用 cron 表达式
|
||||
|
||||
## 📚 文档
|
||||
|
||||
详细指南请参阅以下文档,README 仅涵盖快速入门。
|
||||
|
||||
| 主题 | 说明 |
|
||||
|------|------|
|
||||
| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
|
||||
| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 |
|
||||
| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 |
|
||||
| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 |
|
||||
| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
|
||||
| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||
| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||
| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||
| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
|
||||
| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills |
|
||||
| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 |
|
||||
|
||||
## 🤝 贡献与路线图
|
||||
|
||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
||||
|
||||
查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)。
|
||||
查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
||||
|
||||
@@ -254,4 +574,10 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format)
|
||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
@@ -71,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||
|
||||
// If no openai in ModelList, add it
|
||||
if !foundOpenAI {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
@@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error {
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format, for backward compatibility)
|
||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
@@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error {
|
||||
|
||||
// If no antigravity in ModelList, add it
|
||||
if !foundAntigravity {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
@@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error {
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
@@ -221,7 +213,7 @@ func authLoginAnthropicSetupToken() error {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "oauth",
|
||||
@@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error {
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
@@ -298,7 +289,7 @@ func authLoginPasteToken(provider string) error {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "token",
|
||||
@@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error {
|
||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||
}
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
@@ -317,7 +307,7 @@ func authLoginPasteToken(provider string) error {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "token",
|
||||
@@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear AuthMethod in Providers (legacy)
|
||||
switch provider {
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
@@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
|
||||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
// Clear all AuthMethods in Providers (legacy)
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const Logo = "🦞"
|
||||
const Logo = pkg.Logo
|
||||
|
||||
// GetPicoclawHome returns the picoclaw home directory.
|
||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||
@@ -17,7 +18,7 @@ func GetPicoclawHome() string {
|
||||
return home
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
@@ -32,7 +33,7 @@ func LoadConfig() (*config.Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
|
||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
@@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
@@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||
|
||||
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
|
||||
@@ -56,9 +56,6 @@ Note: 'local-model' is a special value for using a local VLLM server
|
||||
|
||||
func showCurrentModel(cfg *config.Config) {
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
if defaultModel == "" {
|
||||
defaultModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
if defaultModel == "" {
|
||||
fmt.Println("No default model is currently set.")
|
||||
@@ -78,16 +75,13 @@ func listAvailableModels(cfg *config.Config) {
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
if defaultModel == "" {
|
||||
defaultModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
for _, model := range cfg.ModelList {
|
||||
marker := " "
|
||||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
if model.APIKey() == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
@@ -98,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey != "" && model.ModelName == modelName {
|
||||
if model.APIKey() != "" && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
@@ -111,12 +105,8 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||
// Update the default model
|
||||
// Clear old model field and set new model_name
|
||||
oldModel := cfg.Agents.Defaults.ModelName
|
||||
if oldModel == "" {
|
||||
oldModel = cfg.Agents.Defaults.Model
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = modelName
|
||||
cfg.Agents.Defaults.Model = "" // Clear deprecated field
|
||||
|
||||
// Save config back to file
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
|
||||
@@ -58,17 +58,24 @@ func TestNewModelCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
@@ -81,17 +88,20 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "",
|
||||
Model: "",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
@@ -101,26 +111,9 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Model: "legacy-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: legacy-model")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_Empty(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []config.ModelConfig{},
|
||||
ModelList: []*config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
@@ -131,18 +124,25 @@ func TestListAvailableModels_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
||||
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
{ModelName: "no-key-model", Model: "openai/test"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
@@ -157,17 +157,24 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
{ModelName: "old-model", Model: "openai/old-model"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
@@ -180,44 +187,25 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Model: "legacy-old",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||
}
|
||||
@@ -225,17 +213,24 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"no-key-model": {
|
||||
APIKeys: []string{""},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||
}
|
||||
@@ -244,16 +239,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||
// Use an invalid path to trigger save error
|
||||
invalidPath := "/nonexistent/directory/config.json"
|
||||
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||
|
||||
@@ -285,16 +284,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
// Create a test config
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"test-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
@@ -312,17 +315,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||
func TestModelCommandExecution_Set(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}}
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
||||
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old"},
|
||||
{ModelName: "new-model", Model: "openai/new"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(sec)
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
@@ -346,18 +357,28 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "middle-model",
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
|
||||
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
||||
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first"},
|
||||
{ModelName: "middle-model", Model: "openai/middle"},
|
||||
{ModelName: "last-model", Model: "openai/last"},
|
||||
},
|
||||
}
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"first-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"middle-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"last-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
|
||||
@@ -6,20 +6,32 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
||||
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
|
||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||
}
|
||||
|
||||
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
||||
if _, err := os.Stat(agentsPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
||||
agentPath := filepath.Join(targetDir, "AGENT.md")
|
||||
if _, err := os.Stat(agentPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentPath, err)
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||
soulPath := filepath.Join(targetDir, "SOUL.md")
|
||||
if _, err := os.Stat(soulPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", soulPath, err)
|
||||
}
|
||||
|
||||
userPath := filepath.Join(targetDir, "USER.md")
|
||||
if _, err := os.Stat(userPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", userPath, err)
|
||||
}
|
||||
|
||||
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
|
||||
legacyPath := filepath.Join(targetDir, legacyName)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||
}
|
||||
|
||||
cfg.Channels.Weixin.Enabled = true
|
||||
cfg.Channels.Weixin.Token = token
|
||||
cfg.Channels.Weixin.SetToken(token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
cfg.Channels.Weixin.BaseURL = baseURL
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||
d.workspace = cfg.WorkspacePath()
|
||||
installer, err := skills.NewSkillInstaller(
|
||||
d.workspace,
|
||||
cfg.Tools.Skills.Github.Token,
|
||||
cfg.Tools.Skills.Github.Token(),
|
||||
cfg.Tools.Skills.Github.Proxy,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
@@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
|
||||
return
|
||||
}
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
@@ -42,48 +42,6 @@ func statusCmd() {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||
|
||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
||||
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
||||
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
||||
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
||||
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
||||
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
||||
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
||||
|
||||
status := func(enabled bool) string {
|
||||
if enabled {
|
||||
return "✓"
|
||||
}
|
||||
return "not set"
|
||||
}
|
||||
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
||||
fmt.Println("Anthropic API:", status(hasAnthropic))
|
||||
fmt.Println("OpenAI API:", status(hasOpenAI))
|
||||
fmt.Println("Gemini API:", status(hasGemini))
|
||||
fmt.Println("Zhipu API:", status(hasZhipu))
|
||||
fmt.Println("Qwen API:", status(hasQwen))
|
||||
fmt.Println("Groq API:", status(hasGroq))
|
||||
fmt.Println("Moonshot API:", status(hasMoonshot))
|
||||
fmt.Println("DeepSeek API:", status(hasDeepSeek))
|
||||
fmt.Println("VolcEngine API:", status(hasVolcEngine))
|
||||
fmt.Println("Nvidia API:", status(hasNvidia))
|
||||
if hasVLLM {
|
||||
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
||||
} else {
|
||||
fmt.Println("vLLM/Local: not set")
|
||||
}
|
||||
if hasOllama {
|
||||
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
|
||||
} else {
|
||||
fmt.Println("Ollama: not set")
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"log_level": "fatal",
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"context_window": 131072,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20,
|
||||
"summarize_message_threshold": 20,
|
||||
@@ -547,11 +547,22 @@
|
||||
"monitor_usb": true
|
||||
},
|
||||
"voice": {
|
||||
"model_name": "",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"defaults": {
|
||||
"observer_timeout_ms": 500,
|
||||
"interceptor_timeout_ms": 5000,
|
||||
"approval_timeout_ms": 60000
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790,
|
||||
"hot_reload": false
|
||||
"hot_reload": false,
|
||||
"log_level": "fatal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Context
|
||||
|
||||
## What this document covers
|
||||
|
||||
This document makes explicit the boundaries of context management in the agent loop:
|
||||
|
||||
- what fills the context window and how space is divided
|
||||
- what is stored in session history vs. built at request time
|
||||
- when and how context compression happens
|
||||
- how token budgets are estimated
|
||||
|
||||
These are existing concepts. This document clarifies their boundaries rather than introducing new ones.
|
||||
|
||||
---
|
||||
|
||||
## Context window regions
|
||||
|
||||
The context window is the model's total input capacity. Four regions fill it:
|
||||
|
||||
| Region | Assembled by | Stored in session? |
|
||||
|---|---|---|
|
||||
| System prompt | `BuildMessages()` — static + dynamic parts | No |
|
||||
| Summary | `SetSummary()` stores it; `BuildMessages()` injects it | Separate from history |
|
||||
| Session history | User / assistant / tool messages | Yes |
|
||||
| Tool definitions | Provider adapter injects at call time | No |
|
||||
|
||||
`MaxTokens` (the output generation limit) must also be reserved from the total budget.
|
||||
|
||||
The available space for history is therefore:
|
||||
|
||||
```
|
||||
history_budget = ContextWindow - system_prompt - summary - tool_definitions - MaxTokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ContextWindow vs MaxTokens
|
||||
|
||||
These serve different purposes:
|
||||
|
||||
- **MaxTokens** — maximum tokens the LLM may generate in one response. Sent as the `max_tokens` request parameter.
|
||||
- **ContextWindow** — the model's total input context capacity.
|
||||
|
||||
These were previously set to the same value, which caused the summarization threshold to fire either far too early (at the default 32K) or not at all (when a user raised `max_tokens`).
|
||||
|
||||
Current default when not explicitly configured: `ContextWindow = MaxTokens * 4`.
|
||||
|
||||
---
|
||||
|
||||
## Session history
|
||||
|
||||
Session history stores only conversation messages:
|
||||
|
||||
- `user` — user input
|
||||
- `assistant` — LLM response (may include `ToolCalls`)
|
||||
- `tool` — tool execution results
|
||||
|
||||
Session history does **not** contain:
|
||||
|
||||
- System prompts — assembled at request time by `BuildMessages`
|
||||
- Summary content — stored separately via `SetSummary`, injected by `BuildMessages`
|
||||
|
||||
This distinction matters: any code that operates on session history — compression, boundary detection, token estimation — must not assume a system message is present.
|
||||
|
||||
---
|
||||
|
||||
## Turn
|
||||
|
||||
A **Turn** is one complete cycle:
|
||||
|
||||
> user message -> LLM iterations (possibly including tool calls) -> final assistant response
|
||||
|
||||
This definition comes from the agent loop design (#1316). In session history, Turn boundaries are identified by `user`-role messages.
|
||||
|
||||
Turn is the atomic unit for compression. Cutting inside a Turn can orphan tool-call sequences — an assistant message with `ToolCalls` separated from its corresponding `tool` results. Compressing at Turn boundaries avoids this by construction.
|
||||
|
||||
`parseTurnBoundaries(history)` returns the starting index of each Turn.
|
||||
`findSafeBoundary(history, targetIndex)` snaps a target cut point to the nearest Turn boundary.
|
||||
|
||||
---
|
||||
|
||||
## Compression paths
|
||||
|
||||
Three compression paths exist, in order of preference:
|
||||
|
||||
### 1. Async summarization
|
||||
|
||||
`maybeSummarize` runs after each Turn completes.
|
||||
|
||||
Triggers when message count exceeds a threshold, or when estimated history tokens exceed a percentage of `ContextWindow`. If triggered, a background goroutine calls the LLM to produce a summary of the oldest messages. The summary is stored via `SetSummary`; `BuildMessages` injects it into the system prompt on the next call.
|
||||
|
||||
Cut point uses `findSafeBoundary` so no Turn is split.
|
||||
|
||||
### 2. Proactive budget check
|
||||
|
||||
`isOverContextBudget` runs before each LLM call.
|
||||
|
||||
Uses the full budget formula: `message_tokens + tool_def_tokens + MaxTokens > ContextWindow`. If over budget, triggers `forceCompression` and rebuilds messages before calling the LLM.
|
||||
|
||||
This prevents wasted (and billed) LLM calls that would otherwise fail with a context-window error.
|
||||
|
||||
### 3. Emergency compression (reactive)
|
||||
|
||||
`forceCompression` runs when the LLM returns a context-window error despite the proactive check.
|
||||
|
||||
Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop.
|
||||
|
||||
Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt.
|
||||
|
||||
This is the fallback for when the token estimate undershoots reality.
|
||||
|
||||
---
|
||||
|
||||
## Token estimation
|
||||
|
||||
Estimation uses a heuristic of ~2.5 characters per token (`chars * 2 / 5`).
|
||||
|
||||
`estimateMessageTokens` counts:
|
||||
|
||||
- `Content` (rune count, for multibyte correctness)
|
||||
- `ReasoningContent` (extended thinking / chain-of-thought)
|
||||
- `ToolCalls` — ID, type, function name, arguments
|
||||
- `ToolCallID` (tool result metadata)
|
||||
- Per-message overhead (role label, JSON structure)
|
||||
- `Media` items — flat per-item token estimate, added directly to the final count (not through the character heuristic, since actual cost depends on resolution and provider-specific image tokenization)
|
||||
|
||||
`estimateToolDefsTokens` counts tool definition overhead: name, description, JSON schema of parameters.
|
||||
|
||||
These are deliberately heuristic. The proactive check handles the common case; the reactive path catches estimation errors.
|
||||
|
||||
---
|
||||
|
||||
## Interface boundaries
|
||||
|
||||
Context budget functions (`parseTurnBoundaries`, `findSafeBoundary`, `estimateMessageTokens`, `isOverContextBudget`) are **pure functions**. They take `[]providers.Message` and integer parameters. They have no dependency on `AgentLoop` or any other runtime struct.
|
||||
|
||||
`BuildMessages` is the sole assembler of the final message array sent to the LLM. Budget functions inform compression decisions but do not construct messages.
|
||||
|
||||
`forceCompression` and `summarizeSession` mutate session state (history and summary). `BuildMessages` reads that state to construct context. The flow is:
|
||||
|
||||
```
|
||||
budget check --> compression decision --> mutate session --> BuildMessages reads session --> LLM call
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known gaps
|
||||
|
||||
These are recognized limitations in the current implementation, documented here for visibility:
|
||||
|
||||
- **Summarization trigger does not use the full budget formula.** `maybeSummarize` compares estimated history tokens against a percentage of `ContextWindow`. It does not account for system prompt size, tool definition overhead, or `MaxTokens` reserve. The proactive check covers the critical path (preventing 400 errors), but the summarization trigger could be aligned with the same budget model for more accurate early compression.
|
||||
|
||||
- **Token estimation is heuristic.** It does not account for provider-specific tokenization, exact system prompt size (assembled separately), or variable image token costs. The two-path design (proactive + reactive) is intended to tolerate this imprecision.
|
||||
|
||||
- **Reactive retry does not preserve media.** When the reactive path rebuilds context after compression, it currently passes empty values for media references. This is a pre-existing issue in the main loop, not introduced by the budget system.
|
||||
|
||||
---
|
||||
|
||||
## What this document does not cover
|
||||
|
||||
- How `AGENT.md` frontmatter configures context parameters — that is part of the Agent definition work
|
||||
- How the context builder assembles context in the new architecture — that is upcoming work
|
||||
- How compression events surface through the event system — that is part of the event model (#1316)
|
||||
- Subagent context isolation — that is a separate track
|
||||
@@ -0,0 +1,64 @@
|
||||
> Retour au [README](../../../README.fr.md)
|
||||
|
||||
# Guide de configuration du canal Matrix
|
||||
|
||||
## 1. Exemple de configuration
|
||||
|
||||
Ajoutez ceci à `config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Référence des champs
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
|----------------------|----------|--------|-------------|
|
||||
| enabled | bool | Oui | Activer ou désactiver le canal Matrix |
|
||||
| homeserver | string | Oui | URL du homeserver Matrix (par exemple `https://matrix.org`) |
|
||||
| user_id | string | Oui | ID utilisateur Matrix du bot (par exemple `@bot:matrix.org`) |
|
||||
| access_token | string | Oui | Jeton d'accès du bot |
|
||||
| device_id | string | Non | ID d'appareil Matrix optionnel |
|
||||
| join_on_invite | bool | Non | Rejoindre automatiquement les salons invités |
|
||||
| allow_from | []string | Non | Liste blanche d'utilisateurs (IDs Matrix) |
|
||||
| group_trigger | object | Non | Stratégie de déclenchement de groupe (`mention_only` / `prefixes`) |
|
||||
| placeholder | object | Non | Configuration du message de remplacement |
|
||||
| reasoning_channel_id | string | Non | Canal cible pour la sortie de raisonnement |
|
||||
| message_format | string | Non | Format de sortie : `"richtext"` (défaut) rend le markdown en HTML ; `"plain"` envoie du texte brut uniquement |
|
||||
|
||||
## 3. Fonctionnalités actuellement supportées
|
||||
|
||||
- Envoi/réception de messages texte avec rendu markdown (gras, italique, titres, blocs de code, etc.)
|
||||
- Format de message configurable (`richtext` / `plain`)
|
||||
- Téléchargement d'images/audio/vidéo/fichiers entrants (MediaStore en priorité, chemin local en secours)
|
||||
- Normalisation de l'audio entrant dans le flux de transcription existant (`[audio: ...]`)
|
||||
- Upload et envoi d'images/audio/vidéo/fichiers sortants
|
||||
- Règles de déclenchement de groupe (y compris le mode mention uniquement)
|
||||
- État de frappe (`m.typing`)
|
||||
- Message de remplacement + remplacement de la réponse finale
|
||||
- Rejoindre automatiquement les salons invités (peut être désactivé)
|
||||
|
||||
## 4. TODO
|
||||
|
||||
- Améliorations des métadonnées des médias riches (par exemple taille et miniatures des images/vidéos)
|
||||
@@ -0,0 +1,64 @@
|
||||
> [README](../../../README.ja.md) に戻る
|
||||
|
||||
# Matrix チャンネル設定ガイド
|
||||
|
||||
## 1. 設定例
|
||||
|
||||
`config.json` に以下を追加してください:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. フィールドリファレンス
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|----------------------|----------|------|------|
|
||||
| enabled | bool | はい | Matrix チャンネルの有効/無効 |
|
||||
| homeserver | string | はい | Matrix ホームサーバー URL(例:`https://matrix.org`) |
|
||||
| user_id | string | はい | ボットの Matrix ユーザー ID(例:`@bot:matrix.org`) |
|
||||
| access_token | string | はい | ボットのアクセストークン |
|
||||
| device_id | string | いいえ | オプションの Matrix デバイス ID |
|
||||
| join_on_invite | bool | いいえ | 招待されたルームに自動参加 |
|
||||
| allow_from | []string | いいえ | ユーザーホワイトリスト(Matrix ユーザー ID) |
|
||||
| group_trigger | object | いいえ | グループトリガー戦略(`mention_only` / `prefixes`) |
|
||||
| placeholder | object | いいえ | プレースホルダーメッセージ設定 |
|
||||
| reasoning_channel_id | string | いいえ | 推論出力のターゲットチャンネル |
|
||||
| message_format | string | いいえ | 出力形式:`"richtext"`(デフォルト)は markdown を HTML としてレンダリング;`"plain"` はプレーンテキストのみ送信 |
|
||||
|
||||
## 3. 現在サポートされている機能
|
||||
|
||||
- markdown レンダリング付きテキストメッセージ送受信(太字、斜体、見出し、コードブロックなど)
|
||||
- 設定可能なメッセージ形式(`richtext` / `plain`)
|
||||
- 受信画像/音声/動画/ファイルのダウンロード(MediaStore 優先、ローカルパスフォールバック)
|
||||
- 受信音声の既存文字起こしフローへの正規化(`[audio: ...]`)
|
||||
- 送信画像/音声/動画/ファイルのアップロードと送信
|
||||
- グループトリガールール(メンションのみモードを含む)
|
||||
- タイピング状態(`m.typing`)
|
||||
- プレースホルダーメッセージ + 最終返信の置き換え
|
||||
- 招待されたルームへの自動参加(無効化可能)
|
||||
|
||||
## 4. TODO
|
||||
|
||||
- リッチメディアメタデータの改善(例:画像/動画のサイズとサムネイル)
|
||||
@@ -1,3 +1,5 @@
|
||||
> Back to [README](../../../README.md)
|
||||
|
||||
# Matrix Channel Configuration Guide
|
||||
|
||||
## 1. Example Configuration
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
> Voltar ao [README](../../../README.pt-br.md)
|
||||
|
||||
# Guia de Configuração do Canal Matrix
|
||||
|
||||
## 1. Exemplo de Configuração
|
||||
|
||||
Adicione isto ao `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Referência de Campos
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|----------------------|----------|-------------|-----------|
|
||||
| enabled | bool | Sim | Habilitar ou desabilitar o canal Matrix |
|
||||
| homeserver | string | Sim | URL do homeserver Matrix (por exemplo `https://matrix.org`) |
|
||||
| user_id | string | Sim | ID de usuário Matrix do bot (por exemplo `@bot:matrix.org`) |
|
||||
| access_token | string | Sim | Token de acesso do bot |
|
||||
| device_id | string | Não | ID de dispositivo Matrix opcional |
|
||||
| join_on_invite | bool | Não | Entrar automaticamente em salas convidadas |
|
||||
| allow_from | []string | Não | Lista branca de usuários (IDs Matrix) |
|
||||
| group_trigger | object | Não | Estratégia de gatilho de grupo (`mention_only` / `prefixes`) |
|
||||
| placeholder | object | Não | Configuração de mensagem de espaço reservado |
|
||||
| reasoning_channel_id | string | Não | Canal alvo para saída de raciocínio |
|
||||
| message_format | string | Não | Formato de saída: `"richtext"` (padrão) renderiza markdown como HTML; `"plain"` envia apenas texto simples |
|
||||
|
||||
## 3. Suporte Atual
|
||||
|
||||
- Envio/recebimento de mensagens de texto com renderização markdown (negrito, itálico, cabeçalhos, blocos de código, etc.)
|
||||
- Formato de mensagem configurável (`richtext` / `plain`)
|
||||
- Download de imagens/áudio/vídeo/arquivos recebidos (MediaStore primeiro, fallback para caminho local)
|
||||
- Normalização de áudio recebido no fluxo de transcrição existente (`[audio: ...]`)
|
||||
- Upload e envio de imagens/áudio/vídeo/arquivos de saída
|
||||
- Regras de gatilho de grupo (incluindo modo somente menção)
|
||||
- Estado de digitação (`m.typing`)
|
||||
- Mensagem de espaço reservado + substituição de resposta final
|
||||
- Entrada automática em salas convidadas (pode ser desabilitado)
|
||||
|
||||
## 4. TODO
|
||||
|
||||
- Melhorias nos metadados de mídia rica (por exemplo tamanho e miniaturas de imagens/vídeos)
|
||||
@@ -0,0 +1,64 @@
|
||||
> Quay lại [README](../../../README.vi.md)
|
||||
|
||||
# Hướng dẫn Cấu hình Kênh Matrix
|
||||
|
||||
## 1. Cấu hình Mẫu
|
||||
|
||||
Thêm vào `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"device_id": "",
|
||||
"join_on_invite": true,
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Tham chiếu Trường
|
||||
|
||||
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||
|----------------------|----------|----------|-------|
|
||||
| enabled | bool | Có | Bật hoặc tắt kênh Matrix |
|
||||
| homeserver | string | Có | URL homeserver Matrix (ví dụ `https://matrix.org`) |
|
||||
| user_id | string | Có | ID người dùng Matrix của bot (ví dụ `@bot:matrix.org`) |
|
||||
| access_token | string | Có | Token truy cập của bot |
|
||||
| device_id | string | Không | ID thiết bị Matrix tùy chọn |
|
||||
| join_on_invite | bool | Không | Tự động tham gia phòng được mời |
|
||||
| allow_from | []string | Không | Danh sách trắng người dùng (ID Matrix) |
|
||||
| group_trigger | object | Không | Chiến lược kích hoạt nhóm (`mention_only` / `prefixes`) |
|
||||
| placeholder | object | Không | Cấu hình tin nhắn giữ chỗ |
|
||||
| reasoning_channel_id | string | Không | Kênh đích cho đầu ra suy luận |
|
||||
| message_format | string | Không | Định dạng đầu ra: `"richtext"` (mặc định) render markdown thành HTML; `"plain"` chỉ gửi văn bản thuần |
|
||||
|
||||
## 3. Tính năng Hiện tại
|
||||
|
||||
- Gửi/nhận tin nhắn văn bản với render markdown (đậm, nghiêng, tiêu đề, khối code, v.v.)
|
||||
- Định dạng tin nhắn có thể cấu hình (`richtext` / `plain`)
|
||||
- Tải xuống hình ảnh/âm thanh/video/tệp đến (MediaStore trước, fallback đường dẫn cục bộ)
|
||||
- Chuẩn hóa âm thanh đến vào luồng phiên âm hiện có (`[audio: ...]`)
|
||||
- Tải lên và gửi hình ảnh/âm thanh/video/tệp đi
|
||||
- Quy tắc kích hoạt nhóm (bao gồm chế độ chỉ đề cập)
|
||||
- Trạng thái đang gõ (`m.typing`)
|
||||
- Tin nhắn giữ chỗ + thay thế phản hồi cuối cùng
|
||||
- Tự động tham gia phòng được mời (có thể tắt)
|
||||
|
||||
## 4. TODO
|
||||
|
||||
- Cải thiện metadata phương tiện phong phú (ví dụ kích thước và hình thu nhỏ hình ảnh/video)
|
||||
@@ -1,3 +1,5 @@
|
||||
> 返回 [README](../../../README.zh.md)
|
||||
|
||||
# Matrix 通道配置指南
|
||||
|
||||
## 1. 配置示例
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Telegram
|
||||
|
||||
The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription via Groq Whisper, and built-in command handling.
|
||||
The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../providers.md#voice-transcription)), and built-in command handling.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Telegram
|
||||
|
||||
Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
|
||||
Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../zh/providers.md#语音转录)),以及内置命令处理器。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
+29
-14
@@ -10,22 +10,23 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
||||
|
||||
| Channel | Difficulty | Description | Documentation |
|
||||
| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) |
|
||||
| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) |
|
||||
| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](channels/telegram/README.md) |
|
||||
| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](channels/discord/README.md) |
|
||||
| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) |
|
||||
| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](../channels/weixin/README.md) |
|
||||
| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) |
|
||||
| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) |
|
||||
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) |
|
||||
| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) |
|
||||
| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.md) / [App](../channels/wecom/wecom_app/README.md) / [AI Bot](../channels/wecom/wecom_aibot/README.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
|
||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | - |
|
||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
|
||||
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
|
||||
| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](#weixin) |
|
||||
| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](channels/slack/README.md) |
|
||||
| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](channels/matrix/README.md) |
|
||||
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) |
|
||||
| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) |
|
||||
| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](channels/wecom/wecom_bot/README.md) / [App](channels/wecom/wecom_app/README.md) / [AI Bot](channels/wecom/wecom_aibot/README.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) |
|
||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) |
|
||||
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](channels/maixcam/README.md) |
|
||||
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
||||
@@ -44,7 +45,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": false,
|
||||
"use_markdown_v2": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +71,7 @@ You can set use_markdown_v2: true to enable enhanced formatting options. This al
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -143,6 +145,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||
|
||||
@@ -170,12 +173,14 @@ If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Personal)</summary>
|
||||
|
||||
PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
|
||||
|
||||
**1. Login**
|
||||
|
||||
Run the interactive QR login flow:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
@@ -183,6 +188,7 @@ picoclaw onboard weixin
|
||||
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
|
||||
|
||||
**2. Configure**
|
||||
|
||||
(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
|
||||
```json
|
||||
{
|
||||
@@ -203,6 +209,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -244,6 +251,7 @@ If you prefer to create the bot manually:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
@@ -277,6 +285,7 @@ picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -311,6 +320,7 @@ For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`,
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -359,6 +369,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
@@ -473,6 +484,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>Feishu (Lark)</b></summary>
|
||||
|
||||
@@ -514,6 +526,7 @@ For full options, see [Feishu Channel Configuration Guide](channels/feishu/READM
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -547,6 +560,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -580,6 +594,7 @@ The bot will connect to the IRC server and join the specified channels.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot (QQ via OneBot protocol)</b></summary>
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Config Schema Versioning Guide
|
||||
|
||||
## Overview
|
||||
|
||||
PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves.
|
||||
|
||||
## Version History
|
||||
|
||||
### Version 1
|
||||
- **Introduction**: Initial version with version field support
|
||||
- **Changes**: Added `version` field to Config struct
|
||||
- **Migration**: No structural changes needed for existing configs
|
||||
|
||||
## How It Works
|
||||
|
||||
### Automatic Migration
|
||||
When you load a config file:
|
||||
1. The system first reads the `version` field from the JSON
|
||||
2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.)
|
||||
3. If the loaded version is less than the latest, migrations are applied incrementally
|
||||
4. The version number is updated automatically
|
||||
5. The migrated config is automatically saved back to disk
|
||||
|
||||
### Version Field
|
||||
The `version` field in `config.json` indicates the schema version:
|
||||
- `0` or missing: Legacy config (no version field)
|
||||
- `1`: Current version with versioning support
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {...},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Migration
|
||||
|
||||
When making breaking changes to the config schema:
|
||||
|
||||
### Step 1: Define the New Version Struct
|
||||
|
||||
Create a new struct for the new version if the structure changes significantly:
|
||||
|
||||
```go
|
||||
// ConfigV2 represents version 2 config structure
|
||||
type ConfigV2 struct {
|
||||
Version int `json:"version"`
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
// ... other fields with new structure
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Update Current Config Version
|
||||
|
||||
```go
|
||||
const CurrentConfigVersion = 2 // Increment this
|
||||
```
|
||||
|
||||
### Step 3: Add a Loader Function
|
||||
|
||||
```go
|
||||
// loadConfigV2 loads a version 2 config
|
||||
func loadConfigV2(data []byte) (*Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Parse to ConfigV2 struct
|
||||
var v2 ConfigV2
|
||||
if err := json.Unmarshal(data, &v2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to current Config
|
||||
cfg.Version = v2.Version
|
||||
cfg.Agents = v2.Agents
|
||||
// ... map other fields
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Migration Logic
|
||||
|
||||
```go
|
||||
// applyMigration applies a single migration step from fromVersion to toVersion
|
||||
func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) {
|
||||
switch toVersion {
|
||||
case 1:
|
||||
// Migration from version 0 to 1
|
||||
return &Config{
|
||||
Version: 1,
|
||||
Agents: cfg.Agents,
|
||||
// ... copy all fields
|
||||
}, nil
|
||||
case 2:
|
||||
// Migration from version 1 to 2
|
||||
// Example: Move or rename fields
|
||||
migrated := *cfg
|
||||
migrated.Version = 2
|
||||
// Apply structural changes
|
||||
if cfg.SomeOldField != "" {
|
||||
migrated.SomeNewField = cfg.SomeOldField
|
||||
}
|
||||
return &migrated, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported migration target version: %d", toVersion)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update LoadConfig Switch
|
||||
|
||||
```go
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
// ... read file ...
|
||||
|
||||
switch versionInfo.Version {
|
||||
case 0:
|
||||
cfg, err = loadConfigV0(data)
|
||||
case 1:
|
||||
cfg, err = loadConfigV1(data)
|
||||
case 2:
|
||||
cfg, err = loadConfigV2(data)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||
}
|
||||
|
||||
// ... migrate and validate ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Test Your Migration
|
||||
|
||||
Create a test in `config_migration_test.go`:
|
||||
|
||||
```go
|
||||
func TestMigrateV1ToV2(t *testing.T) {
|
||||
// Create a version 1 config
|
||||
v1Config := Config{
|
||||
Version: 1,
|
||||
// ... set up test data
|
||||
}
|
||||
|
||||
// Apply migration
|
||||
migrated, err := applyMigration(&v1Config, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify version is updated
|
||||
if migrated.Version != 2 {
|
||||
t.Errorf("Expected version 2, got %d", migrated.Version)
|
||||
}
|
||||
|
||||
// Verify data is preserved/transformed correctly
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes
|
||||
2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs
|
||||
3. **No Data Loss**: Migrations should preserve all user settings
|
||||
4. **Idempotent**: Running the same migration multiple times should be safe
|
||||
5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
|
||||
6. **Test Thoroughly**: Test with real user config files
|
||||
7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
|
||||
|
||||
## Example Migration
|
||||
|
||||
### Scenario: Adding a new field with default value
|
||||
|
||||
Old config (version 1):
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"max_tokens": 32768
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Migration to version 2:
|
||||
```go
|
||||
case 2:
|
||||
migrated := *cfg
|
||||
migrated.Version = 2
|
||||
|
||||
// Add new field with default value if not set
|
||||
if migrated.Agents.Defaults.NewFeatureEnabled == false {
|
||||
// Use default value
|
||||
}
|
||||
|
||||
return &migrated, nil
|
||||
```
|
||||
|
||||
New config (version 2):
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"max_tokens": 32768,
|
||||
"new_feature_enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Config Not Upgrading
|
||||
- Check that `CurrentConfigVersion` is incremented
|
||||
- Verify migration logic in `applyMigration()` handles the target version
|
||||
- Ensure `migrateConfig()` is called in `LoadConfig()`
|
||||
|
||||
### Migration Errors
|
||||
- Check error messages for specific migration failures
|
||||
- Review migration logic for edge cases
|
||||
- Ensure all required fields are properly initialized
|
||||
- Verify the loader function for the source version
|
||||
|
||||
### Data Loss After Migration
|
||||
- Ensure all fields are copied during migration
|
||||
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
||||
- Review the conversion logic in the loader functions
|
||||
|
||||
@@ -347,3 +347,396 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
|
||||
| Feature | Description |
|
||||
| ----------------------- | --------------------------------------------------------- |
|
||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||
| **Independent context** | Subagent has its own context, no session history |
|
||||
| **message tool** | Subagent communicates with user directly via message tool |
|
||||
| **Non-blocking** | After spawning, heartbeat continues to next task |
|
||||
|
||||
#### How Subagent Communication Works
|
||||
|
||||
```
|
||||
Heartbeat triggers
|
||||
↓
|
||||
Agent reads HEARTBEAT.md
|
||||
↓
|
||||
For long task: spawn subagent
|
||||
↓ ↓
|
||||
Continue to next task Subagent works independently
|
||||
↓ ↓
|
||||
All tasks done Subagent uses "message" tool
|
||||
↓ ↓
|
||||
Respond HEARTBEAT_OK User receives result directly
|
||||
```
|
||||
|
||||
The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ---------------------------------- |
|
||||
| `enabled` | `true` | Enable/disable heartbeat |
|
||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers — **zero code changes required!**
|
||||
|
||||
This design also enables **multi-agent support** with flexible provider selection:
|
||||
|
||||
- **Different agents, different providers**: Each agent can use its own LLM provider
|
||||
- **Model fallbacks**: Configure primary and fallback models for resilience
|
||||
- **Load balancing**: Distribute requests across multiple endpoints
|
||||
- **Centralized configuration**: Manage all providers in one place
|
||||
|
||||
#### All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-zhipu-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>VolcEngine (Doubao)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 AI (GLM)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DeepSeek</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Anthropic</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
}
|
||||
```
|
||||
|
||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||
|
||||
For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
```
|
||||
|
||||
> Use `anthropic-messages` when the endpoint requires Anthropic's native `/v1/messages` format instead of OpenAI-compatible `/v1/chat/completions`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ollama (local)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Custom Proxy / LiteLLM</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||
|
||||
</details>
|
||||
|
||||
#### Load Balancing
|
||||
|
||||
Configure multiple endpoints for the same model name — PicoClaw will automatically round-robin between them:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration from Legacy `providers` Config
|
||||
|
||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide.
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
PicoClaw routes providers by protocol family:
|
||||
|
||||
- **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others.
|
||||
- **Anthropic**: Claude-native API behavior.
|
||||
- **Codex/OAuth**: OpenAI OAuth/token authentication route.
|
||||
|
||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu (legacy providers format)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Full config example</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"dm_scope": "per-channel-peer",
|
||||
"backlog_limit": 20
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can set, list, and cancel reminders or recurring jobs that trigger at specified times.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
|
||||
|
||||
### Advanced Topics
|
||||
|
||||
| Topic | Description |
|
||||
| ----- | ----------- |
|
||||
| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks |
|
||||
| [Steering](steering.md) | Inject messages into a running agent loop between tool calls |
|
||||
| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||
| [Context Management](agent-refactor/context.md) | Context boundary detection, proactive budget check, compression |
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
# PicoClaw Hook 系统设计(基于 `refactor/agent`)
|
||||
|
||||
## 背景
|
||||
|
||||
本设计围绕两个议题展开:
|
||||
|
||||
- `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测
|
||||
- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型
|
||||
|
||||
当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。
|
||||
|
||||
## 外部项目对比
|
||||
|
||||
### OpenClaw
|
||||
|
||||
OpenClaw 的扩展能力分成三层:
|
||||
|
||||
- Internal hooks:目录发现,运行在 Gateway 进程内
|
||||
- Plugin hooks:插件在运行时注册 hook,也在进程内
|
||||
- Webhooks:外部系统通过 HTTP 触发 Gateway 动作,属于进程外
|
||||
|
||||
值得借鉴的点:
|
||||
|
||||
- 有“项目内挂载”和“项目外挂载”两种路径
|
||||
- hook 是配置驱动,可启停
|
||||
- 外部入口有明确的安全边界和映射层
|
||||
|
||||
不建议直接照搬的点:
|
||||
|
||||
- OpenClaw 的 hooks / plugin hooks / webhooks 是三套路由,PicoClaw 当前体量下会偏重
|
||||
- HTTP webhook 更适合“事件进入系统”,不适合作为“可同步拦截 agent loop”的基础机制
|
||||
|
||||
### pi-mono
|
||||
|
||||
pi-mono 的核心思路更接近当前分支:
|
||||
|
||||
- 扩展统一为 extension API
|
||||
- 事件分为观察型和可变更型
|
||||
- 某些阶段允许 `transform` / `block` / `replace`
|
||||
- 扩展代码主要是进程内执行
|
||||
- RPC mode 把 UI 交互桥接到进程外客户端
|
||||
|
||||
值得借鉴的点:
|
||||
|
||||
- 不把“观察”和“拦截”混成一个接口
|
||||
- 允许返回结构化动作,而不是只有回调
|
||||
- 进程外通信只暴露必要协议,不把整个内部对象图泄露出去
|
||||
|
||||
## 当前分支现状
|
||||
|
||||
### 已有能力
|
||||
|
||||
当前分支已经具备 hook 系统的地基:
|
||||
|
||||
- `pkg/agent/events.go` 定义了稳定的 `EventKind`、`EventMeta` 和 payload
|
||||
- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus`
|
||||
- `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件
|
||||
- `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort
|
||||
- `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态
|
||||
|
||||
### 现有缺口
|
||||
|
||||
当前分支还缺四件事:
|
||||
|
||||
- 没有 HookManager,只有 EventBus
|
||||
- 没有 Before/After LLM、Before/After Tool 这种同步拦截点
|
||||
- 没有审批型 hook
|
||||
- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流
|
||||
|
||||
### 一个关键现实
|
||||
|
||||
`#1316` 文案里提到“只读并行、写入串行”的工具执行策略,但当前 `runTurn()` 实现已经先收敛成“顺序执行 + 每个工具后检查 steering / interrupt”。因此 hook 设计不应依赖未来的并行模型,而应该先兼容当前顺序执行,再为以后增加 `ReadOnlyIndicator` 留口子。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上
|
||||
- EventBus 负责广播,HookManager 负责拦截,两者职责分离
|
||||
- 项目内挂载要简单,项目外挂载必须走 IPC
|
||||
- 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时
|
||||
- 先覆盖主 turn,不把 sub-turn 一次做满
|
||||
- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()`
|
||||
|
||||
## 总体架构
|
||||
|
||||
分成三层:
|
||||
|
||||
1. `EventBus`
|
||||
负责广播只读事件,现有实现直接复用
|
||||
|
||||
2. `HookManager`
|
||||
负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截
|
||||
|
||||
3. `HookMount`
|
||||
负责两种挂载方式:
|
||||
- 进程内 Go hook
|
||||
- 进程外 IPC hook
|
||||
|
||||
换句话说:
|
||||
|
||||
- EventBus 是“发生了什么”
|
||||
- HookManager 是“谁能介入”
|
||||
- HookMount 是“这些 hook 从哪里来”
|
||||
|
||||
## Hook 分类
|
||||
|
||||
不建议把所有 hook 都设计成 `OnEvent(evt)`。
|
||||
|
||||
建议拆成两类。
|
||||
|
||||
### 1. 观察型
|
||||
|
||||
只消费事件,不修改流程:
|
||||
|
||||
```go
|
||||
type EventObserver interface {
|
||||
OnEvent(ctx context.Context, evt agent.Event) error
|
||||
}
|
||||
```
|
||||
|
||||
这类 hook 直接订阅 EventBus 即可。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 审计日志
|
||||
- 指标上报
|
||||
- 调试 trace
|
||||
- 将事件转发给外部 UI / TUI / Web 面板
|
||||
|
||||
### 2. 拦截型
|
||||
|
||||
只在少数明确节点触发,允许返回动作:
|
||||
|
||||
```go
|
||||
type LLMInterceptor interface {
|
||||
BeforeLLM(ctx context.Context, req *LLMRequest) HookDecision[*LLMRequest]
|
||||
AfterLLM(ctx context.Context, resp *LLMResponse) HookDecision[*LLMResponse]
|
||||
}
|
||||
|
||||
type ToolInterceptor interface {
|
||||
BeforeTool(ctx context.Context, call *ToolCall) HookDecision[*ToolCall]
|
||||
AfterTool(ctx context.Context, result *ToolResultView) HookDecision[*ToolResultView]
|
||||
}
|
||||
|
||||
type ToolApprover interface {
|
||||
ApproveTool(ctx context.Context, req *ToolApprovalRequest) ApprovalDecision
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `HookDecision` 统一支持:
|
||||
|
||||
- `continue`
|
||||
- `modify`
|
||||
- `deny_tool`
|
||||
- `abort_turn`
|
||||
- `hard_abort`
|
||||
|
||||
## 对外暴露的最小 hook 面
|
||||
|
||||
V1 不需要把所有 EventKind 都变成可拦截点。
|
||||
|
||||
建议只开放这些同步 hook:
|
||||
|
||||
- `before_llm`
|
||||
- `after_llm`
|
||||
- `before_tool`
|
||||
- `after_tool`
|
||||
- `approve_tool`
|
||||
|
||||
其余节点继续作为只读事件暴露:
|
||||
|
||||
- `turn_start`
|
||||
- `turn_end`
|
||||
- `llm_request`
|
||||
- `llm_response`
|
||||
- `tool_exec_start`
|
||||
- `tool_exec_end`
|
||||
- `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `follow_up_queued`
|
||||
- `interrupt_received`
|
||||
- `context_compress`
|
||||
- `session_summarize`
|
||||
- `error`
|
||||
|
||||
`subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。
|
||||
|
||||
## 项目内挂载
|
||||
|
||||
内部挂载必须尽量低摩擦。
|
||||
|
||||
建议提供两种等价方式,底层都走 HookManager。
|
||||
|
||||
### 方式 A:代码显式挂载
|
||||
|
||||
```go
|
||||
al.MountHook(hooks.Named("audit", &AuditHook{}))
|
||||
```
|
||||
|
||||
适用于:
|
||||
|
||||
- 仓内内建 hook
|
||||
- 单元测试
|
||||
- feature flag 控制
|
||||
|
||||
### 方式 B:内建 registry
|
||||
|
||||
```go
|
||||
func init() {
|
||||
hooks.RegisterBuiltin("audit", func() hooks.Hook {
|
||||
return &AuditHook{}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
启动时根据配置启用:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"builtins": {
|
||||
"audit": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这比 OpenClaw 的目录扫描更轻,也更贴合 Go 项目。
|
||||
|
||||
## 项目外挂载
|
||||
|
||||
这是本设计的硬要求。
|
||||
|
||||
建议 V1 采用:
|
||||
|
||||
- `JSON-RPC over stdio`
|
||||
|
||||
原因:
|
||||
|
||||
- 跨平台最简单
|
||||
- 不依赖额外端口
|
||||
- 非常适合“由 PicoClaw 启动一个外部 hook 进程”
|
||||
- 比 HTTP webhook 更适合同步拦截
|
||||
|
||||
### 外部 hook 进程模型
|
||||
|
||||
PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。
|
||||
|
||||
配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"processes": {
|
||||
"review-gate": {
|
||||
"enabled": true,
|
||||
"transport": "stdio",
|
||||
"command": ["uvx", "picoclaw-hook-reviewer"],
|
||||
"observe": ["turn_start", "turn_end", "tool_exec_end"],
|
||||
"intercept": ["before_tool", "approve_tool"],
|
||||
"timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 协议边界
|
||||
|
||||
不要把内部 Go 结构体直接暴露给 IPC。
|
||||
|
||||
建议定义稳定的协议对象:
|
||||
|
||||
- `HookHandshake`
|
||||
- `HookEventNotification`
|
||||
- `BeforeLLMRequest`
|
||||
- `AfterLLMRequest`
|
||||
- `BeforeToolRequest`
|
||||
- `AfterToolRequest`
|
||||
- `ApproveToolRequest`
|
||||
- `HookDecision`
|
||||
|
||||
其中:
|
||||
|
||||
- 观察型事件用 notification,fire-and-forget
|
||||
- 拦截型事件用 request/response,同步等待
|
||||
|
||||
### 为什么是 stdio,而不是直接用 HTTP webhook
|
||||
|
||||
因为两者用途不同:
|
||||
|
||||
- HTTP webhook 更适合“外部系统向 PicoClaw 投递事件”
|
||||
- stdio/RPC 更适合“PicoClaw 在 turn 内同步询问外部 hook 是否改写 / 放行 / 拒绝”
|
||||
|
||||
如果未来需要 OpenClaw 式 webhook,可以作为独立入口层,再把外部事件转成 inbound message 或 steering,而不是直接替代 hook IPC。
|
||||
|
||||
## Hook 执行顺序
|
||||
|
||||
建议统一排序规则:
|
||||
|
||||
- 先内建 in-process hook
|
||||
- 再外部 IPC hook
|
||||
- 同组内按 `priority` 从小到大执行
|
||||
|
||||
原因:
|
||||
|
||||
- 内建 hook 延迟更低,适合做基础规范化
|
||||
- 外部 hook 更适合做审批、审计、组织级策略
|
||||
|
||||
## 超时与错误策略
|
||||
|
||||
### 观察型
|
||||
|
||||
- 默认超时:`500ms`
|
||||
- 超时或报错:记录日志,继续主流程
|
||||
|
||||
### 拦截型
|
||||
|
||||
- `before_llm` / `after_llm` / `before_tool` / `after_tool`:默认 `5s`
|
||||
- `approve_tool`:默认 `60s`
|
||||
|
||||
超时行为:
|
||||
|
||||
- 普通拦截:`continue`
|
||||
- 审批:`deny`
|
||||
|
||||
这点应直接沿用 `#1316` 的安全倾向。
|
||||
|
||||
## 与当前分支的对接点
|
||||
|
||||
### 直接复用
|
||||
|
||||
- 事件定义:`pkg/agent/events.go`
|
||||
- 事件广播:`pkg/agent/eventbus.go`
|
||||
- 活跃 turn / interrupt / rollback:`pkg/agent/turn.go`
|
||||
- 事件发射点:`pkg/agent/loop.go`
|
||||
|
||||
### 需要新增
|
||||
|
||||
- `pkg/agent/hooks.go`
|
||||
- Hook 接口
|
||||
- HookDecision / ApprovalDecision
|
||||
- HookManager
|
||||
|
||||
- `pkg/agent/hook_mount.go`
|
||||
- 内建 hook 注册
|
||||
- 外部进程 hook 注册
|
||||
|
||||
- `pkg/agent/hook_ipc.go`
|
||||
- stdio JSON-RPC bridge
|
||||
|
||||
- `pkg/agent/hook_types.go`
|
||||
- IPC 稳定载荷
|
||||
|
||||
### 需要改造
|
||||
|
||||
- `pkg/agent/loop.go`
|
||||
- 在 LLM 和 tool 关键路径前后插入 HookManager 调用
|
||||
|
||||
- `pkg/tools/base.go`
|
||||
- 可选新增 `ReadOnlyIndicator`
|
||||
|
||||
- `pkg/tools/spawn.go`
|
||||
- `pkg/tools/subagent.go`
|
||||
- 先保留现状
|
||||
- 等 sub-turn 迁移后再接入 `subturn_*` hook
|
||||
|
||||
## 一个更贴合当前分支的数据流
|
||||
|
||||
### 观察链路
|
||||
|
||||
```text
|
||||
runTurn() -> emitEvent() -> EventBus -> observers
|
||||
```
|
||||
|
||||
### 拦截链路
|
||||
|
||||
```text
|
||||
runTurn()
|
||||
-> HookManager.BeforeLLM()
|
||||
-> Provider.Chat()
|
||||
-> HookManager.AfterLLM()
|
||||
-> HookManager.BeforeTool()
|
||||
-> HookManager.ApproveTool()
|
||||
-> tool.Execute()
|
||||
-> HookManager.AfterTool()
|
||||
```
|
||||
|
||||
也就是说:
|
||||
|
||||
- observer 不改变现有 `emitEvent()`
|
||||
- interceptor 直接插在 `runTurn()` 热路径
|
||||
|
||||
## 用户可见配置
|
||||
|
||||
建议新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"builtins": {},
|
||||
"processes": {},
|
||||
"defaults": {
|
||||
"observer_timeout_ms": 500,
|
||||
"interceptor_timeout_ms": 5000,
|
||||
"approval_timeout_ms": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V1 不做复杂自动发现。
|
||||
|
||||
原因:
|
||||
|
||||
- 当前分支重点是把地基打稳
|
||||
- 目录扫描、安装器、脚手架可以后置
|
||||
- 先让仓内和仓外都能挂上去,比“管理体验完整”更重要
|
||||
|
||||
## 推荐的 V1 范围
|
||||
|
||||
### 必做
|
||||
|
||||
- HookManager
|
||||
- in-process 挂载
|
||||
- stdio IPC 挂载
|
||||
- observer hooks
|
||||
- `before_tool` / `after_tool` / `approve_tool`
|
||||
- `before_llm` / `after_llm`
|
||||
|
||||
### 可后置
|
||||
|
||||
- hook CLI 管理命令
|
||||
- hook 自动发现
|
||||
- Unix socket / named pipe transport
|
||||
- sub-turn hook 生命周期
|
||||
- read-only 并行分组
|
||||
- webhook 到 inbound message 的映射入口
|
||||
|
||||
## 分阶段落地
|
||||
|
||||
### Phase 1
|
||||
|
||||
- 引入 HookManager
|
||||
- 支持 in-process observer + interceptor
|
||||
- 先只接主 turn
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 引入 `stdio` 外部 hook 进程桥
|
||||
- 支持组织级审批 / 审计 / 参数改写
|
||||
|
||||
### Phase 3
|
||||
|
||||
- 把 `SubagentManager` 迁移到 `runTurn/sub-turn`
|
||||
- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered`
|
||||
|
||||
### Phase 4
|
||||
|
||||
- 视需求补 `ReadOnlyIndicator`
|
||||
- 在主 turn 和 sub-turn 上统一只读并行策略
|
||||
|
||||
## 最终结论
|
||||
|
||||
最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是:
|
||||
|
||||
- 以现有 `EventBus` 为只读观察面
|
||||
- 以新增 `HookManager` 为同步拦截面
|
||||
- 项目内通过 Go 对象直接挂载
|
||||
- 项目外通过 `stdio JSON-RPC` 进程通信挂载
|
||||
|
||||
这样做有三个好处:
|
||||
|
||||
- 和 `#1796` 一致,hooks 只是 EventBus 之上的消费层
|
||||
- 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统
|
||||
- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求
|
||||
@@ -0,0 +1,306 @@
|
||||
# Steering — Implementation Specification
|
||||
|
||||
## Problem
|
||||
|
||||
When the agent is running (executing a chain of tool calls), the user has no way to redirect it. They must wait for the full cycle to complete before sending a new message. This creates a poor experience when the agent takes a wrong direction — the user watches it waste time on tools that are no longer relevant.
|
||||
|
||||
## Solution
|
||||
|
||||
Steering introduces a **message queue** that external callers can push into at any time. The agent loop polls this queue at well-defined checkpoints. When a steering message is found, the agent:
|
||||
|
||||
1. Stops executing further tools in the current batch
|
||||
2. Injects the user's message into the conversation context
|
||||
3. Calls the LLM again with the updated context
|
||||
|
||||
The user's intent reaches the model **as soon as the current tool finishes**, not after the entire turn completes.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph External Callers
|
||||
TG[Telegram]
|
||||
DC[Discord]
|
||||
SL[Slack]
|
||||
end
|
||||
|
||||
subgraph AgentLoop
|
||||
BUS[MessageBus]
|
||||
DRAIN[drainBusToSteering goroutine]
|
||||
SQ[steeringQueue]
|
||||
RLI[runLLMIteration]
|
||||
TE[Tool Execution Loop]
|
||||
LLM[LLM Call]
|
||||
end
|
||||
|
||||
TG -->|PublishInbound| BUS
|
||||
DC -->|PublishInbound| BUS
|
||||
SL -->|PublishInbound| BUS
|
||||
|
||||
BUS -->|ConsumeInbound while busy| DRAIN
|
||||
DRAIN -->|Steer| SQ
|
||||
|
||||
RLI -->|1. initial poll| SQ
|
||||
TE -->|2. poll after each tool| SQ
|
||||
|
||||
SQ -->|pendingMessages| RLI
|
||||
RLI -->|inject into context| LLM
|
||||
```
|
||||
|
||||
### Bus drain mechanism
|
||||
|
||||
Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. Without additional wiring, these messages would sit in the bus buffer until the current `processMessage` finishes — meaning steering would never work for real users.
|
||||
|
||||
The solution: when `Run()` starts processing a message, it spawns a **drain goroutine** (`drainBusToSteering`) that keeps consuming from the bus and calling `Steer()`. When `processMessage` returns, the drain is canceled and normal consumption resumes.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Bus
|
||||
participant Run
|
||||
participant Drain
|
||||
participant AgentLoop
|
||||
|
||||
Run->>Bus: ConsumeInbound() → msg
|
||||
Run->>Drain: spawn drainBusToSteering(ctx)
|
||||
Run->>Run: processMessage(msg)
|
||||
|
||||
Note over Drain: running concurrently
|
||||
|
||||
Bus-->>Drain: ConsumeInbound() → newMsg
|
||||
Drain->>AgentLoop: al.transcribeAudioInMessage(ctx, newMsg)
|
||||
Drain->>AgentLoop: Steer(providers.Message{Content: newMsg.Content})
|
||||
|
||||
Run->>Run: processMessage returns
|
||||
Run->>Drain: cancel context
|
||||
Note over Drain: exits
|
||||
```
|
||||
|
||||
## Data Structures
|
||||
|
||||
### steeringQueue
|
||||
|
||||
A thread-safe FIFO queue, private to the `agent` package.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mu` | `sync.Mutex` | Protects all access to `queue` and `mode` |
|
||||
| `queue` | `[]providers.Message` | Pending steering messages |
|
||||
| `mode` | `SteeringMode` | Dequeue strategy |
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `push(msg) error` | Appends a message to the queue. Returns an error if the queue is full (`MaxQueueSize`) |
|
||||
| `dequeue() []Message` | Removes and returns messages according to `mode`. Returns `nil` if empty |
|
||||
| `len() int` | Returns the current queue length |
|
||||
| `setMode(mode)` | Updates the dequeue strategy |
|
||||
| `getMode() SteeringMode` | Returns the current mode |
|
||||
|
||||
### SteeringMode
|
||||
|
||||
| Value | Constant | Behavior |
|
||||
|-------|----------|----------|
|
||||
| `"one-at-a-time"` | `SteeringOneAtATime` | `dequeue()` returns only the **first** message. Remaining messages stay in the queue for subsequent polls. |
|
||||
| `"all"` | `SteeringAll` | `dequeue()` drains the **entire** queue and returns all messages at once. |
|
||||
|
||||
Default: `"one-at-a-time"`.
|
||||
|
||||
### processOptions extension
|
||||
|
||||
A new field was added to `processOptions`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `SkipInitialSteeringPoll` | `bool` | When `true`, the initial steering poll at loop start is skipped. Used by `Continue()` to avoid double-dequeuing. |
|
||||
|
||||
## Public API on AgentLoop
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. |
|
||||
| `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. |
|
||||
| `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. |
|
||||
| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages. Returns `""` if queue is empty. |
|
||||
|
||||
## Integration into the Agent Loop
|
||||
|
||||
### Where steering is wired
|
||||
|
||||
The steering queue lives as a field on `AgentLoop`:
|
||||
|
||||
```
|
||||
AgentLoop
|
||||
├── bus
|
||||
├── cfg
|
||||
├── registry
|
||||
├── steering *steeringQueue ← new
|
||||
├── ...
|
||||
```
|
||||
|
||||
It is initialized in `NewAgentLoop` from `cfg.Agents.Defaults.SteeringMode`.
|
||||
|
||||
### Detailed flow through runLLMIteration
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant AgentLoop
|
||||
participant runLLMIteration
|
||||
participant ToolExecution
|
||||
participant LLM
|
||||
|
||||
User->>AgentLoop: Steer(message)
|
||||
Note over AgentLoop: steeringQueue.push(message)
|
||||
|
||||
Note over runLLMIteration: ── iteration starts ──
|
||||
|
||||
runLLMIteration->>AgentLoop: dequeueSteeringMessages()<br/>[initial poll]
|
||||
AgentLoop-->>runLLMIteration: [] (empty, or messages)
|
||||
|
||||
alt pendingMessages not empty
|
||||
runLLMIteration->>runLLMIteration: inject into messages[]<br/>save to session
|
||||
end
|
||||
|
||||
runLLMIteration->>LLM: Chat(messages, tools)
|
||||
LLM-->>runLLMIteration: response with toolCalls[0..N]
|
||||
|
||||
loop for each tool call (sequential)
|
||||
ToolExecution->>ToolExecution: execute tool[i]
|
||||
ToolExecution->>ToolExecution: process result,<br/>append to messages[]
|
||||
|
||||
ToolExecution->>AgentLoop: dequeueSteeringMessages()
|
||||
AgentLoop-->>ToolExecution: steeringMessages
|
||||
|
||||
alt steering found
|
||||
opt remaining tools > 0
|
||||
Note over ToolExecution: Mark tool[i+1..N-1] as<br/>"Skipped due to queued user message."
|
||||
end
|
||||
Note over ToolExecution: steeringAfterTools = steeringMessages
|
||||
Note over ToolExecution: break out of tool loop
|
||||
end
|
||||
end
|
||||
|
||||
alt steeringAfterTools not empty
|
||||
ToolExecution-->>runLLMIteration: pendingMessages = steeringAfterTools
|
||||
Note over runLLMIteration: next iteration will inject<br/>these before calling LLM
|
||||
end
|
||||
|
||||
Note over runLLMIteration: ── loop back to iteration start ──
|
||||
```
|
||||
|
||||
### Polling checkpoints
|
||||
|
||||
| # | Location | When | Purpose |
|
||||
|---|----------|------|---------|
|
||||
| 1 | Top of `runLLMIteration`, before first LLM call | Once, at loop entry | Catch messages enqueued while the agent was still setting up context |
|
||||
| 2 | After every tool completes (including the first and the last) | Immediately after each tool's result is processed | Interrupt the batch as early as possible — if steering is found and there are remaining tools, they are all skipped |
|
||||
|
||||
### What happens to skipped tools
|
||||
|
||||
When steering interrupts a tool batch after tool `[i]` completes, all tools from `[i+1]` to `[N-1]` are **not executed**. Instead, a tool result message is generated for each:
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "Skipped due to queued user message.",
|
||||
"tool_call_id": "<original_call_id>"
|
||||
}
|
||||
```
|
||||
|
||||
These results are:
|
||||
- Appended to the conversation `messages[]`
|
||||
- Saved to the session via `AddFullMessage`
|
||||
|
||||
This ensures the LLM knows which of its requested actions were not performed.
|
||||
|
||||
### Loop condition change
|
||||
|
||||
The iteration loop condition was changed from:
|
||||
|
||||
```go
|
||||
for iteration < agent.MaxIterations
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
for iteration < agent.MaxIterations || len(pendingMessages) > 0
|
||||
```
|
||||
|
||||
This allows **one extra iteration** when steering arrives right at the max iteration boundary, ensuring the steering message is always processed.
|
||||
|
||||
### Tool execution: parallel → sequential
|
||||
|
||||
**Before steering:** all tool calls in a batch were executed in parallel using `sync.WaitGroup`.
|
||||
|
||||
**After steering:** tool calls execute **sequentially**. This is required because steering must be polled between individual tool completions. A parallel execution model would not allow interrupting mid-batch.
|
||||
|
||||
> **Trade-off:** This introduces latency when the LLM requests multiple independent tools in a single turn. In practice, most batches contain 1-2 tools, so the impact is minimal. The benefit of being able to interrupt outweighs the cost.
|
||||
|
||||
### Why skip remaining tools (instead of letting them finish)
|
||||
|
||||
Two strategies were considered when a steering message is detected mid-batch:
|
||||
|
||||
1. **Skip remaining tools** (chosen) — stop executing, mark the rest as skipped, inject steering
|
||||
2. **Finish all tools, then inject** — let everything run, append steering afterwards
|
||||
|
||||
Strategy 2 was rejected for three reasons:
|
||||
|
||||
**Irreversible side effects.** Tools can send emails, write files, spawn subagents, or call external APIs. If the user says "stop" or "change direction", those actions have already happened and cannot be undone.
|
||||
|
||||
| Tool batch | Steering | Skip (1) | Finish (2) |
|
||||
|---|---|---|---|
|
||||
| `[search, send_email]` | "don't send it" | Email not sent | Email sent |
|
||||
| `[query, write_file, spawn]` | "wrong database" | Only query runs | File + subagent wasted |
|
||||
| `[fetch₁, fetch₂, fetch₃, write]` | topic change | 1 fetch | 3 fetches + write, all discarded |
|
||||
|
||||
**Wasted latency.** Tools like web fetches and API calls take seconds each. In a 3-tool batch averaging 3-4s per tool, the user would wait 10+ seconds for work that gets thrown away.
|
||||
|
||||
**The LLM retains full awareness.** Skipped tools receive an explicit `"Skipped due to queued user message."` result, so the model knows what was not done and can decide whether to re-execute with the new context or take a different path.
|
||||
|
||||
## The Continue() method
|
||||
|
||||
`Continue` handles the case where the agent is **idle** (its last message was from the assistant) and the user has enqueued steering messages in the meantime.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Continue called] --> B{dequeueSteeringMessages}
|
||||
B -->|empty| C["return ('', nil)"]
|
||||
B -->|messages found| D[Combine message contents]
|
||||
D --> E["runAgentLoop with<br/>SkipInitialSteeringPoll: true"]
|
||||
E --> F[Return response]
|
||||
```
|
||||
|
||||
**Why `SkipInitialSteeringPoll: true`?** Because `Continue` already dequeued the messages itself. Without this flag, `runLLMIteration` would poll again at the start and find nothing (the queue is already empty), or worse, double-process if new messages arrived in the meantime.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"steering_mode": "one-at-a-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Env var |
|
||||
|-------|------|---------|---------|
|
||||
| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` |
|
||||
|
||||
|
||||
## Design decisions and trade-offs
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Sequential tool execution | Required for per-tool steering polls. Parallel execution cannot be interrupted mid-batch. |
|
||||
| Polling-based (not channel/signal) | Keeps the implementation simple. No need for `select` or signal channels. The polling cost is negligible (mutex lock + slice length check). |
|
||||
| `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. |
|
||||
| Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. |
|
||||
| `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. |
|
||||
| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the same steering queue since `processMessage` is sequential. |
|
||||
| Bus drain goroutine in `Run()` | Channels (Telegram, Discord, etc.) publish to the bus via `PublishInbound`. Without the drain, messages would queue in the bus channel buffer and only be consumed after `processMessage` returns — defeating the purpose of steering. The drain goroutine bridges the gap by consuming new bus messages and calling `Steer()` while the agent is busy. |
|
||||
| Audio transcription before steering | The drain goroutine calls `al.transcribeAudioInMessage(ctx, msg)` before steering, so voice messages are converted to text before the agent sees them. If transcription fails, the error is silently discarded and the original message is steered as-is. |
|
||||
| `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. |
|
||||
+51
-1
@@ -13,6 +13,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
|
||||
| **Telegram** | ⭐ Facile | Recommandé, transcription vocale, long polling (pas d'IP publique requise) | [Documentation](../channels/telegram/README.fr.md) |
|
||||
| **Discord** | ⭐ Facile | Socket Mode, groupes/DM, écosystème bot riche | [Documentation](../channels/discord/README.fr.md) |
|
||||
| **WhatsApp** | ⭐ Facile | Natif (scan QR) ou Bridge URL | [Documentation](#whatsapp) |
|
||||
| **Weixin** | ⭐ Facile | Scan QR natif (API Tencent iLink) | [Documentation](#weixin) |
|
||||
| **Slack** | ⭐ Facile | **Socket Mode** (pas d'IP publique requise), entreprise | [Documentation](../channels/slack/README.fr.md) |
|
||||
| **Matrix** | ⭐⭐ Moyen | Protocole fédéré, auto-hébergement possible | [Documentation](../channels/matrix/README.fr.md) |
|
||||
| **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) |
|
||||
@@ -20,11 +21,12 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
|
||||
| **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/README.fr.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
|
||||
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | - |
|
||||
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
|
||||
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
|
||||
| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
|
||||
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommandé)</summary>
|
||||
|
||||
@@ -65,6 +67,7 @@ Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -138,6 +141,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b> (natif via whatsmeow)</summary>
|
||||
|
||||
@@ -165,6 +169,43 @@ Si `session_store_path` est vide, la session est stockée dans `<workspace>/what
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Personnel)</summary>
|
||||
|
||||
PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API officielle Tencent iLink.
|
||||
|
||||
**1. Connexion**
|
||||
|
||||
Lancez le flux de connexion interactif par QR code :
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration.
|
||||
|
||||
**2. Configurer**
|
||||
|
||||
(Optionnel) Ajoutez votre identifiant utilisateur WeChat dans `allow_from` pour restreindre qui peut envoyer des messages au bot :
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Lancer**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -206,6 +247,7 @@ Si vous préférez créer le bot manuellement :
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
@@ -239,6 +281,7 @@ picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -273,6 +316,7 @@ Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeh
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -321,6 +365,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
@@ -435,6 +480,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>Feishu (飞书)</b></summary>
|
||||
|
||||
@@ -476,6 +522,7 @@ Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../cha
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -509,6 +556,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -542,6 +590,7 @@ Le bot se connectera au serveur IRC et rejoindra les canaux spécifiés.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot (QQ via protocole OneBot)</b></summary>
|
||||
|
||||
@@ -580,6 +629,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="maixcam"></a>
|
||||
<details>
|
||||
<summary><b>MaixCam</b></summary>
|
||||
|
||||
|
||||
+146
-1
@@ -214,5 +214,150 @@ L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toute
|
||||
Pour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** :
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
# Tâches Périodiques
|
||||
|
||||
## Tâches Rapides (répondre directement)
|
||||
|
||||
- Indiquer l'heure actuelle
|
||||
|
||||
## Tâches Longues (utiliser spawn pour l'asynchrone)
|
||||
|
||||
- Rechercher les actualités IA sur le web et résumer
|
||||
- Vérifier les e-mails et signaler les messages importants
|
||||
```
|
||||
|
||||
**Comportements clés :**
|
||||
|
||||
| Fonctionnalité | Description |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat |
|
||||
| **Contexte indépendant** | Le subagent a son propre contexte, sans historique de session |
|
||||
| **message tool** | Le subagent communique directement avec l'utilisateur |
|
||||
| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante |
|
||||
|
||||
#### Flux de Communication du Subagent
|
||||
|
||||
```
|
||||
Heartbeat déclenché
|
||||
↓
|
||||
Agent lit HEARTBEAT.md
|
||||
↓
|
||||
Tâche longue : spawn subagent
|
||||
↓ ↓
|
||||
Continue tâche suivante Subagent travaille indépendamment
|
||||
↓ ↓
|
||||
Toutes tâches terminées Subagent utilise "message" tool
|
||||
↓ ↓
|
||||
Répond HEARTBEAT_OK Utilisateur reçoit le résultat
|
||||
```
|
||||
|
||||
**Configuration :**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Défaut | Description |
|
||||
| ---------- | ------ | ---------------------------------------- |
|
||||
| `enabled` | `true` | Activer/désactiver le heartbeat |
|
||||
| `interval` | `30` | Intervalle en minutes (minimum : 5) |
|
||||
|
||||
**Variables d'environnement :**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq fournit une transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||
|
||||
| Provider | Usage | Obtenir une clé API |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (recommandé, accès à tous modèles) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Transcription vocale** (Whisper)| [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### Configuration des Modèles (model_list)
|
||||
|
||||
> **Nouveauté :** PicoClaw utilise désormais une approche **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (ex. `zhipu/glm-4.7`) pour ajouter de nouveaux providers — **aucune modification de code requise !**
|
||||
|
||||
#### Tous les Vendors Supportés
|
||||
|
||||
| Vendor | Préfixe `model` | API Base par défaut | Protocole | API Key |
|
||||
| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir](https://openrouter.ai/keys) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||
|
||||
#### Équilibrage de Charge
|
||||
|
||||
Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin :
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration depuis l'ancienne config `providers`
|
||||
|
||||
L'ancienne configuration `providers` est **dépréciée** mais toujours supportée. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
### Architecture des Providers
|
||||
|
||||
PicoClaw route les providers par famille de protocole :
|
||||
|
||||
- **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres.
|
||||
- **Anthropic** : Comportement natif de l'API Claude.
|
||||
- **Codex/OAuth** : Route d'authentification OAuth/token OpenAI.
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
|
||||
PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspace/cron/`.
|
||||
|
||||
### Sujets Avancés
|
||||
|
||||
| Sujet | Description |
|
||||
| ----- | ----------- |
|
||||
| [Système de Hooks](../hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
|
||||
| [Steering](../steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
|
||||
| [SubTurn](../subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
|
||||
| [Gestion du Contexte](../agent-refactor/context.md) | Détection des limites de contexte, compression |
|
||||
|
||||
@@ -41,14 +41,6 @@ Paramètres généraux pour la récupération et le traitement du contenu des pa
|
||||
| `fetch_limit_bytes` | int | 10485760 | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo). |
|
||||
| `format` | string | "plaintext" | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé). |
|
||||
|
||||
### Brave
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|------------|---------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche Brave |
|
||||
| `api_key` | string | - | Clé API Brave Search |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
@@ -56,13 +48,73 @@ Paramètres généraux pour la récupération et le traitement du contenu des pa
|
||||
| `enabled` | bool | true | Activer la recherche DuckDuckGo |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
### Baidu Search
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|-----------------------------------------------------------------|------------------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche Baidu |
|
||||
| `api_key` | string | - | Clé API Qianfan |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL de l'API Baidu Search |
|
||||
| `max_results` | int | 10 | Nombre maximum de résultats |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|------------|--------------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche Perplexity |
|
||||
| `api_key` | string | - | Clé API Perplexity |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
| `enabled` | bool | false | Activer la recherche Perplexity |
|
||||
| `api_key` | string | - | Clé API Perplexity |
|
||||
| `api_keys` | string[] | - | Plusieurs clés API Perplexity pour la rotation (`api_key` prioritaire) |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
### Brave
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|------------|---------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche Brave |
|
||||
| `api_key` | string | - | Clé API Brave Search |
|
||||
| `api_keys` | string[] | - | Plusieurs clés API Brave Search pour la rotation (`api_key` prioritaire) |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
### Tavily
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|------------|------------------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche Tavily |
|
||||
| `api_key` | string | - | Clé API Tavily |
|
||||
| `base_url` | string | - | URL de base Tavily personnalisée |
|
||||
| `max_results` | int | 0 | Nombre maximum de résultats (0 = défaut) |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|---------------|--------|--------------------------|--------------------------------|
|
||||
| `enabled` | bool | false | Activer la recherche SearXNG |
|
||||
| `base_url` | string | `http://localhost:8888` | URL de l'instance SearXNG |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
### GLM Search
|
||||
|
||||
| Config | Type | Par défaut | Description |
|
||||
|-----------------|--------|------------------------------------------------------|---------------------------|
|
||||
| `enabled` | bool | false | Activer GLM Search |
|
||||
| `api_key` | string | - | Clé API GLM |
|
||||
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL de l'API GLM Search |
|
||||
| `search_engine` | string | `search_std` | Type de moteur de recherche |
|
||||
| `max_results` | int | 5 | Nombre maximum de résultats |
|
||||
|
||||
## Outil Exec
|
||||
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
# Hook System Guide
|
||||
|
||||
This document describes the hook system that is implemented in the current repository, not the older design draft.
|
||||
|
||||
The current implementation supports two mounting modes:
|
||||
|
||||
1. In-process hooks
|
||||
2. Out-of-process process hooks (`JSON-RPC over stdio`)
|
||||
|
||||
The repository no longer ships standalone example source files. The Go and Python examples below are embedded directly in this document. If you want to use them, copy them into your own local files first.
|
||||
|
||||
## Supported Hook Types
|
||||
|
||||
| Type | Interface | Stage | Can modify data |
|
||||
| --- | --- | --- | --- |
|
||||
| Observer | `EventObserver` | EventBus broadcast | No |
|
||||
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
|
||||
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
|
||||
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
|
||||
|
||||
The currently exposed synchronous hook points are:
|
||||
|
||||
- `before_llm`
|
||||
- `after_llm`
|
||||
- `before_tool`
|
||||
- `after_tool`
|
||||
- `approve_tool`
|
||||
|
||||
Everything else is exposed as read-only events.
|
||||
|
||||
## Execution Order
|
||||
|
||||
`HookManager` sorts hooks like this:
|
||||
|
||||
1. In-process hooks first
|
||||
2. Process hooks second
|
||||
3. Lower `priority` first within the same source
|
||||
4. Name order as the final tie-breaker
|
||||
|
||||
## Timeouts
|
||||
|
||||
Global defaults live under `hooks.defaults`:
|
||||
|
||||
- `observer_timeout_ms`
|
||||
- `interceptor_timeout_ms`
|
||||
- `approval_timeout_ms`
|
||||
|
||||
Note: the current implementation does not support per-process-hook `timeout_ms`. Timeouts are global defaults.
|
||||
|
||||
## Quick Start
|
||||
|
||||
If your first goal is simply to prove that the hook flow works and observe real requests, the easiest path is the Python process-hook example below:
|
||||
|
||||
1. Enable `hooks.enabled`
|
||||
2. Save the Python example from this document to a local file, for example `/tmp/review_gate.py`
|
||||
3. Set `PICOCLAW_HOOK_LOG_FILE`
|
||||
4. Restart the gateway
|
||||
5. Watch the log file with `tail -f`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"py_review_gate": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": [
|
||||
"python3",
|
||||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
"approve_tool"
|
||||
],
|
||||
"env": {
|
||||
"PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Watch it with:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/picoclaw-hook-review-gate.log
|
||||
```
|
||||
|
||||
If you are developing PicoClaw itself rather than only validating the protocol, continue with the Go in-process example as well.
|
||||
|
||||
## What The Two Examples Are For
|
||||
|
||||
- Go in-process example
|
||||
Best for validating the host-side hook chain and understanding `MountHook()` plus the synchronous stages
|
||||
- Python process example
|
||||
Best for understanding the `JSON-RPC over stdio` protocol and verifying the message flow between PicoClaw and an external process
|
||||
|
||||
Both examples are intentionally safe: they only log, never rewrite, and never deny.
|
||||
|
||||
## Go In-Process Example
|
||||
|
||||
The following is a minimal logging hook for in-process use. It implements:
|
||||
|
||||
1. `EventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
|
||||
It only records activity. It does not rewrite requests or reject tools.
|
||||
|
||||
You can save it as your own Go file, for example `pkg/myhooks/example_logger.go`:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
type ExampleLoggerHookOptions struct {
|
||||
LogFile string `json:"log_file,omitempty"`
|
||||
LogEvents bool `json:"log_events,omitempty"`
|
||||
}
|
||||
|
||||
type ExampleLoggerHook struct {
|
||||
logFile string
|
||||
logEvents bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
||||
return &ExampleLoggerHook{
|
||||
logFile: strings.TrimSpace(opts.LogFile),
|
||||
logEvents: opts.LogEvents,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *agent.LLMHookRequest,
|
||||
) (*agent.LLMHookRequest, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("before_llm", req.Meta, req, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *agent.LLMHookResponse,
|
||||
) (*agent.LLMHookResponse, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("after_llm", resp.Meta, resp, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return resp, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("before_tool", call.Meta, call, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *agent.ToolResultHookResponse,
|
||||
) (*agent.ToolResultHookResponse, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("after_tool", result.Meta, result, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return result, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) ApproveTool(
|
||||
ctx context.Context,
|
||||
req *agent.ToolApprovalRequest,
|
||||
) (agent.ApprovalDecision, error) {
|
||||
_ = ctx
|
||||
decision := agent.ApprovalDecision{Approved: true}
|
||||
h.record("approve_tool", req.Meta, req, decision)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
if h == nil || h.logFile == "" {
|
||||
return
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log encode failed", map[string]any{
|
||||
"stage": stage,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if dir := filepath.Dir(h.logFile); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log mkdir failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(h.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log open failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
if _, err := file.Write(append(body, '\n')); err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log write failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mounting It In Code
|
||||
|
||||
If code mounting is enough, call this after `AgentLoop` is initialized:
|
||||
|
||||
```go
|
||||
hook := myhooks.NewExampleLoggerHook(myhooks.ExampleLoggerHookOptions{
|
||||
LogFile: "/tmp/picoclaw-hook-example-logger.log",
|
||||
LogEvents: true,
|
||||
})
|
||||
|
||||
if err := al.MountHook(agent.NamedHook("example-logger", hook)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### If You Also Want Config Mounting
|
||||
|
||||
The hook system supports builtin hooks, but that requires you to compile the factory into your binary. In practice, that means you need registration code like this alongside the hook definition above:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := agent.RegisterBuiltinHook("example_logger", func(
|
||||
ctx context.Context,
|
||||
spec config.BuiltinHookConfig,
|
||||
) (any, error) {
|
||||
_ = ctx
|
||||
|
||||
var opts ExampleLoggerHookOptions
|
||||
if len(spec.Config) > 0 {
|
||||
if err := json.Unmarshal(spec.Config, &opts); err != nil {
|
||||
return nil, fmt.Errorf("decode example_logger config: %w", err)
|
||||
}
|
||||
}
|
||||
return NewExampleLoggerHook(opts), nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only after you register that builtin will the following config work:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"builtins": {
|
||||
"example_logger": {
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"config": {
|
||||
"log_file": "/tmp/picoclaw-hook-example-logger.log",
|
||||
"log_events": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How To Observe It
|
||||
|
||||
- If `log_file` is set, each hook call is appended as JSON Lines
|
||||
- If `log_file` is not set, the hook still writes summaries to the gateway log
|
||||
- Requests that only hit the LLM path usually show `before_llm` and `after_llm`
|
||||
- Requests that trigger tools usually also show `before_tool`, `approve_tool`, and `after_tool`
|
||||
- If `log_events=true`, you will also see `event`
|
||||
|
||||
Typical log lines:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-21T14:10:00Z","stage":"before_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"action":"continue"}}
|
||||
{"ts":"2026-03-21T14:10:00Z","stage":"approve_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"approved":true}}
|
||||
```
|
||||
|
||||
If you only see `before_llm` and `after_llm`, that usually means the request did not trigger any tool call, not that the hook failed to mount.
|
||||
|
||||
## Python Process-Hook Example
|
||||
|
||||
The following script is a minimal process-hook example. It uses only the Python standard library and supports:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
It only records activity. It does not rewrite or deny anything.
|
||||
|
||||
Save it to any local path, for example `/tmp/review_gate.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
LOG_EVENTS = os.getenv("PICOCLAW_HOOK_LOG_EVENTS", "1").lower() not in {"0", "false", "no"}
|
||||
LOG_FILE = os.getenv("PICOCLAW_HOOK_LOG_FILE", "").strip()
|
||||
|
||||
|
||||
def append_log(entry: dict[str, Any]) -> None:
|
||||
if not LOG_FILE:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
**entry,
|
||||
}
|
||||
try:
|
||||
log_dir = os.path.dirname(LOG_FILE)
|
||||
if log_dir:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
except OSError as exc:
|
||||
log_stderr(f"failed to write hook log file {LOG_FILE}: {exc}")
|
||||
|
||||
|
||||
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
}
|
||||
if error is not None:
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
append_log({
|
||||
"direction": "out",
|
||||
"id": message_id,
|
||||
"response": payload.get("result"),
|
||||
"error": payload.get("error"),
|
||||
})
|
||||
|
||||
try:
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
except BrokenPipeError:
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
def log_stderr(message: str) -> None:
|
||||
try:
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
except BrokenPipeError:
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
def handle_shutdown_signal(signum: int, _frame: Any) -> None:
|
||||
raise KeyboardInterrupt(f"received signal {signum}")
|
||||
|
||||
|
||||
def handle_before_tool(params: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = params
|
||||
return {"action": "continue"}
|
||||
|
||||
|
||||
def handle_approve_tool(params: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = params
|
||||
return {"approved": True}
|
||||
|
||||
|
||||
def handle_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "hook.hello":
|
||||
return {"ok": True, "name": "python-review-gate"}
|
||||
if method == "hook.before_tool":
|
||||
return handle_before_tool(params)
|
||||
if method == "hook.approve_tool":
|
||||
return handle_approve_tool(params)
|
||||
if method == "hook.before_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_tool":
|
||||
return {"action": "continue"}
|
||||
raise KeyError(f"method not found: {method}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
log_stderr(f"failed to decode request: {exc}")
|
||||
append_log({
|
||||
"direction": "in",
|
||||
"decode_error": str(exc),
|
||||
"raw": line,
|
||||
})
|
||||
continue
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
append_log({
|
||||
"direction": "in",
|
||||
"id": message_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
"notification": not bool(message_id),
|
||||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
except KeyError as exc:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
continue
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
continue
|
||||
|
||||
send_response(int(message_id), result=result)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, handle_shutdown_signal)
|
||||
signal.signal(signal.SIGTERM, handle_shutdown_signal)
|
||||
raise SystemExit(main())
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"py_review_gate": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": [
|
||||
"python3",
|
||||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
"approve_tool"
|
||||
],
|
||||
"env": {
|
||||
"PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
Whether to write `hook.event` summaries to `stderr`, enabled by default
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
|
||||
|
||||
Note: `PICOCLAW_HOOK_LOG_FILE` has no default. If you do not set it, the script does not write any file logs.
|
||||
|
||||
### How To Confirm It Received Hooks
|
||||
|
||||
Watch two places:
|
||||
|
||||
- Gateway logs
|
||||
Useful for confirming that the host successfully started the process and for seeing event summaries written to `stderr`
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
Useful for seeing the exact requests the script received and the exact responses it returned
|
||||
|
||||
Typical interpretation:
|
||||
|
||||
- Only `hook.hello`
|
||||
The process started and completed the handshake, but no business hook request has arrived yet
|
||||
- `hook.event`
|
||||
The `observe` configuration is working
|
||||
- `hook.before_tool`
|
||||
The `intercept: ["before_tool", ...]` configuration is working
|
||||
- `hook.approve_tool`
|
||||
The approval hook path is working
|
||||
|
||||
Because this example never rewrites or denies, the expected responses look like:
|
||||
|
||||
```json
|
||||
{"direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
{"direction":"out","id":8,"response":{"approved":true},"error":null}
|
||||
```
|
||||
|
||||
A complete sample:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
|
||||
Additional notes:
|
||||
|
||||
- Timestamps are UTC
|
||||
- `notification=true` means it was a notification such as `hook.event`, which does not expect a response
|
||||
- `id` increases within a single hook process; if the process restarts, the counter starts over
|
||||
|
||||
## Process-Hook Protocol
|
||||
|
||||
Current process hooks use `JSON-RPC over stdio`:
|
||||
|
||||
- PicoClaw starts the external process
|
||||
- Requests and responses are exchanged as one JSON message per line
|
||||
- `hook.event` is a notification and does not need a response
|
||||
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
|
||||
|
||||
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
### `hooks.builtins.<name>`
|
||||
|
||||
- `enabled`
|
||||
- `priority`
|
||||
- `config`
|
||||
|
||||
### `hooks.processes.<name>`
|
||||
|
||||
- `enabled`
|
||||
- `priority`
|
||||
- `transport`
|
||||
Currently only `stdio` is supported
|
||||
- `command`
|
||||
- `dir`
|
||||
- `env`
|
||||
- `observe`
|
||||
- `intercept`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If a hook looks like it is not firing, check these in order:
|
||||
|
||||
1. `hooks.enabled`
|
||||
2. Whether the target builtin or process hook is `enabled`
|
||||
3. Whether the process-hook `command` path is correct
|
||||
4. Whether you are watching the correct log file
|
||||
5. Whether the current request actually reached the stage you care about
|
||||
6. Whether `observe` or `intercept` contains the hook point you want
|
||||
|
||||
A practical minimal troubleshooting pair is:
|
||||
|
||||
- Use the Python process-hook example from this document to validate the external protocol
|
||||
- Use the Go in-process example from this document to validate the host-side chain
|
||||
|
||||
If the Python side shows `hook.hello` but no business hook requests, the protocol is usually fine; the current request simply did not trigger the stage you expected.
|
||||
|
||||
## Scope And Limits
|
||||
|
||||
The current hook system is best suited for:
|
||||
|
||||
- LLM request rewriting
|
||||
- Tool argument normalization
|
||||
- Pre-execution tool approval
|
||||
- Auditing and observability
|
||||
|
||||
It is not yet well suited for:
|
||||
|
||||
- External hooks actively sending channel messages
|
||||
- Suspending a turn and waiting for human approval replies
|
||||
- Full inbound/outbound message interception across the whole platform
|
||||
|
||||
If you want a real human approval workflow, use hooks as the approval entry point and keep the state machine plus channel interaction in a separate `ApprovalManager`.
|
||||
@@ -0,0 +1,679 @@
|
||||
# Hook 系统使用说明
|
||||
|
||||
这份文档对应当前仓库里已经实现的 hook 系统,而不是设计草案。
|
||||
|
||||
当前实现支持两类挂载方式:
|
||||
|
||||
1. 进程内 hook
|
||||
2. 进程外 process hook(`JSON-RPC over stdio`)
|
||||
|
||||
当前仓库不再内置示例代码文件。下面的 Go / Python 示例都直接写在本文档里;如果你要使用它们,需要先复制到你自己的文件路径。
|
||||
|
||||
## 支持的 hook 类型
|
||||
|
||||
| 类型 | 接口 | 作用阶段 | 能否改写 |
|
||||
| --- | --- | --- | --- |
|
||||
| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 |
|
||||
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
|
||||
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
|
||||
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
|
||||
|
||||
当前公开的同步点位只有:
|
||||
|
||||
- `before_llm`
|
||||
- `after_llm`
|
||||
- `before_tool`
|
||||
- `after_tool`
|
||||
- `approve_tool`
|
||||
|
||||
其余 lifecycle 通过事件形式只读暴露。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
HookManager 的排序规则是:
|
||||
|
||||
1. 先执行进程内 hook
|
||||
2. 再执行 process hook
|
||||
3. 同一来源内按 `priority` 从小到大
|
||||
4. 若 `priority` 相同,再按名字排序
|
||||
|
||||
## 超时
|
||||
|
||||
当前配置在 `hooks.defaults` 中统一设置:
|
||||
|
||||
- `observer_timeout_ms`
|
||||
- `interceptor_timeout_ms`
|
||||
- `approval_timeout_ms`
|
||||
|
||||
注意:当前实现还没有单个 process hook 自己的 `timeout_ms` 字段,超时配置是全局默认值。
|
||||
|
||||
## 快速开始
|
||||
|
||||
如果你的目标只是先把当前 hook 流程跑通并观察到实际请求,最省事的是先用下面的 Python process hook 示例:
|
||||
|
||||
1. 打开 `hooks.enabled`
|
||||
2. 把下面文档里的 Python 示例保存到本地文件,例如 `/tmp/review_gate.py`
|
||||
3. 给它配置 `PICOCLAW_HOOK_LOG_FILE`
|
||||
4. 重启 gateway
|
||||
5. 用 `tail -f` 观察日志文件
|
||||
|
||||
例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"py_review_gate": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": [
|
||||
"python3",
|
||||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
"approve_tool"
|
||||
],
|
||||
"env": {
|
||||
"PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
观察方式:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/picoclaw-hook-review-gate.log
|
||||
```
|
||||
|
||||
如果你是在开发 PicoClaw 本体,而不是只想验证协议,那么再看后面的 Go in-process 示例。
|
||||
|
||||
## 两个示例的定位
|
||||
|
||||
- Go in-process 示例
|
||||
适合验证宿主内的 hook 链路、理解 `MountHook()` 和各个同步点位
|
||||
- Python process 示例
|
||||
适合理解 `JSON-RPC over stdio` 协议、确认宿主和外部进程之间的消息来回是否正常
|
||||
|
||||
这两个示例都刻意保持为“只记录、不改写、不拒绝”的安全模式。它们的目的不是提供策略能力,而是帮你观察当前 hook 系统。
|
||||
|
||||
## Go 进程内示例
|
||||
|
||||
下面这段代码是一个最小的“记录型” in-process hook。它实现了:
|
||||
|
||||
1. `EventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
|
||||
它只记录,不改写请求,也不拒绝工具。
|
||||
|
||||
你可以把它保存成你自己的 Go 文件,例如 `pkg/myhooks/example_logger.go`:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
type ExampleLoggerHookOptions struct {
|
||||
LogFile string `json:"log_file,omitempty"`
|
||||
LogEvents bool `json:"log_events,omitempty"`
|
||||
}
|
||||
|
||||
type ExampleLoggerHook struct {
|
||||
logFile string
|
||||
logEvents bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
||||
return &ExampleLoggerHook{
|
||||
logFile: strings.TrimSpace(opts.LogFile),
|
||||
logEvents: opts.LogEvents,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *agent.LLMHookRequest,
|
||||
) (*agent.LLMHookRequest, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("before_llm", req.Meta, req, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *agent.LLMHookResponse,
|
||||
) (*agent.LLMHookResponse, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("after_llm", resp.Meta, resp, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return resp, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("before_tool", call.Meta, call, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *agent.ToolResultHookResponse,
|
||||
) (*agent.ToolResultHookResponse, agent.HookDecision, error) {
|
||||
_ = ctx
|
||||
h.record("after_tool", result.Meta, result, agent.HookDecision{Action: agent.HookActionContinue})
|
||||
return result, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) ApproveTool(
|
||||
ctx context.Context,
|
||||
req *agent.ToolApprovalRequest,
|
||||
) (agent.ApprovalDecision, error) {
|
||||
_ = ctx
|
||||
decision := agent.ApprovalDecision{Approved: true}
|
||||
h.record("approve_tool", req.Meta, req, decision)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
if h == nil || h.logFile == "" {
|
||||
return
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log encode failed", map[string]any{
|
||||
"stage": stage,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if dir := filepath.Dir(h.logFile); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log mkdir failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(h.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log open failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
if _, err := file.Write(append(body, '\n')); err != nil {
|
||||
logger.WarnCF("hooks", "Example hook log write failed", map[string]any{
|
||||
"stage": stage,
|
||||
"path": h.logFile,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 如何挂载
|
||||
|
||||
如果你只需要代码挂载,直接在 `AgentLoop` 初始化后调用:
|
||||
|
||||
```go
|
||||
hook := myhooks.NewExampleLoggerHook(myhooks.ExampleLoggerHookOptions{
|
||||
LogFile: "/tmp/picoclaw-hook-example-logger.log",
|
||||
LogEvents: true,
|
||||
})
|
||||
|
||||
if err := al.MountHook(agent.NamedHook("example-logger", hook)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### 如果你还想用配置挂载
|
||||
|
||||
当前 hook 系统支持 builtin hook,但这要求你自己把 factory 编进二进制。也就是说,下面这段注册代码需要和上面的 hook 定义一起放进你的工程里:
|
||||
|
||||
```go
|
||||
package myhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := agent.RegisterBuiltinHook("example_logger", func(
|
||||
ctx context.Context,
|
||||
spec config.BuiltinHookConfig,
|
||||
) (any, error) {
|
||||
_ = ctx
|
||||
|
||||
var opts ExampleLoggerHookOptions
|
||||
if len(spec.Config) > 0 {
|
||||
if err := json.Unmarshal(spec.Config, &opts); err != nil {
|
||||
return nil, fmt.Errorf("decode example_logger config: %w", err)
|
||||
}
|
||||
}
|
||||
return NewExampleLoggerHook(opts), nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只有在你自己注册了 builtin 之后,下面的配置才会生效:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"builtins": {
|
||||
"example_logger": {
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"config": {
|
||||
"log_file": "/tmp/picoclaw-hook-example-logger.log",
|
||||
"log_events": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 如何观察它是否生效
|
||||
|
||||
- 如果设置了 `log_file`,它会把每次 hook 调用按 JSON Lines 写入文件
|
||||
- 如果没有设置 `log_file`,它仍然会把摘要写到 gateway 日志
|
||||
- 普通只走 LLM 的请求,通常会看到 `before_llm` 和 `after_llm`
|
||||
- 触发工具调用的请求,通常还会看到 `before_tool`、`approve_tool`、`after_tool`
|
||||
- 如果 `log_events=true`,还会额外看到 `event`
|
||||
|
||||
典型日志:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-21T14:10:00Z","stage":"before_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"action":"continue"}}
|
||||
{"ts":"2026-03-21T14:10:00Z","stage":"approve_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"approved":true}}
|
||||
```
|
||||
|
||||
如果你只看到了 `before_llm` / `after_llm`,没有看到 tool 相关阶段,通常不是 hook 没挂上,而是这次请求本身没有触发工具调用。
|
||||
|
||||
## Python process hook 示例
|
||||
|
||||
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
它默认只记录,不改写,也不拒绝。
|
||||
|
||||
你可以把它保存到任意本地路径,例如 `/tmp/review_gate.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
LOG_EVENTS = os.getenv("PICOCLAW_HOOK_LOG_EVENTS", "1").lower() not in {"0", "false", "no"}
|
||||
LOG_FILE = os.getenv("PICOCLAW_HOOK_LOG_FILE", "").strip()
|
||||
|
||||
|
||||
def append_log(entry: dict[str, Any]) -> None:
|
||||
if not LOG_FILE:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
**entry,
|
||||
}
|
||||
try:
|
||||
log_dir = os.path.dirname(LOG_FILE)
|
||||
if log_dir:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
except OSError as exc:
|
||||
log_stderr(f"failed to write hook log file {LOG_FILE}: {exc}")
|
||||
|
||||
|
||||
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
}
|
||||
if error is not None:
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
append_log({
|
||||
"direction": "out",
|
||||
"id": message_id,
|
||||
"response": payload.get("result"),
|
||||
"error": payload.get("error"),
|
||||
})
|
||||
|
||||
try:
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
except BrokenPipeError:
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
def log_stderr(message: str) -> None:
|
||||
try:
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
except BrokenPipeError:
|
||||
raise SystemExit(0) from None
|
||||
|
||||
|
||||
def handle_shutdown_signal(signum: int, _frame: Any) -> None:
|
||||
raise KeyboardInterrupt(f"received signal {signum}")
|
||||
|
||||
|
||||
def handle_before_tool(params: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = params
|
||||
return {"action": "continue"}
|
||||
|
||||
|
||||
def handle_approve_tool(params: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = params
|
||||
return {"approved": True}
|
||||
|
||||
|
||||
def handle_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "hook.hello":
|
||||
return {"ok": True, "name": "python-review-gate"}
|
||||
if method == "hook.before_tool":
|
||||
return handle_before_tool(params)
|
||||
if method == "hook.approve_tool":
|
||||
return handle_approve_tool(params)
|
||||
if method == "hook.before_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_llm":
|
||||
return {"action": "continue"}
|
||||
if method == "hook.after_tool":
|
||||
return {"action": "continue"}
|
||||
raise KeyError(f"method not found: {method}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
log_stderr(f"failed to decode request: {exc}")
|
||||
append_log({
|
||||
"direction": "in",
|
||||
"decode_error": str(exc),
|
||||
"raw": line,
|
||||
})
|
||||
continue
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
append_log({
|
||||
"direction": "in",
|
||||
"id": message_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
"notification": not bool(message_id),
|
||||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
except KeyError as exc:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
continue
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
continue
|
||||
|
||||
send_response(int(message_id), result=result)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, handle_shutdown_signal)
|
||||
signal.signal(signal.SIGTERM, handle_shutdown_signal)
|
||||
raise SystemExit(main())
|
||||
```
|
||||
|
||||
### 如何配置
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"processes": {
|
||||
"py_review_gate": {
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"transport": "stdio",
|
||||
"command": [
|
||||
"python3",
|
||||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
"approve_tool"
|
||||
],
|
||||
"env": {
|
||||
"PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
是否把 `hook.event` 写到 `stderr`,默认开启
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
|
||||
|
||||
注意:`PICOCLAW_HOOK_LOG_FILE` 没有默认值。不设置时,脚本不会自动落盘日志。
|
||||
|
||||
### 如何确认它收到了 hook
|
||||
|
||||
推荐同时看两个地方:
|
||||
|
||||
- gateway 日志
|
||||
用来观察宿主是否成功启动了外部进程,以及脚本写到 `stderr` 的事件摘要
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
用来观察脚本实际收到了什么请求、返回了什么响应
|
||||
|
||||
典型判断方式:
|
||||
|
||||
- 只看到 `hook.hello`
|
||||
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
|
||||
- 看到 `hook.event`
|
||||
说明 `observe` 配置生效了
|
||||
- 看到 `hook.before_tool`
|
||||
说明 `intercept: ["before_tool", ...]` 生效了
|
||||
- 看到 `hook.approve_tool`
|
||||
说明审批 hook 生效了
|
||||
|
||||
这份示例脚本不会改写任何参数,也不会拒绝工具,所以你应该看到的典型返回是:
|
||||
|
||||
```json
|
||||
{"direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
{"direction":"out","id":8,"response":{"approved":true},"error":null}
|
||||
```
|
||||
|
||||
一组完整样例:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
- 时间戳是 UTC,不是本地时区
|
||||
- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知
|
||||
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
|
||||
|
||||
## Process Hook 协议约定
|
||||
|
||||
当前 process hook 使用 `JSON-RPC over stdio`:
|
||||
|
||||
- PicoClaw 启动外部进程
|
||||
- 请求和响应都按“一行一个 JSON 消息”传输
|
||||
- `hook.event` 是 notification,不需要响应
|
||||
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
|
||||
|
||||
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。
|
||||
|
||||
## 配置字段
|
||||
|
||||
### `hooks.builtins.<name>`
|
||||
|
||||
- `enabled`
|
||||
- `priority`
|
||||
- `config`
|
||||
|
||||
### `hooks.processes.<name>`
|
||||
|
||||
- `enabled`
|
||||
- `priority`
|
||||
- `transport`
|
||||
当前只支持 `stdio`
|
||||
- `command`
|
||||
- `dir`
|
||||
- `env`
|
||||
- `observe`
|
||||
- `intercept`
|
||||
|
||||
## 排查建议
|
||||
|
||||
当你觉得“hook 没触发”时,优先按这个顺序排查:
|
||||
|
||||
1. `hooks.enabled` 是否为 `true`
|
||||
2. 对应的 builtin/process hook 是否 `enabled`
|
||||
3. process hook 的 `command` 路径是否正确
|
||||
4. 你看的是否是正确的日志文件
|
||||
5. 当前请求是否真的走到了对应阶段
|
||||
6. `observe` / `intercept` 是否包含了你想看的点位
|
||||
|
||||
一个很实用的最小排查组合是:
|
||||
|
||||
- 先用文档里的 Python process 示例确认外部协议没问题
|
||||
- 再用文档里的 Go in-process 示例确认宿主内的 hook 链路没问题
|
||||
|
||||
如果前者有 `hook.hello` 但没有业务请求,通常不是协议挂了,而是当前这次请求没有真正触发对应的 hook 点位。
|
||||
|
||||
## 适用边界
|
||||
|
||||
当前 hook 系统最适合做这些事:
|
||||
|
||||
- LLM 请求改写
|
||||
- 工具参数规范化
|
||||
- 工具执行前审批
|
||||
- 审计和观测
|
||||
|
||||
当前还不适合直接承载这些需求:
|
||||
|
||||
- 外部 hook 主动发 channel 消息
|
||||
- 挂起 turn 并等待人工审批回复
|
||||
- inbound/outbound 全链路消息拦截
|
||||
|
||||
如果你要做人审流转,推荐把 hook 作为审批入口,把审批状态机和 channel 交互放到独立的 `ApprovalManager`。
|
||||
+51
-1
@@ -15,6 +15,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
|
||||
| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.ja.md) |
|
||||
| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.ja.md) |
|
||||
| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](#whatsapp) |
|
||||
| **微信 (Weixin)** | ⭐ 簡単 | ネイティブ QR スキャン(Tencent iLink API)| [ドキュメント](#weixin) |
|
||||
| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.ja.md) |
|
||||
| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.ja.md) |
|
||||
| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) |
|
||||
@@ -22,13 +23,14 @@ PicoClaw は複数のチャットプラットフォームをサポートして
|
||||
| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) |
|
||||
| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/README.ja.md) |
|
||||
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
|
||||
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | - |
|
||||
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
|
||||
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
|
||||
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
|
||||
|
||||
---
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
||||
@@ -69,6 +71,7 @@ Telegram 側はコマンドメニュー登録機能を保持し、汎用コマ
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -143,6 +146,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b>(ネイティブ whatsmeow)</summary>
|
||||
|
||||
@@ -170,6 +174,43 @@ PicoClaw は 2 つの WhatsApp 接続方式をサポートしています:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>微信 (Weixin)</b></summary>
|
||||
|
||||
PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウントへの接続をサポートしています。
|
||||
|
||||
**1. ログイン**
|
||||
|
||||
インタラクティブな QR ログインフローを実行します:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。
|
||||
|
||||
**2. 設定**
|
||||
|
||||
(オプション)ボットと会話できるユーザーを制限するために `allow_from` に WeChat ユーザー ID を追加します:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 実行**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -204,6 +245,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -245,6 +287,7 @@ QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンク
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -278,6 +321,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -311,6 +355,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
@@ -345,6 +390,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -393,6 +439,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>Feishu (飛書)</b></summary>
|
||||
|
||||
@@ -434,6 +481,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>WeCom (企業微信)</b></summary>
|
||||
|
||||
@@ -548,6 +596,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot(OneBot プロトコル経由の QQ)</b></summary>
|
||||
|
||||
@@ -586,6 +635,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="maixcam"></a>
|
||||
<details>
|
||||
<summary><b>MaixCam</b></summary>
|
||||
|
||||
|
||||
@@ -256,3 +256,109 @@ Agent は 30 分ごと(設定可能)にこのファイルを読み取り、
|
||||
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更
|
||||
|
||||
#### サブ Agent の通信フロー
|
||||
|
||||
```
|
||||
ハートビート起動
|
||||
↓
|
||||
Agent が HEARTBEAT.md を読む
|
||||
↓
|
||||
長時間タスク:spawn サブ Agent
|
||||
↓ ↓
|
||||
次のタスクへ継続 サブ Agent が独立して動作
|
||||
↓ ↓
|
||||
全タスク完了 サブ Agent が "message" ツールを使用
|
||||
↓ ↓
|
||||
HEARTBEAT_OK を返信 ユーザーが直接結果を受信
|
||||
```
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq は Whisper による無料音声文字起こしを提供します。設定すると、任意のチャンネルの音声メッセージが Agent レベルで自動的に文字起こしされます。
|
||||
|
||||
| Provider | 用途 | API キー取得 |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM(Qwen 直接) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM(Vivgrid 直接) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### モデル設定 (model_list)
|
||||
|
||||
> **新機能:** PicoClaw は**モデル中心**の設定アプローチを採用しました。`vendor/model` 形式(例:`zhipu/glm-4.7`)を指定するだけで新しい Provider を追加できます — **コード変更不要!**
|
||||
|
||||
#### サポートされている全 Vendor
|
||||
|
||||
| Vendor | `model` プレフィックス | デフォルト API Base | プロトコル | API Key |
|
||||
| ----------------------- | ---------------------- | --------------------------------------------------- | ---------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [取得](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) |
|
||||
| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [取得](https://openrouter.ai/keys) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth のみ |
|
||||
|
||||
#### ロードバランシング
|
||||
|
||||
同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンします:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 旧 `providers` 設定からの移行
|
||||
|
||||
旧 `providers` 設定は**非推奨**ですが後方互換性のためサポートされています。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
|
||||
|
||||
### Provider アーキテクチャ
|
||||
|
||||
PicoClaw はプロトコルファミリーで Provider をルーティングします:
|
||||
|
||||
- **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。
|
||||
- **Anthropic**:Claude ネイティブ API の動作。
|
||||
- **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。
|
||||
|
||||
### スケジュールタスク / リマインダー
|
||||
|
||||
PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
スケジュールタスクは再起動後も `~/.picoclaw/workspace/cron/` に保存されます。
|
||||
|
||||
### 高度なトピック
|
||||
|
||||
| トピック | 説明 |
|
||||
| -------- | ---- |
|
||||
| [Hook システム](../hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
|
||||
| [Steering](../steering.md) | 実行中の Agent ループにメッセージを注入 |
|
||||
| [SubTurn](../subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
|
||||
| [コンテキスト管理](../agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 |
|
||||
|
||||
@@ -41,14 +41,6 @@ Web ツールはウェブ検索とフェッチに使用されます。
|
||||
| `fetch_limit_bytes` | int | 10485760 | 取得するウェブページペイロードの最大サイズ(バイト単位、デフォルトは10MB)。 |
|
||||
| `format` | string | "plaintext" | 取得コンテンツの出力形式。オプション:`plaintext` または `markdown`(推奨)。 |
|
||||
|
||||
### Brave
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|------------|-----------------------|
|
||||
| `enabled` | bool | false | Brave 検索を有効にする |
|
||||
| `api_key` | string | - | Brave Search API キー |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
@@ -56,13 +48,73 @@ Web ツールはウェブ検索とフェッチに使用されます。
|
||||
| `enabled` | bool | true | DuckDuckGo 検索を有効にする |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
### Baidu Search
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|-----------------------------------------------------------------|-------------------------------|
|
||||
| `enabled` | bool | false | Baidu 検索を有効にする |
|
||||
| `api_key` | string | - | Qianfan API キー |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
|
||||
| `max_results` | int | 10 | 最大結果数 |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|------------|---------------------------|
|
||||
| `enabled` | bool | false | Perplexity 検索を有効にする |
|
||||
| `api_key` | string | - | Perplexity API キー |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
| `enabled` | bool | false | Perplexity 検索を有効にする |
|
||||
| `api_key` | string | - | Perplexity API キー |
|
||||
| `api_keys` | string[] | - | 複数の Perplexity API キー(ローテーション用、`api_key` より優先) |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
### Brave
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|------------|-----------------------|
|
||||
| `enabled` | bool | false | Brave 検索を有効にする |
|
||||
| `api_key` | string | - | Brave Search API キー |
|
||||
| `api_keys` | string[] | - | 複数の Brave Search API キー(ローテーション用、`api_key` より優先) |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
### Tavily
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|------------|-----------------------------------|
|
||||
| `enabled` | bool | false | Tavily 検索を有効にする |
|
||||
| `api_key` | string | - | Tavily API キー |
|
||||
| `base_url` | string | - | カスタム Tavily API ベース URL |
|
||||
| `max_results` | int | 0 | 最大結果数(0 = デフォルト) |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|---------------|--------|--------------------------|---------------------------|
|
||||
| `enabled` | bool | false | SearXNG 検索を有効にする |
|
||||
| `base_url` | string | `http://localhost:8888` | SearXNG インスタンス URL |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
### GLM Search
|
||||
|
||||
| 設定項目 | 型 | デフォルト | 説明 |
|
||||
|-----------------|--------|------------------------------------------------------|---------------------------|
|
||||
| `enabled` | bool | false | GLM Search を有効にする |
|
||||
| `api_key` | string | - | GLM API キー |
|
||||
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
|
||||
| `search_engine` | string | `search_std` | 検索エンジンタイプ |
|
||||
| `max_results` | int | 5 | 最大結果数 |
|
||||
|
||||
## Exec ツール
|
||||
|
||||
|
||||
+32
-1
@@ -5,7 +5,7 @@
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
|
||||
> Voice transcription can use a configured multimodal model via `voice.model_name`. Groq Whisper remains available as a fallback when no voice model is configured.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
@@ -101,6 +101,33 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||
}
|
||||
```
|
||||
|
||||
#### Voice Transcription
|
||||
|
||||
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
||||
|
||||
If `voice.model_name` is not configured, PicoClaw will continue to fall back to Groq transcription when a Groq API key is available.
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "voice-gemini",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"api_key": "your-gemini-key"
|
||||
}
|
||||
],
|
||||
"voice": {
|
||||
"model_name": "voice-gemini",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"providers": {
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
**OpenAI**
|
||||
@@ -344,6 +371,10 @@ picoclaw agent -m "Hello"
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"model_name": "voice-gemini",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
|
||||
+51
-1
@@ -13,6 +13,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
|
||||
| **Telegram** | ⭐ Fácil | Recomendado, voz para texto, long polling (sem IP público) | [Documentação](../channels/telegram/README.pt-br.md) |
|
||||
| **Discord** | ⭐ Fácil | Socket Mode, suporte a grupos/DM, ecossistema bot rico | [Documentação](../channels/discord/README.pt-br.md) |
|
||||
| **WhatsApp** | ⭐ Fácil | Nativo (scan QR) ou Bridge URL | [Documentação](#whatsapp) |
|
||||
| **Weixin** | ⭐ Fácil | Scan QR nativo (API Tencent iLink) | [Documentação](#weixin) |
|
||||
| **Slack** | ⭐ Fácil | **Socket Mode** (sem IP público), empresarial | [Documentação](../channels/slack/README.pt-br.md) |
|
||||
| **Matrix** | ⭐⭐ Médio | Protocolo federado, suporte a auto-hospedagem | [Documentação](../channels/matrix/README.pt-br.md) |
|
||||
| **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) |
|
||||
@@ -20,11 +21,12 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
|
||||
| **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
|
||||
| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | - |
|
||||
| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) |
|
||||
| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
|
||||
| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
|
||||
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||
|
||||
@@ -65,6 +67,7 @@ Se o registro de comandos falhar (erros transitórios de rede/API), o canal aind
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -138,6 +141,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b> (nativo via whatsmeow)</summary>
|
||||
|
||||
@@ -165,6 +169,43 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `<workspace>/w
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Pessoal)</summary>
|
||||
|
||||
O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial Tencent iLink.
|
||||
|
||||
**1. Login**
|
||||
|
||||
Execute o fluxo de login interativo por QR code:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração.
|
||||
|
||||
**2. Configurar**
|
||||
|
||||
(Opcional) Adicione seu ID de usuário WeChat em `allow_from` para restringir quem pode enviar mensagens ao bot:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Executar**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -206,6 +247,7 @@ Se preferir criar o bot manualmente:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
@@ -240,6 +282,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="maixcam"></a>
|
||||
<details>
|
||||
<summary><b>MaixCam</b></summary>
|
||||
|
||||
@@ -262,6 +305,7 @@ picoclaw gateway
|
||||
</details>
|
||||
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -296,6 +340,7 @@ Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeh
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -344,6 +389,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
@@ -457,6 +503,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>Feishu (Lark)</b></summary>
|
||||
|
||||
@@ -498,6 +545,7 @@ Para opções completas, veja o [Guia de Configuração do Canal Feishu](../chan
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -531,6 +579,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -564,6 +613,7 @@ O bot se conectará ao servidor IRC e entrará nos canais especificados.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot (QQ via protocolo OneBot)</b></summary>
|
||||
|
||||
|
||||
@@ -216,4 +216,149 @@ Para tarefas de longa duração (busca na web, chamadas de API), use a ferrament
|
||||
|
||||
```markdown
|
||||
# Tarefas Periódicas
|
||||
|
||||
## Tarefas Rápidas (responder diretamente)
|
||||
|
||||
- Informar a hora atual
|
||||
|
||||
## Tarefas Longas (usar spawn para assíncrono)
|
||||
|
||||
- Pesquisar notícias de IA na web e resumir
|
||||
- Verificar e-mails e reportar mensagens importantes
|
||||
```
|
||||
|
||||
**Comportamentos principais:**
|
||||
|
||||
| Funcionalidade | Descrição |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat |
|
||||
| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão |
|
||||
| **message tool** | Subagente comunica diretamente com o usuário via message tool |
|
||||
| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
|
||||
|
||||
#### Fluxo de Comunicação do Subagente
|
||||
|
||||
```
|
||||
Heartbeat disparado
|
||||
↓
|
||||
Agent lê HEARTBEAT.md
|
||||
↓
|
||||
Tarefa longa: spawn subagente
|
||||
↓ ↓
|
||||
Continua próxima tarefa Subagente trabalha independentemente
|
||||
↓ ↓
|
||||
Todas tarefas concluídas Subagente usa ferramenta "message"
|
||||
↓ ↓
|
||||
Responde HEARTBEAT_OK Usuário recebe resultado diretamente
|
||||
```
|
||||
|
||||
**Configuração:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Opção | Padrão | Descrição |
|
||||
| ---------- | ------ | -------------------------------------- |
|
||||
| `enabled` | `true` | Ativar/desativar heartbeat |
|
||||
| `interval` | `30` | Intervalo em minutos (mínimo: 5) |
|
||||
|
||||
**Variáveis de ambiente:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` para desativar
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
|
||||
|
||||
| Provider | Finalidade | Obter API Key |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM (Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (recomendado, acesso a todos modelos) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direto) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direto) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM (Vivgrid direto) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### Configuração de Modelos (model_list)
|
||||
|
||||
> **Novidade:** PicoClaw agora usa uma abordagem **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos providers — **sem alterações de código!**
|
||||
|
||||
#### Todos os Vendors Suportados
|
||||
|
||||
| Vendor | Prefixo `model` | API Base padrão | Protocolo | API Key |
|
||||
| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter](https://openrouter.ai/keys) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Somente OAuth |
|
||||
|
||||
#### Balanceamento de Carga
|
||||
|
||||
Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará round-robin automaticamente:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Migração da Configuração Legada `providers`
|
||||
|
||||
A configuração antiga `providers` está **depreciada** mas ainda é suportada. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
### Arquitetura de Providers
|
||||
|
||||
PicoClaw roteia providers por família de protocolo:
|
||||
|
||||
- **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros.
|
||||
- **Anthropic**: Comportamento nativo da API Claude.
|
||||
- **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI.
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
|
||||
PicoClaw suporta tarefas agendadas via ferramenta `cron`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspace/cron/`.
|
||||
|
||||
### Tópicos Avançados
|
||||
|
||||
| Tópico | Descrição |
|
||||
| ------ | --------- |
|
||||
| [Sistema de Hooks](../hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação |
|
||||
| [Steering](../steering.md) | Injetar mensagens em um loop de agente em execução |
|
||||
| [SubTurn](../subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
|
||||
| [Gerenciamento de Contexto](../agent-refactor/context.md) | Detecção de limites de contexto, compressão |
|
||||
|
||||
@@ -41,14 +41,6 @@ Configurações gerais para busca e processamento de conteúdo de páginas web.
|
||||
| `fetch_limit_bytes` | int | 10485760 | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB). |
|
||||
| `format` | string | "plaintext" | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado). |
|
||||
|
||||
### Brave
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|--------|----------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa Brave |
|
||||
| `api_key` | string | - | Chave API do Brave Search |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
@@ -56,13 +48,73 @@ Configurações gerais para busca e processamento de conteúdo de páginas web.
|
||||
| `enabled` | bool | true | Habilitar pesquisa DuckDuckGo |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
### Baidu Search
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|-----------------------------------------------------------------|------------------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa Baidu |
|
||||
| `api_key` | string | - | Chave API Qianfan |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL da API Baidu Search |
|
||||
| `max_results` | int | 10 | Número máximo de resultados |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|--------|--------------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa Perplexity |
|
||||
| `api_key` | string | - | Chave API do Perplexity |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
| `enabled` | bool | false | Habilitar pesquisa Perplexity |
|
||||
| `api_key` | string | - | Chave API do Perplexity |
|
||||
| `api_keys` | string[] | - | Várias chaves API do Perplexity para rotação (prioridade sobre `api_key`) |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
### Brave
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|--------|----------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa Brave |
|
||||
| `api_key` | string | - | Chave API única do Brave Search |
|
||||
| `api_keys` | string[] | - | Várias chaves API do Brave para rotação (prioridade sobre `api_key`) |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
### Tavily
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|--------|------------------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa Tavily |
|
||||
| `api_key` | string | - | Chave API do Tavily |
|
||||
| `base_url` | string | - | URL base personalizada do Tavily |
|
||||
| `max_results` | int | 0 | Número máximo de resultados (0 = padrão) |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|---------------|--------|--------------------------|--------------------------------|
|
||||
| `enabled` | bool | false | Habilitar pesquisa SearXNG |
|
||||
| `base_url` | string | `http://localhost:8888` | URL da instância SearXNG |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
### GLM Search
|
||||
|
||||
| Config | Tipo | Padrão | Descrição |
|
||||
|-----------------|--------|------------------------------------------------------|----------------------------|
|
||||
| `enabled` | bool | false | Habilitar GLM Search |
|
||||
| `api_key` | string | - | Chave API GLM |
|
||||
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL da API GLM Search |
|
||||
| `search_engine` | string | `search_std` | Tipo de motor de busca |
|
||||
| `max_results` | int | 5 | Número máximo de resultados |
|
||||
|
||||
## Ferramenta Exec
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Steering
|
||||
|
||||
Steering allows injecting messages into an already-running agent loop, interrupting it between tool calls without waiting for the entire cycle to complete.
|
||||
|
||||
## How it works
|
||||
|
||||
When the agent is executing a sequence of tool calls (e.g. the model requested 3 tools in a single turn), steering checks the queue **after each tool** completes. If it finds queued messages:
|
||||
|
||||
1. The remaining tools are **skipped** and receive `"Skipped due to queued user message."` as their result
|
||||
2. The steering messages are **injected into the conversation context**
|
||||
3. The model is called again with the updated context, including the user's steering message
|
||||
|
||||
```
|
||||
User ──► Steer("change approach")
|
||||
│
|
||||
Agent Loop ▼
|
||||
├─ tool[0] ✔ (executed)
|
||||
├─ [polling] → steering found!
|
||||
├─ tool[1] ✘ (skipped)
|
||||
├─ tool[2] ✘ (skipped)
|
||||
└─ new LLM turn with steering message
|
||||
```
|
||||
|
||||
## Scoped queues
|
||||
|
||||
Steering is now isolated per resolved session scope, not stored in a single
|
||||
global queue.
|
||||
|
||||
- The active turn writes and reads from its own scope key (usually the routed session key such as `agent:<agent_id>:...`)
|
||||
- `Steer()` still works outside an active turn through a legacy fallback queue
|
||||
- `Continue()` first dequeues messages for the requested session scope, then falls back to the legacy queue for backwards compatibility
|
||||
|
||||
This prevents a message arriving from another chat, DM peer, or routed agent
|
||||
session from being injected into the wrong conversation.
|
||||
|
||||
## Configuration
|
||||
|
||||
In `config.json`, under `agents.defaults`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"steering_mode": "one-at-a-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `"one-at-a-time"` | **(default)** Dequeues only one message per polling cycle. If there are 3 messages in the queue, they are processed one at a time across 3 successive iterations. |
|
||||
| `"all"` | Drains the entire queue in a single poll. All pending messages are injected into the context together. |
|
||||
|
||||
The environment variable `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` can be used as an alternative.
|
||||
|
||||
## Go API
|
||||
|
||||
### Steer — Send a steering message
|
||||
|
||||
```go
|
||||
err := agentLoop.Steer(providers.Message{
|
||||
Role: "user",
|
||||
Content: "change direction, focus on X instead",
|
||||
})
|
||||
if err != nil {
|
||||
// Queue is full (MaxQueueSize=10) or not initialized
|
||||
}
|
||||
```
|
||||
|
||||
The message is enqueued in a thread-safe manner. Returns an error if the queue is full or not initialized. It will be picked up at the next polling point (after the current tool finishes).
|
||||
|
||||
### SteeringMode / SetSteeringMode
|
||||
|
||||
```go
|
||||
// Read the current mode
|
||||
mode := agentLoop.SteeringMode() // SteeringOneAtATime | SteeringAll
|
||||
|
||||
// Change it at runtime
|
||||
agentLoop.SetSteeringMode(agent.SteeringAll)
|
||||
```
|
||||
|
||||
### Continue — Resume an idle agent
|
||||
|
||||
When the agent is idle (it has finished processing and its last message was from the assistant), `Continue` checks if there are steering messages in the queue and uses them to start a new cycle:
|
||||
|
||||
```go
|
||||
response, err := agentLoop.Continue(ctx, sessionKey, channel, chatID)
|
||||
if err != nil {
|
||||
// Error (e.g. "no default agent available")
|
||||
}
|
||||
if response == "" {
|
||||
// No steering messages in queue, the agent stays idle
|
||||
}
|
||||
```
|
||||
|
||||
`Continue` internally uses `SkipInitialSteeringPoll: true` to avoid double-dequeuing the same messages (since it already extracted them and passes them directly as input).
|
||||
|
||||
`Continue` also resolves the target agent from the provided session key, so
|
||||
agent-scoped sessions continue on the correct agent instead of always using
|
||||
the default one.
|
||||
|
||||
## Polling points in the loop
|
||||
|
||||
Steering is checked at the following points in the agent cycle:
|
||||
|
||||
1. **At loop start** — before the first LLM call, to catch messages enqueued during setup
|
||||
2. **After every tool completes** — including the first and the last. If steering is found and there are remaining tools, they are all skipped immediately
|
||||
3. **After a direct LLM response** — if a new steering message arrived while the model was generating a non-tool response, the loop continues instead of returning a stale answer
|
||||
4. **Right before the turn is finalized** — if steering arrived at the very end of the turn, the agent immediately starts a continuation turn instead of leaving the message orphaned in the queue
|
||||
|
||||
## Why remaining tools are skipped
|
||||
|
||||
When a steering message is detected, all remaining tools in the batch are skipped rather than executed. The alternative — let all tools finish and inject the steering message afterwards — was considered and rejected. Here is why.
|
||||
|
||||
### Preventing unwanted side effects
|
||||
|
||||
Tools can have **irreversible side effects**. If the user says "no, wait" while the agent is mid-batch, executing the remaining tools means those side effects happen anyway:
|
||||
|
||||
| Tool batch | Steering message | With skip | Without skip |
|
||||
|---|---|---|---|
|
||||
| `[web_search, send_email]` | "don't send it" | Email **not** sent | Email sent, damage done |
|
||||
| `[query_db, write_file, spawn_agent]` | "use another database" | Only the query runs | File written + subagent spawned, all wasted |
|
||||
| `[search₁, search₂, search₃, write_file]` | user changes topic entirely | 1 search | 3 searches + file write, all irrelevant |
|
||||
|
||||
### Avoiding wasted time
|
||||
|
||||
Tools that take seconds (web fetches, API calls, database queries) would all run to completion before the agent sees the user's correction. In a batch of 3 tools each taking 3-4 seconds, that's 10+ seconds of work that will be discarded.
|
||||
|
||||
With skipping, the agent reacts as soon as the current tool finishes — typically within a few seconds instead of waiting for the entire batch.
|
||||
|
||||
### The LLM gets full context
|
||||
|
||||
Skipped tools receive an explicit error result (`"Skipped due to queued user message."`), so the model knows exactly which actions were not performed. It can then decide whether to re-execute them with the new context, or take a different path entirely.
|
||||
|
||||
### Trade-off: sequential execution
|
||||
|
||||
Skipping requires tools to run **sequentially** (the previous implementation ran them in parallel). This introduces latency when the LLM requests multiple independent tools in a single turn. In practice, most batches contain 1-2 tools, so the impact is minimal compared to the benefit of being able to stop unwanted actions.
|
||||
|
||||
## Skipped tool result format
|
||||
|
||||
When steering interrupts a batch, each tool that was not executed receives a `tool` result with:
|
||||
|
||||
```
|
||||
Content: "Skipped due to queued user message."
|
||||
```
|
||||
|
||||
This is saved to the session via `AddFullMessage` and sent to the model, so it is aware that some requested actions were not performed.
|
||||
|
||||
## Full flow example
|
||||
|
||||
```
|
||||
1. User: "search for info on X, write a file, and send me a message"
|
||||
|
||||
2. LLM responds with 3 tool calls: [web_search, write_file, message]
|
||||
|
||||
3. web_search is executed → result saved
|
||||
|
||||
4. [polling] → User called Steer("no, search for Y instead")
|
||||
|
||||
5. write_file is skipped → "Skipped due to queued user message."
|
||||
message is skipped → "Skipped due to queued user message."
|
||||
|
||||
6. Message "search for Y instead" injected into context
|
||||
|
||||
7. LLM receives the full updated context and responds accordingly
|
||||
```
|
||||
|
||||
## Automatic bus drain
|
||||
|
||||
When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means:
|
||||
|
||||
- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy
|
||||
- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is
|
||||
- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally
|
||||
- `system` inbound messages are not treated as steering input
|
||||
- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes
|
||||
|
||||
## Steering with media
|
||||
|
||||
Steering messages can include `Media` refs, just like normal inbound user
|
||||
messages.
|
||||
|
||||
- The original `media://` refs are preserved in session history via `AddFullMessage`
|
||||
- Before the next provider call, steering messages go through the normal media resolution pipeline
|
||||
- Image refs are converted to data URLs for multimodal providers; non-image refs are resolved the same way as standard inbound media
|
||||
|
||||
This applies both to in-turn steering and to idle-session continuation through
|
||||
`Continue()`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Steering **does not interrupt** a tool that is currently executing. It waits for the current tool to finish, then checks the queue.
|
||||
- With `one-at-a-time` mode, if multiple messages are enqueued rapidly, they will be processed one per iteration. This gives the model the opportunity to react to each message individually.
|
||||
- With `all` mode, all pending messages are combined into a single injection. Useful when you want the agent to receive all the context at once.
|
||||
- The steering queue has a maximum capacity of 10 messages (`MaxQueueSize`). `Steer()` returns an error when the queue is full. In the bus drain path, the error is logged as a warning and the message is effectively dropped.
|
||||
- Manual `Steer()` calls made outside an active turn still go to the legacy fallback queue, so older integrations keep working.
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
# 🔄 SubTurn Mechanism
|
||||
|
||||
> Back to [README](../README.md)
|
||||
|
||||
## Overview
|
||||
|
||||
The `SubTurn` mechanism is a core feature in PicoClaw that allows tools to spawn isolated, nested agent loops to handle complex sub-tasks.
|
||||
|
||||
By using a SubTurn, an agent can break down a problem and run a separate LLM invocation in an independent, ephemeral session. This ensures that intermediate reasoning, background tasks, or sub-agent outputs do not pollute the main conversation history.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Context Isolation**: Each SubTurn uses an `ephemeralSessionStore`. Its message history does not leak into the parent task and is destroyed upon completion. The ephemeral session holds at most **50 messages**; older messages are automatically truncated when this limit is reached.
|
||||
- **Depth & Concurrency Limits**: Prevents infinite loops and resource exhaustion.
|
||||
- **Maximum Depth**: Up to 3 nested levels.
|
||||
- **Maximum Concurrency**: Up to 5 concurrent sub-turns per parent turn (managed via a semaphore with a 30-second timeout).
|
||||
- **Context Protection**: Supports soft context limits (`MaxContextRunes`). It proactively truncates old messages (while preserving system prompts and recent context) before hitting the provider's hard context window limit.
|
||||
- **Error Recovery**: Automatically detects and recovers from provider context length exceeded errors and truncation errors by compressing history and retrying.
|
||||
|
||||
## Configuration (`SubTurnConfig`)
|
||||
|
||||
When spawning a SubTurn, you must provide a `SubTurnConfig`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `Model` | `string` | The LLM model to use for the sub-turn (e.g., `gpt-4o-mini`). **Required.** |
|
||||
| `Tools` | `[]tools.Tool` | Tools granted to the sub-turn. If empty, it inherits the parent's tools. |
|
||||
| `SystemPrompt` | `string` | The task description for the sub-turn. Sent as the first user message to the LLM (not as a system prompt override). |
|
||||
| `ActualSystemPrompt` | `string` | Optional explicit system prompt to replace the agent's default. Leave empty to inherit the parent agent's system prompt. |
|
||||
| `MaxTokens` | `int` | Maximum tokens for the generated response. |
|
||||
| `Async` | `bool` | Controls the result delivery mode (Synchronous vs. Asynchronous). |
|
||||
| `Critical` | `bool` | If `true`, the sub-turn continues running even if the parent finishes gracefully. |
|
||||
| `Timeout` | `time.Duration` | Maximum execution time (default: 5 minutes). |
|
||||
| `MaxContextRunes`| `int` | Soft context limit. `0` = auto-calculate (75% of model's context window, recommended), `-1` = no limit (disable soft truncation, rely only on hard context error recovery), `>0` = use specified rune limit. |
|
||||
|
||||
> **Note:** The `Async` flag does **not** make the call non-blocking. It only controls whether the result is also delivered to the parent's `pendingResults` channel. Both modes block the caller until the sub-turn completes. For true non-blocking execution, the caller must spawn the sub-turn in a separate goroutine.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Synchronous (`Async: false`)
|
||||
|
||||
This is the standard mode where the caller needs the result immediately to proceed.
|
||||
|
||||
- The caller blocks until the sub-turn completes.
|
||||
- The result is **only** returned directly via the function return value.
|
||||
- It is **not** delivered to the parent's pending results channel.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
cfg := agent.SubTurnConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
SystemPrompt: "Analyze the provided codebase...",
|
||||
Async: false,
|
||||
}
|
||||
result, err := agent.SpawnSubTurn(ctx, cfg)
|
||||
// Process result immediately
|
||||
```
|
||||
|
||||
### Asynchronous (`Async: true`)
|
||||
|
||||
Used for "fire-and-forget" operations or parallel processing where the parent turn collects results later.
|
||||
|
||||
- The result is delivered to the parent turn's `pendingResults` channel.
|
||||
- The result is **also** returned via the function return value (for consistency).
|
||||
- The parent's Agent Loop will poll this channel in subsequent iterations and automatically inject the results into the ongoing conversation context as `[SubTurn Result]`.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
cfg := agent.SubTurnConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
SystemPrompt: "Run a background security scan...",
|
||||
Async: true,
|
||||
}
|
||||
result, err := agent.SpawnSubTurn(ctx, cfg)
|
||||
// The result will also be injected into the parent loop later via channel
|
||||
```
|
||||
|
||||
## Error Recovery and Retries
|
||||
|
||||
SubTurns implement automatic retry mechanisms for transient errors:
|
||||
|
||||
| Error Type | Max Retries | Recovery Action |
|
||||
|:-----------|:------------|:----------------|
|
||||
| Context Length Exceeded | 2 | Force compress history and retry |
|
||||
| Response Truncated (`finish_reason="truncated"`) | 2 | Inject recovery prompt and retry |
|
||||
|
||||
### Truncation Recovery
|
||||
When the LLM response is truncated (`finish_reason="truncated"`), SubTurn automatically:
|
||||
1. Detects the truncation from `turnState.lastFinishReason`
|
||||
2. Injects a recovery prompt: "Your previous response was truncated due to length. Please provide a shorter, complete response..."
|
||||
3. Retries up to 2 times
|
||||
|
||||
### Context Error Recovery
|
||||
When the provider returns a context length error (e.g., `context_length_exceeded`):
|
||||
1. Force compresses the message history (drops oldest 50% of conversation)
|
||||
2. Retries with the compressed context
|
||||
3. Up to 2 retries before failing
|
||||
|
||||
## Lifecycle and Cancellation
|
||||
|
||||
SubTurns operate within an independent context but maintain a structural link to their parent `turnState`.
|
||||
|
||||
### Graceful Parent Finish
|
||||
When the parent task finishes naturally (`Finish(false)`):
|
||||
- **Non-critical** sub-turns receive a signal to exit gracefully without throwing an error.
|
||||
- **Critical** (`Critical: true`) sub-turns continue running in the background. Once finished, their results are emitted as **Orphan Results** so the data is not lost.
|
||||
|
||||
### Hard Abort
|
||||
When the parent task is forcefully aborted (e.g., user interrupts with `/stop`):
|
||||
- A cascading cancellation is triggered, instantly terminating all child and grandchild sub-turns.
|
||||
- The root turn's session history rolls back to the snapshot taken at turn start (`initialHistoryLength`), preventing dirty context. SubTurns are not affected by this rollback as they use ephemeral sessions that are discarded anyway.
|
||||
|
||||
## Agent Loop Integration
|
||||
|
||||
### Bus Draining During Processing
|
||||
|
||||
When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them.
|
||||
|
||||
This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`.
|
||||
|
||||
The drain goroutine stops automatically when `processMessage` returns (via a cancellable context).
|
||||
|
||||
### Pending Result Polling
|
||||
|
||||
The agent loop polls for async SubTurn results at two points per iteration:
|
||||
1. **Before the LLM call**: injects any arrived results as `[SubTurn Result]` messages into the conversation context.
|
||||
2. **After all tool executions**: polls again during the tool loop to catch results that arrived during tool execution.
|
||||
3. **After the final iteration**: one last poll before the turn ends to avoid losing late-arriving results.
|
||||
|
||||
### Turn State Tracking
|
||||
|
||||
All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
|
||||
|
||||
## Event Bus Integration
|
||||
|
||||
SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging:
|
||||
|
||||
| Event Kind | When Emitted | Payload |
|
||||
|:------|:-------------|:--------|
|
||||
| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
|
||||
| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
|
||||
| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
|
||||
| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
|
||||
|
||||
## API Reference
|
||||
|
||||
### SpawnSubTurn (Public Entry Point)
|
||||
|
||||
```go
|
||||
func SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*tools.ToolResult, error)
|
||||
```
|
||||
|
||||
This is the exported package-level entry point for agent-internal code (e.g., tests, direct invocations). It retrieves `AgentLoop` and `turnState` from context and delegates to the internal `spawnSubTurn`.
|
||||
|
||||
**Requirements:**
|
||||
- `AgentLoop` must be injected into context via `WithAgentLoop()`
|
||||
- Parent `turnState` must exist in context (automatically set when called from tools)
|
||||
|
||||
**Returns:**
|
||||
- `*tools.ToolResult`: Contains `ForLLM` field with the sub-turn's output
|
||||
- `error`: One of the defined error types or context errors
|
||||
|
||||
### AgentLoopSpawner (Interface Implementation)
|
||||
|
||||
```go
|
||||
type AgentLoopSpawner struct { al *AgentLoop }
|
||||
|
||||
func (s *AgentLoopSpawner) SpawnSubTurn(ctx context.Context, cfg tools.SubTurnConfig) (*tools.ToolResult, error)
|
||||
```
|
||||
|
||||
This implements the `tools.SubTurnSpawner` interface for use by tools that need to spawn sub-turns without a direct import of the `agent` package (avoiding circular dependencies). It converts `tools.SubTurnConfig` → `agent.SubTurnConfig` before delegating to the internal `spawnSubTurn`.
|
||||
|
||||
### NewSubTurnSpawner
|
||||
|
||||
```go
|
||||
func NewSubTurnSpawner(al *AgentLoop) *AgentLoopSpawner
|
||||
```
|
||||
|
||||
Creates a new spawner instance for the given AgentLoop. Pass the returned value to `SpawnTool.SetSpawner()` or `SubagentTool.SetSpawner()` during tool registration.
|
||||
|
||||
### Continue
|
||||
|
||||
```go
|
||||
func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error
|
||||
```
|
||||
|
||||
Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving.
|
||||
|
||||
## Context Propagation
|
||||
|
||||
SubTurn relies on context values for proper operation:
|
||||
|
||||
| Context Key | Purpose |
|
||||
|:------------|:--------|
|
||||
| `agentLoopKey` | Stores `*AgentLoop` for tool access and SubTurn spawning |
|
||||
| `turnStateKey` | Stores `*turnState` for hierarchy tracking and result delivery |
|
||||
|
||||
### Injecting Dependencies
|
||||
|
||||
```go
|
||||
// Before calling tools that may spawn SubTurns
|
||||
ctx = WithAgentLoop(ctx, agentLoop)
|
||||
ctx = withTurnState(ctx, turnState)
|
||||
```
|
||||
|
||||
### Independent Child Context
|
||||
|
||||
**Important**: The child SubTurn uses an **independent context** derived from `context.Background()`, not from the parent context. This design choice:
|
||||
|
||||
- Allows critical SubTurns to continue after parent cancellation
|
||||
- Prevents parent timeout from affecting child execution
|
||||
- Child has its own timeout for self-protection (`Timeout` config or 5 minutes default)
|
||||
|
||||
## Error Types
|
||||
|
||||
| Error | Condition |
|
||||
|:------|:----------|
|
||||
| `ErrDepthLimitExceeded` | SubTurn depth exceeds 3 levels |
|
||||
| `ErrInvalidSubTurnConfig` | Required field `Model` is empty |
|
||||
| `ErrConcurrencyTimeout` | All 5 concurrency slots occupied for 30+ seconds |
|
||||
| Context errors | Parent context cancelled during semaphore acquisition |
|
||||
|
||||
## Thread Safety
|
||||
|
||||
SubTurns are designed for concurrent execution:
|
||||
|
||||
- **Parent-child relationships**: Managed under mutex (`parentTS.mu.Lock()`)
|
||||
- **Active turn tracking**: Uses `sync.Map` for concurrent access to `activeTurnStates`
|
||||
- **ID generation**: Uses `atomic.Int64` for unique SubTurn IDs (format: `subturn-N`, globally monotonic per `AgentLoop` instance)
|
||||
- **Result delivery**: Reads parent state under lock, releases before channel send (small race window acceptable)
|
||||
|
||||
## Orphan Results
|
||||
|
||||
An orphan result occurs when:
|
||||
1. Parent turn finishes before the SubTurn completes
|
||||
2. The `pendingResults` channel is full (buffer size: 16)
|
||||
|
||||
When a result becomes orphan:
|
||||
- `SubTurnOrphanResultEvent` is emitted to EventBus
|
||||
- The result is **NOT** delivered to the LLM context
|
||||
- External systems can listen to this event for custom handling
|
||||
|
||||
### Preventing Orphan Results
|
||||
- Use `Critical: true` for important SubTurns that must complete
|
||||
- Monitor `SubTurnOrphanResultEvent` for observability
|
||||
- Consider the 16-buffer limit when spawning many async SubTurns
|
||||
|
||||
## Tool Inheritance
|
||||
|
||||
### When `cfg.Tools` is empty:
|
||||
- SubTurn inherits **all** tools from the parent agent
|
||||
- Tools are registered in a new `ToolRegistry` instance
|
||||
- Tool TTL is managed independently from parent
|
||||
|
||||
### When `cfg.Tools` is specified:
|
||||
- Only the specified tools are available to the SubTurn
|
||||
- Parent tools are **NOT** merged
|
||||
- Use this to restrict SubTurn capabilities for security or focus
|
||||
|
||||
**Example - Restricted SubTurn:**
|
||||
```go
|
||||
cfg := agent.SubTurnConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
Tools: []tools.Tool{readOnlyTool}, // Only read-only access
|
||||
SystemPrompt: "Analyze the file structure...",
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| Constant | Value |
|
||||
|:---------|:------|
|
||||
| `maxSubTurnDepth` | 3 |
|
||||
| `maxConcurrentSubTurns` | 5 |
|
||||
| `concurrencyTimeout` | 30s |
|
||||
| `defaultSubTurnTimeout` | 5m |
|
||||
| `maxEphemeralHistorySize` | 50 messages |
|
||||
| `pendingResults` buffer | 16 |
|
||||
| `MaxContextRunes` default | 75% of model context window |
|
||||
@@ -55,6 +55,31 @@ General settings for fetching and processing webpage content.
|
||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Baidu Search
|
||||
|
||||
Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|---------------|--------|------------------------------------------------------------------|---------------------------|
|
||||
| `enabled` | bool | false | Enable Baidu Search |
|
||||
| `api_key` | string | - | Qianfan API key |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
|
||||
| `max_results` | int | 10 | Maximum number of results |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|
||||
+51
-1
@@ -13,6 +13,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
|
||||
| **Telegram** | ⭐ Dễ | Khuyến nghị, chuyển giọng nói thành văn bản, long polling (không cần IP công khai) | [Tài liệu](../channels/telegram/README.vi.md) |
|
||||
| **Discord** | ⭐ Dễ | Socket Mode, hỗ trợ nhóm/DM, hệ sinh thái bot phong phú | [Tài liệu](../channels/discord/README.vi.md) |
|
||||
| **WhatsApp** | ⭐ Dễ | Bản địa (quét QR) hoặc Bridge URL | [Tài liệu](#whatsapp) |
|
||||
| **Weixin** | ⭐ Dễ | Quét QR gốc (API Tencent iLink) | [Tài liệu](#weixin) |
|
||||
| **Slack** | ⭐ Dễ | **Socket Mode** (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/slack/README.vi.md) |
|
||||
| **Matrix** | ⭐⭐ Trung bình | Giao thức liên kết, hỗ trợ tự lưu trữ | [Tài liệu](../channels/matrix/README.vi.md) |
|
||||
| **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) |
|
||||
@@ -20,11 +21,12 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
|
||||
| **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/README.vi.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
|
||||
| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | - |
|
||||
| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) |
|
||||
| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
|
||||
| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
|
||||
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Khuyến nghị)</summary>
|
||||
|
||||
@@ -65,6 +67,7 @@ Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫ
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -138,6 +141,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b> (native qua whatsmeow)</summary>
|
||||
|
||||
@@ -165,6 +169,43 @@ Nếu `session_store_path` trống, phiên được lưu tại `<workspace>/what
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Cá nhân)</summary>
|
||||
|
||||
PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn thông qua API chính thức Tencent iLink.
|
||||
|
||||
**1. Đăng nhập**
|
||||
|
||||
Chạy luồng đăng nhập QR tương tác:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình.
|
||||
|
||||
**2. Cấu hình**
|
||||
|
||||
(Tùy chọn) Thêm ID người dùng WeChat vào `allow_from` để giới hạn ai có thể nhắn tin với bot:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chạy**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -206,6 +247,7 @@ Nếu bạn muốn tạo bot thủ công:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
@@ -240,6 +282,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="maixcam"></a>
|
||||
<details>
|
||||
<summary><b>MaixCam</b></summary>
|
||||
|
||||
@@ -262,6 +305,7 @@ picoclaw gateway
|
||||
</details>
|
||||
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -296,6 +340,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -344,6 +389,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
@@ -458,6 +504,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>Feishu (Lark)</b></summary>
|
||||
|
||||
@@ -499,6 +546,7 @@ Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũ
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -532,6 +580,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -565,6 +614,7 @@ Bot sẽ kết nối đến máy chủ IRC và tham gia các kênh đã chỉ đ
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot (QQ qua giao thức OneBot)</b></summary>
|
||||
|
||||
|
||||
@@ -216,4 +216,149 @@ Cho tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `
|
||||
|
||||
```markdown
|
||||
# Tác Vụ Định Kỳ
|
||||
|
||||
## Tác Vụ Nhanh (trả lời trực tiếp)
|
||||
|
||||
- Báo giờ hiện tại
|
||||
|
||||
## Tác Vụ Dài (dùng spawn cho bất đồng bộ)
|
||||
|
||||
- Tìm kiếm tin tức AI trên web và tóm tắt
|
||||
- Kiểm tra email và báo cáo tin nhắn quan trọng
|
||||
```
|
||||
|
||||
**Hành vi chính:**
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
|
||||
| **Ngữ cảnh độc lập** | Subagent có ngữ cảnh riêng, không có lịch sử phiên |
|
||||
| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua message tool |
|
||||
| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
|
||||
|
||||
#### Luồng Giao Tiếp Của Subagent
|
||||
|
||||
```
|
||||
Heartbeat kích hoạt
|
||||
↓
|
||||
Agent đọc HEARTBEAT.md
|
||||
↓
|
||||
Tác vụ dài: spawn subagent
|
||||
↓ ↓
|
||||
Tiếp tục tác vụ tiếp theo Subagent hoạt động độc lập
|
||||
↓ ↓
|
||||
Hoàn thành tất cả tác vụ Subagent dùng công cụ "message"
|
||||
↓ ↓
|
||||
Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
|
||||
```
|
||||
|
||||
**Cấu hình:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Tùy chọn | Mặc định | Mô tả |
|
||||
| ---------- | -------- | -------------------------------------- |
|
||||
| `enabled` | `true` | Bật/tắt heartbeat |
|
||||
| `interval` | `30` | Khoảng thời gian kiểm tra tính bằng phút (tối thiểu: 5) |
|
||||
|
||||
**Biến môi trường:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq cung cấp chuyển đổi giọng nói thành văn bản miễn phí qua Whisper. Nếu được cấu hình, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển đổi ở cấp độ agent.
|
||||
|
||||
| Provider | Mục đích | Lấy API Key |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM (Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (khuyến nghị, truy cập tất cả mô hình) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Chuyển đổi giọng nói** (Whisper)| [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM (Vivgrid trực tiếp) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### Cấu Hình Mô Hình (model_list)
|
||||
|
||||
> **Tính năng mới:** PicoClaw hiện sử dụng cách tiếp cận **lấy mô hình làm trung tâm**. Chỉ cần chỉ định định dạng `vendor/model` (ví dụ: `zhipu/glm-4.7`) để thêm provider mới — **không cần thay đổi code!**
|
||||
|
||||
#### Tất Cả Vendor Được Hỗ Trợ
|
||||
|
||||
| Vendor | Tiền tố `model` | API Base mặc định | Giao thức | API Key |
|
||||
| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy](https://openrouter.ai/keys) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Chỉ OAuth |
|
||||
|
||||
#### Cân Bằng Tải
|
||||
|
||||
Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
|
||||
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Di Chuyển Từ Cấu Hình `providers` Cũ
|
||||
|
||||
Cấu hình `providers` cũ đã **bị deprecated** nhưng vẫn được hỗ trợ. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
|
||||
|
||||
### Kiến Trúc Provider
|
||||
|
||||
PicoClaw định tuyến provider theo họ giao thức:
|
||||
|
||||
- **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác.
|
||||
- **Anthropic**: Hành vi API Claude gốc.
|
||||
- **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI.
|
||||
|
||||
### Tác Vụ Đã Lên Lịch / Nhắc Nhở
|
||||
|
||||
PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi động lại tại `~/.picoclaw/workspace/cron/`.
|
||||
|
||||
### Chủ Đề Nâng Cao
|
||||
|
||||
| Chủ đề | Mô tả |
|
||||
| ------ | ----- |
|
||||
| [Hệ Thống Hook](../hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook |
|
||||
| [Steering](../steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
|
||||
| [SubTurn](../subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
|
||||
| [Quản Lý Ngữ Cảnh](../agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén |
|
||||
|
||||
@@ -41,14 +41,6 @@ Cài đặt chung để tải và xử lý nội dung trang web.
|
||||
| `fetch_limit_bytes` | int | 10485760 | Kích thước tối đa của payload trang web cần tải, tính bằng byte (mặc định là 10MB). |
|
||||
| `format` | string | "plaintext" | Định dạng đầu ra của nội dung đã tải. Tùy chọn: `plaintext` hoặc `markdown` (khuyến nghị). |
|
||||
|
||||
### Brave
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|----------|----------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm Brave |
|
||||
| `api_key` | string | - | Khóa API Brave Search |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
@@ -56,13 +48,73 @@ Cài đặt chung để tải và xử lý nội dung trang web.
|
||||
| `enabled` | bool | true | Bật tìm kiếm DuckDuckGo |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
### Baidu Search
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|-----------------------------------------------------------------|------------------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm Baidu |
|
||||
| `api_key` | string | - | Khóa API Qianfan |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL API Baidu Search |
|
||||
| `max_results` | int | 10 | Số kết quả tối đa |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|----------|-------------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm Perplexity |
|
||||
| `api_key` | string | - | Khóa API Perplexity |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
| `enabled` | bool | false | Bật tìm kiếm Perplexity |
|
||||
| `api_key` | string | - | Khóa API Perplexity |
|
||||
| `api_keys` | string[] | - | Nhiều khóa API Perplexity để xoay vòng (ưu tiên hơn `api_key`) |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
### Brave
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|----------|----------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm Brave |
|
||||
| `api_key` | string | - | Khóa API Brave Search |
|
||||
| `api_keys` | string[] | - | Nhiều khóa API Brave Search để xoay vòng (ưu tiên hơn `api_key`) |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
### Tavily
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|----------|------------------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm Tavily |
|
||||
| `api_key` | string | - | Khóa API Tavily |
|
||||
| `base_url` | string | - | URL cơ sở Tavily tùy chỉnh |
|
||||
| `max_results` | int | 0 | Số kết quả tối đa (0 = mặc định) |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|----------------|--------|--------------------------|----------------------------|
|
||||
| `enabled` | bool | false | Bật tìm kiếm SearXNG |
|
||||
| `base_url` | string | `http://localhost:8888` | URL phiên bản SearXNG |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
### GLM Search
|
||||
|
||||
| Cấu hình | Kiểu | Mặc định | Mô tả |
|
||||
|------------------|--------|------------------------------------------------------|----------------------------|
|
||||
| `enabled` | bool | false | Bật GLM Search |
|
||||
| `api_key` | string | - | Khóa API GLM |
|
||||
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL API GLM Search |
|
||||
| `search_engine` | string | `search_std` | Loại công cụ tìm kiếm |
|
||||
| `max_results` | int | 5 | Số kết quả tối đa |
|
||||
|
||||
## Công cụ Exec
|
||||
|
||||
|
||||
+24
-8
@@ -15,7 +15,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) |
|
||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) |
|
||||
| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) |
|
||||
| **Weixin** | ⭐ 简单 | 原生扫码登录 (腾讯 iLink API) | [查看文档](../channels/weixin/README.zh.md) |
|
||||
| **微信 (Weixin)** | ⭐ 简单 | 原生扫码(腾讯 iLink API) | [查看文档](#weixin) |
|
||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) |
|
||||
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) |
|
||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
|
||||
@@ -23,13 +23,14 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||
| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) |
|
||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
|
||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | - |
|
||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
|
||||
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
|
||||
|
||||
---
|
||||
|
||||
<a id="telegram"></a>
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推荐)</summary>
|
||||
|
||||
@@ -70,6 +71,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
|
||||
|
||||
</details>
|
||||
|
||||
<a id="discord"></a>
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
@@ -144,6 +146,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="whatsapp"></a>
|
||||
<details>
|
||||
<summary><b>WhatsApp</b>(原生 whatsmeow)</summary>
|
||||
|
||||
@@ -171,27 +174,30 @@ PicoClaw 支持两种 WhatsApp 连接方式:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="weixin"></a>
|
||||
<details>
|
||||
<summary><b>Weixin</b> (微信个人号)</summary>
|
||||
<summary><b>微信 (Weixin)</b></summary>
|
||||
|
||||
PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
|
||||
PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。
|
||||
|
||||
**1. 登录**
|
||||
|
||||
运行交互式扫码登录流程:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
在终端扫描打印出的二维码。登录成功后,Token 将自动保存到您的配置文件中。
|
||||
用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。
|
||||
|
||||
**2. 配置**
|
||||
(可选)更新 `allow_from` 填写微信 User ID,以限制哪些用户可以给机器人发消息:
|
||||
|
||||
(可选)在 `allow_from` 中填入你的微信用户 ID,限制可以与机器人对话的用户:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "你的_TOKEN",
|
||||
"allow_from": ["你的_USER_ID"]
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +210,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="matrix"></a>
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
@@ -238,6 +245,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="qq"></a>
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -279,6 +287,7 @@ QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面:
|
||||
|
||||
</details>
|
||||
|
||||
<a id="slack"></a>
|
||||
<details>
|
||||
<summary><b>Slack</b></summary>
|
||||
|
||||
@@ -312,6 +321,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="irc"></a>
|
||||
<details>
|
||||
<summary><b>IRC</b></summary>
|
||||
|
||||
@@ -345,6 +355,7 @@ Bot 将连接到 IRC 服务器并加入指定的频道。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="dingtalk"></a>
|
||||
<details>
|
||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
||||
|
||||
@@ -379,6 +390,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="line"></a>
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
@@ -427,6 +439,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="feishu"></a>
|
||||
<details>
|
||||
<summary><b>飞书 (Feishu)</b></summary>
|
||||
|
||||
@@ -468,6 +481,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="wecom"></a>
|
||||
<details>
|
||||
<summary><b>企业微信 (WeCom)</b></summary>
|
||||
|
||||
@@ -582,6 +596,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="onebot"></a>
|
||||
<details>
|
||||
<summary><b>OneBot(通过 OneBot 协议连接 QQ)</b></summary>
|
||||
|
||||
@@ -620,6 +635,7 @@ picoclaw gateway
|
||||
|
||||
</details>
|
||||
|
||||
<a id="maixcam"></a>
|
||||
<details>
|
||||
<summary><b>MaixCam</b></summary>
|
||||
|
||||
|
||||
@@ -256,3 +256,356 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
|
||||
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
#### 子 Agent 通信流程
|
||||
|
||||
```
|
||||
心跳触发
|
||||
↓
|
||||
Agent 读取 HEARTBEAT.md
|
||||
↓
|
||||
遇到耗时任务:spawn 子 Agent
|
||||
↓ ↓
|
||||
继续处理下一个任务 子 Agent 独立运行
|
||||
↓ ↓
|
||||
所有任务完成 子 Agent 使用 "message" 工具
|
||||
↓ ↓
|
||||
回复 HEARTBEAT_OK 用户直接收到结果
|
||||
```
|
||||
|
||||
子 Agent 拥有工具访问权限(message、web_search 等),可以独立与用户通信,无需经过主 Agent。
|
||||
|
||||
### Providers(模型提供商)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费语音转录。配置后,任意渠道的语音消息都会在 Agent 层自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM(Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM(智谱直连) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `volcengine` | LLM(火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM(推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM(Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM(GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM(DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM(通义千问直连) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **语音转录**(Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM(Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
||||
| `vivgrid` | LLM(Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) |
|
||||
|
||||
### 模型配置 (model_list)
|
||||
|
||||
> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!**
|
||||
|
||||
这一设计同时支持**多 Agent**场景,灵活选择提供商:
|
||||
|
||||
- **不同 Agent 使用不同提供商**:每个 Agent 可以使用独立的 LLM 提供商
|
||||
- **模型降级**:配置主模型和备用模型,提升可用性
|
||||
- **负载均衡**:将请求分发到多个端点
|
||||
- **集中管理**:在一处管理所有提供商配置
|
||||
|
||||
#### 所有支持的厂商
|
||||
|
||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key |
|
||||
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
|
||||
| **火山引擎 (豆包)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
|
||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
|
||||
|
||||
#### 基础配置
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-zhipu-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>火山引擎(豆包)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 AI (GLM)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DeepSeek</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Anthropic</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
}
|
||||
```
|
||||
|
||||
> 运行 `picoclaw auth login --provider anthropic` 粘贴 API Token。
|
||||
|
||||
如需直连 Anthropic 原生接口(不兼容 OpenAI 格式的端点):
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-opus-4-6",
|
||||
"model": "anthropic-messages/claude-opus-4-6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com"
|
||||
}
|
||||
```
|
||||
|
||||
> 当端点不支持 OpenAI 兼容格式(`/v1/chat/completions`),需要 Anthropic 原生 `/v1/messages` 时使用 `anthropic-messages`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ollama(本地)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>自定义代理 / LiteLLM</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`。
|
||||
|
||||
</details>
|
||||
|
||||
#### 负载均衡
|
||||
|
||||
为同一模型名称配置多个端点,PicoClaw 会自动轮询:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 从旧版 `providers` 配置迁移
|
||||
|
||||
旧版 `providers` 配置**已废弃**,但仍向后兼容。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。
|
||||
|
||||
### Provider 架构
|
||||
|
||||
PicoClaw 按协议族路由提供商:
|
||||
|
||||
- **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。
|
||||
- **Anthropic**:Claude 原生 API 行为。
|
||||
- **Codex/OAuth**:OpenAI OAuth/Token 认证路由。
|
||||
|
||||
这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。
|
||||
|
||||
<details>
|
||||
<summary><b>智谱(旧版 providers 格式)</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完整配置示例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"dm_scope": "per-channel-peer",
|
||||
"backlog_limit": 20
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 定时任务 / 提醒
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"cron": {
|
||||
"enabled": true,
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
定时任务在重启后持久保存,存储于 `~/.picoclaw/workspace/cron/`。
|
||||
|
||||
### 进阶主题
|
||||
|
||||
| 主题 | 说明 |
|
||||
| ---- | ---- |
|
||||
| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||
| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||
| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||
| [上下文管理](../agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 |
|
||||
|
||||
+32
-1
@@ -5,7 +5,7 @@
|
||||
### 提供商 (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||
> 语音转录现在可以通过 `voice.model_name` 指定的多模态模型完成;如果未配置语音模型,Groq Whisper 仍可作为回退方案。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
@@ -99,6 +99,33 @@
|
||||
}
|
||||
```
|
||||
|
||||
#### 语音转录
|
||||
|
||||
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||
|
||||
如果没有配置 `voice.model_name`,且存在 Groq API Key,PicoClaw 会继续回退到 Groq 转录。
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "voice-gemini",
|
||||
"model": "gemini/gemini-2.5-flash",
|
||||
"api_key": "your-gemini-key"
|
||||
}
|
||||
],
|
||||
"voice": {
|
||||
"model_name": "voice-gemini",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"providers": {
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
**OpenAI**
|
||||
@@ -342,6 +369,10 @@ picoclaw agent -m "你好"
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"model_name": "voice-gemini",
|
||||
"echo_transcription": false
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -41,30 +41,30 @@ Web 工具用于网页搜索和抓取。
|
||||
| `fetch_limit_bytes` | int | 10485760 | 抓取网页负载的最大大小,单位为字节(默认 10MB)。 |
|
||||
| `format` | string | "plaintext" | 抓取内容的输出格式。选项:`plaintext` 或 `markdown`(推荐)。 |
|
||||
|
||||
### Brave
|
||||
### 百度搜索
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|----------|--------|------------------------------------------------|
|
||||
| `enabled` | bool | false | 启用 Brave 搜索 |
|
||||
| `api_key` | string | - | Brave Search API 密钥 |
|
||||
| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
使用[千帆 AI 搜索 API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5),国内访问稳定,中文搜索效果好。
|
||||
|
||||
### DuckDuckGo
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|--------|----------------------------------------------------------------|-----------------------|
|
||||
| `enabled` | bool | false | 启用百度搜索 |
|
||||
| `api_key` | string | - | 千帆 API 密钥 |
|
||||
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | 百度搜索 API URL |
|
||||
| `max_results` | int | 10 | 最大结果数 |
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|------|--------|-----------------------|
|
||||
| `enabled` | bool | true | 启用 DuckDuckGo 搜索 |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### Perplexity
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|----------|--------|------------------------------------------------|
|
||||
| `enabled` | bool | false | 启用 Perplexity 搜索 |
|
||||
| `api_key` | string | - | Perplexity API 密钥 |
|
||||
| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"baidu_search": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tavily
|
||||
|
||||
@@ -75,14 +75,6 @@ Web 工具用于网页搜索和抓取。
|
||||
| `base_url` | string | - | 自定义 Tavily API 基础 URL |
|
||||
| `max_results` | int | 0 | 最大结果数(0 = 默认) |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|--------|--------------------------|-----------------------|
|
||||
| `enabled` | bool | false | 启用 SearXNG 搜索 |
|
||||
| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### GLM Search
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
@@ -93,6 +85,45 @@ Web 工具用于网页搜索和抓取。
|
||||
| `search_engine` | string | `search_std` | 搜索引擎类型 |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
> ⚠️ 国内访问困难,建议搭配代理使用。
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|------|--------|-----------------------|
|
||||
| `enabled` | bool | true | 启用 DuckDuckGo 搜索 |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### Perplexity
|
||||
|
||||
> ⚠️ 国内访问困难,建议搭配代理使用。
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|----------|--------|------------------------------------------------|
|
||||
| `enabled` | bool | false | 启用 Perplexity 搜索 |
|
||||
| `api_key` | string | - | Perplexity API 密钥 |
|
||||
| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### Brave
|
||||
|
||||
> ⚠️ 国内访问困难,建议搭配代理使用。
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|----------|--------|------------------------------------------------|
|
||||
| `enabled` | bool | false | 启用 Brave 搜索 |
|
||||
| `api_key` | string | - | Brave Search API 密钥 |
|
||||
| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### SearXNG
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|---------------|--------|--------------------------|-----------------------|
|
||||
| `enabled` | bool | false | 启用 SearXNG 搜索 |
|
||||
| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL |
|
||||
| `max_results` | int | 5 | 最大结果数 |
|
||||
|
||||
### 其他 Web 设置
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|
||||
@@ -3,8 +3,8 @@ module github.com/sipeed/picoclaw
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
|
||||
+29
-17
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
@@ -59,7 +60,7 @@ func getGlobalConfigDir() string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||
}
|
||||
|
||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||
@@ -222,13 +223,10 @@ func (cb *ContextBuilder) InvalidateCache() {
|
||||
// invalidation (bootstrap files + memory). Skill roots are handled separately
|
||||
// because they require both directory-level and recursive file-level checks.
|
||||
func (cb *ContextBuilder) sourcePaths() []string {
|
||||
return []string{
|
||||
filepath.Join(cb.workspace, "AGENTS.md"),
|
||||
filepath.Join(cb.workspace, "SOUL.md"),
|
||||
filepath.Join(cb.workspace, "USER.md"),
|
||||
filepath.Join(cb.workspace, "IDENTITY.md"),
|
||||
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
|
||||
}
|
||||
agentDefinition := cb.LoadAgentDefinition()
|
||||
paths := agentDefinition.trackedPaths(cb.workspace)
|
||||
paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md"))
|
||||
return uniquePaths(paths)
|
||||
}
|
||||
|
||||
// skillRoots returns all skill root directories that can affect
|
||||
@@ -432,18 +430,32 @@ func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Ti
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
bootstrapFiles := []string{
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
var sb strings.Builder
|
||||
|
||||
agentDefinition := cb.LoadAgentDefinition()
|
||||
if agentDefinition.Agent != nil {
|
||||
label := string(agentDefinition.Source)
|
||||
if label == "" {
|
||||
label = relativeWorkspacePath(cb.workspace, agentDefinition.Agent.Path)
|
||||
}
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", label, agentDefinition.Agent.Body)
|
||||
}
|
||||
if agentDefinition.Soul != nil {
|
||||
fmt.Fprintf(
|
||||
&sb,
|
||||
"## %s\n\n%s\n\n",
|
||||
relativeWorkspacePath(cb.workspace, agentDefinition.Soul.Path),
|
||||
agentDefinition.Soul.Content,
|
||||
)
|
||||
}
|
||||
if agentDefinition.User != nil {
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "USER.md", agentDefinition.User.Content)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, filename := range bootstrapFiles {
|
||||
filePath := filepath.Join(cb.workspace, filename)
|
||||
if agentDefinition.Source != AgentDefinitionSourceAgent {
|
||||
filePath := filepath.Join(cb.workspace, "IDENTITY.md")
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "IDENTITY.md", data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// parseTurnBoundaries returns the starting index of each Turn in the history.
|
||||
// A Turn is a complete "user input → LLM iterations → final response" cycle
|
||||
// (as defined in #1316). Each Turn begins at a user message and extends
|
||||
// through all subsequent assistant/tool messages until the next user message.
|
||||
//
|
||||
// Cutting at a Turn boundary guarantees that no tool-call sequence
|
||||
// (assistant+ToolCalls → tool results) is split across the cut.
|
||||
func parseTurnBoundaries(history []providers.Message) []int {
|
||||
var starts []int
|
||||
for i, msg := range history {
|
||||
if msg.Role == "user" {
|
||||
starts = append(starts, i)
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// isSafeBoundary reports whether index is a valid Turn boundary — i.e.,
|
||||
// a position where the kept portion (history[index:]) begins at a user
|
||||
// message, so no tool-call sequence is torn apart.
|
||||
func isSafeBoundary(history []providers.Message, index int) bool {
|
||||
if index <= 0 || index >= len(history) {
|
||||
return true
|
||||
}
|
||||
return history[index].Role == "user"
|
||||
}
|
||||
|
||||
// findSafeBoundary locates the nearest Turn boundary to targetIndex.
|
||||
// It prefers the boundary at or before targetIndex (preserving more recent
|
||||
// context). Falls back to the nearest boundary after targetIndex, and
|
||||
// returns targetIndex unchanged only when no Turn boundary exists at all.
|
||||
func findSafeBoundary(history []providers.Message, targetIndex int) int {
|
||||
if len(history) == 0 {
|
||||
return 0
|
||||
}
|
||||
if targetIndex <= 0 {
|
||||
return 0
|
||||
}
|
||||
if targetIndex >= len(history) {
|
||||
return len(history)
|
||||
}
|
||||
|
||||
turns := parseTurnBoundaries(history)
|
||||
if len(turns) == 0 {
|
||||
return targetIndex
|
||||
}
|
||||
|
||||
// Find the last Turn boundary at or before targetIndex.
|
||||
// Prefer backward: keeps more recent messages.
|
||||
backward := -1
|
||||
for _, t := range turns {
|
||||
if t <= targetIndex {
|
||||
backward = t
|
||||
}
|
||||
}
|
||||
if backward > 0 {
|
||||
return backward
|
||||
}
|
||||
|
||||
// No valid Turn boundary before target (or only at index 0 which
|
||||
// would keep everything). Use the first Turn after targetIndex.
|
||||
for _, t := range turns {
|
||||
if t > targetIndex {
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// No Turn boundary after targetIndex either. The only boundary is at
|
||||
// index 0, meaning the entire history is a single Turn. Return 0 to
|
||||
// signal that safe compression is not possible — callers check for
|
||||
// mid <= 0 and skip compression in that case.
|
||||
return 0
|
||||
}
|
||||
|
||||
// estimateMessageTokens estimates the token count for a single message,
|
||||
// including Content, ReasoningContent, ToolCalls arguments, ToolCallID
|
||||
// metadata, and Media items. Uses a heuristic of 2.5 characters per token.
|
||||
func estimateMessageTokens(msg providers.Message) int {
|
||||
chars := utf8.RuneCountInString(msg.Content)
|
||||
|
||||
// ReasoningContent (extended thinking / chain-of-thought) can be
|
||||
// substantial and is stored in session history via AddFullMessage.
|
||||
if msg.ReasoningContent != "" {
|
||||
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
||||
}
|
||||
|
||||
for _, tc := range msg.ToolCalls {
|
||||
chars += len(tc.ID) + len(tc.Type)
|
||||
if tc.Function != nil {
|
||||
// Count function name + arguments (the wire format for most providers).
|
||||
// tc.Name mirrors tc.Function.Name — count only once to avoid double-counting.
|
||||
chars += len(tc.Function.Name) + len(tc.Function.Arguments)
|
||||
} else {
|
||||
// Fallback: some provider formats use top-level Name without Function.
|
||||
chars += len(tc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.ToolCallID != "" {
|
||||
chars += len(msg.ToolCallID)
|
||||
}
|
||||
|
||||
// Per-message overhead for role label, JSON structure, separators.
|
||||
const messageOverhead = 12
|
||||
chars += messageOverhead
|
||||
|
||||
tokens := chars * 2 / 5
|
||||
|
||||
// Media items (images, files) are serialized by provider adapters into
|
||||
// multipart or image_url payloads. Add a fixed per-item token estimate
|
||||
// directly (not through the chars heuristic) since actual cost depends
|
||||
// on resolution and provider-specific image tokenization.
|
||||
const mediaTokensPerItem = 256
|
||||
tokens += len(msg.Media) * mediaTokensPerItem
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// estimateToolDefsTokens estimates the total token cost of tool definitions
|
||||
// as they appear in the LLM request. Each tool's name, description, and
|
||||
// JSON schema parameters contribute to the context window budget.
|
||||
func estimateToolDefsTokens(defs []providers.ToolDefinition) int {
|
||||
if len(defs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
totalChars := 0
|
||||
for _, d := range defs {
|
||||
totalChars += len(d.Function.Name) + len(d.Function.Description)
|
||||
|
||||
if d.Function.Parameters != nil {
|
||||
if paramJSON, err := json.Marshal(d.Function.Parameters); err == nil {
|
||||
totalChars += len(paramJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-tool overhead: type field, JSON structure, separators.
|
||||
totalChars += 20
|
||||
}
|
||||
|
||||
return totalChars * 2 / 5
|
||||
}
|
||||
|
||||
// isOverContextBudget checks whether the assembled messages plus tool definitions
|
||||
// and output reserve would exceed the model's context window. This enables
|
||||
// proactive compression before calling the LLM, rather than reacting to 400 errors.
|
||||
func isOverContextBudget(
|
||||
contextWindow int,
|
||||
messages []providers.Message,
|
||||
toolDefs []providers.ToolDefinition,
|
||||
maxTokens int,
|
||||
) bool {
|
||||
msgTokens := 0
|
||||
for _, m := range messages {
|
||||
msgTokens += estimateMessageTokens(m)
|
||||
}
|
||||
|
||||
toolTokens := estimateToolDefsTokens(toolDefs)
|
||||
total := msgTokens + toolTokens + maxTokens
|
||||
|
||||
return total > contextWindow
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// msgUser creates a user message.
|
||||
func msgUser(content string) providers.Message {
|
||||
return providers.Message{Role: "user", Content: content}
|
||||
}
|
||||
|
||||
// msgAssistant creates a plain assistant message (no tool calls).
|
||||
func msgAssistant(content string) providers.Message {
|
||||
return providers.Message{Role: "assistant", Content: content}
|
||||
}
|
||||
|
||||
// msgAssistantTC creates an assistant message with tool calls.
|
||||
func msgAssistantTC(toolIDs ...string) providers.Message {
|
||||
tcs := make([]providers.ToolCall, len(toolIDs))
|
||||
for i, id := range toolIDs {
|
||||
tcs[i] = providers.ToolCall{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Name: "tool_" + id,
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "tool_" + id,
|
||||
Arguments: `{"key":"value"}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
return providers.Message{Role: "assistant", ToolCalls: tcs}
|
||||
}
|
||||
|
||||
// msgTool creates a tool result message.
|
||||
func msgTool(callID, content string) providers.Message {
|
||||
return providers.Message{Role: "tool", ToolCallID: callID, Content: content}
|
||||
}
|
||||
|
||||
func TestParseTurnBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
history []providers.Message
|
||||
want []int
|
||||
}{
|
||||
{
|
||||
name: "empty history",
|
||||
history: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "simple exchange",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistant("a2"),
|
||||
},
|
||||
want: []int{0, 2},
|
||||
},
|
||||
{
|
||||
name: "tool-call Turn",
|
||||
history: []providers.Message{
|
||||
msgUser("search"),
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "result"),
|
||||
msgAssistant("found it"),
|
||||
msgUser("thanks"),
|
||||
msgAssistant("welcome"),
|
||||
},
|
||||
want: []int{0, 4},
|
||||
},
|
||||
{
|
||||
name: "chained tool calls in single Turn",
|
||||
history: []providers.Message{
|
||||
msgUser("save and notify"),
|
||||
msgAssistantTC("tc_save"),
|
||||
msgTool("tc_save", "saved"),
|
||||
msgAssistantTC("tc_notify"),
|
||||
msgTool("tc_notify", "notified"),
|
||||
msgAssistant("done"),
|
||||
},
|
||||
want: []int{0},
|
||||
},
|
||||
{
|
||||
name: "no user messages",
|
||||
history: []providers.Message{
|
||||
msgAssistant("a1"),
|
||||
msgAssistant("a2"),
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "leading non-user messages",
|
||||
history: []providers.Message{
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "r1"),
|
||||
msgAssistant("greeting"),
|
||||
msgUser("hello"),
|
||||
msgAssistant("hi"),
|
||||
},
|
||||
want: []int{3},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseTurnBoundaries(tt.history)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseTurnBoundaries() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("parseTurnBoundaries()[%d] = %d, want %d", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
history []providers.Message
|
||||
index int
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty history, index 0",
|
||||
history: nil,
|
||||
index: 0,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "single user message, index 0",
|
||||
history: []providers.Message{msgUser("hi")},
|
||||
index: 0,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "single user message, index 1 (end)",
|
||||
history: []providers.Message{msgUser("hi")},
|
||||
index: 1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "at user message",
|
||||
history: []providers.Message{
|
||||
msgAssistant("hello"),
|
||||
msgUser("how are you"),
|
||||
msgAssistant("fine"),
|
||||
},
|
||||
index: 1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "at assistant without tool calls",
|
||||
history: []providers.Message{
|
||||
msgUser("hello"),
|
||||
msgAssistant("response"),
|
||||
msgUser("follow up"),
|
||||
},
|
||||
index: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "at assistant with tool calls",
|
||||
history: []providers.Message{
|
||||
msgUser("search something"),
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "result"),
|
||||
msgAssistant("here is what I found"),
|
||||
},
|
||||
index: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "at tool result",
|
||||
history: []providers.Message{
|
||||
msgUser("do something"),
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "done"),
|
||||
msgAssistant("completed"),
|
||||
},
|
||||
index: 2,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negative index",
|
||||
history: []providers.Message{
|
||||
msgUser("hello"),
|
||||
},
|
||||
index: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "index beyond length",
|
||||
history: []providers.Message{
|
||||
msgUser("hello"),
|
||||
},
|
||||
index: 5,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSafeBoundary(tt.history, tt.index)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSafeBoundary(history, %d) = %v, want %v", tt.index, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSafeBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
history []providers.Message
|
||||
targetIndex int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "empty history",
|
||||
history: nil,
|
||||
targetIndex: 0,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "target at 0",
|
||||
history: []providers.Message{msgUser("hi")},
|
||||
targetIndex: 0,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "target beyond length",
|
||||
history: []providers.Message{msgUser("hi")},
|
||||
targetIndex: 5,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "target already at user message",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistant("a2"),
|
||||
},
|
||||
targetIndex: 2,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "target at assistant, scan backward finds user",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistant("a2"),
|
||||
msgUser("q3"),
|
||||
},
|
||||
targetIndex: 3, // assistant "a2"
|
||||
want: 2, // backward to user "q2"
|
||||
},
|
||||
{
|
||||
name: "target inside tool sequence, scan backward finds user",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistantTC("tc1", "tc2"),
|
||||
msgTool("tc1", "r1"),
|
||||
msgTool("tc2", "r2"),
|
||||
msgAssistant("summary"),
|
||||
msgUser("q3"),
|
||||
},
|
||||
targetIndex: 4, // tool result "r1"
|
||||
want: 2, // backward: 3=assistant+TC (not safe), 2=user → safe
|
||||
},
|
||||
{
|
||||
name: "target inside tool sequence, backward finds user before chain",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistantTC("tc1", "tc2"),
|
||||
msgTool("tc1", "r1"),
|
||||
msgTool("tc2", "r2"),
|
||||
msgAssistant("summary"),
|
||||
msgUser("q3"),
|
||||
},
|
||||
targetIndex: 5, // tool result "r2"
|
||||
want: 2, // backward: 4=tool, 3=assistant+TC, 2=user → safe
|
||||
},
|
||||
{
|
||||
name: "no backward user, scan forward finds one",
|
||||
history: []providers.Message{
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "r1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q1"),
|
||||
},
|
||||
targetIndex: 1, // tool result
|
||||
want: 3, // forward to user "q1"
|
||||
},
|
||||
{
|
||||
name: "multi-step tool chain preserves atomicity",
|
||||
history: []providers.Message{
|
||||
msgUser("q1"),
|
||||
msgAssistant("a1"),
|
||||
msgUser("q2"),
|
||||
msgAssistantTC("tc1"),
|
||||
msgTool("tc1", "r1"),
|
||||
msgAssistantTC("tc2"),
|
||||
msgTool("tc2", "r2"),
|
||||
msgAssistant("final"),
|
||||
msgUser("q3"),
|
||||
msgAssistant("a3"),
|
||||
},
|
||||
targetIndex: 5, // second assistant+TC
|
||||
want: 2, // backward: 4=tool, 3=assistant+TC, 2=user → safe
|
||||
},
|
||||
{
|
||||
name: "all non-user messages returns target unchanged",
|
||||
history: []providers.Message{
|
||||
msgAssistant("a1"),
|
||||
msgAssistant("a2"),
|
||||
msgAssistant("a3"),
|
||||
},
|
||||
targetIndex: 1,
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := findSafeBoundary(tt.history, tt.targetIndex)
|
||||
if got != tt.want {
|
||||
t.Errorf("findSafeBoundary(history, %d) = %d, want %d",
|
||||
tt.targetIndex, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSafeBoundary_SingleTurnReturnsZero(t *testing.T) {
|
||||
// A single Turn with no subsequent user message. The only Turn boundary
|
||||
// is at index 0; cutting anywhere else would split the Turn's tool
|
||||
// sequence. findSafeBoundary must return 0 so callers skip compression.
|
||||
history := []providers.Message{
|
||||
msgUser("do everything"), // 0 ← only Turn boundary
|
||||
msgAssistantTC("tc1"), // 1
|
||||
msgTool("tc1", "result"), // 2
|
||||
msgAssistant("all done"), // 3
|
||||
}
|
||||
|
||||
got := findSafeBoundary(history, 2)
|
||||
if got != 0 {
|
||||
t.Errorf("findSafeBoundary(single_turn, 2) = %d, want 0 (cannot split single Turn)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSafeBoundary_BackwardScanSkipsToolSequence(t *testing.T) {
|
||||
// A long tool-call chain: user → assistant+TC → tool → tool → ... → assistant → user
|
||||
// Target is inside the chain; boundary should skip the entire chain backward.
|
||||
history := []providers.Message{
|
||||
msgUser("start"), // 0
|
||||
msgAssistant("before chain"), // 1
|
||||
msgUser("trigger"), // 2 ← expected safe boundary
|
||||
msgAssistantTC("t1", "t2", "t3"), // 3
|
||||
msgTool("t1", "r1"), // 4
|
||||
msgTool("t2", "r2"), // 5
|
||||
msgTool("t3", "r3"), // 6
|
||||
msgAssistantTC("t4"), // 7
|
||||
msgTool("t4", "r4"), // 8
|
||||
msgAssistant("chain done"), // 9
|
||||
msgUser("next"), // 10
|
||||
}
|
||||
|
||||
// Target at index 6 (middle of tool results)
|
||||
got := findSafeBoundary(history, 6)
|
||||
if got != 2 {
|
||||
t.Errorf("findSafeBoundary(history, 6) = %d, want 2 (user before chain)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg providers.Message
|
||||
want int // minimum expected tokens (exact value depends on overhead)
|
||||
}{
|
||||
{
|
||||
name: "plain user message",
|
||||
msg: msgUser("Hello, world!"),
|
||||
want: 1, // at least some tokens
|
||||
},
|
||||
{
|
||||
name: "empty message still has overhead",
|
||||
msg: providers.Message{Role: "user"},
|
||||
want: 1, // message overhead alone
|
||||
},
|
||||
{
|
||||
name: "assistant with tool calls",
|
||||
msg: msgAssistantTC("tc_123"),
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "tool result with ID",
|
||||
msg: msgTool("call_abc", "Here is the search result with lots of content"),
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := estimateMessageTokens(tt.msg)
|
||||
if got < tt.want {
|
||||
t.Errorf("estimateMessageTokens() = %d, want >= %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_ToolCallsContribute(t *testing.T) {
|
||||
plain := msgAssistant("thinking")
|
||||
withTC := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: "thinking",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Name: "web_search",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "web_search",
|
||||
Arguments: `{"query":"picoclaw agent framework","max_results":5}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
withTCTokens := estimateMessageTokens(withTC)
|
||||
|
||||
if withTCTokens <= plainTokens {
|
||||
t.Errorf("message with ToolCalls (%d tokens) should exceed plain message (%d tokens)",
|
||||
withTCTokens, plainTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_MultibyteContent(t *testing.T) {
|
||||
// Multi-byte characters (e.g. emoji, accented letters) are single runes
|
||||
// but may map to different token counts. The heuristic should still produce
|
||||
// reasonable estimates via RuneCountInString.
|
||||
msg := msgUser("caf\u00e9 na\u00efve r\u00e9sum\u00e9 \u00fcber stra\u00dfe")
|
||||
tokens := estimateMessageTokens(msg)
|
||||
if tokens <= 0 {
|
||||
t.Errorf("multibyte message should produce positive token count, got %d", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_LargeArguments(t *testing.T) {
|
||||
// Simulate a tool call with large JSON arguments.
|
||||
largeArgs := fmt.Sprintf(`{"content":"%s"}`, strings.Repeat("x", 5000))
|
||||
msg := providers.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_large",
|
||||
Type: "function",
|
||||
Name: "write_file",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "write_file",
|
||||
Arguments: largeArgs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateMessageTokens(msg)
|
||||
// 5000+ chars → at least 2000 tokens with the 2.5 char/token heuristic
|
||||
if tokens < 2000 {
|
||||
t.Errorf("large tool call arguments should produce significant token count, got %d", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_ReasoningContent(t *testing.T) {
|
||||
plain := msgAssistant("result")
|
||||
withReasoning := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: "result",
|
||||
ReasoningContent: strings.Repeat("thinking step ", 200),
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
reasoningTokens := estimateMessageTokens(withReasoning)
|
||||
|
||||
if reasoningTokens <= plainTokens {
|
||||
t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)",
|
||||
reasoningTokens, plainTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_MediaItems(t *testing.T) {
|
||||
plain := msgUser("describe this")
|
||||
withMedia := providers.Message{
|
||||
Role: "user",
|
||||
Content: "describe this",
|
||||
Media: []string{"media://img1.png", "media://img2.png"},
|
||||
}
|
||||
|
||||
plainTokens := estimateMessageTokens(plain)
|
||||
mediaTokens := estimateMessageTokens(withMedia)
|
||||
|
||||
if mediaTokens <= plainTokens {
|
||||
t.Errorf("message with Media (%d tokens) should exceed plain message (%d tokens)",
|
||||
mediaTokens, plainTokens)
|
||||
}
|
||||
|
||||
// Each media item should add exactly 256 tokens (not run through chars*2/5).
|
||||
expectedDelta := 256 * 2
|
||||
actualDelta := mediaTokens - plainTokens
|
||||
if actualDelta != expectedDelta {
|
||||
t.Errorf("2 media items should add %d tokens, got delta %d", expectedDelta, actualDelta)
|
||||
}
|
||||
}
|
||||
|
||||
// --- estimateToolDefsTokens tests ---
|
||||
|
||||
func TestEstimateToolDefsTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defs []providers.ToolDefinition
|
||||
want int // minimum expected tokens
|
||||
}{
|
||||
{
|
||||
name: "empty tool list",
|
||||
defs: nil,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "single tool with params",
|
||||
defs: []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "web_search",
|
||||
Description: "Search the web for information",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "tool without params",
|
||||
defs: []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "list_dir",
|
||||
Description: "List directory contents",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := estimateToolDefsTokens(tt.defs)
|
||||
if got < tt.want {
|
||||
t.Errorf("estimateToolDefsTokens() = %d, want >= %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateToolDefsTokens_ScalesWithCount(t *testing.T) {
|
||||
makeTool := func(name string) providers.ToolDefinition {
|
||||
return providers.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: name,
|
||||
Description: "A test tool that does something useful",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string", "description": "Input value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
one := estimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")})
|
||||
three := estimateToolDefsTokens([]providers.ToolDefinition{
|
||||
makeTool("tool_a"), makeTool("tool_b"), makeTool("tool_c"),
|
||||
})
|
||||
|
||||
if three <= one {
|
||||
t.Errorf("3 tools (%d tokens) should exceed 1 tool (%d tokens)", three, one)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isOverContextBudget tests ---
|
||||
|
||||
func TestIsOverContextBudget(t *testing.T) {
|
||||
systemMsg := providers.Message{Role: "system", Content: strings.Repeat("x", 1000)}
|
||||
userMsg := msgUser("hello")
|
||||
smallHistory := []providers.Message{systemMsg, msgUser("q1"), msgAssistant("a1"), userMsg}
|
||||
|
||||
tools := []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "test_tool",
|
||||
Description: "A test tool",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contextWindow int
|
||||
messages []providers.Message
|
||||
toolDefs []providers.ToolDefinition
|
||||
maxTokens int
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "within budget",
|
||||
contextWindow: 100000,
|
||||
messages: smallHistory,
|
||||
toolDefs: tools,
|
||||
maxTokens: 4096,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "over budget with small window",
|
||||
contextWindow: 100, // very small window
|
||||
messages: smallHistory,
|
||||
toolDefs: tools,
|
||||
maxTokens: 4096,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "large max_tokens eats budget",
|
||||
contextWindow: 2000,
|
||||
messages: smallHistory,
|
||||
toolDefs: tools,
|
||||
maxTokens: 1800, // leaves almost no room
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty messages within budget",
|
||||
contextWindow: 10000,
|
||||
messages: nil,
|
||||
toolDefs: nil,
|
||||
maxTokens: 4096,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isOverContextBudget(tt.contextWindow, tt.messages, tt.toolDefs, tt.maxTokens)
|
||||
if got != tt.want {
|
||||
t.Errorf("isOverContextBudget() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests reflecting actual session data shape ---
|
||||
// Session history never contains system messages. The system prompt is
|
||||
// built dynamically by BuildMessages. These tests use realistic history
|
||||
// shapes: user/assistant/tool only, with tool chains and reasoning content.
|
||||
|
||||
func TestFindSafeBoundary_SessionHistoryNoSystem(t *testing.T) {
|
||||
// Real session history starts with a user message, not a system message.
|
||||
history := []providers.Message{
|
||||
msgUser("hello"), // 0
|
||||
msgAssistant("hi there"), // 1
|
||||
msgUser("search for X"), // 2
|
||||
msgAssistantTC("tc1"), // 3
|
||||
msgTool("tc1", "found X"), // 4
|
||||
msgAssistant("here is X"), // 5
|
||||
msgUser("thanks"), // 6
|
||||
msgAssistant("you're welcome"), // 7
|
||||
}
|
||||
|
||||
// Mid-point is 4 (tool result). Should snap backward to 2 (user).
|
||||
got := findSafeBoundary(history, 4)
|
||||
if got != 2 {
|
||||
t.Errorf("findSafeBoundary(session_history, 4) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSafeBoundary_SessionWithChainedTools(t *testing.T) {
|
||||
// Session with chained tool calls (save then notify).
|
||||
history := []providers.Message{
|
||||
msgUser("save and notify"), // 0
|
||||
msgAssistantTC("tc_save"), // 1
|
||||
msgTool("tc_save", "saved"), // 2
|
||||
msgAssistantTC("tc_notify"), // 3
|
||||
msgTool("tc_notify", "notified"), // 4
|
||||
msgAssistant("done"), // 5
|
||||
msgUser("check status"), // 6
|
||||
msgAssistant("all good"), // 7
|
||||
}
|
||||
|
||||
// Target at 3 (inside chain). Should find user at 0, but backward
|
||||
// scan stops at i>0, so forward scan finds user at 6.
|
||||
// Actually: backward from 3: 2=tool (no), 1=assistantTC (no). Forward: 4=tool, 5=asst, 6=user ✓
|
||||
got := findSafeBoundary(history, 3)
|
||||
if got != 6 {
|
||||
t.Errorf("findSafeBoundary(chained_tools, 3) = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) {
|
||||
// Message with all fields populated — mirrors what AddFullMessage stores.
|
||||
msg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: "Here is the analysis.",
|
||||
ReasoningContent: strings.Repeat("Let me think about this carefully. ", 50),
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Name: "analyze",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "analyze",
|
||||
Arguments: `{"data":"sample","depth":3}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateMessageTokens(msg)
|
||||
|
||||
// ReasoningContent alone is ~1700 chars → ~680 tokens.
|
||||
// Content + TC + overhead adds more. Should be well above 500.
|
||||
if tokens < 500 {
|
||||
t.Errorf("message with reasoning+toolcalls should have significant tokens, got %d", tokens)
|
||||
}
|
||||
|
||||
// Compare without reasoning to ensure it's counted.
|
||||
msgNoReasoning := msg
|
||||
msgNoReasoning.ReasoningContent = ""
|
||||
tokensNoReasoning := estimateMessageTokens(msgNoReasoning)
|
||||
|
||||
if tokens <= tokensNoReasoning {
|
||||
t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOverContextBudget_RealisticSession(t *testing.T) {
|
||||
// Simulate what BuildMessages produces: system + session history + current user.
|
||||
// System message is built by BuildMessages, not stored in session.
|
||||
systemMsg := providers.Message{
|
||||
Role: "system",
|
||||
Content: strings.Repeat("system prompt content ", 100),
|
||||
}
|
||||
sessionHistory := []providers.Message{
|
||||
msgUser("first question"),
|
||||
msgAssistant("first answer"),
|
||||
msgUser("use tool X"),
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "I'll use tool X",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "tc1", Type: "function", Name: "tool_x",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "tool_x",
|
||||
Arguments: `{"query":"test","verbose":true}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: strings.Repeat("result data ", 200), ToolCallID: "tc1"},
|
||||
msgAssistant("Here are the results from tool X."),
|
||||
}
|
||||
currentUser := msgUser("follow up question")
|
||||
|
||||
// Assemble as BuildMessages would.
|
||||
messages := make([]providers.Message, 0, 1+len(sessionHistory)+1)
|
||||
messages = append(messages, systemMsg)
|
||||
messages = append(messages, sessionHistory...)
|
||||
messages = append(messages, currentUser)
|
||||
|
||||
tools := []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "tool_x",
|
||||
Description: "A useful tool",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// With a large context window, should be within budget.
|
||||
if isOverContextBudget(131072, messages, tools, 32768) {
|
||||
t.Error("realistic session should be within 131072 context window")
|
||||
}
|
||||
|
||||
// With a tiny context window, should exceed budget.
|
||||
if !isOverContextBudget(500, messages, tools, 32768) {
|
||||
t.Error("realistic session should exceed 500 context window")
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func setupWorkspace(t *testing.T, files map[string]string) string {
|
||||
// Codex (only reads last system message as instructions).
|
||||
func TestSingleSystemMessage(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nTest agent.",
|
||||
"AGENT.md": "# Agent\nTest agent.",
|
||||
})
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
@@ -202,10 +202,10 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "bootstrap file change",
|
||||
file: "IDENTITY.md",
|
||||
contentV1: "# Original Identity",
|
||||
contentV2: "# Updated Identity",
|
||||
checkField: "Updated Identity",
|
||||
file: "AGENT.md",
|
||||
contentV1: "# Original Agent",
|
||||
contentV2: "# Updated Agent",
|
||||
checkField: "Updated Agent",
|
||||
},
|
||||
{
|
||||
name: "memory file change",
|
||||
@@ -280,7 +280,7 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||
// even when source files haven't changed (useful for tests and reload commands).
|
||||
func TestExplicitInvalidateCache(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Test Identity",
|
||||
"AGENT.md": "# Test Agent",
|
||||
})
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
@@ -307,8 +307,8 @@ func TestExplicitInvalidateCache(t *testing.T) {
|
||||
// when no files change (regression test for issue #607).
|
||||
func TestCacheStability(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nContent",
|
||||
"SOUL.md": "# Soul\nContent",
|
||||
"AGENT.md": "# Agent\nContent",
|
||||
"SOUL.md": "# Soul\nContent",
|
||||
})
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
@@ -607,7 +607,7 @@ description: delete-me-v1
|
||||
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
||||
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
||||
"AGENT.md": "# Agent\nConcurrency test agent.",
|
||||
"SOUL.md": "# Soul\nBe helpful.",
|
||||
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
||||
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
||||
@@ -714,7 +714,7 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||
|
||||
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
|
||||
for _, name := range []string{"AGENT.md", "SOUL.md"} {
|
||||
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// AgentDefinitionSource identifies which agent bootstrap file produced the definition.
|
||||
type AgentDefinitionSource string
|
||||
|
||||
const (
|
||||
// AgentDefinitionSourceAgent indicates the new AGENT.md format.
|
||||
AgentDefinitionSourceAgent AgentDefinitionSource = "AGENT.md"
|
||||
// AgentDefinitionSourceAgents indicates the legacy AGENTS.md format.
|
||||
AgentDefinitionSourceAgents AgentDefinitionSource = "AGENTS.md"
|
||||
)
|
||||
|
||||
// AgentFrontmatter holds machine-readable AGENT.md configuration.
|
||||
//
|
||||
// Known fields are exposed directly for convenience. Fields keeps the full
|
||||
// parsed frontmatter so future refactors can read additional keys without
|
||||
// changing the loader contract again.
|
||||
type AgentFrontmatter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxTurns *int `json:"maxTurns,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
MCPServers []string `json:"mcpServers,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file.
|
||||
type AgentPromptDefinition struct {
|
||||
Path string `json:"path"`
|
||||
Raw string `json:"raw"`
|
||||
Body string `json:"body"`
|
||||
RawFrontmatter string `json:"raw_frontmatter,omitempty"`
|
||||
Frontmatter AgentFrontmatter `json:"frontmatter"`
|
||||
}
|
||||
|
||||
// SoulDefinition represents the resolved SOUL.md file linked to the agent.
|
||||
type SoulDefinition struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// UserDefinition represents the resolved USER.md file linked to the workspace.
|
||||
type UserDefinition struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AgentContextDefinition captures the workspace agent definition in a runtime-friendly shape.
|
||||
type AgentContextDefinition struct {
|
||||
Source AgentDefinitionSource `json:"source,omitempty"`
|
||||
Agent *AgentPromptDefinition `json:"agent,omitempty"`
|
||||
Soul *SoulDefinition `json:"soul,omitempty"`
|
||||
User *UserDefinition `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// LoadAgentDefinition parses the workspace agent bootstrap files.
|
||||
//
|
||||
// It prefers the new AGENT.md format and its paired SOUL.md file. When the
|
||||
// structured files are absent, it falls back to the legacy AGENTS.md layout so
|
||||
// the current runtime can transition incrementally.
|
||||
func (cb *ContextBuilder) LoadAgentDefinition() AgentContextDefinition {
|
||||
return loadAgentDefinition(cb.workspace)
|
||||
}
|
||||
|
||||
func loadAgentDefinition(workspace string) AgentContextDefinition {
|
||||
definition := AgentContextDefinition{}
|
||||
definition.User = loadUserDefinition(workspace)
|
||||
agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent))
|
||||
if content, err := os.ReadFile(agentPath); err == nil {
|
||||
prompt := parseAgentPromptDefinition(agentPath, string(content))
|
||||
definition.Source = AgentDefinitionSourceAgent
|
||||
definition.Agent = &prompt
|
||||
soulPath := filepath.Join(workspace, "SOUL.md")
|
||||
if content, err := os.ReadFile(soulPath); err == nil {
|
||||
definition.Soul = &SoulDefinition{
|
||||
Path: soulPath,
|
||||
Content: string(content),
|
||||
}
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(workspace, string(AgentDefinitionSourceAgents))
|
||||
if content, err := os.ReadFile(legacyPath); err == nil {
|
||||
definition.Source = AgentDefinitionSourceAgents
|
||||
definition.Agent = &AgentPromptDefinition{
|
||||
Path: legacyPath,
|
||||
Raw: string(content),
|
||||
Body: string(content),
|
||||
}
|
||||
}
|
||||
|
||||
defaultSoulPath := filepath.Join(workspace, "SOUL.md")
|
||||
if definition.Source != "" || fileExists(defaultSoulPath) {
|
||||
if content, err := os.ReadFile(defaultSoulPath); err == nil {
|
||||
definition.Soul = &SoulDefinition{
|
||||
Path: defaultSoulPath,
|
||||
Content: string(content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
func (definition AgentContextDefinition) trackedPaths(workspace string) []string {
|
||||
paths := []string{
|
||||
filepath.Join(workspace, string(AgentDefinitionSourceAgent)),
|
||||
filepath.Join(workspace, "SOUL.md"),
|
||||
filepath.Join(workspace, "USER.md"),
|
||||
}
|
||||
if definition.Source != AgentDefinitionSourceAgent {
|
||||
paths = append(paths,
|
||||
filepath.Join(workspace, string(AgentDefinitionSourceAgents)),
|
||||
filepath.Join(workspace, "IDENTITY.md"),
|
||||
)
|
||||
}
|
||||
return uniquePaths(paths)
|
||||
}
|
||||
|
||||
func loadUserDefinition(workspace string) *UserDefinition {
|
||||
userPath := filepath.Join(workspace, "USER.md")
|
||||
if content, err := os.ReadFile(userPath); err == nil {
|
||||
return &UserDefinition{
|
||||
Path: userPath,
|
||||
Content: string(content),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
|
||||
frontmatter, body := splitAgentFrontmatter(content)
|
||||
return AgentPromptDefinition{
|
||||
Path: path,
|
||||
Raw: content,
|
||||
Body: body,
|
||||
RawFrontmatter: frontmatter,
|
||||
Frontmatter: parseAgentFrontmatter(path, frontmatter),
|
||||
}
|
||||
}
|
||||
|
||||
func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
||||
frontmatter = strings.TrimSpace(frontmatter)
|
||||
if frontmatter == "" {
|
||||
return AgentFrontmatter{}
|
||||
}
|
||||
|
||||
rawFields := make(map[string]any)
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &rawFields); err != nil {
|
||||
logger.WarnCF("agent", "Failed to parse AGENT.md frontmatter", map[string]any{
|
||||
"path": path,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return AgentFrontmatter{}
|
||||
}
|
||||
|
||||
var typed struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tools []string `yaml:"tools"`
|
||||
Model string `yaml:"model"`
|
||||
MaxTurns *int `yaml:"maxTurns"`
|
||||
Skills []string `yaml:"skills"`
|
||||
MCPServers []string `yaml:"mcpServers"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &typed); err != nil {
|
||||
logger.WarnCF("agent", "Failed to decode AGENT.md frontmatter fields", map[string]any{
|
||||
"path": path,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return AgentFrontmatter{}
|
||||
}
|
||||
|
||||
return AgentFrontmatter{
|
||||
Name: strings.TrimSpace(typed.Name),
|
||||
Description: strings.TrimSpace(typed.Description),
|
||||
Tools: append([]string(nil), typed.Tools...),
|
||||
Model: strings.TrimSpace(typed.Model),
|
||||
MaxTurns: typed.MaxTurns,
|
||||
Skills: append([]string(nil), typed.Skills...),
|
||||
MCPServers: append([]string(nil), typed.MCPServers...),
|
||||
Fields: rawFields,
|
||||
}
|
||||
}
|
||||
|
||||
func splitAgentFrontmatter(content string) (frontmatter, body string) {
|
||||
normalized := string(parser.NormalizeNewlines([]byte(content)))
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) == 0 || lines[0] != "---" {
|
||||
return "", content
|
||||
}
|
||||
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if lines[i] == "---" {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end == -1 {
|
||||
return "", content
|
||||
}
|
||||
|
||||
frontmatter = strings.Join(lines[1:end], "\n")
|
||||
body = strings.Join(lines[end+1:], "\n")
|
||||
body = strings.TrimLeft(body, "\n")
|
||||
return frontmatter, body
|
||||
}
|
||||
|
||||
func relativeWorkspacePath(workspace, path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
relativePath, err := filepath.Rel(workspace, path)
|
||||
if err == nil && relativePath != "." && !strings.HasPrefix(relativePath, "..") {
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func uniquePaths(paths []string) []string {
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
if slices.Contains(result, cleaned) {
|
||||
continue
|
||||
}
|
||||
result = append(result, cleaned)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadAgentDefinitionParsesFrontmatterAndSoul(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: pico
|
||||
description: Structured agent
|
||||
model: claude-3-7-sonnet
|
||||
tools:
|
||||
- shell
|
||||
- search
|
||||
maxTurns: 8
|
||||
skills:
|
||||
- review
|
||||
- search-docs
|
||||
mcpServers:
|
||||
- github
|
||||
metadata:
|
||||
mode: strict
|
||||
---
|
||||
# Agent
|
||||
|
||||
Act directly and use tools first.
|
||||
`,
|
||||
"SOUL.md": "# Soul\nStay precise.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
definition := cb.LoadAgentDefinition()
|
||||
|
||||
if definition.Source != AgentDefinitionSourceAgent {
|
||||
t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgent, definition.Source)
|
||||
}
|
||||
if definition.Agent == nil {
|
||||
t.Fatal("expected AGENT.md definition to be loaded")
|
||||
}
|
||||
if definition.Agent.Body == "" || !strings.Contains(definition.Agent.Body, "Act directly") {
|
||||
t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body)
|
||||
}
|
||||
if definition.Agent.Frontmatter.Name != "pico" {
|
||||
t.Fatalf("expected name to be parsed, got %q", definition.Agent.Frontmatter.Name)
|
||||
}
|
||||
if definition.Agent.Frontmatter.Model != "claude-3-7-sonnet" {
|
||||
t.Fatalf("expected model to be parsed, got %q", definition.Agent.Frontmatter.Model)
|
||||
}
|
||||
if len(definition.Agent.Frontmatter.Tools) != 2 {
|
||||
t.Fatalf("expected tools to be parsed, got %v", definition.Agent.Frontmatter.Tools)
|
||||
}
|
||||
if definition.Agent.Frontmatter.MaxTurns == nil || *definition.Agent.Frontmatter.MaxTurns != 8 {
|
||||
t.Fatalf("expected maxTurns to be parsed, got %v", definition.Agent.Frontmatter.MaxTurns)
|
||||
}
|
||||
if len(definition.Agent.Frontmatter.Skills) != 2 {
|
||||
t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills)
|
||||
}
|
||||
if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" {
|
||||
t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers)
|
||||
}
|
||||
if definition.Agent.Frontmatter.Fields["metadata"] == nil {
|
||||
t.Fatal("expected arbitrary frontmatter fields to remain available")
|
||||
}
|
||||
|
||||
if definition.Soul == nil {
|
||||
t.Fatal("expected SOUL.md to be loaded")
|
||||
}
|
||||
if !strings.Contains(definition.Soul.Content, "Stay precise") {
|
||||
t.Fatalf("expected soul content to be loaded, got %q", definition.Soul.Content)
|
||||
}
|
||||
if definition.Soul.Path != filepath.Join(tmpDir, "SOUL.md") {
|
||||
t.Fatalf("expected default SOUL.md path, got %q", definition.Soul.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENTS.md": "# Legacy Agent\nKeep compatibility.",
|
||||
"SOUL.md": "# Soul\nLegacy soul.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
definition := cb.LoadAgentDefinition()
|
||||
|
||||
if definition.Source != AgentDefinitionSourceAgents {
|
||||
t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgents, definition.Source)
|
||||
}
|
||||
if definition.Agent == nil {
|
||||
t.Fatal("expected AGENTS.md to be loaded")
|
||||
}
|
||||
if definition.Agent.RawFrontmatter != "" {
|
||||
t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter)
|
||||
}
|
||||
if !strings.Contains(definition.Agent.Body, "Keep compatibility") {
|
||||
t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body)
|
||||
}
|
||||
if definition.Soul == nil || !strings.Contains(definition.Soul.Content, "Legacy soul") {
|
||||
t.Fatal("expected default SOUL.md to be loaded for legacy format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentDefinitionLoadsWorkspaceUserMarkdown(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": "# Agent\nStructured agent.",
|
||||
"USER.md": "# User\nWorkspace preferences.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
definition := cb.LoadAgentDefinition()
|
||||
|
||||
if definition.User == nil {
|
||||
t.Fatal("expected USER.md to be loaded")
|
||||
}
|
||||
if definition.User.Path != filepath.Join(tmpDir, "USER.md") {
|
||||
t.Fatalf("expected workspace USER.md path, got %q", definition.User.Path)
|
||||
}
|
||||
if !strings.Contains(definition.User.Content, "Workspace preferences") {
|
||||
t.Fatalf("expected workspace USER.md content, got %q", definition.User.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentDefinitionInvalidFrontmatterFallsBackToEmptyStructuredFields(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: pico
|
||||
tools:
|
||||
- shell
|
||||
broken
|
||||
---
|
||||
# Agent
|
||||
|
||||
Keep going.
|
||||
`,
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
definition := cb.LoadAgentDefinition()
|
||||
|
||||
if definition.Agent == nil {
|
||||
t.Fatal("expected AGENT.md definition to be loaded")
|
||||
}
|
||||
if !strings.Contains(definition.Agent.Body, "Keep going.") {
|
||||
t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body)
|
||||
}
|
||||
if definition.Agent.Frontmatter.Name != "" ||
|
||||
definition.Agent.Frontmatter.Description != "" ||
|
||||
definition.Agent.Frontmatter.Model != "" ||
|
||||
definition.Agent.Frontmatter.MaxTurns != nil ||
|
||||
len(definition.Agent.Frontmatter.Tools) != 0 ||
|
||||
len(definition.Agent.Frontmatter.Skills) != 0 ||
|
||||
len(definition.Agent.Frontmatter.MCPServers) != 0 ||
|
||||
len(definition.Agent.Frontmatter.Fields) != 0 {
|
||||
t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapFilesUsesAgentBodyNotFrontmatter(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": `---
|
||||
name: pico
|
||||
model: codex-mini
|
||||
---
|
||||
# Agent
|
||||
|
||||
Follow the body prompt.
|
||||
`,
|
||||
"SOUL.md": "# Soul\nSpeak plainly.",
|
||||
"IDENTITY.md": "# Identity\nWorkspace identity.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
bootstrap := cb.LoadBootstrapFiles()
|
||||
|
||||
if !strings.Contains(bootstrap, "Follow the body prompt") {
|
||||
t.Fatalf("expected AGENT.md body in bootstrap, got %q", bootstrap)
|
||||
}
|
||||
if !strings.Contains(bootstrap, "Speak plainly") {
|
||||
t.Fatalf("expected resolved soul content in bootstrap, got %q", bootstrap)
|
||||
}
|
||||
if strings.Contains(bootstrap, "name: pico") {
|
||||
t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap)
|
||||
}
|
||||
if strings.Contains(bootstrap, "model: codex-mini") {
|
||||
t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap)
|
||||
}
|
||||
if !strings.Contains(bootstrap, "SOUL.md") {
|
||||
t.Fatalf("expected bootstrap to label SOUL.md, got %q", bootstrap)
|
||||
}
|
||||
if strings.Contains(bootstrap, "Workspace identity") {
|
||||
t.Fatalf("structured bootstrap should ignore IDENTITY.md, got %q", bootstrap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapFilesIncludesWorkspaceUserMarkdown(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": "# Agent\nFollow the new structure.",
|
||||
"SOUL.md": "# Soul\nSpeak plainly.",
|
||||
"USER.md": "# User\nShared profile.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
bootstrap := cb.LoadBootstrapFiles()
|
||||
|
||||
if !strings.Contains(bootstrap, "Shared profile") {
|
||||
t.Fatalf("expected workspace USER.md in bootstrap, got %q", bootstrap)
|
||||
}
|
||||
if !strings.Contains(bootstrap, "## USER.md") {
|
||||
t.Fatalf("expected USER.md heading in bootstrap, got %q", bootstrap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": "# Agent\nFollow the new structure.",
|
||||
"SOUL.md": "# Soul\nVersion one.",
|
||||
"IDENTITY.md": "# Identity\nLegacy identity.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
promptV1 := cb.BuildSystemPromptWithCache()
|
||||
if strings.Contains(promptV1, "Legacy identity") {
|
||||
t.Fatalf("structured prompt should not include IDENTITY.md, got %q", promptV1)
|
||||
}
|
||||
|
||||
identityPath := filepath.Join(tmpDir, "IDENTITY.md")
|
||||
if err := os.WriteFile(identityPath, []byte("# Identity\nVersion two."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(identityPath, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
if changed {
|
||||
t.Fatal("IDENTITY.md should not invalidate cache for structured agent definitions")
|
||||
}
|
||||
|
||||
promptV2 := cb.BuildSystemPromptWithCache()
|
||||
if promptV1 != promptV2 {
|
||||
t.Fatal("structured prompt should remain stable after IDENTITY.md changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredAgentUserChangesInvalidateCache(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"AGENT.md": "# Agent\nFollow the new structure.",
|
||||
"SOUL.md": "# Soul\nVersion one.",
|
||||
"USER.md": "# User\nInitial workspace preferences.",
|
||||
})
|
||||
defer cleanupWorkspace(t, tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
promptV1 := cb.BuildSystemPromptWithCache()
|
||||
if !strings.Contains(promptV1, "Initial workspace preferences") {
|
||||
t.Fatalf("expected workspace USER.md in prompt, got %q", promptV1)
|
||||
}
|
||||
|
||||
userPath := filepath.Join(tmpDir, "USER.md")
|
||||
if err := os.WriteFile(userPath, []byte("# User\nUpdated workspace preferences."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(userPath, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
if !changed {
|
||||
t.Fatal("workspace USER.md changes should invalidate cache")
|
||||
}
|
||||
|
||||
promptV2 := cb.BuildSystemPromptWithCache()
|
||||
if !strings.Contains(promptV2, "Updated workspace preferences") {
|
||||
t.Fatalf("expected updated workspace USER.md in prompt, got %q", promptV2)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupWorkspace(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
t.Fatalf("failed to clean up workspace %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultEventSubscriberBuffer = 16
|
||||
|
||||
// EventSubscription identifies a subscriber channel returned by EventBus.Subscribe.
|
||||
type EventSubscription struct {
|
||||
ID uint64
|
||||
C <-chan Event
|
||||
}
|
||||
|
||||
type eventSubscriber struct {
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
// EventBus is a lightweight multi-subscriber broadcaster for agent-loop events.
|
||||
type EventBus struct {
|
||||
mu sync.RWMutex
|
||||
subs map[uint64]eventSubscriber
|
||||
nextID uint64
|
||||
closed bool
|
||||
dropped [eventKindCount]atomic.Int64
|
||||
}
|
||||
|
||||
// NewEventBus creates a new in-process event broadcaster.
|
||||
func NewEventBus() *EventBus {
|
||||
return &EventBus{
|
||||
subs: make(map[uint64]eventSubscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber with the requested channel buffer size.
|
||||
// A non-positive buffer uses the default size.
|
||||
func (b *EventBus) Subscribe(buffer int) EventSubscription {
|
||||
if buffer <= 0 {
|
||||
buffer = defaultEventSubscriberBuffer
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.closed {
|
||||
ch := make(chan Event)
|
||||
close(ch)
|
||||
return EventSubscription{C: ch}
|
||||
}
|
||||
|
||||
b.nextID++
|
||||
id := b.nextID
|
||||
ch := make(chan Event, buffer)
|
||||
b.subs[id] = eventSubscriber{ch: ch}
|
||||
return EventSubscription{ID: id, C: ch}
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel.
|
||||
func (b *EventBus) Unsubscribe(id uint64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
sub, ok := b.subs[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(b.subs, id)
|
||||
close(sub.ch)
|
||||
}
|
||||
|
||||
// Emit broadcasts an event to all current subscribers without blocking.
|
||||
// When a subscriber channel is full, the event is dropped for that subscriber.
|
||||
func (b *EventBus) Emit(evt Event) {
|
||||
if evt.Time.IsZero() {
|
||||
evt.Time = time.Now()
|
||||
}
|
||||
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.closed {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range b.subs {
|
||||
select {
|
||||
case sub.ch <- evt:
|
||||
default:
|
||||
if evt.Kind < eventKindCount {
|
||||
b.dropped[evt.Kind].Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped returns the number of dropped events for a given kind.
|
||||
func (b *EventBus) Dropped(kind EventKind) int64 {
|
||||
if kind >= eventKindCount {
|
||||
return 0
|
||||
}
|
||||
return b.dropped[kind].Load()
|
||||
}
|
||||
|
||||
// Close closes all subscriber channels and stops future broadcasts.
|
||||
func (b *EventBus) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.closed {
|
||||
return
|
||||
}
|
||||
|
||||
b.closed = true
|
||||
for id, sub := range b.subs {
|
||||
close(sub.ch)
|
||||
delete(b.subs, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func TestEventBus_SubscribeEmitUnsubscribeClose(t *testing.T) {
|
||||
eventBus := NewEventBus()
|
||||
sub := eventBus.Subscribe(1)
|
||||
|
||||
eventBus.Emit(Event{
|
||||
Kind: EventKindTurnStart,
|
||||
Meta: EventMeta{TurnID: "turn-1"},
|
||||
})
|
||||
|
||||
select {
|
||||
case evt := <-sub.C:
|
||||
if evt.Kind != EventKindTurnStart {
|
||||
t.Fatalf("expected %v, got %v", EventKindTurnStart, evt.Kind)
|
||||
}
|
||||
if evt.Meta.TurnID != "turn-1" {
|
||||
t.Fatalf("expected turn id turn-1, got %q", evt.Meta.TurnID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
|
||||
eventBus.Unsubscribe(sub.ID)
|
||||
if _, ok := <-sub.C; ok {
|
||||
t.Fatal("expected subscriber channel to be closed after unsubscribe")
|
||||
}
|
||||
|
||||
eventBus.Close()
|
||||
closedSub := eventBus.Subscribe(1)
|
||||
if _, ok := <-closedSub.C; ok {
|
||||
t.Fatal("expected closed bus to return a closed subscriber channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventBus_DropsWhenSubscriberIsFull(t *testing.T) {
|
||||
eventBus := NewEventBus()
|
||||
sub := eventBus.Subscribe(1)
|
||||
defer eventBus.Unsubscribe(sub.ID)
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < 1000; i++ {
|
||||
eventBus.Emit(Event{Kind: EventKindLLMRequest})
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("Emit took too long with a blocked subscriber: %s", elapsed)
|
||||
}
|
||||
|
||||
if got := eventBus.Dropped(EventKindLLMRequest); got != 999 {
|
||||
t.Fatalf("expected 999 dropped events, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedToolProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *scriptedToolProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
toolDefs []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Name: "mock_custom",
|
||||
Arguments: map[string]any{"task": "ping"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "done",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *scriptedToolProvider) GetDefaultModel() string {
|
||||
return "scripted-tool-model"
|
||||
}
|
||||
|
||||
func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-eventbus-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &scriptedToolProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(&mockCustomTool{})
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if response != "done" {
|
||||
t.Fatalf("expected final response 'done', got %q", response)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
if len(events) != 8 {
|
||||
t.Fatalf("expected 8 events, got %d", len(events))
|
||||
}
|
||||
|
||||
kinds := make([]EventKind, 0, len(events))
|
||||
for _, evt := range events {
|
||||
kinds = append(kinds, evt.Kind)
|
||||
}
|
||||
|
||||
expectedKinds := []EventKind{
|
||||
EventKindTurnStart,
|
||||
EventKindLLMRequest,
|
||||
EventKindLLMResponse,
|
||||
EventKindToolExecStart,
|
||||
EventKindToolExecEnd,
|
||||
EventKindLLMRequest,
|
||||
EventKindLLMResponse,
|
||||
EventKindTurnEnd,
|
||||
}
|
||||
if !slices.Equal(kinds, expectedKinds) {
|
||||
t.Fatalf("unexpected event sequence: got %v want %v", kinds, expectedKinds)
|
||||
}
|
||||
|
||||
turnID := events[0].Meta.TurnID
|
||||
for i, evt := range events {
|
||||
if evt.Meta.TurnID != turnID {
|
||||
t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Meta.TurnID, turnID)
|
||||
}
|
||||
if evt.Meta.SessionKey != "session-1" {
|
||||
t.Fatalf("event %d has session key %q, want session-1", i, evt.Meta.SessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
startPayload, ok := events[0].Payload.(TurnStartPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnStartPayload, got %T", events[0].Payload)
|
||||
}
|
||||
if startPayload.UserMessage != "run tool" {
|
||||
t.Fatalf("expected user message 'run tool', got %q", startPayload.UserMessage)
|
||||
}
|
||||
|
||||
toolStartPayload, ok := events[3].Payload.(ToolExecStartPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecStartPayload, got %T", events[3].Payload)
|
||||
}
|
||||
if toolStartPayload.Tool != "mock_custom" {
|
||||
t.Fatalf("expected tool name mock_custom, got %q", toolStartPayload.Tool)
|
||||
}
|
||||
|
||||
toolEndPayload, ok := events[4].Payload.(ToolExecEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecEndPayload, got %T", events[4].Payload)
|
||||
}
|
||||
if toolEndPayload.Tool != "mock_custom" {
|
||||
t.Fatalf("expected tool end payload for mock_custom, got %q", toolEndPayload.Tool)
|
||||
}
|
||||
if toolEndPayload.IsError {
|
||||
t.Fatal("expected mock_custom tool to succeed")
|
||||
}
|
||||
|
||||
turnEndPayload, ok := events[len(events)-1].Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnEndPayload, got %T", events[len(events)-1].Payload)
|
||||
}
|
||||
if turnEndPayload.Status != TurnEndStatusCompleted {
|
||||
t.Fatalf("expected completed turn, got %q", turnEndPayload.Status)
|
||||
}
|
||||
if turnEndPayload.Iterations != 2 {
|
||||
t.Fatalf("expected 2 iterations, got %d", turnEndPayload.Iterations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-eventbus-steering-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tool1ExecCh := make(chan struct{})
|
||||
tool1 := &slowTool{name: "tool_one", duration: 50 * time.Millisecond, execCh: tool1ExecCh}
|
||||
tool2 := &slowTool{name: "tool_two", duration: 50 * time.Millisecond}
|
||||
|
||||
provider := &toolCallProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Name: "tool_one",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "tool_one",
|
||||
Arguments: "{}",
|
||||
},
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
{
|
||||
ID: "call_2",
|
||||
Type: "function",
|
||||
Name: "tool_two",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "tool_two",
|
||||
Arguments: "{}",
|
||||
},
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
},
|
||||
finalResp: "steered response",
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(tool1)
|
||||
al.RegisterTool(tool2)
|
||||
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resultCh := make(chan string, 1)
|
||||
go func() {
|
||||
resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1")
|
||||
resultCh <- resp
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-tool1ExecCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for tool_one to start")
|
||||
}
|
||||
|
||||
if err := al.Steer(providers.Message{Role: "user", Content: "change course"}); err != nil {
|
||||
t.Fatalf("Steer failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-resultCh:
|
||||
if resp != "steered response" {
|
||||
t.Fatalf("expected steered response, got %q", resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for steered response")
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
steeringEvt, ok := findEvent(events, EventKindSteeringInjected)
|
||||
if !ok {
|
||||
t.Fatal("expected steering injected event")
|
||||
}
|
||||
steeringPayload, ok := steeringEvt.Payload.(SteeringInjectedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected SteeringInjectedPayload, got %T", steeringEvt.Payload)
|
||||
}
|
||||
if steeringPayload.Count != 1 {
|
||||
t.Fatalf("expected 1 steering message, got %d", steeringPayload.Count)
|
||||
}
|
||||
|
||||
skippedEvt, ok := findEvent(events, EventKindToolExecSkipped)
|
||||
if !ok {
|
||||
t.Fatal("expected skipped tool event")
|
||||
}
|
||||
skippedPayload, ok := skippedEvt.Payload.(ToolExecSkippedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload)
|
||||
}
|
||||
if skippedPayload.Tool != "tool_two" {
|
||||
t.Fatalf("expected skipped tool_two, got %q", skippedPayload.Tool)
|
||||
}
|
||||
|
||||
interruptEvt, ok := findEvent(events, EventKindInterruptReceived)
|
||||
if !ok {
|
||||
t.Fatal("expected interrupt received event")
|
||||
}
|
||||
interruptPayload, ok := interruptEvt.Payload.(InterruptReceivedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected InterruptReceivedPayload, got %T", interruptEvt.Payload)
|
||||
}
|
||||
if interruptPayload.Role != "user" {
|
||||
t.Fatalf("expected interrupt role user, got %q", interruptPayload.Role)
|
||||
}
|
||||
if interruptPayload.Kind != InterruptKindSteering {
|
||||
t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind)
|
||||
}
|
||||
if interruptPayload.ContentLen != len("change course") {
|
||||
t.Fatalf("expected interrupt content len %d, got %d", len("change course"), interruptPayload.ContentLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-eventbus-compress-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
contextErr := stringError("InvalidParameter: Total tokens of image and text exceed max message tokens")
|
||||
provider := &failFirstMockProvider{
|
||||
failures: 1,
|
||||
failError: contextErr,
|
||||
successResp: "Recovered from context error",
|
||||
}
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
defaultAgent.Sessions.SetHistory("session-1", []providers.Message{
|
||||
{Role: "user", Content: "Old message 1"},
|
||||
{Role: "assistant", Content: "Old response 1"},
|
||||
{Role: "user", Content: "Old message 2"},
|
||||
{Role: "assistant", Content: "Old response 2"},
|
||||
{Role: "user", Content: "Trigger message"},
|
||||
})
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "Trigger message",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "Recovered from context error" {
|
||||
t.Fatalf("expected retry success, got %q", resp)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
retryEvt, ok := findEvent(events, EventKindLLMRetry)
|
||||
if !ok {
|
||||
t.Fatal("expected llm retry event")
|
||||
}
|
||||
retryPayload, ok := retryEvt.Payload.(LLMRetryPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected LLMRetryPayload, got %T", retryEvt.Payload)
|
||||
}
|
||||
if retryPayload.Reason != "context_limit" {
|
||||
t.Fatalf("expected context_limit retry reason, got %q", retryPayload.Reason)
|
||||
}
|
||||
if retryPayload.Attempt != 1 {
|
||||
t.Fatalf("expected retry attempt 1, got %d", retryPayload.Attempt)
|
||||
}
|
||||
|
||||
compressEvt, ok := findEvent(events, EventKindContextCompress)
|
||||
if !ok {
|
||||
t.Fatal("expected context compress event")
|
||||
}
|
||||
payload, ok := compressEvt.Payload.(ContextCompressPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload)
|
||||
}
|
||||
if payload.Reason != ContextCompressReasonRetry {
|
||||
t.Fatalf("expected retry compress reason, got %q", payload.Reason)
|
||||
}
|
||||
if payload.DroppedMessages == 0 {
|
||||
t.Fatal("expected dropped messages to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-eventbus-summary-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
ContextWindow: 8000,
|
||||
SummarizeMessageThreshold: 2,
|
||||
SummarizeTokenPercent: 75,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary text"})
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
defaultAgent.Sessions.SetHistory("session-1", []providers.Message{
|
||||
{Role: "user", Content: "Question one"},
|
||||
{Role: "assistant", Content: "Answer one"},
|
||||
{Role: "user", Content: "Question two"},
|
||||
{Role: "assistant", Content: "Answer two"},
|
||||
{Role: "user", Content: "Question three"},
|
||||
{Role: "assistant", Content: "Answer three"},
|
||||
})
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1")
|
||||
al.summarizeSession(defaultAgent, "session-1", turnScope)
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
summaryEvt, ok := findEvent(events, EventKindSessionSummarize)
|
||||
if !ok {
|
||||
t.Fatal("expected session summarize event")
|
||||
}
|
||||
payload, ok := summaryEvt.Payload.(SessionSummarizePayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected SessionSummarizePayload, got %T", summaryEvt.Payload)
|
||||
}
|
||||
if payload.SummaryLen == 0 {
|
||||
t.Fatal("expected non-empty summary length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-eventbus-followup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &toolCallProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_async_1",
|
||||
Type: "function",
|
||||
Name: "async_followup",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "async_followup",
|
||||
Arguments: "{}",
|
||||
},
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
},
|
||||
finalResp: "async launched",
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
doneCh := make(chan struct{})
|
||||
al.RegisterTool(&asyncFollowUpTool{
|
||||
name: "async_followup",
|
||||
followUpText: "background result",
|
||||
completionSig: doneCh,
|
||||
})
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run async tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "async launched" {
|
||||
t.Fatalf("expected final response 'async launched', got %q", resp)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for async tool completion")
|
||||
}
|
||||
|
||||
followUpEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool {
|
||||
return evt.Kind == EventKindFollowUpQueued
|
||||
})
|
||||
payload, ok := followUpEvt.Payload.(FollowUpQueuedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected FollowUpQueuedPayload, got %T", followUpEvt.Payload)
|
||||
}
|
||||
if payload.SourceTool != "async_followup" {
|
||||
t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool)
|
||||
}
|
||||
if payload.Channel != "cli" {
|
||||
t.Fatalf("expected channel cli, got %q", payload.Channel)
|
||||
}
|
||||
if payload.ChatID != "direct" {
|
||||
t.Fatalf("expected chat id direct, got %q", payload.ChatID)
|
||||
}
|
||||
if payload.ContentLen != len("background result") {
|
||||
t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen)
|
||||
}
|
||||
if followUpEvt.Meta.SessionKey != "session-1" {
|
||||
t.Fatalf("expected session key session-1, got %q", followUpEvt.Meta.SessionKey)
|
||||
}
|
||||
if followUpEvt.Meta.TurnID == "" {
|
||||
t.Fatal("expected follow-up event to include turn id")
|
||||
}
|
||||
}
|
||||
|
||||
func collectEventStream(ch <-chan Event) []Event {
|
||||
var events []Event
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-ch:
|
||||
if !ok {
|
||||
return events
|
||||
}
|
||||
events = append(events, evt)
|
||||
default:
|
||||
return events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForEvent(t *testing.T, ch <-chan Event, timeout time.Duration, match func(Event) bool) Event {
|
||||
t.Helper()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("event stream closed before expected event arrived")
|
||||
}
|
||||
if match(evt) {
|
||||
return evt
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatal("timed out waiting for expected event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findEvent(events []Event, kind EventKind) (Event, bool) {
|
||||
for _, evt := range events {
|
||||
if evt.Kind == kind {
|
||||
return evt, true
|
||||
}
|
||||
}
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
type stringError string
|
||||
|
||||
func (e stringError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
type asyncFollowUpTool struct {
|
||||
name string
|
||||
followUpText string
|
||||
completionSig chan struct{}
|
||||
}
|
||||
|
||||
func (t *asyncFollowUpTool) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
func (t *asyncFollowUpTool) Description() string {
|
||||
return "async follow-up tool for testing"
|
||||
}
|
||||
|
||||
func (t *asyncFollowUpTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *asyncFollowUpTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.AsyncResult("async follow-up scheduled")
|
||||
}
|
||||
|
||||
func (t *asyncFollowUpTool) ExecuteAsync(
|
||||
ctx context.Context,
|
||||
args map[string]any,
|
||||
cb tools.AsyncCallback,
|
||||
) *tools.ToolResult {
|
||||
go func() {
|
||||
cb(ctx, &tools.ToolResult{ForLLM: t.followUpText})
|
||||
if t.completionSig != nil {
|
||||
close(t.completionSig)
|
||||
}
|
||||
}()
|
||||
return tools.AsyncResult("async follow-up scheduled")
|
||||
}
|
||||
|
||||
var (
|
||||
_ tools.Tool = (*mockCustomTool)(nil)
|
||||
_ tools.AsyncExecutor = (*asyncFollowUpTool)(nil)
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventKind identifies a structured agent-loop event.
|
||||
type EventKind uint8
|
||||
|
||||
const (
|
||||
// EventKindTurnStart is emitted when a turn begins processing.
|
||||
EventKindTurnStart EventKind = iota
|
||||
// EventKindTurnEnd is emitted when a turn finishes, successfully or with an error.
|
||||
EventKindTurnEnd
|
||||
// EventKindLLMRequest is emitted before a provider chat request is made.
|
||||
EventKindLLMRequest
|
||||
// EventKindLLMDelta is emitted when a streaming provider yields a partial delta.
|
||||
EventKindLLMDelta
|
||||
// EventKindLLMResponse is emitted after a provider chat response is received.
|
||||
EventKindLLMResponse
|
||||
// EventKindLLMRetry is emitted when an LLM request is retried.
|
||||
EventKindLLMRetry
|
||||
// EventKindContextCompress is emitted when session history is forcibly compressed.
|
||||
EventKindContextCompress
|
||||
// EventKindSessionSummarize is emitted when asynchronous summarization completes.
|
||||
EventKindSessionSummarize
|
||||
// EventKindToolExecStart is emitted immediately before a tool executes.
|
||||
EventKindToolExecStart
|
||||
// EventKindToolExecEnd is emitted immediately after a tool finishes executing.
|
||||
EventKindToolExecEnd
|
||||
// EventKindToolExecSkipped is emitted when a queued tool call is skipped.
|
||||
EventKindToolExecSkipped
|
||||
// EventKindSteeringInjected is emitted when queued steering is injected into context.
|
||||
EventKindSteeringInjected
|
||||
// EventKindFollowUpQueued is emitted when an async tool queues a follow-up system message.
|
||||
EventKindFollowUpQueued
|
||||
// EventKindInterruptReceived is emitted when a soft interrupt message is accepted.
|
||||
EventKindInterruptReceived
|
||||
// EventKindSubTurnSpawn is emitted when a sub-turn is spawned.
|
||||
EventKindSubTurnSpawn
|
||||
// EventKindSubTurnEnd is emitted when a sub-turn finishes.
|
||||
EventKindSubTurnEnd
|
||||
// EventKindSubTurnResultDelivered is emitted when a sub-turn result is delivered.
|
||||
EventKindSubTurnResultDelivered
|
||||
// EventKindSubTurnOrphan is emitted when a sub-turn result cannot be delivered.
|
||||
EventKindSubTurnOrphan
|
||||
// EventKindError is emitted when a turn encounters an execution error.
|
||||
EventKindError
|
||||
|
||||
eventKindCount
|
||||
)
|
||||
|
||||
var eventKindNames = [...]string{
|
||||
"turn_start",
|
||||
"turn_end",
|
||||
"llm_request",
|
||||
"llm_delta",
|
||||
"llm_response",
|
||||
"llm_retry",
|
||||
"context_compress",
|
||||
"session_summarize",
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped",
|
||||
"steering_injected",
|
||||
"follow_up_queued",
|
||||
"interrupt_received",
|
||||
"subturn_spawn",
|
||||
"subturn_end",
|
||||
"subturn_result_delivered",
|
||||
"subturn_orphan",
|
||||
"error",
|
||||
}
|
||||
|
||||
// String returns the stable string form of an EventKind.
|
||||
func (k EventKind) String() string {
|
||||
if k >= eventKindCount {
|
||||
return fmt.Sprintf("event_kind(%d)", k)
|
||||
}
|
||||
return eventKindNames[k]
|
||||
}
|
||||
|
||||
// Event is the structured envelope broadcast by the agent EventBus.
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Time time.Time
|
||||
Meta EventMeta
|
||||
Payload any
|
||||
}
|
||||
|
||||
// EventMeta contains correlation fields shared by all agent-loop events.
|
||||
type EventMeta struct {
|
||||
AgentID string
|
||||
TurnID string
|
||||
ParentTurnID string
|
||||
SessionKey string
|
||||
Iteration int
|
||||
TracePath string
|
||||
Source string
|
||||
}
|
||||
|
||||
// TurnEndStatus describes the terminal state of a turn.
|
||||
type TurnEndStatus string
|
||||
|
||||
const (
|
||||
// TurnEndStatusCompleted indicates the turn finished normally.
|
||||
TurnEndStatusCompleted TurnEndStatus = "completed"
|
||||
// TurnEndStatusError indicates the turn ended because of an error.
|
||||
TurnEndStatusError TurnEndStatus = "error"
|
||||
// TurnEndStatusAborted indicates the turn was hard-aborted and rolled back.
|
||||
TurnEndStatusAborted TurnEndStatus = "aborted"
|
||||
)
|
||||
|
||||
// TurnStartPayload describes the start of a turn.
|
||||
type TurnStartPayload struct {
|
||||
Channel string
|
||||
ChatID string
|
||||
UserMessage string
|
||||
MediaCount int
|
||||
}
|
||||
|
||||
// TurnEndPayload describes the completion of a turn.
|
||||
type TurnEndPayload struct {
|
||||
Status TurnEndStatus
|
||||
Iterations int
|
||||
Duration time.Duration
|
||||
FinalContentLen int
|
||||
}
|
||||
|
||||
// LLMRequestPayload describes an outbound LLM request.
|
||||
type LLMRequestPayload struct {
|
||||
Model string
|
||||
MessagesCount int
|
||||
ToolsCount int
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// LLMResponsePayload describes an inbound LLM response.
|
||||
type LLMResponsePayload struct {
|
||||
ContentLen int
|
||||
ToolCalls int
|
||||
HasReasoning bool
|
||||
}
|
||||
|
||||
// LLMDeltaPayload describes a streamed LLM delta.
|
||||
type LLMDeltaPayload struct {
|
||||
ContentDeltaLen int
|
||||
ReasoningDeltaLen int
|
||||
}
|
||||
|
||||
// LLMRetryPayload describes a retry of an LLM request.
|
||||
type LLMRetryPayload struct {
|
||||
Attempt int
|
||||
MaxRetries int
|
||||
Reason string
|
||||
Error string
|
||||
Backoff time.Duration
|
||||
}
|
||||
|
||||
// ContextCompressReason identifies why emergency compression ran.
|
||||
type ContextCompressReason string
|
||||
|
||||
const (
|
||||
// ContextCompressReasonProactive indicates compression before the first LLM call.
|
||||
ContextCompressReasonProactive ContextCompressReason = "proactive_budget"
|
||||
// ContextCompressReasonRetry indicates compression during context-error retry handling.
|
||||
ContextCompressReasonRetry ContextCompressReason = "llm_retry"
|
||||
)
|
||||
|
||||
// ContextCompressPayload describes a forced history compression.
|
||||
type ContextCompressPayload struct {
|
||||
Reason ContextCompressReason
|
||||
DroppedMessages int
|
||||
RemainingMessages int
|
||||
}
|
||||
|
||||
// SessionSummarizePayload describes a completed async session summarization.
|
||||
type SessionSummarizePayload struct {
|
||||
SummarizedMessages int
|
||||
KeptMessages int
|
||||
SummaryLen int
|
||||
OmittedOversized bool
|
||||
}
|
||||
|
||||
// ToolExecStartPayload describes a tool execution request.
|
||||
type ToolExecStartPayload struct {
|
||||
Tool string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
// ToolExecEndPayload describes the outcome of a tool execution.
|
||||
type ToolExecEndPayload struct {
|
||||
Tool string
|
||||
Duration time.Duration
|
||||
ForLLMLen int
|
||||
ForUserLen int
|
||||
IsError bool
|
||||
Async bool
|
||||
}
|
||||
|
||||
// ToolExecSkippedPayload describes a skipped tool call.
|
||||
type ToolExecSkippedPayload struct {
|
||||
Tool string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SteeringInjectedPayload describes steering messages appended before the next LLM call.
|
||||
type SteeringInjectedPayload struct {
|
||||
Count int
|
||||
TotalContentLen int
|
||||
}
|
||||
|
||||
// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus.
|
||||
type FollowUpQueuedPayload struct {
|
||||
SourceTool string
|
||||
Channel string
|
||||
ChatID string
|
||||
ContentLen int
|
||||
}
|
||||
|
||||
type InterruptKind string
|
||||
|
||||
const (
|
||||
InterruptKindSteering InterruptKind = "steering"
|
||||
InterruptKindGraceful InterruptKind = "graceful"
|
||||
InterruptKindHard InterruptKind = "hard_abort"
|
||||
)
|
||||
|
||||
// InterruptReceivedPayload describes accepted turn-control input.
|
||||
type InterruptReceivedPayload struct {
|
||||
Kind InterruptKind
|
||||
Role string
|
||||
ContentLen int
|
||||
QueueDepth int
|
||||
HintLen int
|
||||
}
|
||||
|
||||
// SubTurnSpawnPayload describes the creation of a child turn.
|
||||
type SubTurnSpawnPayload struct {
|
||||
AgentID string
|
||||
Label string
|
||||
ParentTurnID string
|
||||
}
|
||||
|
||||
// SubTurnEndPayload describes the completion of a child turn.
|
||||
type SubTurnEndPayload struct {
|
||||
AgentID string
|
||||
Status string
|
||||
}
|
||||
|
||||
// SubTurnResultDeliveredPayload describes delivery of a sub-turn result.
|
||||
type SubTurnResultDeliveredPayload struct {
|
||||
TargetChannel string
|
||||
TargetChatID string
|
||||
ContentLen int
|
||||
}
|
||||
|
||||
// SubTurnOrphanPayload describes a sub-turn result that could not be delivered.
|
||||
type SubTurnOrphanPayload struct {
|
||||
ParentTurnID string
|
||||
ChildTurnID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ErrorPayload describes an execution error inside the agent loop.
|
||||
type ErrorPayload struct {
|
||||
Stage string
|
||||
Message string
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type hookRuntime struct {
|
||||
initOnce sync.Once
|
||||
mu sync.Mutex
|
||||
initErr error
|
||||
mounted []string
|
||||
}
|
||||
|
||||
func (r *hookRuntime) setInitErr(err error) {
|
||||
r.mu.Lock()
|
||||
r.initErr = err
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *hookRuntime) getInitErr() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.initErr
|
||||
}
|
||||
|
||||
func (r *hookRuntime) setMounted(names []string) {
|
||||
r.mu.Lock()
|
||||
r.mounted = append([]string(nil), names...)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *hookRuntime) reset(al *AgentLoop) {
|
||||
r.mu.Lock()
|
||||
names := append([]string(nil), r.mounted...)
|
||||
r.mounted = nil
|
||||
r.initErr = nil
|
||||
r.initOnce = sync.Once{}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, name := range names {
|
||||
al.UnmountHook(name)
|
||||
}
|
||||
}
|
||||
|
||||
// BuiltinHookFactory constructs an in-process hook from config.
|
||||
type BuiltinHookFactory func(ctx context.Context, spec config.BuiltinHookConfig) (any, error)
|
||||
|
||||
var (
|
||||
builtinHookRegistryMu sync.RWMutex
|
||||
builtinHookRegistry = map[string]BuiltinHookFactory{}
|
||||
)
|
||||
|
||||
// RegisterBuiltinHook registers a named in-process hook factory for config-driven mounting.
|
||||
func RegisterBuiltinHook(name string, factory BuiltinHookFactory) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("builtin hook name is required")
|
||||
}
|
||||
if factory == nil {
|
||||
return fmt.Errorf("builtin hook %q factory is nil", name)
|
||||
}
|
||||
|
||||
builtinHookRegistryMu.Lock()
|
||||
defer builtinHookRegistryMu.Unlock()
|
||||
|
||||
if _, exists := builtinHookRegistry[name]; exists {
|
||||
return fmt.Errorf("builtin hook %q is already registered", name)
|
||||
}
|
||||
builtinHookRegistry[name] = factory
|
||||
return nil
|
||||
}
|
||||
|
||||
func unregisterBuiltinHook(name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
builtinHookRegistryMu.Lock()
|
||||
delete(builtinHookRegistry, name)
|
||||
builtinHookRegistryMu.Unlock()
|
||||
}
|
||||
|
||||
func lookupBuiltinHook(name string) (BuiltinHookFactory, bool) {
|
||||
builtinHookRegistryMu.RLock()
|
||||
defer builtinHookRegistryMu.RUnlock()
|
||||
|
||||
factory, ok := builtinHookRegistry[name]
|
||||
return factory, ok
|
||||
}
|
||||
|
||||
func configureHookManagerFromConfig(hm *HookManager, cfg *config.Config) {
|
||||
if hm == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
hm.ConfigureTimeouts(
|
||||
hookTimeoutFromMS(cfg.Hooks.Defaults.ObserverTimeoutMS),
|
||||
hookTimeoutFromMS(cfg.Hooks.Defaults.InterceptorTimeoutMS),
|
||||
hookTimeoutFromMS(cfg.Hooks.Defaults.ApprovalTimeoutMS),
|
||||
)
|
||||
}
|
||||
|
||||
func hookTimeoutFromMS(ms int) time.Duration {
|
||||
if ms <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
func (al *AgentLoop) ensureHooksInitialized(ctx context.Context) error {
|
||||
if al == nil || al.cfg == nil || al.hooks == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
al.hookRuntime.initOnce.Do(func() {
|
||||
al.hookRuntime.setInitErr(al.loadConfiguredHooks(ctx))
|
||||
})
|
||||
|
||||
return al.hookRuntime.getInitErr()
|
||||
}
|
||||
|
||||
func (al *AgentLoop) loadConfiguredHooks(ctx context.Context) (err error) {
|
||||
if al == nil || al.cfg == nil || !al.cfg.Hooks.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
mounted := make([]string, 0)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
for _, name := range mounted {
|
||||
al.UnmountHook(name)
|
||||
}
|
||||
return
|
||||
}
|
||||
al.hookRuntime.setMounted(mounted)
|
||||
}()
|
||||
|
||||
builtinNames := enabledBuiltinHookNames(al.cfg.Hooks.Builtins)
|
||||
for _, name := range builtinNames {
|
||||
spec := al.cfg.Hooks.Builtins[name]
|
||||
factory, ok := lookupBuiltinHook(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("builtin hook %q is not registered", name)
|
||||
}
|
||||
|
||||
hook, factoryErr := factory(ctx, spec)
|
||||
if factoryErr != nil {
|
||||
return fmt.Errorf("build builtin hook %q: %w", name, factoryErr)
|
||||
}
|
||||
if err := al.MountHook(HookRegistration{
|
||||
Name: name,
|
||||
Priority: spec.Priority,
|
||||
Source: HookSourceInProcess,
|
||||
Hook: hook,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("mount builtin hook %q: %w", name, err)
|
||||
}
|
||||
mounted = append(mounted, name)
|
||||
}
|
||||
|
||||
processNames := enabledProcessHookNames(al.cfg.Hooks.Processes)
|
||||
for _, name := range processNames {
|
||||
spec := al.cfg.Hooks.Processes[name]
|
||||
opts, buildErr := processHookOptionsFromConfig(spec)
|
||||
if buildErr != nil {
|
||||
return fmt.Errorf("configure process hook %q: %w", name, buildErr)
|
||||
}
|
||||
|
||||
processHook, buildErr := NewProcessHook(ctx, name, opts)
|
||||
if buildErr != nil {
|
||||
return fmt.Errorf("start process hook %q: %w", name, buildErr)
|
||||
}
|
||||
if err := al.MountHook(HookRegistration{
|
||||
Name: name,
|
||||
Priority: spec.Priority,
|
||||
Source: HookSourceProcess,
|
||||
Hook: processHook,
|
||||
}); err != nil {
|
||||
_ = processHook.Close()
|
||||
return fmt.Errorf("mount process hook %q: %w", name, err)
|
||||
}
|
||||
mounted = append(mounted, name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func enabledBuiltinHookNames(specs map[string]config.BuiltinHookConfig) []string {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(specs))
|
||||
for name, spec := range specs {
|
||||
if spec.Enabled {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func enabledProcessHookNames(specs map[string]config.ProcessHookConfig) []string {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(specs))
|
||||
for name, spec := range specs {
|
||||
if spec.Enabled {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func processHookOptionsFromConfig(spec config.ProcessHookConfig) (ProcessHookOptions, error) {
|
||||
transport := spec.Transport
|
||||
if transport == "" {
|
||||
transport = "stdio"
|
||||
}
|
||||
if transport != "stdio" {
|
||||
return ProcessHookOptions{}, fmt.Errorf("unsupported transport %q", transport)
|
||||
}
|
||||
if len(spec.Command) == 0 {
|
||||
return ProcessHookOptions{}, fmt.Errorf("command is required")
|
||||
}
|
||||
|
||||
opts := ProcessHookOptions{
|
||||
Command: append([]string(nil), spec.Command...),
|
||||
Dir: spec.Dir,
|
||||
Env: processHookEnvFromMap(spec.Env),
|
||||
}
|
||||
|
||||
observeKinds, observeEnabled, err := processHookObserveKindsFromConfig(spec.Observe)
|
||||
if err != nil {
|
||||
return ProcessHookOptions{}, err
|
||||
}
|
||||
opts.Observe = observeEnabled
|
||||
opts.ObserveKinds = observeKinds
|
||||
|
||||
for _, intercept := range spec.Intercept {
|
||||
switch intercept {
|
||||
case "before_llm", "after_llm":
|
||||
opts.InterceptLLM = true
|
||||
case "before_tool", "after_tool":
|
||||
opts.InterceptTool = true
|
||||
case "approve_tool":
|
||||
opts.ApproveTool = true
|
||||
case "":
|
||||
continue
|
||||
default:
|
||||
return ProcessHookOptions{}, fmt.Errorf("unsupported intercept %q", intercept)
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.Observe && !opts.InterceptLLM && !opts.InterceptTool && !opts.ApproveTool {
|
||||
return ProcessHookOptions{}, fmt.Errorf("no hook modes enabled")
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func processHookEnvFromMap(envMap map[string]string) []string {
|
||||
if len(envMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(envMap))
|
||||
for key := range envMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
env := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
env = append(env, key+"="+envMap[key])
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) {
|
||||
if len(observe) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
validKinds := validHookEventKinds()
|
||||
normalized := make([]string, 0, len(observe))
|
||||
for _, kind := range observe {
|
||||
switch kind {
|
||||
case "", "*", "all":
|
||||
return nil, true, nil
|
||||
default:
|
||||
if _, ok := validKinds[kind]; !ok {
|
||||
return nil, false, fmt.Errorf("unsupported observe event %q", kind)
|
||||
}
|
||||
normalized = append(normalized, kind)
|
||||
}
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func validHookEventKinds() map[string]struct{} {
|
||||
kinds := make(map[string]struct{}, int(eventKindCount))
|
||||
for kind := EventKind(0); kind < eventKindCount; kind++ {
|
||||
kinds[kind.String()] = struct{}{}
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type builtinAutoHookConfig struct {
|
||||
Model string `json:"model"`
|
||||
Suffix string `json:"suffix"`
|
||||
}
|
||||
|
||||
type builtinAutoHook struct {
|
||||
model string
|
||||
suffix string
|
||||
}
|
||||
|
||||
func (h *builtinAutoHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
next := req.Clone()
|
||||
next.Model = h.model
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *builtinAutoHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
next := resp.Clone()
|
||||
if next.Response != nil {
|
||||
next.Response.Content += h.suffix
|
||||
}
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks config.HooksConfig) *AgentLoop {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: t.TempDir(),
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
Hooks: hooks,
|
||||
}
|
||||
|
||||
return NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
}
|
||||
|
||||
func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) {
|
||||
const hookName = "test-auto-builtin-hook"
|
||||
|
||||
if err := RegisterBuiltinHook(hookName, func(
|
||||
ctx context.Context,
|
||||
spec config.BuiltinHookConfig,
|
||||
) (any, error) {
|
||||
var hookCfg builtinAutoHookConfig
|
||||
if len(spec.Config) > 0 {
|
||||
if err := json.Unmarshal(spec.Config, &hookCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &builtinAutoHook{
|
||||
model: hookCfg.Model,
|
||||
suffix: hookCfg.Suffix,
|
||||
}, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterBuiltinHook failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
unregisterBuiltinHook(hookName)
|
||||
})
|
||||
|
||||
rawCfg, err := json.Marshal(builtinAutoHookConfig{
|
||||
Model: "builtin-model",
|
||||
Suffix: "|builtin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
provider := &llmHookTestProvider{}
|
||||
al := newConfiguredHookLoop(t, provider, config.HooksConfig{
|
||||
Enabled: true,
|
||||
Builtins: map[string]config.BuiltinHookConfig{
|
||||
hookName: {
|
||||
Enabled: true,
|
||||
Config: rawCfg,
|
||||
},
|
||||
},
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "provider content|builtin" {
|
||||
t.Fatalf("expected builtin-hooked content, got %q", resp)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
lastModel := provider.lastModel
|
||||
provider.mu.Unlock()
|
||||
if lastModel != "builtin-model" {
|
||||
t.Fatalf("expected builtin model, got %q", lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
eventLog := filepath.Join(t.TempDir(), "events.log")
|
||||
|
||||
al := newConfiguredHookLoop(t, provider, config.HooksConfig{
|
||||
Enabled: true,
|
||||
Processes: map[string]config.ProcessHookConfig{
|
||||
"ipc-auto": {
|
||||
Enabled: true,
|
||||
Command: processHookHelperCommand(),
|
||||
Env: map[string]string{
|
||||
"PICOCLAW_HOOK_HELPER": "1",
|
||||
"PICOCLAW_HOOK_MODE": "rewrite",
|
||||
"PICOCLAW_HOOK_EVENT_LOG": eventLog,
|
||||
},
|
||||
Observe: []string{"turn_end"},
|
||||
Intercept: []string{"before_llm", "after_llm"},
|
||||
},
|
||||
},
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "provider content|ipc" {
|
||||
t.Fatalf("expected process-hooked content, got %q", resp)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
lastModel := provider.lastModel
|
||||
provider.mu.Unlock()
|
||||
if lastModel != "process-model" {
|
||||
t.Fatalf("expected process model, got %q", lastModel)
|
||||
}
|
||||
|
||||
waitForFileContains(t, eventLog, "turn_end")
|
||||
}
|
||||
|
||||
func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al := newConfiguredHookLoop(t, provider, config.HooksConfig{
|
||||
Enabled: true,
|
||||
Processes: map[string]config.ProcessHookConfig{
|
||||
"bad-hook": {
|
||||
Enabled: true,
|
||||
Command: processHookHelperCommand(),
|
||||
Intercept: []string{"not_supported"},
|
||||
},
|
||||
},
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
_, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid configured hook error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
processHookJSONRPCVersion = "2.0"
|
||||
processHookReadBufferSize = 1024 * 1024
|
||||
processHookCloseTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ProcessHookOptions struct {
|
||||
Command []string
|
||||
Dir string
|
||||
Env []string
|
||||
Observe bool
|
||||
ObserveKinds []string
|
||||
InterceptLLM bool
|
||||
InterceptTool bool
|
||||
ApproveTool bool
|
||||
}
|
||||
|
||||
type ProcessHook struct {
|
||||
name string
|
||||
opts ProcessHookOptions
|
||||
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
observeKinds map[string]struct{}
|
||||
|
||||
writeMu sync.Mutex
|
||||
|
||||
pendingMu sync.Mutex
|
||||
pending map[uint64]chan processHookRPCMessage
|
||||
nextID atomic.Uint64
|
||||
|
||||
closed atomic.Bool
|
||||
done chan struct{}
|
||||
closeErr error
|
||||
closeMu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type processHookRPCMessage struct {
|
||||
JSONRPC string `json:"jsonrpc,omitempty"`
|
||||
ID uint64 `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *processHookRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type processHookRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type processHookHelloParams struct {
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
Modes []string `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
type processHookDecisionResponse struct {
|
||||
Action HookAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type processHookBeforeLLMResponse struct {
|
||||
processHookDecisionResponse
|
||||
Request *LLMHookRequest `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type processHookAfterLLMResponse struct {
|
||||
processHookDecisionResponse
|
||||
Response *LLMHookResponse `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
type processHookBeforeToolResponse struct {
|
||||
processHookDecisionResponse
|
||||
Call *ToolCallHookRequest `json:"call,omitempty"`
|
||||
}
|
||||
|
||||
type processHookAfterToolResponse struct {
|
||||
processHookDecisionResponse
|
||||
Result *ToolResultHookResponse `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (*ProcessHook, error) {
|
||||
if len(opts.Command) == 0 {
|
||||
return nil, fmt.Errorf("process hook command is required")
|
||||
}
|
||||
|
||||
cmd := exec.Command(opts.Command[0], opts.Command[1:]...)
|
||||
cmd.Dir = opts.Dir
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), opts.Env...)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process hook stdin: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process hook stdout: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process hook stderr: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start process hook: %w", err)
|
||||
}
|
||||
|
||||
ph := &ProcessHook{
|
||||
name: name,
|
||||
opts: opts,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
observeKinds: newProcessHookObserveKinds(opts.ObserveKinds),
|
||||
pending: make(map[uint64]chan processHookRPCMessage),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go ph.readLoop(stdout)
|
||||
go ph.readStderr(stderr)
|
||||
go ph.waitLoop()
|
||||
|
||||
helloCtx := ctx
|
||||
if helloCtx == nil {
|
||||
var cancel context.CancelFunc
|
||||
helloCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
if err := ph.hello(helloCtx); err != nil {
|
||||
_ = ph.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ph, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) Close() error {
|
||||
if ph == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ph.closeOnce.Do(func() {
|
||||
ph.closed.Store(true)
|
||||
if ph.stdin != nil {
|
||||
_ = ph.stdin.Close()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ph.done:
|
||||
case <-time.After(processHookCloseTimeout):
|
||||
if ph.cmd != nil && ph.cmd.Process != nil {
|
||||
_ = ph.cmd.Process.Kill()
|
||||
}
|
||||
<-ph.done
|
||||
}
|
||||
})
|
||||
|
||||
ph.closeMu.Lock()
|
||||
defer ph.closeMu.Unlock()
|
||||
return ph.closeErr
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error {
|
||||
if ph == nil || !ph.opts.Observe {
|
||||
return nil
|
||||
}
|
||||
if len(ph.observeKinds) > 0 {
|
||||
if _, ok := ph.observeKinds[evt.Kind.String()]; !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ph.notify(ctx, "hook.event", evt)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
if ph == nil || !ph.opts.InterceptLLM {
|
||||
return req, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
var resp processHookBeforeLLMResponse
|
||||
if err := ph.call(ctx, "hook.before_llm", req, &resp); err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
if resp.Request == nil {
|
||||
resp.Request = req
|
||||
}
|
||||
return resp.Request, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
if ph == nil || !ph.opts.InterceptLLM {
|
||||
return resp, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
var result processHookAfterLLMResponse
|
||||
if err := ph.call(ctx, "hook.after_llm", resp, &result); err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
if result.Response == nil {
|
||||
result.Response = resp
|
||||
}
|
||||
return result.Response, HookDecision{Action: result.Action, Reason: result.Reason}, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, error) {
|
||||
if ph == nil || !ph.opts.InterceptTool {
|
||||
return call, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
var resp processHookBeforeToolResponse
|
||||
if err := ph.call(ctx, "hook.before_tool", call, &resp); err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
if resp.Call == nil {
|
||||
resp.Call = call
|
||||
}
|
||||
return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, error) {
|
||||
if ph == nil || !ph.opts.InterceptTool {
|
||||
return result, HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
var resp processHookAfterToolResponse
|
||||
if err := ph.call(ctx, "hook.after_tool", result, &resp); err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
if resp.Result == nil {
|
||||
resp.Result = result
|
||||
}
|
||||
return resp.Result, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) {
|
||||
if ph == nil || !ph.opts.ApproveTool {
|
||||
return ApprovalDecision{Approved: true}, nil
|
||||
}
|
||||
|
||||
var resp ApprovalDecision
|
||||
if err := ph.call(ctx, "hook.approve_tool", req, &resp); err != nil {
|
||||
return ApprovalDecision{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) hello(ctx context.Context) error {
|
||||
modes := make([]string, 0, 4)
|
||||
if ph.opts.Observe {
|
||||
modes = append(modes, "observe")
|
||||
}
|
||||
if ph.opts.InterceptLLM {
|
||||
modes = append(modes, "llm")
|
||||
}
|
||||
if ph.opts.InterceptTool {
|
||||
modes = append(modes, "tool")
|
||||
}
|
||||
if ph.opts.ApproveTool {
|
||||
modes = append(modes, "approve")
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
return ph.call(ctx, "hook.hello", processHookHelloParams{
|
||||
Name: ph.name,
|
||||
Version: 1,
|
||||
Modes: modes,
|
||||
}, &result)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) notify(ctx context.Context, method string, params any) error {
|
||||
msg := processHookRPCMessage{
|
||||
JSONRPC: processHookJSONRPCVersion,
|
||||
Method: method,
|
||||
}
|
||||
if params != nil {
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.Params = body
|
||||
}
|
||||
return ph.send(ctx, msg)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) call(ctx context.Context, method string, params any, out any) error {
|
||||
if ph.closed.Load() {
|
||||
return fmt.Errorf("process hook %q is closed", ph.name)
|
||||
}
|
||||
|
||||
id := ph.nextID.Add(1)
|
||||
respCh := make(chan processHookRPCMessage, 1)
|
||||
ph.pendingMu.Lock()
|
||||
ph.pending[id] = respCh
|
||||
ph.pendingMu.Unlock()
|
||||
|
||||
msg := processHookRPCMessage{
|
||||
JSONRPC: processHookJSONRPCVersion,
|
||||
ID: id,
|
||||
Method: method,
|
||||
}
|
||||
if params != nil {
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
ph.removePending(id)
|
||||
return err
|
||||
}
|
||||
msg.Params = body
|
||||
}
|
||||
|
||||
if err := ph.send(ctx, msg); err != nil {
|
||||
ph.removePending(id)
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case resp, ok := <-respCh:
|
||||
if !ok {
|
||||
return fmt.Errorf("process hook %q closed while waiting for %s", ph.name, method)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("process hook %q %s failed: %s", ph.name, method, resp.Error.Message)
|
||||
}
|
||||
if out != nil && len(resp.Result) > 0 {
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
return fmt.Errorf("decode process hook %q %s result: %w", ph.name, method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
ph.removePending(id)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) send(ctx context.Context, msg processHookRPCMessage) error {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
|
||||
ph.writeMu.Lock()
|
||||
defer ph.writeMu.Unlock()
|
||||
|
||||
if ph.closed.Load() {
|
||||
return fmt.Errorf("process hook %q is closed", ph.name)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, writeErr := ph.stdin.Write(body)
|
||||
done <- writeErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("write process hook %q message: %w", ph.name, err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) readLoop(stdout io.Reader) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), processHookReadBufferSize)
|
||||
|
||||
for scanner.Scan() {
|
||||
var msg processHookRPCMessage
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
logger.WarnCF("hooks", "Failed to decode process hook message", map[string]any{
|
||||
"hook": ph.name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if msg.ID == 0 {
|
||||
continue
|
||||
}
|
||||
ph.pendingMu.Lock()
|
||||
respCh, ok := ph.pending[msg.ID]
|
||||
if ok {
|
||||
delete(ph.pending, msg.ID)
|
||||
}
|
||||
ph.pendingMu.Unlock()
|
||||
if ok {
|
||||
respCh <- msg
|
||||
close(respCh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) readStderr(stderr io.Reader) {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 0, 16*1024), processHookReadBufferSize)
|
||||
for scanner.Scan() {
|
||||
logger.WarnCF("hooks", "Process hook stderr", map[string]any{
|
||||
"hook": ph.name,
|
||||
"stderr": scanner.Text(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) waitLoop() {
|
||||
err := ph.cmd.Wait()
|
||||
ph.closeMu.Lock()
|
||||
ph.closeErr = err
|
||||
ph.closeMu.Unlock()
|
||||
ph.failPending(err)
|
||||
close(ph.done)
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) failPending(err error) {
|
||||
ph.pendingMu.Lock()
|
||||
defer ph.pendingMu.Unlock()
|
||||
|
||||
msg := processHookRPCMessage{
|
||||
Error: &processHookRPCError{
|
||||
Code: -32000,
|
||||
Message: "process exited",
|
||||
},
|
||||
}
|
||||
if err != nil {
|
||||
msg.Error.Message = err.Error()
|
||||
}
|
||||
|
||||
for id, ch := range ph.pending {
|
||||
delete(ph.pending, id)
|
||||
ch <- msg
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProcessHook) removePending(id uint64) {
|
||||
ph.pendingMu.Lock()
|
||||
defer ph.pendingMu.Unlock()
|
||||
|
||||
if ch, ok := ph.pending[id]; ok {
|
||||
delete(ph.pending, id)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) MountProcessHook(ctx context.Context, name string, opts ProcessHookOptions) error {
|
||||
if al == nil {
|
||||
return fmt.Errorf("agent loop is nil")
|
||||
}
|
||||
processHook, err := NewProcessHook(ctx, name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := al.MountHook(HookRegistration{
|
||||
Name: name,
|
||||
Source: HookSourceProcess,
|
||||
Hook: processHook,
|
||||
}); err != nil {
|
||||
_ = processHook.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProcessHookObserveKinds(kinds []string) map[string]struct{} {
|
||||
if len(kinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := make(map[string]struct{}, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
normalized[kind] = struct{}{}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestProcessHook_HelperProcess(t *testing.T) {
|
||||
if os.Getenv("PICOCLAW_HOOK_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
if err := runProcessHookHelper(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
eventLog := filepath.Join(t.TempDir(), "events.log")
|
||||
if err := al.MountProcessHook(context.Background(), "ipc-llm", ProcessHookOptions{
|
||||
Command: processHookHelperCommand(),
|
||||
Env: processHookHelperEnv("rewrite", eventLog),
|
||||
Observe: true,
|
||||
InterceptLLM: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("MountProcessHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "hello",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "provider content|ipc" {
|
||||
t.Fatalf("expected process-hooked llm content, got %q", resp)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
lastModel := provider.lastModel
|
||||
provider.mu.Unlock()
|
||||
if lastModel != "process-model" {
|
||||
t.Fatalf("expected process model, got %q", lastModel)
|
||||
}
|
||||
|
||||
waitForFileContains(t, eventLog, "turn_end")
|
||||
}
|
||||
|
||||
func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) {
|
||||
provider := &toolHookProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
if err := al.MountProcessHook(context.Background(), "ipc-tool", ProcessHookOptions{
|
||||
Command: processHookHelperCommand(),
|
||||
Env: processHookHelperEnv("rewrite", ""),
|
||||
InterceptTool: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("MountProcessHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "ipc:ipc" {
|
||||
t.Fatalf("expected rewritten process-hook tool result, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type blockedToolProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *blockedToolProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.calls++
|
||||
if p.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Name: "blocked_tool",
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: messages[len(messages)-1].Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *blockedToolProvider) GetDefaultModel() string {
|
||||
return "blocked-tool-provider"
|
||||
}
|
||||
|
||||
func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) {
|
||||
provider := &blockedToolProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
if err := al.MountProcessHook(context.Background(), "ipc-approval", ProcessHookOptions{
|
||||
Command: processHookHelperCommand(),
|
||||
Env: processHookHelperEnv("deny", ""),
|
||||
ApproveTool: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("MountProcessHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run blocked tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
|
||||
expected := "Tool execution denied by approval hook: blocked by ipc hook"
|
||||
if resp != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, resp)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
skippedEvt, ok := findEvent(events, EventKindToolExecSkipped)
|
||||
if !ok {
|
||||
t.Fatal("expected tool skipped event")
|
||||
}
|
||||
payload, ok := skippedEvt.Payload.(ToolExecSkippedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload)
|
||||
}
|
||||
if payload.Reason != expected {
|
||||
t.Fatalf("expected reason %q, got %q", expected, payload.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func processHookHelperCommand() []string {
|
||||
return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"}
|
||||
}
|
||||
|
||||
func processHookHelperEnv(mode, eventLog string) []string {
|
||||
env := []string{
|
||||
"PICOCLAW_HOOK_HELPER=1",
|
||||
"PICOCLAW_HOOK_MODE=" + mode,
|
||||
}
|
||||
if eventLog != "" {
|
||||
env = append(env, "PICOCLAW_HOOK_EVENT_LOG="+eventLog)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func waitForFileContains(t *testing.T, path, substring string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && strings.Contains(string(data), substring) {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
t.Fatalf("timed out waiting for %q in %s; current content: %q", substring, path, string(data))
|
||||
}
|
||||
|
||||
func runProcessHookHelper() error {
|
||||
mode := os.Getenv("PICOCLAW_HOOK_MODE")
|
||||
eventLog := os.Getenv("PICOCLAW_HOOK_EVENT_LOG")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), processHookReadBufferSize)
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
|
||||
for scanner.Scan() {
|
||||
var msg processHookRPCMessage
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg.ID == 0 {
|
||||
if msg.Method == "hook.event" && eventLog != "" {
|
||||
var evt map[string]any
|
||||
if err := json.Unmarshal(msg.Params, &evt); err == nil {
|
||||
if rawKind, ok := evt["Kind"].(float64); ok {
|
||||
kind := EventKind(rawKind)
|
||||
_ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result, rpcErr := handleProcessHookRequest(mode, msg)
|
||||
resp := processHookRPCMessage{
|
||||
JSONRPC: processHookJSONRPCVersion,
|
||||
ID: msg.ID,
|
||||
}
|
||||
if rpcErr != nil {
|
||||
resp.Error = rpcErr
|
||||
} else if result != nil {
|
||||
body, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Result = body
|
||||
} else {
|
||||
resp.Result = []byte("{}")
|
||||
}
|
||||
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func handleProcessHookRequest(mode string, msg processHookRPCMessage) (any, *processHookRPCError) {
|
||||
switch msg.Method {
|
||||
case "hook.hello":
|
||||
return map[string]any{"ok": true}, nil
|
||||
case "hook.before_llm":
|
||||
if mode != "rewrite" {
|
||||
return map[string]any{"action": HookActionContinue}, nil
|
||||
}
|
||||
var req map[string]any
|
||||
_ = json.Unmarshal(msg.Params, &req)
|
||||
req["model"] = "process-model"
|
||||
return map[string]any{
|
||||
"action": HookActionModify,
|
||||
"request": req,
|
||||
}, nil
|
||||
case "hook.after_llm":
|
||||
if mode != "rewrite" {
|
||||
return map[string]any{"action": HookActionContinue}, nil
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(msg.Params, &resp)
|
||||
if rawResponse, ok := resp["response"].(map[string]any); ok {
|
||||
if content, ok := rawResponse["content"].(string); ok {
|
||||
rawResponse["content"] = content + "|ipc"
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"action": HookActionModify,
|
||||
"response": resp,
|
||||
}, nil
|
||||
case "hook.before_tool":
|
||||
if mode != "rewrite" {
|
||||
return map[string]any{"action": HookActionContinue}, nil
|
||||
}
|
||||
var call map[string]any
|
||||
_ = json.Unmarshal(msg.Params, &call)
|
||||
rawArgs, ok := call["arguments"].(map[string]any)
|
||||
if !ok || rawArgs == nil {
|
||||
rawArgs = map[string]any{}
|
||||
}
|
||||
rawArgs["text"] = "ipc"
|
||||
call["arguments"] = rawArgs
|
||||
return map[string]any{
|
||||
"action": HookActionModify,
|
||||
"call": call,
|
||||
}, nil
|
||||
case "hook.after_tool":
|
||||
if mode != "rewrite" {
|
||||
return map[string]any{"action": HookActionContinue}, nil
|
||||
}
|
||||
var result map[string]any
|
||||
_ = json.Unmarshal(msg.Params, &result)
|
||||
if rawResult, ok := result["result"].(map[string]any); ok {
|
||||
if forLLM, ok := rawResult["for_llm"].(string); ok {
|
||||
rawResult["for_llm"] = "ipc:" + forLLM
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"action": HookActionModify,
|
||||
"result": result,
|
||||
}, nil
|
||||
case "hook.approve_tool":
|
||||
if mode == "deny" {
|
||||
return ApprovalDecision{
|
||||
Approved: false,
|
||||
Reason: "blocked by ipc hook",
|
||||
}, nil
|
||||
}
|
||||
return ApprovalDecision{Approved: true}, nil
|
||||
default:
|
||||
return nil, &processHookRPCError{
|
||||
Code: -32601,
|
||||
Message: "method not found",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHookObserverTimeout = 500 * time.Millisecond
|
||||
defaultHookInterceptorTimeout = 5 * time.Second
|
||||
defaultHookApprovalTimeout = 60 * time.Second
|
||||
hookObserverBufferSize = 64
|
||||
)
|
||||
|
||||
type HookAction string
|
||||
|
||||
const (
|
||||
HookActionContinue HookAction = "continue"
|
||||
HookActionModify HookAction = "modify"
|
||||
HookActionDenyTool HookAction = "deny_tool"
|
||||
HookActionAbortTurn HookAction = "abort_turn"
|
||||
HookActionHardAbort HookAction = "hard_abort"
|
||||
)
|
||||
|
||||
type HookDecision struct {
|
||||
Action HookAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (d HookDecision) normalizedAction() HookAction {
|
||||
if d.Action == "" {
|
||||
return HookActionContinue
|
||||
}
|
||||
return d.Action
|
||||
}
|
||||
|
||||
type ApprovalDecision struct {
|
||||
Approved bool `json:"approved"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type HookSource uint8
|
||||
|
||||
const (
|
||||
HookSourceInProcess HookSource = iota
|
||||
HookSourceProcess
|
||||
)
|
||||
|
||||
type HookRegistration struct {
|
||||
Name string
|
||||
Priority int
|
||||
Source HookSource
|
||||
Hook any
|
||||
}
|
||||
|
||||
func NamedHook(name string, hook any) HookRegistration {
|
||||
return HookRegistration{
|
||||
Name: name,
|
||||
Source: HookSourceInProcess,
|
||||
Hook: hook,
|
||||
}
|
||||
}
|
||||
|
||||
type EventObserver interface {
|
||||
OnEvent(ctx context.Context, evt Event) error
|
||||
}
|
||||
|
||||
type LLMInterceptor interface {
|
||||
BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision, error)
|
||||
AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision, error)
|
||||
}
|
||||
|
||||
type ToolInterceptor interface {
|
||||
BeforeTool(ctx context.Context, call *ToolCallHookRequest) (*ToolCallHookRequest, HookDecision, error)
|
||||
AfterTool(ctx context.Context, result *ToolResultHookResponse) (*ToolResultHookResponse, HookDecision, error)
|
||||
}
|
||||
|
||||
type ToolApprover interface {
|
||||
ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error)
|
||||
}
|
||||
|
||||
type LLMHookRequest struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Model string `json:"model"`
|
||||
Messages []providers.Message `json:"messages,omitempty"`
|
||||
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
GracefulTerminal bool `json:"graceful_terminal,omitempty"`
|
||||
}
|
||||
|
||||
func (r *LLMHookRequest) Clone() *LLMHookRequest {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *r
|
||||
cloned.Messages = cloneProviderMessages(r.Messages)
|
||||
cloned.Tools = cloneToolDefinitions(r.Tools)
|
||||
cloned.Options = cloneStringAnyMap(r.Options)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type LLMHookResponse struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Model string `json:"model"`
|
||||
Response *providers.LLMResponse `json:"response,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
func (r *LLMHookResponse) Clone() *LLMHookResponse {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *r
|
||||
cloned.Response = cloneLLMResponse(r.Response)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type ToolCallHookRequest struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *r
|
||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type ToolApprovalRequest struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *r
|
||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type ToolResultHookResponse struct {
|
||||
Meta EventMeta `json:"meta"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Result *tools.ToolResult `json:"result,omitempty"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *r
|
||||
cloned.Arguments = cloneStringAnyMap(r.Arguments)
|
||||
cloned.Result = cloneToolResult(r.Result)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type HookManager struct {
|
||||
eventBus *EventBus
|
||||
observerTimeout time.Duration
|
||||
interceptorTimeout time.Duration
|
||||
approvalTimeout time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
hooks map[string]HookRegistration
|
||||
ordered []HookRegistration
|
||||
|
||||
sub EventSubscription
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewHookManager(eventBus *EventBus) *HookManager {
|
||||
hm := &HookManager{
|
||||
eventBus: eventBus,
|
||||
observerTimeout: defaultHookObserverTimeout,
|
||||
interceptorTimeout: defaultHookInterceptorTimeout,
|
||||
approvalTimeout: defaultHookApprovalTimeout,
|
||||
hooks: make(map[string]HookRegistration),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
if eventBus == nil {
|
||||
close(hm.done)
|
||||
return hm
|
||||
}
|
||||
|
||||
hm.sub = eventBus.Subscribe(hookObserverBufferSize)
|
||||
go hm.dispatchEvents()
|
||||
return hm
|
||||
}
|
||||
|
||||
func (hm *HookManager) Close() {
|
||||
if hm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hm.closeOnce.Do(func() {
|
||||
if hm.eventBus != nil {
|
||||
hm.eventBus.Unsubscribe(hm.sub.ID)
|
||||
}
|
||||
<-hm.done
|
||||
hm.closeAllHooks()
|
||||
})
|
||||
}
|
||||
|
||||
func (hm *HookManager) ConfigureTimeouts(observer, interceptor, approval time.Duration) {
|
||||
if hm == nil {
|
||||
return
|
||||
}
|
||||
if observer > 0 {
|
||||
hm.observerTimeout = observer
|
||||
}
|
||||
if interceptor > 0 {
|
||||
hm.interceptorTimeout = interceptor
|
||||
}
|
||||
if approval > 0 {
|
||||
hm.approvalTimeout = approval
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) Mount(reg HookRegistration) error {
|
||||
if hm == nil {
|
||||
return fmt.Errorf("hook manager is nil")
|
||||
}
|
||||
if reg.Name == "" {
|
||||
return fmt.Errorf("hook name is required")
|
||||
}
|
||||
if reg.Hook == nil {
|
||||
return fmt.Errorf("hook %q is nil", reg.Name)
|
||||
}
|
||||
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
|
||||
if existing, ok := hm.hooks[reg.Name]; ok {
|
||||
closeHookIfPossible(existing.Hook)
|
||||
}
|
||||
hm.hooks[reg.Name] = reg
|
||||
hm.rebuildOrdered()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hm *HookManager) Unmount(name string) {
|
||||
if hm == nil || name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
|
||||
if existing, ok := hm.hooks[name]; ok {
|
||||
closeHookIfPossible(existing.Hook)
|
||||
}
|
||||
delete(hm.hooks, name)
|
||||
hm.rebuildOrdered()
|
||||
}
|
||||
|
||||
func (hm *HookManager) dispatchEvents() {
|
||||
defer close(hm.done)
|
||||
|
||||
for evt := range hm.sub.C {
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
observer, ok := reg.Hook.(EventObserver)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hm.runObserver(reg.Name, observer, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) {
|
||||
if hm == nil || req == nil {
|
||||
return req, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
current := req.Clone()
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
interceptor, ok := reg.Hook.(LLMInterceptor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
next, decision, ok := hm.callBeforeLLM(ctx, reg.Name, interceptor, current.Clone())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if next != nil {
|
||||
current = next
|
||||
}
|
||||
case HookActionAbortTurn, HookActionHardAbort:
|
||||
return current, decision
|
||||
default:
|
||||
hm.logUnsupportedAction(reg.Name, "before_llm", decision.Action)
|
||||
}
|
||||
}
|
||||
return current, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision) {
|
||||
if hm == nil || resp == nil {
|
||||
return resp, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
current := resp.Clone()
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
interceptor, ok := reg.Hook.(LLMInterceptor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
next, decision, ok := hm.callAfterLLM(ctx, reg.Name, interceptor, current.Clone())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if next != nil {
|
||||
current = next
|
||||
}
|
||||
case HookActionAbortTurn, HookActionHardAbort:
|
||||
return current, decision
|
||||
default:
|
||||
hm.logUnsupportedAction(reg.Name, "after_llm", decision.Action)
|
||||
}
|
||||
}
|
||||
return current, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
func (hm *HookManager) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision) {
|
||||
if hm == nil || call == nil {
|
||||
return call, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
current := call.Clone()
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
interceptor, ok := reg.Hook.(ToolInterceptor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
next, decision, ok := hm.callBeforeTool(ctx, reg.Name, interceptor, current.Clone())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if next != nil {
|
||||
current = next
|
||||
}
|
||||
case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort:
|
||||
return current, decision
|
||||
default:
|
||||
hm.logUnsupportedAction(reg.Name, "before_tool", decision.Action)
|
||||
}
|
||||
}
|
||||
return current, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
func (hm *HookManager) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision) {
|
||||
if hm == nil || result == nil {
|
||||
return result, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
current := result.Clone()
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
interceptor, ok := reg.Hook.(ToolInterceptor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
next, decision, ok := hm.callAfterTool(ctx, reg.Name, interceptor, current.Clone())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if next != nil {
|
||||
current = next
|
||||
}
|
||||
case HookActionAbortTurn, HookActionHardAbort:
|
||||
return current, decision
|
||||
default:
|
||||
hm.logUnsupportedAction(reg.Name, "after_tool", decision.Action)
|
||||
}
|
||||
}
|
||||
return current, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
func (hm *HookManager) ApproveTool(ctx context.Context, req *ToolApprovalRequest) ApprovalDecision {
|
||||
if hm == nil || req == nil {
|
||||
return ApprovalDecision{Approved: true}
|
||||
}
|
||||
|
||||
for _, reg := range hm.snapshotHooks() {
|
||||
approver, ok := reg.Hook.(ToolApprover)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
decision, ok := hm.callApproveTool(ctx, reg.Name, approver, req.Clone())
|
||||
if !ok {
|
||||
return ApprovalDecision{
|
||||
Approved: false,
|
||||
Reason: fmt.Sprintf("tool approval hook %q failed", reg.Name),
|
||||
}
|
||||
}
|
||||
if !decision.Approved {
|
||||
return decision
|
||||
}
|
||||
}
|
||||
|
||||
return ApprovalDecision{Approved: true}
|
||||
}
|
||||
|
||||
func (hm *HookManager) rebuildOrdered() {
|
||||
hm.ordered = hm.ordered[:0]
|
||||
for _, reg := range hm.hooks {
|
||||
hm.ordered = append(hm.ordered, reg)
|
||||
}
|
||||
sort.SliceStable(hm.ordered, func(i, j int) bool {
|
||||
if hm.ordered[i].Source != hm.ordered[j].Source {
|
||||
return hm.ordered[i].Source < hm.ordered[j].Source
|
||||
}
|
||||
if hm.ordered[i].Priority == hm.ordered[j].Priority {
|
||||
return hm.ordered[i].Name < hm.ordered[j].Name
|
||||
}
|
||||
return hm.ordered[i].Priority < hm.ordered[j].Priority
|
||||
})
|
||||
}
|
||||
|
||||
func (hm *HookManager) snapshotHooks() []HookRegistration {
|
||||
hm.mu.RLock()
|
||||
defer hm.mu.RUnlock()
|
||||
|
||||
snapshot := make([]HookRegistration, len(hm.ordered))
|
||||
copy(snapshot, hm.ordered)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (hm *HookManager) closeAllHooks() {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
|
||||
for name, reg := range hm.hooks {
|
||||
closeHookIfPossible(reg.Hook)
|
||||
delete(hm.hooks, name)
|
||||
}
|
||||
hm.ordered = nil
|
||||
}
|
||||
|
||||
func (hm *HookManager) runObserver(name string, observer EventObserver, evt Event) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hm.observerTimeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- observer.OnEvent(ctx, evt)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
logger.WarnCF("hooks", "Event observer failed", map[string]any{
|
||||
"hook": name,
|
||||
"event": evt.Kind.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
case <-ctx.Done():
|
||||
logger.WarnCF("hooks", "Event observer timed out", map[string]any{
|
||||
"hook": name,
|
||||
"event": evt.Kind.String(),
|
||||
"timeout_ms": hm.observerTimeout.Milliseconds(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) callBeforeLLM(
|
||||
parent context.Context,
|
||||
name string,
|
||||
interceptor LLMInterceptor,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, bool) {
|
||||
return runInterceptorHook(
|
||||
parent,
|
||||
hm.interceptorTimeout,
|
||||
name,
|
||||
"before_llm",
|
||||
func(ctx context.Context) (*LLMHookRequest, HookDecision, error) {
|
||||
return interceptor.BeforeLLM(ctx, req)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (hm *HookManager) callAfterLLM(
|
||||
parent context.Context,
|
||||
name string,
|
||||
interceptor LLMInterceptor,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, bool) {
|
||||
return runInterceptorHook(
|
||||
parent,
|
||||
hm.interceptorTimeout,
|
||||
name,
|
||||
"after_llm",
|
||||
func(ctx context.Context) (*LLMHookResponse, HookDecision, error) {
|
||||
return interceptor.AfterLLM(ctx, resp)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (hm *HookManager) callBeforeTool(
|
||||
parent context.Context,
|
||||
name string,
|
||||
interceptor ToolInterceptor,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, bool) {
|
||||
return runInterceptorHook(
|
||||
parent,
|
||||
hm.interceptorTimeout,
|
||||
name,
|
||||
"before_tool",
|
||||
func(ctx context.Context) (*ToolCallHookRequest, HookDecision, error) {
|
||||
return interceptor.BeforeTool(ctx, call)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (hm *HookManager) callAfterTool(
|
||||
parent context.Context,
|
||||
name string,
|
||||
interceptor ToolInterceptor,
|
||||
resultView *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, bool) {
|
||||
return runInterceptorHook(
|
||||
parent,
|
||||
hm.interceptorTimeout,
|
||||
name,
|
||||
"after_tool",
|
||||
func(ctx context.Context) (*ToolResultHookResponse, HookDecision, error) {
|
||||
return interceptor.AfterTool(ctx, resultView)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (hm *HookManager) callApproveTool(
|
||||
parent context.Context,
|
||||
name string,
|
||||
approver ToolApprover,
|
||||
req *ToolApprovalRequest,
|
||||
) (ApprovalDecision, bool) {
|
||||
return runApprovalHook(
|
||||
parent,
|
||||
hm.approvalTimeout,
|
||||
name,
|
||||
"approve_tool",
|
||||
func(ctx context.Context) (ApprovalDecision, error) {
|
||||
return approver.ApproveTool(ctx, req)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func runInterceptorHook[T any](
|
||||
parent context.Context,
|
||||
timeout time.Duration,
|
||||
name string,
|
||||
stage string,
|
||||
fn func(ctx context.Context) (T, HookDecision, error),
|
||||
) (T, HookDecision, bool) {
|
||||
var zero T
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
value T
|
||||
decision HookDecision
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
value, decision, err := fn(ctx)
|
||||
done <- result{value: value, decision: decision, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
logger.WarnCF("hooks", "Interceptor hook failed", map[string]any{
|
||||
"hook": name,
|
||||
"stage": stage,
|
||||
"error": res.err.Error(),
|
||||
})
|
||||
return zero, HookDecision{}, false
|
||||
}
|
||||
return res.value, res.decision, true
|
||||
case <-ctx.Done():
|
||||
logger.WarnCF("hooks", "Interceptor hook timed out", map[string]any{
|
||||
"hook": name,
|
||||
"stage": stage,
|
||||
"timeout_ms": timeout.Milliseconds(),
|
||||
})
|
||||
return zero, HookDecision{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalHook(
|
||||
parent context.Context,
|
||||
timeout time.Duration,
|
||||
name string,
|
||||
stage string,
|
||||
fn func(ctx context.Context) (ApprovalDecision, error),
|
||||
) (ApprovalDecision, bool) {
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
decision ApprovalDecision
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
decision, err := fn(ctx)
|
||||
done <- result{decision: decision, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
logger.WarnCF("hooks", "Approval hook failed", map[string]any{
|
||||
"hook": name,
|
||||
"stage": stage,
|
||||
"error": res.err.Error(),
|
||||
})
|
||||
return ApprovalDecision{}, false
|
||||
}
|
||||
return res.decision, true
|
||||
case <-ctx.Done():
|
||||
logger.WarnCF("hooks", "Approval hook timed out", map[string]any{
|
||||
"hook": name,
|
||||
"stage": stage,
|
||||
"timeout_ms": timeout.Milliseconds(),
|
||||
})
|
||||
return ApprovalDecision{
|
||||
Approved: false,
|
||||
Reason: fmt.Sprintf("tool approval hook %q timed out", name),
|
||||
}, true
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HookManager) logUnsupportedAction(name, stage string, action HookAction) {
|
||||
logger.WarnCF("hooks", "Hook returned unsupported action for stage", map[string]any{
|
||||
"hook": name,
|
||||
"stage": stage,
|
||||
"action": action,
|
||||
})
|
||||
}
|
||||
|
||||
func cloneProviderMessages(messages []providers.Message) []providers.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]providers.Message, len(messages))
|
||||
for i, msg := range messages {
|
||||
cloned[i] = msg
|
||||
if len(msg.Media) > 0 {
|
||||
cloned[i].Media = append([]string(nil), msg.Media...)
|
||||
}
|
||||
if len(msg.SystemParts) > 0 {
|
||||
cloned[i].SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
cloned[i].ToolCalls = cloneProviderToolCalls(msg.ToolCalls)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneProviderToolCalls(calls []providers.ToolCall) []providers.ToolCall {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]providers.ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
cloned[i] = call
|
||||
if call.Function != nil {
|
||||
fn := *call.Function
|
||||
cloned[i].Function = &fn
|
||||
}
|
||||
if call.Arguments != nil {
|
||||
cloned[i].Arguments = cloneStringAnyMap(call.Arguments)
|
||||
}
|
||||
if call.ExtraContent != nil {
|
||||
extra := *call.ExtraContent
|
||||
if call.ExtraContent.Google != nil {
|
||||
google := *call.ExtraContent.Google
|
||||
extra.Google = &google
|
||||
}
|
||||
cloned[i].ExtraContent = &extra
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition {
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make([]providers.ToolDefinition, len(defs))
|
||||
for i, def := range defs {
|
||||
cloned[i] = def
|
||||
cloned[i].Function.Parameters = cloneStringAnyMap(def.Function.Parameters)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *resp
|
||||
cloned.ToolCalls = cloneProviderToolCalls(resp.ToolCalls)
|
||||
if len(resp.ReasoningDetails) > 0 {
|
||||
cloned.ReasoningDetails = append(cloned.ReasoningDetails[:0:0], resp.ReasoningDetails...)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
usage := *resp.Usage
|
||||
cloned.Usage = &usage
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(src))
|
||||
for k, v := range src {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneToolResult(result *tools.ToolResult) *tools.ToolResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *result
|
||||
if len(result.Media) > 0 {
|
||||
cloned.Media = append([]string(nil), result.Media...)
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func closeHookIfPossible(hook any) {
|
||||
closer, ok := hook.(io.Closer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := closer.Close(); err != nil {
|
||||
logger.WarnCF("hooks", "Failed to close hook", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func newHookTestLoop(
|
||||
t *testing.T,
|
||||
provider providers.LLMProvider,
|
||||
) (*AgentLoop, *AgentInstance, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "agent-hooks-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
return al, agent, func() {
|
||||
al.Close()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookManager_SortsInProcessBeforeProcess(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
defer hm.Close()
|
||||
|
||||
if err := hm.Mount(HookRegistration{
|
||||
Name: "process",
|
||||
Priority: -10,
|
||||
Source: HookSourceProcess,
|
||||
Hook: struct{}{},
|
||||
}); err != nil {
|
||||
t.Fatalf("mount process hook: %v", err)
|
||||
}
|
||||
if err := hm.Mount(HookRegistration{
|
||||
Name: "in-process",
|
||||
Priority: 100,
|
||||
Source: HookSourceInProcess,
|
||||
Hook: struct{}{},
|
||||
}); err != nil {
|
||||
t.Fatalf("mount in-process hook: %v", err)
|
||||
}
|
||||
|
||||
ordered := hm.snapshotHooks()
|
||||
if len(ordered) != 2 {
|
||||
t.Fatalf("expected 2 hooks, got %d", len(ordered))
|
||||
}
|
||||
if ordered[0].Name != "in-process" {
|
||||
t.Fatalf("expected in-process hook first, got %q", ordered[0].Name)
|
||||
}
|
||||
if ordered[1].Name != "process" {
|
||||
t.Fatalf("expected process hook second, got %q", ordered[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
type llmHookTestProvider struct {
|
||||
mu sync.Mutex
|
||||
lastModel string
|
||||
}
|
||||
|
||||
func (p *llmHookTestProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.lastModel = model
|
||||
p.mu.Unlock()
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "provider content",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *llmHookTestProvider) GetDefaultModel() string {
|
||||
return "llm-hook-provider"
|
||||
}
|
||||
|
||||
type llmObserverHook struct {
|
||||
eventCh chan Event
|
||||
}
|
||||
|
||||
func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error {
|
||||
if evt.Kind == EventKindTurnEnd {
|
||||
select {
|
||||
case h.eventCh <- evt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *llmObserverHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
next := req.Clone()
|
||||
next.Model = "hook-model"
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *llmObserverHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
next := resp.Clone()
|
||||
next.Response.Content = "hooked content"
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
hook := &llmObserverHook{eventCh: make(chan Event, 1)}
|
||||
if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "hello",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "hooked content" {
|
||||
t.Fatalf("expected hooked content, got %q", resp)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
lastModel := provider.lastModel
|
||||
provider.mu.Unlock()
|
||||
if lastModel != "hook-model" {
|
||||
t.Fatalf("expected model hook-model, got %q", lastModel)
|
||||
}
|
||||
|
||||
select {
|
||||
case evt := <-hook.eventCh:
|
||||
if evt.Kind != EventKindTurnEnd {
|
||||
t.Fatalf("expected turn end event, got %v", evt.Kind)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for hook observer event")
|
||||
}
|
||||
}
|
||||
|
||||
type toolHookProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *toolHookProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.calls++
|
||||
if p.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Name: "echo_text",
|
||||
Arguments: map[string]any{"text": "original"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
last := messages[len(messages)-1]
|
||||
return &providers.LLMResponse{
|
||||
Content: last.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *toolHookProvider) GetDefaultModel() string {
|
||||
return "tool-hook-provider"
|
||||
}
|
||||
|
||||
type echoTextTool struct{}
|
||||
|
||||
func (t *echoTextTool) Name() string {
|
||||
return "echo_text"
|
||||
}
|
||||
|
||||
func (t *echoTextTool) Description() string {
|
||||
return "echo a text argument"
|
||||
}
|
||||
|
||||
func (t *echoTextTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *echoTextTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
text, _ := args["text"].(string)
|
||||
return tools.SilentResult(text)
|
||||
}
|
||||
|
||||
type toolRewriteHook struct{}
|
||||
|
||||
func (h *toolRewriteHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
) (*ToolCallHookRequest, HookDecision, error) {
|
||||
next := call.Clone()
|
||||
next.Arguments["text"] = "modified"
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *toolRewriteHook) AfterTool(
|
||||
ctx context.Context,
|
||||
result *ToolResultHookResponse,
|
||||
) (*ToolResultHookResponse, HookDecision, error) {
|
||||
next := result.Clone()
|
||||
next.Result.ForLLM = "after:" + next.Result.ForLLM
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) {
|
||||
provider := &toolHookProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
if err := al.MountHook(NamedHook("tool-rewrite", &toolRewriteHook{})); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
if resp != "after:modified" {
|
||||
t.Fatalf("expected rewritten tool result, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type denyApprovalHook struct{}
|
||||
|
||||
func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) {
|
||||
return ApprovalDecision{
|
||||
Approved: false,
|
||||
Reason: "blocked",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) {
|
||||
provider := &toolHookProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
defer cleanup()
|
||||
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
if err := al.MountHook(NamedHook("deny-approval", &denyApprovalHook{})); err != nil {
|
||||
t.Fatalf("MountHook failed: %v", err)
|
||||
}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
|
||||
SessionKey: "session-1",
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
UserMessage: "run tool",
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop failed: %v", err)
|
||||
}
|
||||
expected := "Tool execution denied by approval hook: blocked"
|
||||
if resp != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, resp)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
skippedEvt, ok := findEvent(events, EventKindToolExecSkipped)
|
||||
if !ok {
|
||||
t.Fatal("expected tool skipped event")
|
||||
}
|
||||
payload, ok := skippedEvt.Payload.(ToolExecSkippedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload)
|
||||
}
|
||||
if payload.Reason != expected {
|
||||
t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -130,6 +130,17 @@ func NewAgentInstance(
|
||||
maxTokens = 8192
|
||||
}
|
||||
|
||||
contextWindow := defaults.ContextWindow
|
||||
if contextWindow == 0 {
|
||||
// Default heuristic: 4x the output token limit.
|
||||
// Most models have context windows well above their output limits
|
||||
// (e.g., GPT-4o 128k ctx / 16k out, Claude 200k ctx / 8k out).
|
||||
// 4x is a conservative lower bound that avoids premature
|
||||
// summarization while remaining safe — the reactive
|
||||
// forceCompression handles any overshoot.
|
||||
contextWindow = maxTokens * 4
|
||||
}
|
||||
|
||||
temperature := 0.7
|
||||
if defaults.Temperature != nil {
|
||||
temperature = *defaults.Temperature
|
||||
@@ -182,7 +193,7 @@ func NewAgentInstance(
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
ContextWindow: maxTokens,
|
||||
ContextWindow: contextWindow,
|
||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||
SummarizeTokenPercent: summarizeTokenPercent,
|
||||
Provider: provider,
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
@@ -54,7 +54,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
@@ -83,7 +83,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
@@ -137,10 +137,10 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: tt.aliasName,
|
||||
ModelName: tt.aliasName,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: tt.aliasName,
|
||||
Model: tt.modelName,
|
||||
|
||||
+1597
-437
File diff suppressed because it is too large
Load Diff
+58
-37
@@ -67,7 +67,7 @@ func newTestAgentLoop(
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -90,7 +90,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -179,7 +179,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -215,7 +215,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -272,7 +272,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -308,7 +308,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = tmpDir
|
||||
cfg.Agents.Defaults.Model = "test-model"
|
||||
cfg.Agents.Defaults.ModelName = "test-model"
|
||||
cfg.Agents.Defaults.MaxTokens = 4096
|
||||
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestAgentLoop_Stop(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -558,7 +558,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -614,7 +614,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -694,26 +694,34 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/local-model",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://local.example.invalid/v1",
|
||||
},
|
||||
{
|
||||
ModelName: "deepseek",
|
||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
"deepseek": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &countingMockProvider{response: "LLM reply"}
|
||||
@@ -765,20 +773,26 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/local-model",
|
||||
APIKey: "test-key",
|
||||
APIBase: "https://local.example.invalid/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"test-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &countingMockProvider{response: "LLM reply"}
|
||||
@@ -840,26 +854,34 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Provider: "openai",
|
||||
Model: "local",
|
||||
ModelName: "local",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
ModelList: []config.ModelConfig{
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "local",
|
||||
Model: "openai/Qwen3.5-35B-A3B",
|
||||
APIKey: "local-key",
|
||||
APIBase: localServer.URL,
|
||||
},
|
||||
{
|
||||
ModelName: "deepseek",
|
||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||
APIKey: "remote-key",
|
||||
APIBase: remoteServer.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.WithSecurity(&config.SecurityConfig{
|
||||
ModelList: map[string]config.ModelSecurityEntry{
|
||||
"local": {
|
||||
APIKeys: []string{"local-key"},
|
||||
},
|
||||
"deepseek": {
|
||||
APIKeys: []string{"remote-key"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider, _, err := providers.CreateProvider(cfg)
|
||||
@@ -946,7 +968,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -988,7 +1010,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1059,7 +1081,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1078,11 +1100,11 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Inject some history to simulate a full context
|
||||
// Inject some history to simulate a full context.
|
||||
// Session history only stores user/assistant/tool messages — the system
|
||||
// prompt is built dynamically by BuildMessages and is NOT stored here.
|
||||
sessionKey := "test-session-context"
|
||||
// Create dummy history
|
||||
history := []providers.Message{
|
||||
{Role: "system", Content: "System prompt"},
|
||||
{Role: "user", Content: "Old message 1"},
|
||||
{Role: "assistant", Content: "Old response 1"},
|
||||
{Role: "user", Content: "Old message 2"},
|
||||
@@ -1120,12 +1142,11 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||
// Check final history length
|
||||
finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
// We verify that the history has been modified (compressed)
|
||||
// Original length: 6
|
||||
// Expected behavior: compression drops ~50% of history (mid slice)
|
||||
// We can assert that the length is NOT what it would be without compression.
|
||||
// Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8
|
||||
if len(finalHistory) >= 8 {
|
||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||
// Original length: 5
|
||||
// Expected behavior: compression drops ~50% of Turns
|
||||
// Without compression: 5 + 1 (new user msg) + 1 (assistant msg) = 7
|
||||
if len(finalHistory) >= 7 {
|
||||
t.Errorf("Expected history to be compressed (len < 7), got %d", len(finalHistory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,7 +1161,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
@@ -1171,7 +1192,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 1,
|
||||
},
|
||||
@@ -1228,7 +1249,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1280,7 +1301,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
@@ -1350,7 +1371,7 @@ func TestHandleReasoning(t *testing.T) {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ func testCfg(agents []config.AgentConfig) *config.Config {
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: "/tmp/picoclaw-test-registry",
|
||||
Model: "gpt-4",
|
||||
ModelName: "gpt-4",
|
||||
MaxTokens: 8192,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// SteeringMode controls how queued steering messages are dequeued.
|
||||
type SteeringMode string
|
||||
|
||||
const (
|
||||
// SteeringOneAtATime dequeues only the first queued message per poll.
|
||||
SteeringOneAtATime SteeringMode = "one-at-a-time"
|
||||
// SteeringAll drains the entire queue in a single poll.
|
||||
SteeringAll SteeringMode = "all"
|
||||
// MaxQueueSize number of possible messages in the Steering Queue
|
||||
MaxQueueSize = 10
|
||||
// manualSteeringScope is the legacy fallback queue used when no active
|
||||
// turn/session scope is available.
|
||||
manualSteeringScope = "__manual__"
|
||||
)
|
||||
|
||||
// parseSteeringMode normalizes a config string into a SteeringMode.
|
||||
func parseSteeringMode(s string) SteeringMode {
|
||||
switch s {
|
||||
case "all":
|
||||
return SteeringAll
|
||||
default:
|
||||
return SteeringOneAtATime
|
||||
}
|
||||
}
|
||||
|
||||
// steeringQueue is a thread-safe queue of user messages that can be injected
|
||||
// into a running agent loop to interrupt it between tool calls.
|
||||
type steeringQueue struct {
|
||||
mu sync.Mutex
|
||||
queues map[string][]providers.Message
|
||||
mode SteeringMode
|
||||
}
|
||||
|
||||
func newSteeringQueue(mode SteeringMode) *steeringQueue {
|
||||
return &steeringQueue{
|
||||
queues: make(map[string][]providers.Message),
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSteeringScope(scope string) string {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return manualSteeringScope
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
// push enqueues a steering message in the legacy fallback scope.
|
||||
func (sq *steeringQueue) push(msg providers.Message) error {
|
||||
return sq.pushScope(manualSteeringScope, msg)
|
||||
}
|
||||
|
||||
// pushScope enqueues a steering message for the provided scope.
|
||||
func (sq *steeringQueue) pushScope(scope string, msg providers.Message) error {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
|
||||
scope = normalizeSteeringScope(scope)
|
||||
queue := sq.queues[scope]
|
||||
if len(queue) >= MaxQueueSize {
|
||||
return fmt.Errorf("steering queue is full")
|
||||
}
|
||||
sq.queues[scope] = append(queue, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dequeue removes and returns pending steering messages from the legacy
|
||||
// fallback scope according to the configured mode.
|
||||
func (sq *steeringQueue) dequeue() []providers.Message {
|
||||
return sq.dequeueScope(manualSteeringScope)
|
||||
}
|
||||
|
||||
// dequeueScope removes and returns pending steering messages for the provided
|
||||
// scope according to the configured mode.
|
||||
func (sq *steeringQueue) dequeueScope(scope string) []providers.Message {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
|
||||
return sq.dequeueLocked(normalizeSteeringScope(scope))
|
||||
}
|
||||
|
||||
// dequeueScopeWithFallback drains the scoped queue first and falls back to the
|
||||
// legacy manual scope for backwards compatibility.
|
||||
func (sq *steeringQueue) dequeueScopeWithFallback(scope string) []providers.Message {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
if msgs := sq.dequeueLocked(scope); len(msgs) > 0 {
|
||||
return msgs
|
||||
}
|
||||
}
|
||||
|
||||
return sq.dequeueLocked(manualSteeringScope)
|
||||
}
|
||||
|
||||
func (sq *steeringQueue) dequeueLocked(scope string) []providers.Message {
|
||||
queue := sq.queues[scope]
|
||||
if len(queue) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch sq.mode {
|
||||
case SteeringAll:
|
||||
msgs := append([]providers.Message(nil), queue...)
|
||||
delete(sq.queues, scope)
|
||||
return msgs
|
||||
default:
|
||||
msg := queue[0]
|
||||
queue[0] = providers.Message{} // Clear reference for GC
|
||||
queue = queue[1:]
|
||||
if len(queue) == 0 {
|
||||
delete(sq.queues, scope)
|
||||
} else {
|
||||
sq.queues[scope] = queue
|
||||
}
|
||||
return []providers.Message{msg}
|
||||
}
|
||||
}
|
||||
|
||||
// len returns the number of queued messages across all scopes.
|
||||
func (sq *steeringQueue) len() int {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
|
||||
total := 0
|
||||
for _, queue := range sq.queues {
|
||||
total += len(queue)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// lenScope returns the number of queued messages for a specific scope.
|
||||
func (sq *steeringQueue) lenScope(scope string) int {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
return len(sq.queues[normalizeSteeringScope(scope)])
|
||||
}
|
||||
|
||||
// setMode updates the steering mode.
|
||||
func (sq *steeringQueue) setMode(mode SteeringMode) {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
sq.mode = mode
|
||||
}
|
||||
|
||||
// getMode returns the current steering mode.
|
||||
func (sq *steeringQueue) getMode() SteeringMode {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
return sq.mode
|
||||
}
|
||||
|
||||
// Steer enqueues a user message to be injected into the currently running
|
||||
// agent loop. The message will be picked up after the current tool finishes
|
||||
// executing, causing any remaining tool calls in the batch to be skipped.
|
||||
func (al *AgentLoop) Steer(msg providers.Message) error {
|
||||
scope := ""
|
||||
agentID := ""
|
||||
if ts := al.getAnyActiveTurnState(); ts != nil {
|
||||
scope = ts.sessionKey
|
||||
agentID = ts.agentID
|
||||
}
|
||||
return al.enqueueSteeringMessage(scope, agentID, msg)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers.Message) error {
|
||||
if al.steering == nil {
|
||||
return fmt.Errorf("steering queue is not initialized")
|
||||
}
|
||||
|
||||
if err := al.steering.pushScope(scope, msg); err != nil {
|
||||
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
|
||||
"error": err.Error(),
|
||||
"role": msg.Role,
|
||||
"scope": normalizeSteeringScope(scope),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
queueDepth := al.steering.lenScope(scope)
|
||||
logger.DebugCF("agent", "Steering message enqueued", map[string]any{
|
||||
"role": msg.Role,
|
||||
"content_len": len(msg.Content),
|
||||
"media_count": len(msg.Media),
|
||||
"queue_len": queueDepth,
|
||||
"scope": normalizeSteeringScope(scope),
|
||||
})
|
||||
|
||||
meta := EventMeta{
|
||||
Source: "Steer",
|
||||
TracePath: "turn.interrupt.received",
|
||||
}
|
||||
if ts := al.getAnyActiveTurnState(); ts != nil {
|
||||
meta = ts.eventMeta("Steer", "turn.interrupt.received")
|
||||
} else {
|
||||
if strings.TrimSpace(agentID) != "" {
|
||||
meta.AgentID = agentID
|
||||
}
|
||||
normalizedScope := normalizeSteeringScope(scope)
|
||||
if normalizedScope != manualSteeringScope {
|
||||
meta.SessionKey = normalizedScope
|
||||
}
|
||||
if meta.AgentID == "" {
|
||||
if registry := al.GetRegistry(); registry != nil {
|
||||
if agent := registry.GetDefaultAgent(); agent != nil {
|
||||
meta.AgentID = agent.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
al.emitEvent(
|
||||
EventKindInterruptReceived,
|
||||
meta,
|
||||
InterruptReceivedPayload{
|
||||
Kind: InterruptKindSteering,
|
||||
Role: msg.Role,
|
||||
ContentLen: len(msg.Content),
|
||||
QueueDepth: queueDepth,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SteeringMode returns the current steering mode.
|
||||
func (al *AgentLoop) SteeringMode() SteeringMode {
|
||||
if al.steering == nil {
|
||||
return SteeringOneAtATime
|
||||
}
|
||||
return al.steering.getMode()
|
||||
}
|
||||
|
||||
// SetSteeringMode updates the steering mode.
|
||||
func (al *AgentLoop) SetSteeringMode(mode SteeringMode) {
|
||||
if al.steering == nil {
|
||||
return
|
||||
}
|
||||
al.steering.setMode(mode)
|
||||
}
|
||||
|
||||
// dequeueSteeringMessages is the internal method called by the agent loop
|
||||
// to poll for steering messages in the legacy fallback scope.
|
||||
func (al *AgentLoop) dequeueSteeringMessages() []providers.Message {
|
||||
if al.steering == nil {
|
||||
return nil
|
||||
}
|
||||
return al.steering.dequeue()
|
||||
}
|
||||
|
||||
func (al *AgentLoop) dequeueSteeringMessagesForScope(scope string) []providers.Message {
|
||||
if al.steering == nil {
|
||||
return nil
|
||||
}
|
||||
return al.steering.dequeueScope(scope)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) dequeueSteeringMessagesForScopeWithFallback(scope string) []providers.Message {
|
||||
if al.steering == nil {
|
||||
return nil
|
||||
}
|
||||
return al.steering.dequeueScopeWithFallback(scope)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) pendingSteeringCountForScope(scope string) int {
|
||||
if al.steering == nil {
|
||||
return 0
|
||||
}
|
||||
return al.steering.lenScope(scope)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) continueWithSteeringMessages(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
sessionKey, channel, chatID string,
|
||||
steeringMsgs []providers.Message,
|
||||
) (string, error) {
|
||||
return al.runAgentLoop(ctx, agent, processOptions{
|
||||
SessionKey: sessionKey,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
InitialSteeringMessages: steeringMsgs,
|
||||
SkipInitialSteeringPoll: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
|
||||
registry := al.GetRegistry()
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parsed := routing.ParseAgentSessionKey(sessionKey); parsed != nil {
|
||||
if agent, ok := registry.GetAgent(parsed.AgentID); ok {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
|
||||
return registry.GetDefaultAgent()
|
||||
}
|
||||
|
||||
// Continue resumes an idle agent by dequeuing any pending steering messages
|
||||
// and running them through the agent loop. This is used when the agent's last
|
||||
// message was from the assistant (i.e., it has stopped processing) and the
|
||||
// user has since enqueued steering messages.
|
||||
//
|
||||
// If no steering messages are pending, it returns an empty string.
|
||||
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) {
|
||||
if active := al.GetActiveTurn(); active != nil {
|
||||
return "", fmt.Errorf("turn %s is still active", active.TurnID)
|
||||
}
|
||||
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
||||
if len(steeringMsgs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
agent := al.agentForSession(sessionKey)
|
||||
if agent == nil {
|
||||
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
||||
}
|
||||
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||
resetter.ResetSentInRound()
|
||||
}
|
||||
}
|
||||
|
||||
return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) InterruptGraceful(hint string) error {
|
||||
ts := al.getAnyActiveTurnState()
|
||||
if ts == nil {
|
||||
return fmt.Errorf("no active turn")
|
||||
}
|
||||
if !ts.requestGracefulInterrupt(hint) {
|
||||
return fmt.Errorf("turn %s cannot accept graceful interrupt", ts.turnID)
|
||||
}
|
||||
|
||||
al.emitEvent(
|
||||
EventKindInterruptReceived,
|
||||
ts.eventMeta("InterruptGraceful", "turn.interrupt.received"),
|
||||
InterruptReceivedPayload{
|
||||
Kind: InterruptKindGraceful,
|
||||
HintLen: len(hint),
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) InterruptHard() error {
|
||||
ts := al.getAnyActiveTurnState()
|
||||
if ts == nil {
|
||||
return fmt.Errorf("no active turn")
|
||||
}
|
||||
if !ts.requestHardAbort() {
|
||||
return fmt.Errorf("turn %s is already aborting", ts.turnID)
|
||||
}
|
||||
|
||||
al.emitEvent(
|
||||
EventKindInterruptReceived,
|
||||
ts.eventMeta("InterruptHard", "turn.interrupt.received"),
|
||||
InterruptReceivedPayload{
|
||||
Kind: InterruptKindHard,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================== SubTurn Result Polling ======================
|
||||
|
||||
// dequeuePendingSubTurnResults polls the SubTurn result channel for the given
|
||||
// session and returns all available results without blocking.
|
||||
// Returns nil if no active turn state exists for this session.
|
||||
func (al *AgentLoop) dequeuePendingSubTurnResults(sessionKey string) []*tools.ToolResult {
|
||||
tsInterface, ok := al.activeTurnStates.Load(sessionKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ts, ok := tsInterface.(*turnState)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []*tools.ToolResult
|
||||
for {
|
||||
select {
|
||||
case result, ok := <-ts.pendingResults:
|
||||
if !ok {
|
||||
return results
|
||||
}
|
||||
if result != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
default:
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Hard Abort ======================
|
||||
|
||||
// HardAbort immediately cancels the running agent loop for the given session,
|
||||
// cascading the cancellation to all child SubTurns. This is a destructive operation
|
||||
// that terminates execution without waiting for graceful cleanup.
|
||||
//
|
||||
// Use this when the user explicitly requests immediate termination (e.g., "stop now", "abort").
|
||||
// For graceful interruption that allows the agent to finish the current tool and summarize,
|
||||
// use Steer() instead.
|
||||
func (al *AgentLoop) HardAbort(sessionKey string) error {
|
||||
tsInterface, ok := al.activeTurnStates.Load(sessionKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("no active turn state found for session %s", sessionKey)
|
||||
}
|
||||
|
||||
ts, ok := tsInterface.(*turnState)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid turn state type for session %s", sessionKey)
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Hard abort triggered", map[string]any{
|
||||
"session_key": sessionKey,
|
||||
"turn_id": ts.turnID,
|
||||
"depth": ts.depth,
|
||||
"initial_history_length": ts.initialHistoryLength,
|
||||
})
|
||||
|
||||
// IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns
|
||||
// from adding more messages to the session. This prevents race conditions
|
||||
// where rollback happens while children are still writing.
|
||||
// Use isHardAbort=true for hard abort to immediately cancel all children.
|
||||
ts.Finish(true)
|
||||
|
||||
// Roll back session history to the state before the turn started.
|
||||
if ts.session != nil {
|
||||
history := ts.session.GetHistory(sessionKey)
|
||||
if ts.initialHistoryLength < len(history) {
|
||||
ts.session.SetHistory(sessionKey, history[:ts.initialHistoryLength])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================== Follow-Up Injection ======================
|
||||
|
||||
// InjectFollowUp enqueues a message to be automatically processed after the current
|
||||
// turn completes. Unlike Steer(), which interrupts the current execution, InjectFollowUp
|
||||
// waits for the current turn to finish naturally before processing the message.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Automated workflows that need to chain multiple turns
|
||||
// - Background tasks that should run after the main task completes
|
||||
// - Scheduled follow-up actions
|
||||
//
|
||||
// The message will be processed via Continue() when the agent becomes idle.
|
||||
func (al *AgentLoop) InjectFollowUp(msg providers.Message) error {
|
||||
// InjectFollowUp uses the same steering queue mechanism as Steer(),
|
||||
// but the semantic difference is in when it's called:
|
||||
// - Steer() is called during active execution to interrupt
|
||||
// - InjectFollowUp() is called when planning future work
|
||||
//
|
||||
// Both end up in the same queue and are processed by Continue()
|
||||
// when the agent is idle.
|
||||
return al.Steer(msg)
|
||||
}
|
||||
|
||||
// ====================== API Aliases for Design Document Compatibility ======================
|
||||
|
||||
// InjectSteering is an alias for Steer() to match the design document naming.
|
||||
// It injects a steering message into the currently running agent loop.
|
||||
func (al *AgentLoop) InjectSteering(msg providers.Message) error {
|
||||
return al.Steer(msg)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// ====================== Config & Constants ======================
|
||||
const (
|
||||
// Default values for SubTurn configuration (used when config is not set or is zero)
|
||||
defaultMaxSubTurnDepth = 3
|
||||
defaultMaxConcurrentSubTurns = 5
|
||||
defaultConcurrencyTimeout = 30 * time.Second
|
||||
defaultSubTurnTimeout = 5 * time.Minute
|
||||
// maxEphemeralHistorySize limits the number of messages stored in ephemeral sessions.
|
||||
// This prevents memory accumulation in long-running sub-turns.
|
||||
maxEphemeralHistorySize = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDepthLimitExceeded = errors.New("sub-turn depth limit exceeded")
|
||||
ErrInvalidSubTurnConfig = errors.New("invalid sub-turn config")
|
||||
ErrConcurrencyTimeout = errors.New("timeout waiting for concurrency slot")
|
||||
)
|
||||
|
||||
// getSubTurnConfig returns the effective SubTurn configuration with defaults applied.
|
||||
func (al *AgentLoop) getSubTurnConfig() subTurnRuntimeConfig {
|
||||
cfg := al.cfg.Agents.Defaults.SubTurn
|
||||
|
||||
maxDepth := cfg.MaxDepth
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = defaultMaxSubTurnDepth
|
||||
}
|
||||
|
||||
maxConcurrent := cfg.MaxConcurrent
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = defaultMaxConcurrentSubTurns
|
||||
}
|
||||
|
||||
concurrencyTimeout := time.Duration(cfg.ConcurrencyTimeoutSec) * time.Second
|
||||
if concurrencyTimeout <= 0 {
|
||||
concurrencyTimeout = defaultConcurrencyTimeout
|
||||
}
|
||||
|
||||
defaultTimeout := time.Duration(cfg.DefaultTimeoutMinutes) * time.Minute
|
||||
if defaultTimeout <= 0 {
|
||||
defaultTimeout = defaultSubTurnTimeout
|
||||
}
|
||||
|
||||
return subTurnRuntimeConfig{
|
||||
maxDepth: maxDepth,
|
||||
maxConcurrent: maxConcurrent,
|
||||
concurrencyTimeout: concurrencyTimeout,
|
||||
defaultTimeout: defaultTimeout,
|
||||
defaultTokenBudget: cfg.DefaultTokenBudget,
|
||||
}
|
||||
}
|
||||
|
||||
// subTurnRuntimeConfig holds the effective runtime configuration for SubTurn execution.
|
||||
type subTurnRuntimeConfig struct {
|
||||
maxDepth int
|
||||
maxConcurrent int
|
||||
concurrencyTimeout time.Duration
|
||||
defaultTimeout time.Duration
|
||||
defaultTokenBudget int
|
||||
}
|
||||
|
||||
// ====================== SubTurn Config ======================
|
||||
|
||||
// SubTurnConfig configures the execution of a child sub-turn.
|
||||
//
|
||||
// Usage Examples:
|
||||
//
|
||||
// Synchronous sub-turn (Async=false):
|
||||
//
|
||||
// cfg := SubTurnConfig{
|
||||
// Model: "gpt-4o-mini",
|
||||
// SystemPrompt: "Analyze this code",
|
||||
// Async: false, // Result returned immediately
|
||||
// }
|
||||
// result, err := SpawnSubTurn(ctx, cfg)
|
||||
// // Use result directly here
|
||||
// processResult(result)
|
||||
//
|
||||
// Asynchronous sub-turn (Async=true):
|
||||
//
|
||||
// cfg := SubTurnConfig{
|
||||
// Model: "gpt-4o-mini",
|
||||
// SystemPrompt: "Background analysis",
|
||||
// Async: true, // Result delivered to channel
|
||||
// }
|
||||
// result, err := SpawnSubTurn(ctx, cfg)
|
||||
// // Result also available in parent's pendingResults channel
|
||||
// // Parent turn will poll and process it in a later iteration
|
||||
type SubTurnConfig struct {
|
||||
Model string
|
||||
Tools []tools.Tool
|
||||
SystemPrompt string
|
||||
MaxTokens int
|
||||
|
||||
// Async controls the result delivery mechanism:
|
||||
//
|
||||
// When Async = false (synchronous sub-turn):
|
||||
// - The caller blocks until the sub-turn completes
|
||||
// - The result is ONLY returned via the function return value
|
||||
// - The result is NOT delivered to the parent's pendingResults channel
|
||||
// - This prevents double delivery: caller gets result immediately, no need for channel
|
||||
// - Use case: When the caller needs the result immediately to continue execution
|
||||
// - Example: A tool that needs to process the sub-turn result before returning
|
||||
//
|
||||
// When Async = true (asynchronous sub-turn):
|
||||
// - The sub-turn runs in the background (still blocks the caller, but semantically async)
|
||||
// - The result is delivered to the parent's pendingResults channel
|
||||
// - The result is ALSO returned via the function return value (for consistency)
|
||||
// - The parent turn can poll pendingResults in later iterations to process results
|
||||
// - Use case: Fire-and-forget operations, or when results are processed in batches
|
||||
// - Example: Spawning multiple sub-turns in parallel and collecting results later
|
||||
//
|
||||
// IMPORTANT: The Async flag does NOT make the call non-blocking. It only controls
|
||||
// whether the result is delivered via the channel. For true non-blocking execution,
|
||||
// the caller must spawn the sub-turn in a separate goroutine.
|
||||
Async bool
|
||||
|
||||
// Critical indicates this SubTurn's result is important and should continue
|
||||
// running even after the parent turn finishes gracefully.
|
||||
//
|
||||
// When parent finishes gracefully (Finish(false)):
|
||||
// - Critical=true: SubTurn continues running, delivers result as orphan
|
||||
// - Critical=false: SubTurn exits gracefully without error
|
||||
//
|
||||
// When parent finishes with hard abort (Finish(true)):
|
||||
// - All SubTurns are canceled regardless of Critical flag
|
||||
Critical bool
|
||||
|
||||
// Timeout is the maximum duration for this SubTurn.
|
||||
// If the SubTurn runs longer than this, it will be canceled.
|
||||
// Default is 5 minutes (defaultSubTurnTimeout) if not specified.
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxContextRunes limits the context size (in runes) passed to the SubTurn.
|
||||
// This prevents context window overflow by truncating message history before LLM calls.
|
||||
//
|
||||
// Values:
|
||||
// 0 = Auto-calculate based on model's ContextWindow * 0.75 (default, recommended)
|
||||
// -1 = No limit (disable soft truncation, rely only on hard context errors)
|
||||
// >0 = Use specified rune limit
|
||||
//
|
||||
// The soft limit acts as a first line of defense before hitting the provider's
|
||||
// hard context window limit. When exceeded, older messages are intelligently
|
||||
// truncated while preserving system messages and recent context.
|
||||
MaxContextRunes int
|
||||
|
||||
// ActualSystemPrompt is injected as the true 'system' role message for the childAgent.
|
||||
// The legacy SystemPrompt field is actually used as the first 'user' message (task description).
|
||||
ActualSystemPrompt string
|
||||
|
||||
// InitialMessages preloads the ephemeral session history before the agent loop starts.
|
||||
// Used by evaluator-optimizer patterns to pass the full worker context across multiple iterations.
|
||||
InitialMessages []providers.Message
|
||||
|
||||
// InitialTokenBudget is a shared atomic counter for tracking remaining tokens.
|
||||
// If set, the SubTurn will inherit this budget and deduct tokens after each LLM call.
|
||||
// If nil, the SubTurn will inherit the parent's tokenBudget (if any).
|
||||
// Used by team tool to enforce token limits across all team members.
|
||||
InitialTokenBudget *atomic.Int64
|
||||
|
||||
// Can be extended with temperature, topP, etc.
|
||||
}
|
||||
|
||||
// ====================== Context Keys ======================
|
||||
type agentLoopKeyType struct{}
|
||||
|
||||
var agentLoopKey = agentLoopKeyType{}
|
||||
|
||||
// WithAgentLoop injects AgentLoop into context for tool access
|
||||
func WithAgentLoop(ctx context.Context, al *AgentLoop) context.Context {
|
||||
return context.WithValue(ctx, agentLoopKey, al)
|
||||
}
|
||||
|
||||
// AgentLoopFromContext retrieves AgentLoop from context
|
||||
func AgentLoopFromContext(ctx context.Context) *AgentLoop {
|
||||
al, _ := ctx.Value(agentLoopKey).(*AgentLoop)
|
||||
return al
|
||||
}
|
||||
|
||||
// ====================== Helper Functions ======================
|
||||
|
||||
func (al *AgentLoop) generateSubTurnID() string {
|
||||
return fmt.Sprintf("subturn-%d", al.subTurnCounter.Add(1))
|
||||
}
|
||||
|
||||
// ====================== Core Function: spawnSubTurn ======================
|
||||
|
||||
// AgentLoopSpawner implements tools.SubTurnSpawner interface.
|
||||
// This allows tools to spawn sub-turns without circular dependency.
|
||||
type AgentLoopSpawner struct {
|
||||
al *AgentLoop
|
||||
}
|
||||
|
||||
// SpawnSubTurn implements tools.SubTurnSpawner interface.
|
||||
func (s *AgentLoopSpawner) SpawnSubTurn(
|
||||
ctx context.Context,
|
||||
cfg tools.SubTurnConfig,
|
||||
) (*tools.ToolResult, error) {
|
||||
parentTS := turnStateFromContext(ctx)
|
||||
if parentTS == nil {
|
||||
return nil, errors.New(
|
||||
"parent turnState not found in context - cannot spawn sub-turn outside of a turn",
|
||||
)
|
||||
}
|
||||
|
||||
// Convert tools.SubTurnConfig to agent.SubTurnConfig
|
||||
agentCfg := SubTurnConfig{
|
||||
Model: cfg.Model,
|
||||
Tools: cfg.Tools,
|
||||
SystemPrompt: cfg.SystemPrompt,
|
||||
ActualSystemPrompt: cfg.ActualSystemPrompt,
|
||||
InitialMessages: cfg.InitialMessages,
|
||||
InitialTokenBudget: cfg.InitialTokenBudget,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
Async: cfg.Async,
|
||||
Critical: cfg.Critical,
|
||||
Timeout: cfg.Timeout,
|
||||
MaxContextRunes: cfg.MaxContextRunes,
|
||||
}
|
||||
|
||||
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
|
||||
}
|
||||
|
||||
// NewSubTurnSpawner creates a SubTurnSpawner for the given AgentLoop.
|
||||
func NewSubTurnSpawner(al *AgentLoop) *AgentLoopSpawner {
|
||||
return &AgentLoopSpawner{al: al}
|
||||
}
|
||||
|
||||
// SpawnSubTurn is the exported entry point for tools to spawn sub-turns.
|
||||
// It retrieves AgentLoop and parent turnState from context and delegates to spawnSubTurn.
|
||||
func SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*tools.ToolResult, error) {
|
||||
al := AgentLoopFromContext(ctx)
|
||||
if al == nil {
|
||||
return nil, errors.New(
|
||||
"AgentLoop not found in context - ensure context is properly initialized",
|
||||
)
|
||||
}
|
||||
|
||||
parentTS := turnStateFromContext(ctx)
|
||||
if parentTS == nil {
|
||||
return nil, errors.New(
|
||||
"parent turnState not found in context - cannot spawn sub-turn outside of a turn",
|
||||
)
|
||||
}
|
||||
|
||||
return spawnSubTurn(ctx, al, parentTS, cfg)
|
||||
}
|
||||
|
||||
func spawnSubTurn(
|
||||
ctx context.Context,
|
||||
al *AgentLoop,
|
||||
parentTS *turnState,
|
||||
cfg SubTurnConfig,
|
||||
) (result *tools.ToolResult, err error) {
|
||||
// Get effective SubTurn configuration
|
||||
rtCfg := al.getSubTurnConfig()
|
||||
|
||||
// 0. Acquire concurrency semaphore FIRST to ensure it's released even if early validation fails.
|
||||
// Blocks if parent already has maxConcurrentSubTurns running, with a timeout to prevent indefinite blocking.
|
||||
// Also respects context cancellation so we don't block forever if parent is aborted.
|
||||
// NOTE: The semaphore is released immediately after runTurn completes (not in a defer) to
|
||||
// ensure it is freed before the cleanup phase (async result delivery), which may block on
|
||||
// a full pendingResults channel. Holding the semaphore through cleanup would allow the
|
||||
// parent's goroutine to be blocked waiting for a semaphore slot while child turns are
|
||||
// blocked delivering results — a deadlock.
|
||||
var semAcquired bool
|
||||
if parentTS.concurrencySem != nil {
|
||||
// Create a timeout context for semaphore acquisition
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, rtCfg.concurrencyTimeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case parentTS.concurrencySem <- struct{}{}:
|
||||
semAcquired = true
|
||||
defer func() {
|
||||
if semAcquired {
|
||||
<-parentTS.concurrencySem
|
||||
}
|
||||
}()
|
||||
case <-timeoutCtx.Done():
|
||||
// Check parent context first - if it was canceled, propagate that error
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
// Otherwise it's our timeout
|
||||
return nil, fmt.Errorf("%w: all %d slots occupied for %v",
|
||||
ErrConcurrencyTimeout, rtCfg.maxConcurrent, rtCfg.concurrencyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Depth limit check
|
||||
if parentTS.depth >= rtCfg.maxDepth {
|
||||
logger.WarnCF("subturn", "Depth limit exceeded", map[string]any{
|
||||
"parent_id": parentTS.turnID,
|
||||
"depth": parentTS.depth,
|
||||
"max_depth": rtCfg.maxDepth,
|
||||
})
|
||||
return nil, ErrDepthLimitExceeded
|
||||
}
|
||||
|
||||
// 2. Config validation
|
||||
if cfg.Model == "" {
|
||||
return nil, ErrInvalidSubTurnConfig
|
||||
}
|
||||
|
||||
// 3. Determine timeout for child SubTurn
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = rtCfg.defaultTimeout
|
||||
}
|
||||
|
||||
// 4. Create INDEPENDENT child context (not derived from parent ctx).
|
||||
// This allows the child to continue running after parent finishes gracefully.
|
||||
// The child has its own timeout for self-protection.
|
||||
childCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
childID := al.generateSubTurnID()
|
||||
|
||||
// Get the agent instance from parent, falling back to the default agent.
|
||||
// Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store
|
||||
// so that child turns never pollute or persist to the parent's session history.
|
||||
baseAgent := parentTS.agent
|
||||
if baseAgent == nil {
|
||||
baseAgent = al.registry.GetDefaultAgent()
|
||||
}
|
||||
if baseAgent == nil {
|
||||
return nil, errors.New("parent turnState has no agent instance")
|
||||
}
|
||||
ephemeralStore := newEphemeralSession(nil)
|
||||
agent := *baseAgent // shallow copy
|
||||
agent.Sessions = ephemeralStore
|
||||
// Clone the tool registry so child turn's tool registrations
|
||||
// don't pollute the parent's registry.
|
||||
if baseAgent.Tools != nil {
|
||||
agent.Tools = baseAgent.Tools.Clone()
|
||||
}
|
||||
|
||||
// Create processOptions for the child turn
|
||||
opts := processOptions{
|
||||
SessionKey: childID,
|
||||
Channel: parentTS.channel,
|
||||
ChatID: parentTS.chatID,
|
||||
SenderID: parentTS.opts.SenderID,
|
||||
SenderDisplayName: parentTS.opts.SenderDisplayName,
|
||||
UserMessage: cfg.SystemPrompt, // Task description becomes the first user message
|
||||
SystemPromptOverride: cfg.ActualSystemPrompt,
|
||||
Media: nil,
|
||||
InitialSteeringMessages: cfg.InitialMessages,
|
||||
DefaultResponse: "",
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
NoHistory: true, // SubTurns don't use session history
|
||||
SkipInitialSteeringPoll: true,
|
||||
}
|
||||
|
||||
// Create event scope for the child turn
|
||||
scope := al.newTurnEventScope(agent.ID, childID)
|
||||
|
||||
// Create child turnState using the new API
|
||||
childTS := newTurnState(&agent, opts, scope)
|
||||
|
||||
// Set SubTurn-specific fields
|
||||
childTS.cancelFunc = cancel
|
||||
childTS.critical = cfg.Critical
|
||||
childTS.depth = parentTS.depth + 1
|
||||
childTS.parentTurnID = parentTS.turnID
|
||||
childTS.parentTurnState = parentTS
|
||||
childTS.pendingResults = make(chan *tools.ToolResult, 16)
|
||||
childTS.concurrencySem = make(chan struct{}, rtCfg.maxConcurrent)
|
||||
childTS.al = al // back-ref for hard abort cascade
|
||||
childTS.session = ephemeralStore // same store as agent.Sessions
|
||||
|
||||
// Token budget initialization/inheritance
|
||||
// If InitialTokenBudget is explicitly provided (e.g., by team tool), use it.
|
||||
// Otherwise, inherit from parent's tokenBudget (for nested SubTurns).
|
||||
if cfg.InitialTokenBudget != nil {
|
||||
childTS.tokenBudget = cfg.InitialTokenBudget
|
||||
} else if parentTS.tokenBudget != nil {
|
||||
childTS.tokenBudget = parentTS.tokenBudget
|
||||
} else if rtCfg.defaultTokenBudget > 0 {
|
||||
// Apply default token budget from config if no budget is set
|
||||
budget := &atomic.Int64{}
|
||||
budget.Store(int64(rtCfg.defaultTokenBudget))
|
||||
childTS.tokenBudget = budget
|
||||
}
|
||||
|
||||
// IMPORTANT: Put childTS into childCtx so that code inside runTurn can retrieve it
|
||||
childCtx = withTurnState(childCtx, childTS)
|
||||
childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn
|
||||
|
||||
childTS.ctx = childCtx
|
||||
|
||||
// Register child turn state so GetAllActiveTurns/Subagents can find it
|
||||
al.activeTurnStates.Store(childID, childTS)
|
||||
defer al.activeTurnStates.Delete(childID)
|
||||
|
||||
// 5. Establish parent-child relationship (thread-safe)
|
||||
parentTS.mu.Lock()
|
||||
parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID)
|
||||
parentTS.mu.Unlock()
|
||||
|
||||
// 6. Emit Spawn event
|
||||
al.emitEvent(EventKindSubTurnSpawn,
|
||||
childTS.eventMeta("spawnSubTurn", "subturn.spawn"),
|
||||
SubTurnSpawnPayload{
|
||||
AgentID: childTS.agentID,
|
||||
Label: childID,
|
||||
ParentTurnID: parentTS.turnID,
|
||||
},
|
||||
)
|
||||
|
||||
// 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("subturn panicked: %v", r)
|
||||
result = nil
|
||||
logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{
|
||||
"child_id": childID,
|
||||
"parent_id": parentTS.turnID,
|
||||
"panic": r,
|
||||
})
|
||||
}
|
||||
|
||||
// Result Delivery Strategy (Async vs Sync)
|
||||
if cfg.Async {
|
||||
deliverSubTurnResult(al, parentTS, childID, result)
|
||||
}
|
||||
|
||||
status := "completed"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
al.emitEvent(EventKindSubTurnEnd,
|
||||
childTS.eventMeta("spawnSubTurn", "subturn.end"),
|
||||
SubTurnEndPayload{
|
||||
AgentID: childTS.agentID,
|
||||
Status: status,
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
// 8. Execute sub-turn via the real agent loop.
|
||||
turnRes, turnErr := al.runTurn(childCtx, childTS)
|
||||
|
||||
// Release the concurrency semaphore immediately after runTurn completes,
|
||||
// before the cleanup defer runs. This prevents a deadlock where:
|
||||
// - All semaphore slots are held by sub-turns in their cleanup phase
|
||||
// - Cleanup blocks on a full pendingResults channel
|
||||
// - The parent goroutine is blocked waiting for a semaphore slot
|
||||
// - The parent cannot consume pendingResults because it is blocked on the semaphore
|
||||
if semAcquired {
|
||||
<-parentTS.concurrencySem
|
||||
semAcquired = false // prevent the defer from double-releasing
|
||||
}
|
||||
|
||||
// Convert turnResult to tools.ToolResult
|
||||
if turnErr != nil {
|
||||
err = turnErr
|
||||
result = &tools.ToolResult{
|
||||
Err: turnErr,
|
||||
ForLLM: fmt.Sprintf("SubTurn failed: %v", turnErr),
|
||||
}
|
||||
} else {
|
||||
result = &tools.ToolResult{
|
||||
ForLLM: turnRes.finalContent,
|
||||
ForUser: turnRes.finalContent,
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ====================== Result Delivery ======================
|
||||
|
||||
// deliverSubTurnResult delivers a sub-turn result to the parent turn's pendingResults channel.
|
||||
//
|
||||
// IMPORTANT: This function is ONLY called for asynchronous sub-turns (Async=true).
|
||||
// For synchronous sub-turns (Async=false), results are returned directly via the function
|
||||
// return value to avoid double delivery.
|
||||
//
|
||||
// Delivery behavior:
|
||||
// - If parent turn is still running: attempts to deliver to pendingResults channel
|
||||
// - If channel is full: emits SubTurnOrphanResultEvent (result is lost from channel but tracked)
|
||||
// - If parent turn has finished: emits SubTurnOrphanResultEvent (late arrival)
|
||||
//
|
||||
// Thread safety:
|
||||
// - Reads parent state under lock, then releases lock before channel send
|
||||
// - Small race window exists but is acceptable (worst case: result becomes orphan)
|
||||
//
|
||||
// Event emissions:
|
||||
// - SubTurnResultDeliveredEvent: successful delivery to channel
|
||||
// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full)
|
||||
func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, result *tools.ToolResult) {
|
||||
// Let GC clean up the pendingResults channel; parent Finish will no longer close it.
|
||||
// We use defer/recover to catch any unlikely channel panics if it were ever closed.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{
|
||||
"parent_id": parentTS.turnID,
|
||||
"child_id": childID,
|
||||
"recover": r,
|
||||
})
|
||||
if result != nil && al != nil {
|
||||
al.emitEvent(EventKindSubTurnOrphan,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
|
||||
SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"},
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
parentTS.mu.Lock()
|
||||
isFinished := parentTS.isFinished.Load()
|
||||
resultChan := parentTS.pendingResults
|
||||
parentTS.mu.Unlock()
|
||||
|
||||
// If parent turn has already finished, treat this as an orphan result
|
||||
if isFinished || resultChan == nil {
|
||||
if result != nil && al != nil {
|
||||
al.emitEvent(EventKindSubTurnOrphan,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
|
||||
SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parent Turn is still running → attempt to deliver result
|
||||
// We use a select statement with parentTS.Finished() to ensure that if the
|
||||
// parent turn finishes while we are waiting to send the result (e.g. channel
|
||||
// is full), we don't leak this goroutine by blocking forever.
|
||||
select {
|
||||
case resultChan <- result:
|
||||
// Successfully delivered
|
||||
if al != nil {
|
||||
al.emitEvent(EventKindSubTurnResultDelivered,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.result_delivered"),
|
||||
SubTurnResultDeliveredPayload{ContentLen: len(result.ForLLM)},
|
||||
)
|
||||
}
|
||||
case <-parentTS.Finished():
|
||||
// Parent finished while we were waiting to deliver.
|
||||
// The result cannot be delivered to the LLM, so it becomes an orphan.
|
||||
logger.WarnCF("subturn", "parent finished before result could be delivered", map[string]any{
|
||||
"parent_id": parentTS.turnID,
|
||||
"child_id": childID,
|
||||
})
|
||||
if result != nil && al != nil {
|
||||
al.emitEvent(
|
||||
EventKindSubTurnOrphan,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
|
||||
SubTurnOrphanPayload{
|
||||
ParentTurnID: parentTS.turnID,
|
||||
ChildTurnID: childID,
|
||||
Reason: "parent_finished_waiting",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Other Types ======================
|
||||
|
||||
// ephemeralSessionStore is an in-memory session.SessionStore used by SubTurns.
|
||||
// It does not persist to disk and auto-truncates history to maxEphemeralHistorySize.
|
||||
type ephemeralSessionStore struct {
|
||||
mu sync.Mutex
|
||||
history []providers.Message
|
||||
summary string
|
||||
}
|
||||
|
||||
func newEphemeralSession(initial []providers.Message) ephemeralSessionStoreIface {
|
||||
s := &ephemeralSessionStore{}
|
||||
if len(initial) > 0 {
|
||||
s.history = append(s.history, initial...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ephemeralSessionStoreIface is satisfied by *ephemeralSessionStore.
|
||||
// Declared so newEphemeralSession can return a typed interface.
|
||||
type ephemeralSessionStoreIface interface {
|
||||
AddMessage(sessionKey, role, content string)
|
||||
AddFullMessage(sessionKey string, msg providers.Message)
|
||||
GetHistory(key string) []providers.Message
|
||||
GetSummary(key string) string
|
||||
SetSummary(key, summary string)
|
||||
SetHistory(key string, history []providers.Message)
|
||||
TruncateHistory(key string, keepLast int)
|
||||
Save(key string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) AddMessage(_, role, content string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = append(e.history, providers.Message{Role: role, Content: content})
|
||||
e.truncateLocked()
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = append(e.history, msg)
|
||||
e.truncateLocked()
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) GetHistory(_ string) []providers.Message {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]providers.Message, len(e.history))
|
||||
copy(out, e.history)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) GetSummary(_ string) string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.summary
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) SetSummary(_, summary string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.summary = summary
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = make([]providers.Message, len(history))
|
||||
copy(e.history, history)
|
||||
e.truncateLocked()
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) TruncateHistory(_ string, keepLast int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if keepLast <= 0 {
|
||||
e.history = nil
|
||||
return
|
||||
}
|
||||
|
||||
if keepLast >= len(e.history) {
|
||||
return
|
||||
}
|
||||
e.history = e.history[len(e.history)-keepLast:]
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) Save(_ string) error { return nil }
|
||||
func (e *ephemeralSessionStore) Close() error { return nil }
|
||||
|
||||
func (e *ephemeralSessionStore) truncateLocked() {
|
||||
if len(e.history) > maxEphemeralHistorySize {
|
||||
e.history = e.history[len(e.history)-maxEphemeralHistorySize:]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
type TurnPhase string
|
||||
|
||||
const (
|
||||
TurnPhaseSetup TurnPhase = "setup"
|
||||
TurnPhaseRunning TurnPhase = "running"
|
||||
TurnPhaseTools TurnPhase = "tools"
|
||||
TurnPhaseFinalizing TurnPhase = "finalizing"
|
||||
TurnPhaseCompleted TurnPhase = "completed"
|
||||
TurnPhaseAborted TurnPhase = "aborted"
|
||||
)
|
||||
|
||||
type ActiveTurnInfo struct {
|
||||
TurnID string
|
||||
AgentID string
|
||||
SessionKey string
|
||||
Channel string
|
||||
ChatID string
|
||||
UserMessage string
|
||||
Phase TurnPhase
|
||||
Iteration int
|
||||
StartedAt time.Time
|
||||
Depth int
|
||||
ParentTurnID string
|
||||
ChildTurnIDs []string
|
||||
}
|
||||
|
||||
type turnResult struct {
|
||||
finalContent string
|
||||
status TurnEndStatus
|
||||
followUps []bus.InboundMessage
|
||||
}
|
||||
|
||||
type turnState struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
agent *AgentInstance
|
||||
opts processOptions
|
||||
scope turnEventScope
|
||||
|
||||
turnID string
|
||||
agentID string
|
||||
sessionKey string
|
||||
|
||||
channel string
|
||||
chatID string
|
||||
userMessage string
|
||||
media []string
|
||||
|
||||
phase TurnPhase
|
||||
iteration int
|
||||
startedAt time.Time
|
||||
finalContent string
|
||||
|
||||
followUps []bus.InboundMessage
|
||||
|
||||
gracefulInterrupt bool
|
||||
gracefulInterruptHint string
|
||||
gracefulTerminalUsed bool
|
||||
hardAbort bool
|
||||
providerCancel context.CancelFunc
|
||||
turnCancel context.CancelFunc
|
||||
|
||||
restorePointHistory []providers.Message
|
||||
restorePointSummary string
|
||||
persistedMessages []providers.Message
|
||||
|
||||
// SubTurn support (from HEAD)
|
||||
depth int // SubTurn depth (0 for root turn)
|
||||
parentTurnID string // Parent turn ID (empty for root turn)
|
||||
childTurnIDs []string // Child turn IDs
|
||||
pendingResults chan *tools.ToolResult // Channel for SubTurn results
|
||||
concurrencySem chan struct{} // Semaphore for limiting concurrent SubTurns
|
||||
isFinished atomic.Bool // Whether this turn has finished
|
||||
session session.SessionStore // Session store reference
|
||||
initialHistoryLength int // Snapshot of history length at turn start
|
||||
|
||||
// Additional SubTurn fields
|
||||
ctx context.Context // Context for this turn
|
||||
cancelFunc context.CancelFunc // Cancel function for this turn's context
|
||||
critical bool // Whether this SubTurn should continue after parent ends
|
||||
parentTurnState *turnState // Reference to parent turnState
|
||||
parentEnded atomic.Bool // Whether parent has ended
|
||||
closeOnce sync.Once // Ensures pendingResults channel is closed once
|
||||
finishedChan chan struct{} // Closed when turn finishes
|
||||
|
||||
// Token budget tracking
|
||||
tokenBudget *atomic.Int64 // Shared token budget counter
|
||||
lastFinishReason string // Last LLM finish_reason
|
||||
lastUsage *providers.UsageInfo // Last LLM usage info
|
||||
|
||||
// Back-reference to the owning AgentLoop (set for SubTurns only, used for hard abort cascade)
|
||||
al *AgentLoop
|
||||
}
|
||||
|
||||
func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState {
|
||||
ts := &turnState{
|
||||
agent: agent,
|
||||
opts: opts,
|
||||
scope: scope,
|
||||
turnID: scope.turnID,
|
||||
agentID: agent.ID,
|
||||
sessionKey: opts.SessionKey,
|
||||
channel: opts.Channel,
|
||||
chatID: opts.ChatID,
|
||||
userMessage: opts.UserMessage,
|
||||
media: append([]string(nil), opts.Media...),
|
||||
phase: TurnPhaseSetup,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Bind session store and capture initial history length for rollback logic
|
||||
if agent != nil && agent.Sessions != nil {
|
||||
ts.session = agent.Sessions
|
||||
ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.SessionKey))
|
||||
}
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func (al *AgentLoop) registerActiveTurn(ts *turnState) {
|
||||
al.activeTurnStates.Store(ts.sessionKey, ts)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
||||
al.activeTurnStates.Delete(ts.sessionKey)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
||||
if val, ok := al.activeTurnStates.Load(sessionKey); ok {
|
||||
return val.(*turnState)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAnyActiveTurnState returns any active turn state (for backward compatibility)
|
||||
func (al *AgentLoop) getAnyActiveTurnState() *turnState {
|
||||
var firstTS *turnState
|
||||
al.activeTurnStates.Range(func(key, value any) bool {
|
||||
firstTS = value.(*turnState)
|
||||
return false // stop after first
|
||||
})
|
||||
return firstTS
|
||||
}
|
||||
|
||||
func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo {
|
||||
// For backward compatibility, return the first active turn found
|
||||
// In the new architecture, there can be multiple concurrent turns
|
||||
var firstTS *turnState
|
||||
al.activeTurnStates.Range(func(key, value any) bool {
|
||||
firstTS = value.(*turnState)
|
||||
return false // stop after first
|
||||
})
|
||||
if firstTS == nil {
|
||||
return nil
|
||||
}
|
||||
info := firstTS.snapshot()
|
||||
return &info
|
||||
}
|
||||
|
||||
func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo {
|
||||
ts := al.getActiveTurnState(sessionKey)
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
info := ts.snapshot()
|
||||
return &info
|
||||
}
|
||||
|
||||
func (ts *turnState) snapshot() ActiveTurnInfo {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
|
||||
return ActiveTurnInfo{
|
||||
TurnID: ts.turnID,
|
||||
AgentID: ts.agentID,
|
||||
SessionKey: ts.sessionKey,
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
UserMessage: ts.userMessage,
|
||||
Phase: ts.phase,
|
||||
Iteration: ts.iteration,
|
||||
StartedAt: ts.startedAt,
|
||||
Depth: ts.depth,
|
||||
ParentTurnID: ts.parentTurnID,
|
||||
ChildTurnIDs: append([]string(nil), ts.childTurnIDs...),
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *turnState) setPhase(phase TurnPhase) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.phase = phase
|
||||
}
|
||||
|
||||
func (ts *turnState) setIteration(iteration int) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.iteration = iteration
|
||||
}
|
||||
|
||||
func (ts *turnState) currentIteration() int {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.iteration
|
||||
}
|
||||
|
||||
func (ts *turnState) setFinalContent(content string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.finalContent = content
|
||||
}
|
||||
|
||||
func (ts *turnState) finalContentLen() int {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return len(ts.finalContent)
|
||||
}
|
||||
|
||||
func (ts *turnState) setTurnCancel(cancel context.CancelFunc) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.turnCancel = cancel
|
||||
}
|
||||
|
||||
func (ts *turnState) setProviderCancel(cancel context.CancelFunc) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.providerCancel = cancel
|
||||
}
|
||||
|
||||
func (ts *turnState) clearProviderCancel(_ context.CancelFunc) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.providerCancel = nil
|
||||
}
|
||||
|
||||
func (ts *turnState) requestGracefulInterrupt(hint string) bool {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
if ts.hardAbort {
|
||||
return false
|
||||
}
|
||||
ts.gracefulInterrupt = true
|
||||
ts.gracefulInterruptHint = hint
|
||||
return true
|
||||
}
|
||||
|
||||
func (ts *turnState) gracefulInterruptRequested() (bool, string) {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.gracefulInterrupt && !ts.gracefulTerminalUsed, ts.gracefulInterruptHint
|
||||
}
|
||||
|
||||
func (ts *turnState) markGracefulTerminalUsed() {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.gracefulTerminalUsed = true
|
||||
}
|
||||
|
||||
func (ts *turnState) requestHardAbort() bool {
|
||||
ts.mu.Lock()
|
||||
if ts.hardAbort {
|
||||
ts.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
ts.hardAbort = true
|
||||
turnCancel := ts.turnCancel
|
||||
providerCancel := ts.providerCancel
|
||||
ts.mu.Unlock()
|
||||
|
||||
if providerCancel != nil {
|
||||
providerCancel()
|
||||
}
|
||||
if turnCancel != nil {
|
||||
turnCancel()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (ts *turnState) hardAbortRequested() bool {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.hardAbort
|
||||
}
|
||||
|
||||
func (ts *turnState) eventMeta(source, tracePath string) EventMeta {
|
||||
snap := ts.snapshot()
|
||||
return EventMeta{
|
||||
AgentID: snap.AgentID,
|
||||
TurnID: snap.TurnID,
|
||||
SessionKey: snap.SessionKey,
|
||||
Iteration: snap.Iteration,
|
||||
Source: source,
|
||||
TracePath: tracePath,
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *turnState) captureRestorePoint(history []providers.Message, summary string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.restorePointHistory = append([]providers.Message(nil), history...)
|
||||
ts.restorePointSummary = summary
|
||||
}
|
||||
|
||||
func (ts *turnState) recordPersistedMessage(msg providers.Message) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.persistedMessages = append(ts.persistedMessages, msg)
|
||||
}
|
||||
|
||||
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
|
||||
history := agent.Sessions.GetHistory(ts.sessionKey)
|
||||
summary := agent.Sessions.GetSummary(ts.sessionKey)
|
||||
|
||||
ts.mu.RLock()
|
||||
persisted := append([]providers.Message(nil), ts.persistedMessages...)
|
||||
ts.mu.RUnlock()
|
||||
|
||||
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
|
||||
history = append([]providers.Message(nil), history[:len(history)-matched]...)
|
||||
}
|
||||
|
||||
ts.captureRestorePoint(history, summary)
|
||||
}
|
||||
|
||||
func (ts *turnState) restoreSession(agent *AgentInstance) error {
|
||||
ts.mu.RLock()
|
||||
history := append([]providers.Message(nil), ts.restorePointHistory...)
|
||||
summary := ts.restorePointSummary
|
||||
ts.mu.RUnlock()
|
||||
|
||||
agent.Sessions.SetHistory(ts.sessionKey, history)
|
||||
agent.Sessions.SetSummary(ts.sessionKey, summary)
|
||||
return agent.Sessions.Save(ts.sessionKey)
|
||||
}
|
||||
|
||||
func matchingTurnMessageTail(history, persisted []providers.Message) int {
|
||||
maxMatch := min(len(history), len(persisted))
|
||||
for size := maxMatch; size > 0; size-- {
|
||||
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (ts *turnState) interruptHintMessage() providers.Message {
|
||||
_, hint := ts.gracefulInterruptRequested()
|
||||
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
|
||||
if hint != "" {
|
||||
content += "\n\nInterrupt hint: " + hint
|
||||
}
|
||||
return providers.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
// SubTurn-related methods
|
||||
|
||||
// Finish marks the turn as finished and closes the pendingResults channel
|
||||
func (ts *turnState) Finish(isHardAbort bool) {
|
||||
ts.isFinished.Store(true)
|
||||
|
||||
// Close pendingResults channel exactly once
|
||||
ts.closeOnce.Do(func() {
|
||||
if ts.pendingResults != nil {
|
||||
close(ts.pendingResults)
|
||||
}
|
||||
ts.mu.Lock()
|
||||
if ts.finishedChan == nil {
|
||||
ts.finishedChan = make(chan struct{})
|
||||
}
|
||||
close(ts.finishedChan)
|
||||
ts.mu.Unlock()
|
||||
})
|
||||
|
||||
// If this is a graceful finish (not hard abort), signal to children
|
||||
if !isHardAbort && ts.parentTurnState == nil {
|
||||
// This is a root turn finishing gracefully
|
||||
ts.parentEnded.Store(true)
|
||||
}
|
||||
|
||||
// Cancel the turn context
|
||||
if ts.cancelFunc != nil {
|
||||
ts.cancelFunc()
|
||||
}
|
||||
|
||||
// Hard abort cascades to all child turns
|
||||
if isHardAbort && ts.al != nil {
|
||||
ts.mu.RLock()
|
||||
children := append([]string(nil), ts.childTurnIDs...)
|
||||
ts.mu.RUnlock()
|
||||
for _, childID := range children {
|
||||
if val, ok := ts.al.activeTurnStates.Load(childID); ok {
|
||||
val.(*turnState).Finish(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finished returns whether the turn has finished
|
||||
func (ts *turnState) Finished() chan struct{} {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
if ts.finishedChan == nil {
|
||||
ts.finishedChan = make(chan struct{})
|
||||
}
|
||||
return ts.finishedChan
|
||||
}
|
||||
|
||||
// IsParentEnded checks if the parent turn has ended
|
||||
func (ts *turnState) IsParentEnded() bool {
|
||||
if ts.parentTurnState == nil {
|
||||
return false
|
||||
}
|
||||
return ts.parentTurnState.parentEnded.Load()
|
||||
}
|
||||
|
||||
// GetLastFinishReason returns the last LLM finish_reason
|
||||
func (ts *turnState) GetLastFinishReason() string {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.lastFinishReason
|
||||
}
|
||||
|
||||
// SetLastFinishReason sets the last LLM finish_reason
|
||||
func (ts *turnState) SetLastFinishReason(reason string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.lastFinishReason = reason
|
||||
}
|
||||
|
||||
// GetLastUsage returns the last LLM usage info
|
||||
func (ts *turnState) GetLastUsage() *providers.UsageInfo {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.lastUsage
|
||||
}
|
||||
|
||||
// SetLastUsage sets the last LLM usage info
|
||||
func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.lastUsage = usage
|
||||
}
|
||||
|
||||
// Context helper functions for SubTurn
|
||||
|
||||
type turnStateKeyType struct{}
|
||||
|
||||
var turnStateKey = turnStateKeyType{}
|
||||
|
||||
func withTurnState(ctx context.Context, ts *turnState) context.Context {
|
||||
return context.WithValue(ctx, turnStateKey, ts)
|
||||
}
|
||||
|
||||
func turnStateFromContext(ctx context.Context) *turnState {
|
||||
ts, _ := ctx.Value(turnStateKey).(*turnState)
|
||||
return ts
|
||||
}
|
||||
|
||||
// TurnStateFromContext retrieves turnState from context (exported for tools)
|
||||
func TurnStateFromContext(ctx context.Context) *turnState {
|
||||
return turnStateFromContext(ctx)
|
||||
}
|
||||
+2
-1
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
@@ -44,7 +45,7 @@ func authFilePath() string {
|
||||
return filepath.Join(home, "auth.json")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome, "auth.json")
|
||||
}
|
||||
|
||||
func LoadStore() (*AuthStore, error) {
|
||||
|
||||
@@ -36,7 +36,7 @@ type DingTalkChannel struct {
|
||||
|
||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
||||
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||
if cfg.ClientID == "" || cfg.ClientSecret() == "" {
|
||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
clientID: cfg.ClientID,
|
||||
clientSecret: cfg.ClientSecret,
|
||||
clientSecret: cfg.ClientSecret(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||
discordgo.LogDebug: logger.DEBUG,
|
||||
}).Log
|
||||
|
||||
session, err := discordgo.New("Bot " + cfg.Token)
|
||||
session, err := discordgo.New("Bot " + cfg.Token())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||
}
|
||||
@@ -396,8 +396,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
storeMedia := func(localPath, filename string) string {
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "discord",
|
||||
Filename: filename,
|
||||
Source: "discord",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
|
||||
@@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
tokenCache: tc,
|
||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),
|
||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
|
||||
}
|
||||
ch.SetOwner(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||
if c.config.AppID == "" || c.config.AppSecret() == "" {
|
||||
return fmt.Errorf("feishu app_id or app_secret is empty")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
||||
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()).
|
||||
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
@@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
}
|
||||
c.wsClient = larkws.NewClient(
|
||||
c.config.AppID,
|
||||
c.config.AppSecret,
|
||||
c.config.AppSecret(),
|
||||
larkws.WithEventHandler(dispatcher),
|
||||
larkws.WithDomain(domain),
|
||||
)
|
||||
@@ -725,8 +725,9 @@ func (c *FeishuChannel) downloadResource(
|
||||
out.Close()
|
||||
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "feishu",
|
||||
Filename: filename,
|
||||
Source: "feishu",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
// onConnect is called after a successful connection (and on reconnect).
|
||||
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
||||
// NickServ auth (only if SASL is not configured)
|
||||
if c.config.NickServPassword != "" && c.config.SASLUser == "" {
|
||||
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
|
||||
if c.config.NickServPassword() != "" && c.config.SASLUser == "" {
|
||||
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword())
|
||||
}
|
||||
|
||||
// Join configured channels
|
||||
|
||||
@@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||
Nick: c.config.Nick,
|
||||
User: user,
|
||||
RealName: realName,
|
||||
Password: c.config.Password,
|
||||
Password: c.config.Password(),
|
||||
UseTLS: c.config.TLS,
|
||||
RequestCaps: caps,
|
||||
QuitMessage: "Goodbye",
|
||||
@@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// SASL auth (takes priority over NickServ)
|
||||
if c.config.SASLUser != "" && c.config.SASLPassword != "" {
|
||||
if c.config.SASLUser != "" && c.config.SASLPassword() != "" {
|
||||
conn.SASLLogin = c.config.SASLUser
|
||||
conn.SASLPassword = c.config.SASLPassword
|
||||
conn.SASLPassword = c.config.SASLPassword()
|
||||
}
|
||||
|
||||
// Register event handlers
|
||||
|
||||
@@ -62,7 +62,7 @@ type LINEChannel struct {
|
||||
|
||||
// NewLINEChannel creates a new LINE channel instance.
|
||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
||||
if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" {
|
||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
|
||||
|
||||
resp, err := c.infoClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
|
||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret()))
|
||||
mac.Write(body)
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
@@ -301,8 +301,9 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
storeMedia := func(localPath, filename string) string {
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "line",
|
||||
Filename: filename,
|
||||
Source: "line",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
@@ -654,7 +655,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
|
||||
|
||||
resp, err := c.apiClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -679,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "line",
|
||||
ExtraHeaders: map[string]string{
|
||||
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
||||
"Authorization": "Bearer " + c.config.ChannelAccessToken(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+10
-11
@@ -319,7 +319,7 @@ func (m *Manager) initChannel(name, displayName string) {
|
||||
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
logger.InfoC("channels", "Initializing channel manager")
|
||||
|
||||
if channels.Telegram.Enabled && channels.Telegram.Token != "" {
|
||||
if channels.Telegram.Enabled && channels.Telegram.Token() != "" {
|
||||
m.initChannel("telegram", "Telegram")
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
m.initChannel("feishu", "Feishu")
|
||||
}
|
||||
|
||||
if channels.Discord.Enabled && channels.Discord.Token != "" {
|
||||
if channels.Discord.Enabled && channels.Discord.Token() != "" {
|
||||
m.initChannel("discord", "Discord")
|
||||
}
|
||||
|
||||
@@ -352,18 +352,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
m.initChannel("dingtalk", "DingTalk")
|
||||
}
|
||||
|
||||
if channels.Slack.Enabled && channels.Slack.BotToken != "" {
|
||||
if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
|
||||
m.initChannel("slack", "Slack")
|
||||
}
|
||||
|
||||
if channels.Matrix.Enabled &&
|
||||
m.config.Channels.Matrix.Homeserver != "" &&
|
||||
m.config.Channels.Matrix.UserID != "" &&
|
||||
m.config.Channels.Matrix.AccessToken != "" {
|
||||
m.config.Channels.Matrix.AccessToken() != "" {
|
||||
m.initChannel("matrix", "Matrix")
|
||||
}
|
||||
|
||||
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" {
|
||||
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
|
||||
m.initChannel("line", "LINE")
|
||||
}
|
||||
|
||||
@@ -371,13 +371,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
m.initChannel("onebot", "OneBot")
|
||||
}
|
||||
|
||||
if channels.WeCom.Enabled && channels.WeCom.Token != "" {
|
||||
if channels.WeCom.Enabled && channels.WeCom.Token() != "" {
|
||||
m.initChannel("wecom", "WeCom")
|
||||
}
|
||||
|
||||
if m.config.Channels.WeComAIBot.Enabled &&
|
||||
((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") ||
|
||||
m.config.Channels.WeComAIBot.Token != "") {
|
||||
if channels.WeComAIBot.Enabled && (channels.WeComAIBot.Token() != "" ||
|
||||
(channels.WeComAIBot.Secret() != "" && channels.WeComAIBot.BotID != "")) {
|
||||
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
||||
}
|
||||
|
||||
@@ -385,11 +384,11 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
m.initChannel("wecom_app", "WeCom App")
|
||||
}
|
||||
|
||||
if channels.Weixin.Enabled && channels.Weixin.Token != "" {
|
||||
if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
|
||||
m.initChannel("weixin", "Weixin")
|
||||
}
|
||||
|
||||
if channels.Pico.Enabled && channels.Pico.Token != "" {
|
||||
if channels.Pico.Enabled && channels.Pico.Token() != "" {
|
||||
m.initChannel("pico", "Pico")
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||
if !value["enabled"].(bool) {
|
||||
continue
|
||||
}
|
||||
hiddenValues(key, value, ch)
|
||||
valueBytes, _ := json.Marshal(value)
|
||||
hash := md5.Sum(valueBytes)
|
||||
result[key] = hex.EncodeToString(hash[:])
|
||||
@@ -29,6 +30,49 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
||||
switch key {
|
||||
case "pico":
|
||||
value["token"] = ch.Pico.Token()
|
||||
case "telegram":
|
||||
value["token"] = ch.Telegram.Token()
|
||||
case "discord":
|
||||
value["token"] = ch.Discord.Token()
|
||||
case "slack":
|
||||
value["bot_token"] = ch.Slack.BotToken()
|
||||
value["app_token"] = ch.Slack.AppToken()
|
||||
case "matrix":
|
||||
value["token"] = ch.Matrix.AccessToken()
|
||||
case "onebot":
|
||||
value["token"] = ch.OneBot.AccessToken()
|
||||
case "line":
|
||||
value["token"] = ch.LINE.ChannelAccessToken()
|
||||
value["secret"] = ch.LINE.ChannelSecret()
|
||||
case "wecom":
|
||||
value["token"] = ch.WeCom.Token()
|
||||
value["key"] = ch.WeCom.EncodingAESKey()
|
||||
case "wecom_app":
|
||||
value["token"] = ch.WeComApp.Token()
|
||||
value["secret"] = ch.WeComApp.CorpSecret()
|
||||
case "wecom_aibot":
|
||||
value["token"] = ch.WeComAIBot.Token()
|
||||
value["key"] = ch.WeComAIBot.EncodingAESKey()
|
||||
value["secret"] = ch.WeComAIBot.Secret()
|
||||
case "dingtalk":
|
||||
value["secret"] = ch.QQ.AppSecret()
|
||||
case "qq":
|
||||
value["secret"] = ch.DingTalk.ClientSecret()
|
||||
case "irc":
|
||||
value["password"] = ch.IRC.Password()
|
||||
value["serv_password"] = ch.IRC.NickServPassword()
|
||||
value["sasl_password"] = ch.IRC.SASLPassword()
|
||||
case "feishu":
|
||||
value["app_secret"] = ch.Feishu.AppSecret()
|
||||
value["encrypt_key"] = ch.Feishu.EncryptKey()
|
||||
value["verification_token"] = ch.Feishu.VerificationToken()
|
||||
}
|
||||
}
|
||||
|
||||
func compareChannels(old, news map[string]string) (added, removed []string) {
|
||||
for key, newHash := range news {
|
||||
if oldHash, ok := old[key]; ok {
|
||||
@@ -82,5 +126,61 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateKeys(result, &ch)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func updateKeys(newcfg, old *config.ChannelsConfig) {
|
||||
if newcfg.Pico.Enabled {
|
||||
newcfg.Pico.SetToken(old.Pico.Token())
|
||||
}
|
||||
if newcfg.Telegram.Enabled {
|
||||
newcfg.Telegram.SetToken(old.Telegram.Token())
|
||||
}
|
||||
if newcfg.Discord.Enabled {
|
||||
newcfg.Discord.SetToken(old.Discord.Token())
|
||||
}
|
||||
if newcfg.Slack.Enabled {
|
||||
newcfg.Slack.SetBotToken(old.Slack.BotToken())
|
||||
newcfg.Slack.SetAppToken(old.Slack.AppToken())
|
||||
}
|
||||
if newcfg.Matrix.Enabled {
|
||||
newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken())
|
||||
}
|
||||
if newcfg.OneBot.Enabled {
|
||||
newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken())
|
||||
}
|
||||
if newcfg.LINE.Enabled {
|
||||
newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken())
|
||||
newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
|
||||
}
|
||||
if newcfg.WeCom.Enabled {
|
||||
newcfg.WeCom.SetToken(old.WeCom.Token())
|
||||
newcfg.WeCom.SetEncodingAESKey(old.WeCom.EncodingAESKey())
|
||||
}
|
||||
if newcfg.WeComApp.Enabled {
|
||||
newcfg.WeComApp.SetToken(old.WeComApp.Token())
|
||||
newcfg.WeComApp.SetCorpSecret(old.WeComApp.CorpSecret())
|
||||
}
|
||||
if newcfg.WeComAIBot.Enabled {
|
||||
newcfg.WeComAIBot.SetToken(old.WeComAIBot.Token())
|
||||
newcfg.WeComAIBot.SetEncodingAESKey(old.WeComAIBot.EncodingAESKey())
|
||||
}
|
||||
if newcfg.DingTalk.Enabled {
|
||||
newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
|
||||
}
|
||||
if newcfg.QQ.Enabled {
|
||||
newcfg.QQ.SetAppSecret(old.QQ.AppSecret())
|
||||
}
|
||||
if newcfg.IRC.Enabled {
|
||||
newcfg.IRC.SetPassword(old.IRC.Password())
|
||||
newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword())
|
||||
newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword())
|
||||
}
|
||||
if newcfg.Feishu.Enabled {
|
||||
newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret())
|
||||
newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey())
|
||||
newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestToChannelHashes(t *testing.T) {
|
||||
added, removed = compareChannels(results2, results3)
|
||||
assert.EqualValues(t, []string{"dingtalk"}, removed)
|
||||
assert.EqualValues(t, []string{"telegram"}, added)
|
||||
cfg3.Channels.Telegram.Token = "114314"
|
||||
cfg3.Channels.Telegram.SetToken("114314")
|
||||
results4 := toChannelHashes(cfg3)
|
||||
assert.Equal(t, 1, len(results4))
|
||||
logger.Debugf("results4: %v", results4)
|
||||
@@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) {
|
||||
cc, err := toChannelConfig(cfg3, added)
|
||||
assert.NoError(t, err)
|
||||
logger.Debugf("cc: %#v", cc.Telegram)
|
||||
assert.Equal(t, "114314", cc.Telegram.Token)
|
||||
assert.Equal(t, "114314", cc.Telegram.Token())
|
||||
assert.Equal(t, true, cc.Telegram.Enabled)
|
||||
cc, err = toChannelConfig(cfg2, added)
|
||||
assert.NoError(t, err)
|
||||
logger.Debugf("cc: %#v", cc.Telegram)
|
||||
assert.Equal(t, "", cc.Telegram.Token)
|
||||
assert.Equal(t, "", cc.Telegram.Token())
|
||||
assert.Equal(t, false, cc.Telegram.Enabled)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ type MatrixChannel struct {
|
||||
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
||||
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||
userID := strings.TrimSpace(cfg.UserID)
|
||||
accessToken := strings.TrimSpace(cfg.AccessToken)
|
||||
accessToken := strings.TrimSpace(cfg.AccessToken())
|
||||
if homeserver == "" {
|
||||
return nil, fmt.Errorf("matrix homeserver is required")
|
||||
}
|
||||
@@ -692,6 +692,9 @@ func (c *MatrixChannel) extractInboundMedia(
|
||||
|
||||
func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string {
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
if meta.CleanupPolicy == "" {
|
||||
meta.CleanupPolicy = media.CleanupPolicyDeleteOnCleanup
|
||||
}
|
||||
ref, err := store.Store(localPath, meta, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
|
||||
@@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error {
|
||||
dialer.HandshakeTimeout = 10 * time.Second
|
||||
|
||||
header := make(map[string][]string)
|
||||
if c.config.AccessToken != "" {
|
||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
|
||||
if c.config.AccessToken() != "" {
|
||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken()}
|
||||
}
|
||||
|
||||
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
||||
@@ -749,8 +749,9 @@ func (c *OneBotChannel) parseMessageSegments(
|
||||
storeFile := func(localPath, filename string) string {
|
||||
if store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "onebot",
|
||||
Filename: filename,
|
||||
Source: "onebot",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
|
||||
@@ -64,7 +64,7 @@ type PicoChannel struct {
|
||||
|
||||
// NewPicoChannel creates a new Pico Protocol channel.
|
||||
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
|
||||
if cfg.Token == "" {
|
||||
if cfg.Token() == "" {
|
||||
return nil, fmt.Errorf("pico token is required")
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
||||
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
||||
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||
token := c.config.Token
|
||||
token := c.config.Token()
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
||||
// the configured token, or "" if none do.
|
||||
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
||||
token := c.config.Token
|
||||
token := c.config.Token()
|
||||
for _, proto := range websocket.Subprotocols(r) {
|
||||
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
||||
return proto
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const qqVoiceMaxDuration = 60 * time.Second
|
||||
|
||||
func qqAudioDuration(localPath, filename, contentType string) (time.Duration, bool, error) {
|
||||
if localPath == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
switch qqAudioDurationFormat(localPath, filename, contentType) {
|
||||
case "wav":
|
||||
return qqWAVDuration(localPath)
|
||||
case "ogg":
|
||||
return qqOggDuration(localPath)
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func qqAudioDurationFormat(localPath, filename, contentType string) string {
|
||||
contentType = strings.ToLower(contentType)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "audio/wav"), strings.HasPrefix(contentType, "audio/x-wav"):
|
||||
return "wav"
|
||||
case strings.HasPrefix(contentType, "audio/ogg"),
|
||||
contentType == "application/ogg",
|
||||
contentType == "application/x-ogg":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
switch filepath.Ext(strings.ToLower(filename)) {
|
||||
case ".wav":
|
||||
return "wav"
|
||||
case ".ogg", ".opus":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
switch filepath.Ext(strings.ToLower(localPath)) {
|
||||
case ".wav":
|
||||
return "wav"
|
||||
case ".ogg", ".opus":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func qqWAVDuration(localPath string) (time.Duration, bool, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var header [12]byte
|
||||
if _, err := io.ReadFull(file, header[:]); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
var order binary.ByteOrder
|
||||
switch string(header[:4]) {
|
||||
case "RIFF":
|
||||
order = binary.LittleEndian
|
||||
case "RIFX":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
if string(header[8:12]) != "WAVE" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
var byteRate uint32
|
||||
var dataSize uint32
|
||||
var foundFmt bool
|
||||
var foundData bool
|
||||
|
||||
for {
|
||||
var chunkHeader [8]byte
|
||||
if _, err := io.ReadFull(file, chunkHeader[:]); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
chunkSize := order.Uint32(chunkHeader[4:8])
|
||||
switch string(chunkHeader[:4]) {
|
||||
case "fmt ":
|
||||
chunkData := make([]byte, chunkSize)
|
||||
if _, err := io.ReadFull(file, chunkData); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if len(chunkData) >= 12 {
|
||||
byteRate = order.Uint32(chunkData[8:12])
|
||||
foundFmt = true
|
||||
}
|
||||
case "data":
|
||||
dataSize = chunkSize
|
||||
foundData = true
|
||||
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
default:
|
||||
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
if chunkSize%2 == 1 {
|
||||
if _, err := io.CopyN(io.Discard, file, 1); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
if foundFmt && foundData {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundFmt || !foundData || byteRate == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
durationNS := int64(dataSize) * int64(time.Second) / int64(byteRate)
|
||||
return time.Duration(durationNS), true, nil
|
||||
}
|
||||
|
||||
func qqOggDuration(localPath string) (time.Duration, bool, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var firstPacket []byte
|
||||
var codec string
|
||||
var sampleRate uint32
|
||||
var lastGranule uint64
|
||||
var haveGranule bool
|
||||
|
||||
for {
|
||||
var header [27]byte
|
||||
if _, err := io.ReadFull(file, header[:]); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
if string(header[:4]) != "OggS" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
pageSegments := int(header[26])
|
||||
segments := make([]byte, pageSegments)
|
||||
if _, err := io.ReadFull(file, segments); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
payloadLen := 0
|
||||
for _, segLen := range segments {
|
||||
payloadLen += int(segLen)
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadLen)
|
||||
if _, err := io.ReadFull(file, payload); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
granule := binary.LittleEndian.Uint64(header[6:14])
|
||||
if granule != ^uint64(0) {
|
||||
lastGranule = granule
|
||||
haveGranule = true
|
||||
}
|
||||
|
||||
if codec == "" {
|
||||
offset := 0
|
||||
for _, segLen := range segments {
|
||||
firstPacket = append(firstPacket, payload[offset:offset+int(segLen)]...)
|
||||
offset += int(segLen)
|
||||
if segLen < 255 {
|
||||
codec, sampleRate = qqParseOggCodec(firstPacket)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !haveGranule || codec == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
switch codec {
|
||||
case "opus":
|
||||
return time.Duration(lastGranule) * time.Second / 48000, true, nil
|
||||
case "vorbis":
|
||||
if sampleRate == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return time.Duration(lastGranule) * time.Second / time.Duration(sampleRate), true, nil
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func qqParseOggCodec(packet []byte) (string, uint32) {
|
||||
if len(packet) >= 8 && string(packet[:8]) == "OpusHead" {
|
||||
return "opus", 48000
|
||||
}
|
||||
|
||||
if len(packet) >= 16 && packet[0] == 0x01 && string(packet[1:7]) == "vorbis" {
|
||||
sampleRate := binary.LittleEndian.Uint32(packet[12:16])
|
||||
if sampleRate > 0 {
|
||||
return "vorbis", sampleRate
|
||||
}
|
||||
}
|
||||
|
||||
return "", 0
|
||||
}
|
||||
+59
-9
@@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||
}
|
||||
|
||||
func (c *QQChannel) Start(ctx context.Context) error {
|
||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||
if c.config.AppID == "" || c.config.AppSecret() == "" {
|
||||
return fmt.Errorf("QQ app_id and app_secret not configured")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||
// create token source
|
||||
credentials := &token.QQBotCredentials{
|
||||
AppID: c.config.AppID,
|
||||
AppSecret: c.config.AppSecret,
|
||||
AppSecret: c.config.AppSecret(),
|
||||
}
|
||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||
|
||||
@@ -387,12 +387,11 @@ func (c *QQChannel) uploadMedia(
|
||||
}
|
||||
|
||||
func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {
|
||||
payload := &qqMediaUpload{
|
||||
FileType: qqFileType(part.Type),
|
||||
}
|
||||
payload := &qqMediaUpload{}
|
||||
|
||||
mediaRef := part.Ref
|
||||
if isHTTPURL(mediaRef) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = mediaRef
|
||||
return payload, nil
|
||||
}
|
||||
@@ -402,15 +401,23 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
resolved, err := store.Resolve(part.Ref)
|
||||
resolved, meta, err := store.ResolveWithMeta(part.Ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
||||
}
|
||||
if part.Filename == "" {
|
||||
part.Filename = meta.Filename
|
||||
}
|
||||
if part.ContentType == "" {
|
||||
part.ContentType = meta.ContentType
|
||||
}
|
||||
|
||||
if isHTTPURL(resolved) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = resolved
|
||||
return payload, nil
|
||||
}
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
||||
|
||||
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
||||
info, statErr := os.Stat(resolved)
|
||||
@@ -437,6 +444,48 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
||||
if part.Type != "audio" {
|
||||
return part.Type
|
||||
}
|
||||
|
||||
if localPath == "" {
|
||||
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
|
||||
duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType)
|
||||
if err != nil {
|
||||
logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
if !ok {
|
||||
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
if duration > qqVoiceMaxDuration {
|
||||
logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
"duration_seconds": duration.Seconds(),
|
||||
"limit_seconds": qqVoiceMaxDuration.Seconds(),
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
|
||||
return "audio"
|
||||
}
|
||||
|
||||
func (c *QQChannel) sendUploadedMedia(
|
||||
ctx context.Context,
|
||||
chatKind, chatID string,
|
||||
@@ -670,9 +719,10 @@ func (c *QQChannel) extractInboundAttachments(
|
||||
storeMedia := func(localPath string, attachment *dto.MessageAttachment) string {
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: qqAttachmentFilename(attachment),
|
||||
ContentType: attachment.ContentType,
|
||||
Source: "qq",
|
||||
Filename: qqAttachmentFilename(attachment),
|
||||
ContentType: attachment.ContentType,
|
||||
Source: "qq",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user